feat: enhance migration logic and add file type definitions

- Refactored migration logic in migrate.ts to improve error handling and state management.
- Introduced new file type definitions in file.ts, including interfaces for local and remote files, and type guards for file validation.
- Updated index.ts to export new file types and removed redundant file type definitions.
- Cleaned up file utility functions by removing unused base64 conversion function.
- Updated yarn.lock to include new dependencies and version updates for @mistralai/mistralai and @cherrystudio/mac-system-ocr.
This commit is contained in:
suyao
2025-05-10 11:59:34 +08:00
parent 47c8e8096c
commit c14dd77861
33 changed files with 514 additions and 1831 deletions
+2 -2
View File
@@ -70,7 +70,7 @@
"@electron-toolkit/utils": "^3.0.0",
"@electron/notarize": "^2.5.0",
"@langchain/community": "^0.3.36",
"@mistralai/mistralai": "^1.5.2",
"@mistralai/mistralai": "^1.6.0",
"@strongtz/win32-arm64-msvc": "^0.4.7",
"@tanstack/react-query": "^5.27.0",
"@types/react-infinite-scroll-component": "^5.0.0",
@@ -78,9 +78,9 @@
"archiver": "^7.0.1",
"async-mutex": "^0.5.0",
"bufferutil": "^4.0.9",
"canvas": "3.1.0",
"color": "^5.0.0",
"diff": "^7.0.0",
"canvas": "3.1.0",
"docx": "^9.0.2",
"electron-log": "^5.1.5",
"electron-store": "^8.2.0",
+6
View File
@@ -113,6 +113,12 @@ export enum IpcChannel {
File_Base64File = 'file:base64File',
Fs_Read = 'fs:read',
// file service
FileService_Upload = 'file-service:upload',
FileService_List = 'file-service:list',
FileService_Delete = 'file-service:delete',
FileService_Retrieve = 'file-service:retrieve',
Export_Word = 'export:word',
Shortcuts_Update = 'shortcuts:update',
+6 -8
View File
@@ -14,14 +14,14 @@ import BackupManager from './services/BackupManager'
import { configManager } from './services/ConfigManager'
import CopilotService from './services/CopilotService'
import { ExportService } from './services/ExportService'
import { FileServiceManager } from './services/file/FileServiceManager'
import FileService from './services/FileService'
import FileStorage from './services/FileStorage'
import FileService from './services/FileSystemService'
import KnowledgeService from './services/KnowledgeService'
import mcpService from './services/MCPService'
import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService'
import { ProxyConfig, proxyManager } from './services/ProxyManager'
import { FileServiceManager } from './services/remotefile/FileServiceManager'
import { searchService } from './services/SearchService'
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
import storeSyncService from './services/StoreSyncService'
@@ -217,22 +217,22 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage)
// file service
ipcMain.handle('file-service:upload', async (_, type: string, apiKey: string, file: LocalFileSource) => {
ipcMain.handle(IpcChannel.FileService_Upload, async (_, type: string, apiKey: string, file: LocalFileSource) => {
const service = FileServiceManager.getInstance().getService(type, apiKey)
return await service.uploadFile(file)
})
ipcMain.handle('file-service:list', async (_, type: string, apiKey: string) => {
ipcMain.handle(IpcChannel.FileService_List, async (_, type: string, apiKey: string) => {
const service = FileServiceManager.getInstance().getService(type, apiKey)
return await service.listFiles()
})
ipcMain.handle('file-service:delete', async (_, type: string, apiKey: string, fileId: string) => {
ipcMain.handle(IpcChannel.FileService_Delete, async (_, type: string, apiKey: string, fileId: string) => {
const service = FileServiceManager.getInstance().getService(type, apiKey)
return await service.deleteFile(fileId)
})
ipcMain.handle('file-service:retrieve', async (_, type: string, apiKey: string, fileId: string) => {
ipcMain.handle(IpcChannel.FileService_Retrieve, async (_, type: string, apiKey: string, fileId: string) => {
const service = FileServiceManager.getInstance().getService(type, apiKey)
return await service.retrieveFile(fileId)
})
@@ -336,8 +336,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
NutstoreService.getDirectoryContents(token, path)
)
// search window
ipcMain.handle(IpcChannel.SearchWindow_Open, async (_, uid: string) => {
await searchService.openSearchWindow(uid)
+1 -1
View File
@@ -1,7 +1,7 @@
import fs from 'node:fs'
import { MistralService } from '@main/services/file/MistralService'
import { MistralClientManager } from '@main/services/MistralClientManager'
import { MistralService } from '@main/services/remotefile/MistralService'
import { Mistral } from '@mistralai/mistralai'
import { DocumentURLChunk } from '@mistralai/mistralai/models/components/documenturlchunk'
import { ImageURLChunk } from '@mistralai/mistralai/models/components/imageurlchunk'
@@ -2,6 +2,6 @@ import fs from 'node:fs'
export default class FileService {
public static async readFile(_: Electron.IpcMainInvokeEvent, path: string) {
return fs.readFileSync(path, 'utf8')
return fs.promises.readFile(path, 'utf8')
}
}
-68
View File
@@ -1,68 +0,0 @@
import { File, FileState, GoogleGenAI, Pager } from '@google/genai'
import { FileType } from '@types'
import fs from 'fs'
import { CacheService } from './CacheService'
export class GeminiService {
private static readonly FILE_LIST_CACHE_KEY = 'gemini_file_list'
private static readonly CACHE_DURATION = 3000
static async uploadFile(_: Electron.IpcMainInvokeEvent, file: FileType, apiKey: string): Promise<File> {
const sdk = new GoogleGenAI({ vertexai: false, apiKey })
return await sdk.files.upload({
file: file.path,
config: {
mimeType: 'application/pdf',
name: file.id,
displayName: file.origin_name
}
})
}
static async base64File(_: Electron.IpcMainInvokeEvent, file: FileType) {
return {
data: Buffer.from(fs.readFileSync(file.path)).toString('base64'),
mimeType: 'application/pdf'
}
}
static async retrieveFile(_: Electron.IpcMainInvokeEvent, file: FileType, apiKey: string): Promise<File | undefined> {
const sdk = new GoogleGenAI({ vertexai: false, apiKey })
const cachedResponse = CacheService.get<any>(GeminiService.FILE_LIST_CACHE_KEY)
if (cachedResponse) {
return GeminiService.processResponse(cachedResponse, file)
}
const response = await sdk.files.list()
CacheService.set(GeminiService.FILE_LIST_CACHE_KEY, response, GeminiService.CACHE_DURATION)
return GeminiService.processResponse(response, file)
}
private static async processResponse(response: Pager<File>, file: FileType) {
for await (const f of response) {
if (f.state === FileState.ACTIVE) {
if (f.displayName === file.origin_name && Number(f.sizeBytes) === file.size) {
return f
}
}
}
return undefined
}
static async listFiles(_: Electron.IpcMainInvokeEvent, apiKey: string): Promise<File[]> {
const sdk = new GoogleGenAI({ vertexai: false, apiKey })
const files: File[] = []
for await (const f of await sdk.files.list()) {
files.push(f)
}
return files
}
static async deleteFile(_: Electron.IpcMainInvokeEvent, fileId: string, apiKey: string) {
const sdk = new GoogleGenAI({ vertexai: false, apiKey })
await sdk.files.delete({ name: fileId })
}
}
@@ -1,5 +1,6 @@
import { FileState, GoogleAIFileManager } from '@google/generative-ai/server'
import { File, Files, FileState, GoogleGenAI } from '@google/genai'
import { FileListResponse, FileUploadResponse, LocalFileSource } from '@types'
import { v4 as uuidv4 } from 'uuid'
import { CacheService } from '../CacheService'
import { BaseFileService } from './BaseFileService'
@@ -9,22 +10,26 @@ export class GeminiService extends BaseFileService {
private static readonly FILE_CACHE_DURATION = 48 * 60 * 60 * 1000
private static readonly LIST_CACHE_DURATION = 3000
protected readonly fileManager: GoogleAIFileManager
protected readonly fileManager: Files
constructor(apiKey: string) {
super(apiKey)
this.fileManager = new GoogleAIFileManager(apiKey)
this.fileManager = new GoogleGenAI({ vertexai: false, apiKey }).files
}
async uploadFile(file: LocalFileSource): Promise<FileUploadResponse> {
const uploadResult = await this.fileManager.uploadFile(file.path, {
mimeType: 'application/pdf',
displayName: file.origin_name
const uploadResult = await this.fileManager.upload({
file: file.path,
config: {
mimeType: 'application/pdf',
name: file.id,
displayName: file.origin_name
}
})
// 根据文件状态设置响应状态
let status: 'success' | 'processing' | 'failed' | 'unknown'
switch (uploadResult.file.state) {
switch (uploadResult.state) {
case FileState.ACTIVE:
status = 'success'
break
@@ -39,7 +44,7 @@ export class GeminiService extends BaseFileService {
}
const response: FileUploadResponse = {
fileId: uploadResult.file.name || '',
fileId: uploadResult.name || '',
displayName: file.origin_name,
status,
originalFile: uploadResult
@@ -59,17 +64,20 @@ export class GeminiService extends BaseFileService {
if (cachedResponse) {
return cachedResponse
}
const files: File[] = []
const response = await this.fileManager.listFiles()
if (response.files) {
const file = response.files.filter((file) => file.state === FileState.ACTIVE).find((file) => file.name === fileId)
if (file) {
return {
fileId: fileId,
displayName: file.displayName || '',
status: 'success',
originalFile: file
for await (const f of await this.fileManager.list()) {
files.push(f)
}
const file = files.filter((file) => file.state === FileState.ACTIVE).find((file) => file.name === fileId)
if (file) {
return {
fileId: fileId,
displayName: file.displayName || '',
status: 'success',
originalFile: {
type: 'gemini',
file
}
}
}
@@ -87,17 +95,24 @@ export class GeminiService extends BaseFileService {
if (cachedList) {
return cachedList
}
const response = await this.fileManager.listFiles()
const geminiFiles: File[] = []
for await (const f of await this.fileManager.list()) {
geminiFiles.push(f)
}
const fileList: FileListResponse = {
files: (response.files || [])
files: geminiFiles
.filter((file) => file.state === FileState.ACTIVE)
.map((file) => {
// 更新单个文件的缓存
const fileResponse: FileUploadResponse = {
fileId: file.name,
fileId: file.name || uuidv4(),
displayName: file.displayName || '',
status: 'success',
originalFile: file
originalFile: {
type: 'gemini',
file
}
}
CacheService.set(
`${GeminiService.FILE_LIST_CACHE_KEY}_${file.name}`,
@@ -106,11 +121,14 @@ export class GeminiService extends BaseFileService {
)
return {
id: file.name,
id: file.name || uuidv4(),
displayName: file.displayName || '',
size: Number(file.sizeBytes),
status: 'success',
originalFile: file
originalFile: {
type: 'gemini',
file
}
}
})
}
@@ -121,6 +139,6 @@ export class GeminiService extends BaseFileService {
}
async deleteFile(fileId: string): Promise<void> {
await this.fileManager.deleteFile(fileId)
await this.fileManager.delete({ name: fileId })
}
}
@@ -52,7 +52,10 @@ export class MistralService extends BaseFileService {
displayName: file.filename || '',
size: file.sizeBytes,
status: 'success', // All listed files are processed,
originalFile: file
originalFile: {
type: 'mistral',
file
}
}))
}
} catch (error) {
+8 -9
View File
@@ -65,12 +65,11 @@ const api = {
ipcRenderer.invoke(IpcChannel.File_Save, path, content, options),
selectFolder: () => ipcRenderer.invoke(IpcChannel.File_SelectFolder),
saveImage: (name: string, data: string) => ipcRenderer.invoke(IpcChannel.File_SaveImage, name, data),
base64Image: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64Image, fileId),
base64File: (filePath: string) => ipcRenderer.invoke('file:base64File', filePath),
download: (url: string) => ipcRenderer.invoke(IpcChannel.File_Download, url),
copy: (fileId: string, destPath: string) => ipcRenderer.invoke(IpcChannel.File_Copy, fileId, destPath),
binaryImage: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryImage, fileId),
base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId)
base64Image: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64Image, fileId),
base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId),
download: (url: string) => ipcRenderer.invoke(IpcChannel.File_Download, url),
copy: (fileId: string, destPath: string) => ipcRenderer.invoke(IpcChannel.File_Copy, fileId, destPath)
},
fs: {
read: (path: string) => ipcRenderer.invoke(IpcChannel.Fs_Read, path)
@@ -109,12 +108,12 @@ const api = {
},
fileService: {
upload: (type: string, apiKey: string, file: FileType) =>
ipcRenderer.invoke('file-service:upload', type, apiKey, file),
list: (type: string, apiKey: string) => ipcRenderer.invoke('file-service:list', type, apiKey),
ipcRenderer.invoke(IpcChannel.FileService_Upload, type, apiKey, file),
list: (type: string, apiKey: string) => ipcRenderer.invoke(IpcChannel.FileService_List, type, apiKey),
delete: (type: string, apiKey: string, fileId: string) =>
ipcRenderer.invoke('file-service:delete', type, apiKey, fileId),
ipcRenderer.invoke(IpcChannel.FileService_Delete, type, apiKey, fileId),
retrieve: (type: string, apiKey: string, fileId: string) =>
ipcRenderer.invoke('file-service:retrieve', type, apiKey, fileId)
ipcRenderer.invoke(IpcChannel.FileService_Retrieve, type, apiKey, fileId)
},
selectionMenu: {
action: (action: string) => ipcRenderer.invoke('selection-menu:action', action)
-28
View File
@@ -1,4 +1,3 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { db } from '@renderer/databases'
import KnowledgeQueue from '@renderer/queue/KnowledgeQueue'
import FileManager from '@renderer/services/FileManager'
@@ -21,7 +20,6 @@ import {
} from '@renderer/store/knowledge'
import { FileType, KnowledgeBase, KnowledgeItem, ProcessingStatus } from '@renderer/types'
import { runAsyncFunction } from '@renderer/utils'
import { IpcChannel } from '@shared/IpcChannel'
import { useCallback, useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { v4 as uuidv4 } from 'uuid'
@@ -202,32 +200,6 @@ export const useKnowledge = (baseId: string) => {
return base?.items.filter((item) => item.type === type && item.processingStatus !== undefined) || []
}
// 获取目录处理进度
const getDirectoryProcessingPercent = (itemId?: string) => {
const [percent, setPercent] = useState<number>(0)
useEffect(() => {
if (!itemId) {
return
}
const cleanup = window.electron.ipcRenderer.on(
IpcChannel.DirectoryProcessingPercent,
(_, { itemId: id, percent }: { itemId: string; percent: number }) => {
if (itemId === id) {
setPercent(percent)
}
}
)
return () => {
cleanup()
}
}, [itemId])
return percent
}
// 清除已完成的项目
const clearCompleted = () => {
dispatch(clearCompletedProcessing({ baseId }))
+13 -13
View File
@@ -1564,14 +1564,14 @@
"search_provider": "搜索服务商",
"search_provider_placeholder": "选择一个搜索服务商",
"subscribe": "黑名单订阅",
"subscribe_update": "立即更新",
"subscribe_add": "添加订阅",
"subscribe_url": "订阅源地址",
"subscribe_name": "替代名字",
"subscribe_name.placeholder": "当下载的订阅源没有名称时所使用的替代名称",
"subscribe_add_success": "订阅源添加成功!",
"subscribe_delete": "删除订阅源",
"search_result_default": "默认",
"subscribe_update": "立即更新",
"subscribe_add": "添加订阅",
"subscribe_url": "订阅源地址",
"subscribe_name": "替代名字",
"subscribe_name.placeholder": "当下载的订阅源没有名称时所使用的替代名称",
"subscribe_add_success": "订阅源添加成功!",
"subscribe_delete": "删除订阅源",
"search_result_default": "默认",
"search_with_time": "搜索包含日期",
"tavily": {
"api_key": "Tavily API 密钥",
@@ -1580,10 +1580,11 @@
"title": "Tavily"
},
"title": "网络搜索",
"apikey": "API 密钥",
"free": "免费",
"content_limit": "内容长度限制",
"content_limit_tooltip": "限制搜索结果的内容长度, 超过限制的内容将被截断"
"apikey": "API 密钥",
"free": "免费",
"content_limit": "内容长度限制",
"content_limit_tooltip": "限制搜索结果的内容长度, 超过限制的内容将被截断"
}
},
"quickPhrase": {
"title": "快捷短语",
@@ -1609,7 +1610,6 @@
"privacy": {
"title": "隐私设置",
"enable_privacy_mode": "匿名发送错误报告和数据统计"
}
},
"topic.position": "话题位置",
"topic.position.left": "左侧",
@@ -10,6 +10,7 @@ import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import ListItem from '@renderer/components/ListItem'
import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
import db from '@renderer/databases'
import { useProviders } from '@renderer/hooks/useProvider'
import FileManager from '@renderer/services/FileManager'
import store from '@renderer/store'
import { FileType, FileTypes } from '@renderer/types'
@@ -33,6 +34,7 @@ const FilesPage: FC = () => {
const [fileType, setFileType] = useState<string>('document')
const [sortField, setSortField] = useState<SortField>('created_at')
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
const { providers } = useProviders()
const mistralProviders = providers.filter((provider) => provider.type === 'mistral')
const tempFilesSort = (files: FileType[]) => {
return files.sort((a, b) => {
+10 -134
View File
@@ -1,148 +1,24 @@
import { DeleteOutlined } from '@ant-design/icons'
import type { File } from '@google/genai'
import { useProvider } from '@renderer/hooks/useProvider'
import { runAsyncFunction } from '@renderer/utils'
import { MB } from '@shared/config/constant'
import { Spin } from 'antd'
import dayjs from 'dayjs'
import { FC, useCallback, useEffect, useState } from 'react'
import styled from 'styled-components'
import { FC } from 'react'
import FileItem from './FileItem'
import RemoteFileList from './RemoteFileList'
interface GeminiFilesProps {
id: string
}
const GeminiFiles: FC<GeminiFilesProps> = ({ id }) => {
const { provider } = useProvider(id)
const [files, setFiles] = useState<File[]>([])
const [loading, setLoading] = useState(false)
const fetchFiles = useCallback(async () => {
const reponses = await window.api.fileService.list(provider.type, provider.apiKey)
const files = reponses.files.map((file) => file.originalFile as FileMetadataResponse)
setFiles(files)
}, [provider])
const columns: ColumnsType<FileMetadataResponse> = [
{
title: t('files.name'),
dataIndex: 'displayName',
key: 'displayName'
},
{
title: t('files.type'),
dataIndex: 'mimeType',
key: 'mimeType'
},
{
title: t('files.size'),
dataIndex: 'sizeBytes',
key: 'sizeBytes',
render: (size: string) => `${(parseInt(size) / 1024 / 1024).toFixed(2)} MB`
},
{
title: t('files.created_at'),
dataIndex: 'createTime',
key: 'createTime',
render: (time: string) => new Date(time).toLocaleString()
},
{
title: t('files.actions'),
dataIndex: 'actions',
key: 'actions',
align: 'center',
render: (_, record) => {
return (
<DeleteOutlined
style={{ cursor: 'pointer', color: 'var(--color-error)' }}
onClick={() => {
setFiles(files.filter((file) => file.name !== record.name))
window.api.fileService.delete(provider.type, provider.apiKey, record.name).catch((error) => {
console.error('Failed to delete file:', error)
setFiles((prev) => [...prev, record])
})
}}
/>
)
}
}
]
useEffect(() => {
runAsyncFunction(async () => {
try {
setLoading(true)
await fetchFiles()
setLoading(false)
} catch (error: any) {
console.error('Failed to fetch files:', error)
window.message.error(error.message)
setLoading(false)
}
})
}, [fetchFiles])
useEffect(() => {
setFiles([])
}, [id])
if (loading) {
return (
<Container>
<LoadingWrapper>
<Spin />
</LoadingWrapper>
</Container>
)
const formatFileDate = (date: string | number): string => {
return dayjs(date).format('MM-DD HH:mm')
}
return (
<Container>
<FileListContainer>
{files.map((file) => (
<FileItem
key={file.name}
fileInfo={{
name: file.displayName,
ext: `.${file.name?.split('.').pop()}`,
extra: `${dayjs(file.createTime).format('MM-DD HH:mm')} · ${(parseInt(file.sizeBytes || '0') / MB).toFixed(2)} MB`,
actions: (
<DeleteOutlined
style={{ cursor: 'pointer', color: 'var(--color-error)' }}
onClick={() => {
setFiles(files.filter((f) => f.name !== file.name))
window.api.gemini.deleteFile(file.name!, provider.apiKey).catch((error) => {
console.error('Failed to delete file:', error)
setFiles((prev) => [...prev, file])
})
}}
/>
)
}}
/>
))}
</FileListContainer>
</Container>
)
const formatFileSize = (size: string | number): string => {
const sizeNumber = typeof size === 'string' ? parseInt(size) : size
return `${(sizeNumber / MB).toFixed(2)} MB`
}
return <RemoteFileList id={id} formatFileDate={formatFileDate} formatFileSize={formatFileSize} />
}
const Container = styled.div`
width: 100%;
`
const FileListContainer = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`
const LoadingWrapper = styled.div`
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
`
export default GeminiFiles
+13 -87
View File
@@ -1,99 +1,25 @@
import { DeleteOutlined } from '@ant-design/icons'
import { type FileSchema } from '@mistralai/mistralai/src/models/components'
import { useProvider } from '@renderer/hooks/useProvider'
import { runAsyncFunction } from '@renderer/utils'
import { Table } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { FC, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { FC } from 'react'
import RemoteFileList from './RemoteFileList'
interface MistralFilesProps {
id: string
}
const MistralFiles: FC<MistralFilesProps> = ({ id }) => {
const { provider } = useProvider(id)
const { t } = useTranslation()
const [files, setFiles] = useState<FileSchema[]>([])
const [loading, setLoading] = useState(false)
const fetchFiles = useCallback(async () => {
const response = await window.api.fileService.list(provider.type, provider.apiKey)
const files = response.files.map((file) => file.originalFile as FileSchema)
setFiles(files)
}, [provider])
const columns: ColumnsType<FileSchema> = [
{
title: t('files.name'),
dataIndex: 'filename',
key: 'filename'
},
{
title: t('files.type'),
dataIndex: 'object',
key: 'object'
},
{
title: t('files.size'),
dataIndex: 'sizeBytes',
key: 'sizeBytes',
render: (size: string) => `${(parseInt(size) / 1024 / 1024).toFixed(2)} MB`
},
{
title: t('files.created_at'),
dataIndex: 'createdAt',
key: 'createdAt',
render: (time: number) => new Date(time * 1000).toLocaleString()
},
{
title: t('files.actions'),
dataIndex: 'actions',
key: 'actions',
align: 'center',
render: (_, record) => {
return (
<DeleteOutlined
style={{ cursor: 'pointer', color: 'var(--color-error)' }}
onClick={() => {
setFiles(files.filter((file) => file.id !== record.id))
window.api.fileService.delete(provider.type, provider.apiKey, record.id).catch((error) => {
console.error('Failed to delete file:', error)
setFiles((prev) => [...prev, record])
})
}}
/>
)
}
const formatFileDate = (date: string | number): string => {
if (typeof date === 'number') {
return new Date(date * 1000).toLocaleString()
}
]
return new Date(date).toLocaleString()
}
useEffect(() => {
runAsyncFunction(async () => {
try {
setLoading(true)
await fetchFiles()
setLoading(false)
} catch (error: any) {
console.error('Failed to fetch files:', error)
window.message.error(error.message)
setLoading(false)
}
})
}, [fetchFiles])
const formatFileSize = (size: string | number): string => {
const sizeNumber = typeof size === 'string' ? parseInt(size) : size
return `${(sizeNumber / 1024 / 1024).toFixed(2)} MB`
}
useEffect(() => {
setFiles([])
}, [id])
return (
<Container>
<Table columns={columns} dataSource={files} rowKey="name" loading={loading} />
</Container>
)
return <RemoteFileList id={id} formatFileDate={formatFileDate} formatFileSize={formatFileSize} />
}
const Container = styled.div``
export default MistralFiles
@@ -0,0 +1,152 @@
import { DeleteOutlined } from '@ant-design/icons'
import { useProvider } from '@renderer/hooks/useProvider'
import { isGeminiFile, isMistralFile, RemoteFile } from '@renderer/types'
import { runAsyncFunction } from '@renderer/utils'
import { Spin } from 'antd'
import { FC, useCallback, useEffect, useState } from 'react'
import styled from 'styled-components'
import FileItem from './FileItem'
interface RemoteFileListProps {
id: string
formatFileDate: (date: string | number) => string
formatFileSize: (size: string | number) => string
}
const RemoteFileList: FC<RemoteFileListProps> = ({ id, formatFileDate, formatFileSize }) => {
const { provider } = useProvider(id)
const [files, setFiles] = useState<RemoteFile[]>([])
const [loading, setLoading] = useState(false)
const getFileName = (file: RemoteFile): string => {
if (isGeminiFile(file)) {
return file.file.displayName || ''
} else if (isMistralFile(file)) {
return file.file.filename
} else {
return ''
}
}
const getFileId = (file: RemoteFile): string => {
if (isGeminiFile(file)) {
return file.file.name || ''
} else if (isMistralFile(file)) {
return file.file.id || ''
} else {
return ''
}
}
const getFileDate = (file: RemoteFile): string | number => {
if (isGeminiFile(file)) {
return file.file.createTime || new Date().toISOString()
} else if (isMistralFile(file)) {
return file.file.createdAt || new Date().toISOString()
} else {
return new Date().toISOString()
}
}
const getFileSize = (file: RemoteFile): string | number => {
return file.file.sizeBytes || '0'
}
// Use provided formatters
const formatDateFn = formatFileDate
const formatSizeFn = formatFileSize
const getFileNameFn = getFileName
const getFileIdFn = getFileId
const fetchFiles = useCallback(async () => {
const type = provider.type
const response = await window.api.fileService.list(type, provider.apiKey)
const fetchedFiles = response.files.filter(Boolean).map((file) => file.originalFile)
setFiles(fetchedFiles)
}, [provider.apiKey, provider.type])
useEffect(() => {
runAsyncFunction(async () => {
try {
setLoading(true)
await fetchFiles()
setLoading(false)
} catch (error: any) {
console.error('Failed to fetch files:', error)
window.message.error(error.message)
setLoading(false)
}
})
}, [fetchFiles])
useEffect(() => {
setFiles([])
}, [id])
const handleDeleteFile = (file: RemoteFile) => {
const fileId = getFileIdFn(file)
setFiles(files.filter((f) => getFileIdFn(f) !== fileId))
window.api.fileService.delete(file.type, provider.apiKey, fileId).catch((error) => {
console.error('Failed to delete file:', error)
setFiles((prev) => [...prev, file])
})
}
if (loading) {
return (
<LoadingWrapper>
<Spin />
</LoadingWrapper>
)
}
return (
<Container>
<FileListContainer>
{files.map((file) => {
const fileName = getFileNameFn(file)
const fileExt = `.${fileName?.split('.').pop()}`
return (
<FileItem
key={getFileIdFn(file)}
fileInfo={{
name: fileName,
ext: fileExt,
extra: `${formatDateFn(getFileDate(file))} · ${formatSizeFn(getFileSize(file))}`,
actions: (
<DeleteOutlined
style={{ cursor: 'pointer', color: 'var(--color-error)' }}
onClick={() => handleDeleteFile(file)}
/>
)
}}
/>
)
})}
</FileListContainer>
</Container>
)
}
const Container = styled.div`
width: 100%;
`
const FileListContainer = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`
const LoadingWrapper = styled.div`
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
`
export default RemoteFileList
@@ -15,7 +15,7 @@ import { Alert, Button, Dropdown, Empty, message, Tag, Tooltip, Upload } from 'a
import dayjs from 'dayjs'
import { ChevronsDown, ChevronsUp, Plus, Search, Settings2 } from 'lucide-react'
import VirtualList from 'rc-virtual-list'
import { FC, useState, useEffect, useState } from 'react'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -247,7 +247,7 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
<Button
type="text"
icon={<Settings2 size={18} color="var(--color-icon)" />}
onClick={() => KnowledgeSettingsPopup.show({ base })}
onClick={() => KnowledgeSettings.show({ base })}
size="small"
/>
<div className="model-row">
@@ -361,6 +361,7 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
base={base}
getProcessingStatus={getProcessingStatus}
type="file"
progress={progressMap.get(item.id)}
/>
</StatusIconWrapper>
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
@@ -415,7 +416,6 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
sourceId={item.id}
base={base}
getProcessingStatus={getProcessingStatus}
getProcessingPercent={getProgressingPercentForItem}
type="directory"
/>
</StatusIconWrapper>
@@ -1,343 +0,0 @@
import { DownOutlined, WarningOutlined } from '@ant-design/icons'
import { TopView } from '@renderer/components/TopView'
import { DEFAULT_KNOWLEDGE_DOCUMENT_COUNT } from '@renderer/config/constant'
import { getEmbeddingMaxContext } from '@renderer/config/embedings'
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
import { SUPPORTED_REANK_PROVIDERS } from '@renderer/config/providers'
import { useKnowledge } from '@renderer/hooks/useKnowledge'
import { useProviders } from '@renderer/hooks/useProvider'
import { SettingHelpText } from '@renderer/pages/settings'
import { getModelUniqId } from '@renderer/services/ModelService'
import { KnowledgeBase } from '@renderer/types'
import { Alert, Form, Input, InputNumber, Modal, Select, Slider } from 'antd'
import { sortBy } from 'lodash'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface ShowParams {
base: KnowledgeBase
}
interface FormData {
name: string
model: string
documentCount?: number
dimensions?: number
chunkSize?: number
chunkOverlap?: number
threshold?: number
rerankModel?: string
topN?: number
}
interface Props extends ShowParams {
resolve: (data: any) => void
}
const PopupContainer: React.FC<Props> = ({ base: _base, resolve }) => {
const [open, setOpen] = useState(true)
const [showAdvanced, setShowAdvanced] = useState(false)
const [form] = Form.useForm<FormData>()
const { t } = useTranslation()
const { providers } = useProviders()
const { base, updateKnowledgeBase } = useKnowledge(_base.id)
useEffect(() => {
form.setFieldsValue({ documentCount: base?.documentCount || 6 })
}, [base, form])
if (!base) {
resolve(null)
return null
}
const selectOptions = providers
.filter((p) => p.models.length > 0)
.map((p) => ({
label: p.isSystem ? t(`provider.${p.id}`) : p.name,
title: p.name,
options: sortBy(p.models, 'name')
.filter((model) => isEmbeddingModel(model) && !isRerankModel(model))
.map((m) => ({
label: m.name,
value: getModelUniqId(m)
}))
}))
.filter((group) => group.options.length > 0)
const rerankSelectOptions = providers
.filter((p) => p.models.length > 0)
.filter((p) => SUPPORTED_REANK_PROVIDERS.includes(p.id))
.map((p) => ({
label: p.isSystem ? t(`provider.${p.id}`) : p.name,
title: p.name,
options: sortBy(p.models, 'name')
.filter((model) => isRerankModel(model))
.map((m) => ({
label: m.name,
value: getModelUniqId(m)
}))
}))
.filter((group) => group.options.length > 0)
const onOk = async () => {
try {
const values = await form.validateFields()
const newBase = {
...base,
name: values.name,
documentCount: values.documentCount || DEFAULT_KNOWLEDGE_DOCUMENT_COUNT,
dimensions: values.dimensions || base.dimensions,
chunkSize: values.chunkSize,
chunkOverlap: values.chunkOverlap,
threshold: values.threshold ?? undefined,
rerankModel: values.rerankModel
? providers.flatMap((p) => p.models).find((m) => getModelUniqId(m) === values.rerankModel)
: undefined,
topN: values.topN
}
updateKnowledgeBase(newBase)
setOpen(false)
setTimeout(() => resolve(newBase), 350)
} catch (error) {
console.error('Validation failed:', error)
}
}
const onCancel = () => {
setOpen(false)
}
const onClose = () => {
resolve(null)
}
KnowledgeSettingsPopup.hide = onCancel
return (
<Modal
title={t('knowledge.settings.title')}
open={open}
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
destroyOnClose
maskClosable={false}
centered>
<Form form={form} layout="vertical" className="compact-form">
<Form.Item
name="name"
label={t('common.name')}
initialValue={base.name}
rules={[{ required: true, message: t('message.error.enter.name') }]}>
<Input placeholder={t('common.name')} />
</Form.Item>
<Form.Item
name="model"
label={t('models.embedding_model')}
initialValue={getModelUniqId(base.model)}
tooltip={{ title: t('models.embedding_model_tooltip'), placement: 'right' }}
rules={[{ required: true, message: t('message.error.enter.model') }]}>
<Select style={{ width: '100%' }} options={selectOptions} placeholder={t('settings.models.empty')} disabled />
</Form.Item>
<Form.Item
name="rerankModel"
label={t('models.rerank_model')}
tooltip={{ title: t('models.rerank_model_tooltip'), placement: 'right' }}
initialValue={getModelUniqId(base.rerankModel) || undefined}
rules={[{ required: false, message: t('message.error.enter.model') }]}>
<Select
style={{ width: '100%' }}
options={rerankSelectOptions}
placeholder={t('settings.models.empty')}
allowClear
/>
</Form.Item>
<SettingHelpText style={{ marginTop: -15, marginBottom: 20 }}>
{t('models.rerank_model_support_provider', {
provider: SUPPORTED_REANK_PROVIDERS.map((id) => t(`provider.${id}`))
})}
</SettingHelpText>
<Form.Item
name="documentCount"
label={t('knowledge.document_count')}
tooltip={{ title: t('knowledge.document_count_help') }}>
<Slider
style={{ width: '100%' }}
min={1}
max={30}
step={1}
marks={{ 1: '1', 6: t('knowledge.document_count_default'), 30: '30' }}
/>
</Form.Item>
<AdvancedSettingsButton onClick={() => setShowAdvanced(!showAdvanced)}>
<DownOutlined
style={{
transform: showAdvanced ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.3s',
marginRight: 8
}}
/>
{t('common.advanced_settings')}
</AdvancedSettingsButton>
<div style={{ display: showAdvanced ? 'block' : 'none' }}>
<Form.Item
name="dimensions"
label={t('knowledge.dimensions')}
layout="horizontal"
initialValue={base.dimensions}
tooltip={{ title: t('knowledge.dimensions_size_tooltip') }}
rules={[
{
validator(_, value) {
const maxContext = getEmbeddingMaxContext(base.model.id)
if (value && maxContext && value > maxContext) {
return Promise.reject(
new Error(t('knowledge.dimensions_size_too_large', { max_context: maxContext }))
)
}
return Promise.resolve()
}
}
]}>
<InputNumber
style={{ width: '100%' }}
defaultValue={base.dimensions}
placeholder={t('knowledge.dimensions_size_placeholder')}
disabled={base.model.id !== 'voyage-3-large'}
/>
</Form.Item>
<Form.Item
name="chunkSize"
label={t('knowledge.chunk_size')}
layout="horizontal"
tooltip={{ title: t('knowledge.chunk_size_tooltip') }}
initialValue={base.chunkSize}
rules={[
{
validator(_, value) {
const maxContext = getEmbeddingMaxContext(base.model.id)
if (value && maxContext && value > maxContext) {
return Promise.reject(new Error(t('knowledge.chunk_size_too_large', { max_context: maxContext })))
}
return Promise.resolve()
}
}
]}>
<InputNumber
style={{ width: '100%' }}
min={100}
defaultValue={base.chunkSize}
placeholder={t('knowledge.chunk_size_placeholder')}
/>
</Form.Item>
<Form.Item
name="chunkOverlap"
label={t('knowledge.chunk_overlap')}
layout="horizontal"
initialValue={base.chunkOverlap}
tooltip={{ title: t('knowledge.chunk_overlap_tooltip') }}
rules={[
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('chunkSize') > value) {
return Promise.resolve()
}
return Promise.reject(new Error(t('message.error.chunk_overlap_too_large')))
}
})
]}
dependencies={['chunkSize']}>
<InputNumber
style={{ width: '100%' }}
min={0}
defaultValue={base.chunkOverlap}
placeholder={t('knowledge.chunk_overlap_placeholder')}
/>
</Form.Item>
<Form.Item
name="threshold"
label={t('knowledge.threshold')}
layout="horizontal"
tooltip={{ title: t('knowledge.threshold_tooltip') }}
initialValue={base.threshold}
rules={[
{
validator(_, value) {
if (value && (value > 1 || value < 0)) {
return Promise.reject(new Error(t('knowledge.threshold_too_large_or_small')))
}
return Promise.resolve()
}
}
]}>
<InputNumber placeholder={t('knowledge.threshold_placeholder')} step={0.1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="topN"
label={t('knowledge.topN')}
layout="horizontal"
initialValue={base.topN}
rules={[
{
validator(_, value) {
if (value && (value < 0 || value > 10)) {
return Promise.reject(new Error(t('knowledge.topN_too_large_or_small')))
}
return Promise.resolve()
}
}
]}>
<InputNumber placeholder={t('knowledge.topN_placeholder')} style={{ width: '100%' }} />
</Form.Item>
<Alert
message={t('knowledge.chunk_size_change_warning')}
type="warning"
showIcon
icon={<WarningOutlined />}
/>
</div>
</Form>
</Modal>
)
}
const TopViewKey = 'KnowledgeSettingsPopup'
const AdvancedSettingsButton = styled.div`
cursor: pointer;
margin-bottom: 16px;
margin-top: -10px;
color: var(--color-primary);
display: flex;
align-items: center;
`
export default class KnowledgeSettingsPopup {
static hide() {
TopView.hide(TopViewKey)
}
static show(props: ShowParams) {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
{...props}
resolve={(v) => {
resolve(v)
TopView.hide(TopViewKey)
}}
/>,
TopViewKey
)
})
}
}
@@ -10,10 +10,10 @@ import {
LayoutGrid,
MonitorCog,
Package,
PenTool,
Rocket,
Settings2,
SquareTerminal,
Tool,
Zap
} from 'lucide-react'
// 导入useAppSelector
@@ -69,9 +69,9 @@ const SettingsPage: FC = () => {
{t('settings.websearch.title')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/tools">
<MenuItem className={isRoute('/settings/tools')}>
<Tool size={18} />
<MenuItemLink to="/settings/tool">
<MenuItem className={isRoute('/settings/tool')}>
<PenTool size={18} />
{t('settings.tool.title')}
</MenuItem>
</MenuItemLink>
@@ -46,7 +46,7 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
try {
new URL(url)
} catch (e) {
window.message.error(t('settings.websearch.url_invalid'))
window.message.error(t('settings.tool.websearch.url_invalid'))
return
}
@@ -70,7 +70,7 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
colon={false}
style={{ marginTop: 25 }}
onFinish={onFinish}>
<Form.Item name="url" label={t('settings.websearch.subscribe_url')} rules={[{ required: true }]}>
<Form.Item name="url" label={t('settings.tool.websearch.subscribe_url')} rules={[{ required: true }]}>
<Input
placeholder="https://git.io/ublacklist"
spellCheck={false}
@@ -1,9 +1,9 @@
import { InfoCircleOutlined } from '@ant-design/icons'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setEnhanceMode, setMaxResult, setSearchWithTime } from '@renderer/store/websearch'
import { Slider, Switch, Tooltip } from 'antd'
import { setContentLimit, setMaxResult, setSearchWithTime } from '@renderer/store/websearch'
import { Input, Slider, Switch, Tooltip } from 'antd'
import { t } from 'i18next'
import { Info } from 'lucide-react'
import { FC } from 'react'
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '../..'
@@ -11,43 +11,57 @@ import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle
const BasicSettings: FC = () => {
const { theme } = useTheme()
const searchWithTime = useAppSelector((state) => state.websearch.searchWithTime)
const enhanceMode = useAppSelector((state) => state.websearch.enhanceMode)
const maxResults = useAppSelector((state) => state.websearch.maxResults)
const contentLimit = useAppSelector((state) => state.websearch.contentLimit)
const dispatch = useAppDispatch()
return (
<SettingGroup theme={theme} style={{ paddingBottom: 8 }}>
<SettingTitle>{t('settings.general.title')}</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.tool.websearch.search_with_time')}</SettingRowTitle>
<Switch checked={searchWithTime} onChange={(checked) => dispatch(setSearchWithTime(checked))} />
</SettingRow>
<SettingDivider style={{ marginTop: 15, marginBottom: 12 }} />
<SettingRow>
<SettingRowTitle>
{t('settings.tool.websearch.enhance_mode')}
<Tooltip title={t('settings.tool.websearch.enhance_mode_tooltip')} placement="right">
<InfoCircleOutlined style={{ marginLeft: 5, color: 'var(--color-icon)', cursor: 'pointer' }} />
</Tooltip>
</SettingRowTitle>
<Switch checked={enhanceMode} onChange={(checked) => dispatch(setEnhanceMode(checked))} />
</SettingRow>
<SettingDivider style={{ marginTop: 15, marginBottom: 12 }} />
<SettingRow>
<SettingRowTitle>{t('settings.tool.websearch.search_max_result')}</SettingRowTitle>
<Slider
defaultValue={maxResults}
style={{ width: '200px' }}
min={1}
max={20}
step={1}
marks={{ 1: '1', 5: t('settings.tool.websearch.search_result_default'), 20: '20' }}
onChangeComplete={(value) => dispatch(setMaxResult(value))}
/>
</SettingRow>
</SettingGroup>
<>
<SettingGroup theme={theme} style={{ paddingBottom: 8 }}>
<SettingTitle>{t('settings.general.title')}</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.websearch.search_with_time')}</SettingRowTitle>
<Switch checked={searchWithTime} onChange={(checked) => dispatch(setSearchWithTime(checked))} />
</SettingRow>
<SettingDivider style={{ marginTop: 15, marginBottom: 10 }} />
<SettingRow style={{ height: 40 }}>
<SettingRowTitle>{t('settings.websearch.search_max_result')}</SettingRowTitle>
<Slider
defaultValue={maxResults}
style={{ width: '200px' }}
min={1}
max={20}
step={1}
marks={{ 1: '1', 5: t('settings.websearch.search_result_default'), 20: '20' }}
onChangeComplete={(value) => dispatch(setMaxResult(value))}
/>
</SettingRow>
<SettingDivider style={{ marginTop: 15, marginBottom: 10 }} />
<SettingRow>
<SettingRowTitle>
{t('settings.websearch.content_limit')}
<Tooltip title={t('settings.websearch.content_limit_tooltip')} placement="right">
<Info size={16} color="var(--color-icon)" style={{ marginLeft: 5, cursor: 'pointer' }} />
</Tooltip>
</SettingRowTitle>
<Input
style={{ width: '100px' }}
placeholder="2000"
value={contentLimit === undefined ? '' : contentLimit}
onChange={(e) => {
const value = e.target.value
if (value === '') {
dispatch(setContentLimit(undefined))
} else if (!isNaN(Number(value)) && Number(value) > 0) {
dispatch(setContentLimit(Number(value)))
}
}}
/>
</SettingRow>
</SettingGroup>
</>
)
}
export default BasicSettings
@@ -9,7 +9,7 @@ import TextArea from 'antd/es/input/TextArea'
import { t } from 'i18next'
import { FC, useEffect, useState } from 'react'
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '../..'
import AddSubscribePopup from './AddSubscribePopup'
type TableRowSelection<T extends object = object> = TableProps<T>['rowSelection']
@@ -27,7 +27,6 @@ const columns: TableProps<DataType>['columns'] = [
key: 'url'
}
]
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '../..'
const BlacklistSettings: FC = () => {
const [errFormat, setErrFormat] = useState(false)
@@ -12,7 +12,14 @@ import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { SettingDivider, SettingHelpLink, SettingHelpText, SettingHelpTextRow, SettingSubtitle, SettingTitle } from '../..'
import {
SettingDivider,
SettingHelpLink,
SettingHelpText,
SettingHelpTextRow,
SettingSubtitle,
SettingTitle
} from '../..'
import ApiCheckPopup from '../../ProviderSettings/ApiCheckPopup'
interface Props {
@@ -44,7 +44,7 @@ const WebSearchSettings: FC = () => {
placeholder={t('settings.tool.websearch.search_provider_placeholder')}
options={providers.map((p) => ({
value: p.id,
label: `${p.name} (${hasObjectKey(p, 'apiKey') ? t('settings.websearch.apikey') : t('settings.websearch.free')})`
label: `${p.name} (${hasObjectKey(p, 'apiKey') ? t('settings.tool.websearch.apikey') : t('settings.tool.websearch.free')})`
}))}
/>
</div>
@@ -1,67 +0,0 @@
import { useTheme } from '@renderer/context/ThemeProvider'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setContentLimit, setMaxResult, setSearchWithTime } from '@renderer/store/websearch'
import { Input, Slider, Switch, Tooltip } from 'antd'
import { t } from 'i18next'
import { Info } from 'lucide-react'
import { FC } from 'react'
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
const BasicSettings: FC = () => {
const { theme } = useTheme()
const searchWithTime = useAppSelector((state) => state.websearch.searchWithTime)
const maxResults = useAppSelector((state) => state.websearch.maxResults)
const contentLimit = useAppSelector((state) => state.websearch.contentLimit)
const dispatch = useAppDispatch()
return (
<>
<SettingGroup theme={theme} style={{ paddingBottom: 8 }}>
<SettingTitle>{t('settings.general.title')}</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.websearch.search_with_time')}</SettingRowTitle>
<Switch checked={searchWithTime} onChange={(checked) => dispatch(setSearchWithTime(checked))} />
</SettingRow>
<SettingDivider style={{ marginTop: 15, marginBottom: 10 }} />
<SettingRow style={{ height: 40 }}>
<SettingRowTitle>{t('settings.websearch.search_max_result')}</SettingRowTitle>
<Slider
defaultValue={maxResults}
style={{ width: '200px' }}
min={1}
max={20}
step={1}
marks={{ 1: '1', 5: t('settings.websearch.search_result_default'), 20: '20' }}
onChangeComplete={(value) => dispatch(setMaxResult(value))}
/>
</SettingRow>
<SettingDivider style={{ marginTop: 15, marginBottom: 10 }} />
<SettingRow>
<SettingRowTitle>
{t('settings.websearch.content_limit')}
<Tooltip title={t('settings.websearch.content_limit_tooltip')} placement="right">
<Info size={16} color="var(--color-icon)" style={{ marginLeft: 5, cursor: 'pointer' }} />
</Tooltip>
</SettingRowTitle>
<Input
style={{ width: '100px' }}
placeholder="2000"
value={contentLimit === undefined ? '' : contentLimit}
onChange={(e) => {
const value = e.target.value
if (value === '') {
dispatch(setContentLimit(undefined))
} else if (!isNaN(Number(value)) && Number(value) > 0) {
dispatch(setContentLimit(Number(value)))
}
}}
/>
</SettingRow>
</SettingGroup>
</>
)
}
export default BasicSettings
@@ -37,6 +37,7 @@ import {
EFFORT_RATIO,
FileType,
FileTypes,
FileUploadResponse,
MCPCallToolResponse,
MCPTool,
MCPToolResponse,
@@ -88,17 +89,18 @@ export default class GeminiProvider extends BaseProvider {
const isSmallFile = file.size < smallFileSize
if (isSmallFile) {
const { data, mimeType } = await window.api.gemini.base64File(file)
const { data, mime } = await window.api.file.base64File(file.path)
return {
inlineData: {
data,
mimeType
mime
} as Part['inlineData']
}
}
// Retrieve file from Gemini uploaded files
const fileMetadata: File | undefined = await window.api.gemini.retrieveFile(file, this.apiKey)
const response: FileUploadResponse = await window.api.fileService.retrieve(this.provider.type, this.apiKey, file.id)
const fileMetadata = response.originalFile as File
if (fileMetadata) {
return {
@@ -110,7 +112,7 @@ export default class GeminiProvider extends BaseProvider {
}
// If file is not found, upload it to Gemini
const result = await window.api.gemini.uploadFile(file, this.apiKey)
const result = (await window.api.fileService.upload(this.provider.type, this.apiKey, file)).originalFile as File
return {
fileData: {
@@ -1,827 +0,0 @@
import {
ContentListUnion,
createPartFromBase64,
FinishReason,
GenerateContentResponse,
GoogleGenAI
} from '@google/genai'
import {
Content,
FileDataPart,
FunctionCallPart,
FunctionResponsePart,
GenerateContentStreamResult,
GoogleGenerativeAI,
HarmBlockThreshold,
HarmCategory,
InlineDataPart,
Part,
RequestOptions,
SafetySetting,
TextPart
} from '@google/generative-ai'
import { isGemmaModel, isWebSearchModel } from '@renderer/config/models'
import { getStoreSetting } from '@renderer/hooks/useSettings'
import i18n from '@renderer/i18n'
import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/AssistantService'
import { EVENT_NAMES } from '@renderer/services/EventService'
import {
filterContextMessages,
filterEmptyMessages,
filterUserRoleStartMessages
} from '@renderer/services/MessagesService'
import {
Assistant,
FileType,
FileTypes,
FileUploadResponse,
MCPToolResponse,
Message,
Model,
Provider,
Suggestion
} from '@renderer/types'
import { removeSpecialCharactersForTopicName } from '@renderer/utils'
import { fileToBase64 } from '@renderer/utils/file'
import {
callMCPTool,
geminiFunctionCallToMcpTool,
mcpToolsToGeminiTools,
upsertMCPToolResponse
} from '@renderer/utils/mcp-tools'
import axios from 'axios'
import { isEmpty, takeRight } from 'lodash'
import OpenAI from 'openai'
import { ChunkCallbackData, CompletionsParams } from '.'
import BaseProvider from './BaseProvider'
export default class GeminiProvider extends BaseProvider {
private sdk: GoogleGenerativeAI
private requestOptions: RequestOptions
private imageSdk: GoogleGenAI
constructor(provider: Provider) {
super(provider)
this.sdk = new GoogleGenerativeAI(this.apiKey)
/// this sdk is experimental
this.imageSdk = new GoogleGenAI({ apiKey: this.apiKey })
this.requestOptions = {
baseUrl: this.getBaseURL()
}
}
public getBaseURL(): string {
return this.provider.apiHost
}
/**
* Handle a PDF file
* @param file - The file
* @returns The part
*/
private async handlePdfFile(file: FileType): Promise<Part> {
const smallFileSize = 20 * 1024 * 1024
const isSmallFile = file.size < smallFileSize
if (isSmallFile) {
const { data, mimeType } = await fileToBase64(file.path)
return {
inlineData: {
data: data,
mimeType
}
} as InlineDataPart
}
// 尝试检索文件
const response: FileUploadResponse = await window.api.fileService.retrieve(this.provider.type, this.apiKey, file.id)
if (response && response.status === 'success') {
return {
fileData: {
fileUri: response.originalFile.uri,
mimeType: response.originalFile.mimeType
}
} as FileDataPart
} else {
console.log('file not found', response)
}
// 如果文件不存在,上传新文件
const uploadResponse: FileUploadResponse = await window.api.fileService.upload(
this.provider.type,
this.apiKey,
file
)
return {
fileData: {
fileUri: uploadResponse.originalFile.file.uri,
mimeType: uploadResponse.originalFile.file.mimeType
}
} as FileDataPart
}
/**
* Get the message contents
* @param message - The message
* @returns The message contents
*/
private async getMessageContents(message: Message): Promise<Content> {
const role = message.role === 'user' ? 'user' : 'model'
const parts: Part[] = [{ text: await this.getMessageContent(message) }]
// Add any generated images from previous responses
if (message.metadata?.generateImage?.images && message.metadata.generateImage.images.length > 0) {
for (const imageUrl of message.metadata.generateImage.images) {
if (imageUrl && imageUrl.startsWith('data:')) {
// Extract base64 data and mime type from the data URL
const matches = imageUrl.match(/^data:(.+);base64,(.*)$/)
if (matches && matches.length === 3) {
const mimeType = matches[1]
const base64Data = matches[2]
parts.push({
inlineData: {
data: base64Data,
mimeType: mimeType
}
} as InlineDataPart)
}
}
}
}
for (const file of message.files || []) {
if (file.type === FileTypes.IMAGE) {
const base64Data = await window.api.file.base64Image(file.id + file.ext)
parts.push({
inlineData: {
data: base64Data.base64,
mimeType: base64Data.mime
}
} as InlineDataPart)
}
if (file.ext === '.pdf') {
parts.push(await this.handlePdfFile(file))
continue
}
if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) {
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
parts.push({
text: file.origin_name + '\n' + fileContent
} as TextPart)
}
}
return {
role,
parts
}
}
/**
* Get the safety settings
* @param modelId - The model ID
* @returns The safety settings
*/
private getSafetySettings(modelId: string): SafetySetting[] {
const safetyThreshold = modelId.includes('gemini-2.0-flash-exp')
? ('OFF' as HarmBlockThreshold)
: HarmBlockThreshold.BLOCK_NONE
return [
{
category: HarmCategory.HARM_CATEGORY_HATE_SPEECH,
threshold: safetyThreshold
},
{
category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
threshold: safetyThreshold
},
{
category: HarmCategory.HARM_CATEGORY_HARASSMENT,
threshold: safetyThreshold
},
{
category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
threshold: safetyThreshold
},
{
category: 'HARM_CATEGORY_CIVIC_INTEGRITY' as HarmCategory,
threshold: safetyThreshold
}
]
}
/**
* Generate completions
* @param messages - The messages
* @param assistant - The assistant
* @param mcpTools - The MCP tools
* @param onChunk - The onChunk callback
* @param onFilterMessages - The onFilterMessages callback
*/
public async completions({ messages, assistant, mcpTools, onChunk, onFilterMessages }: CompletionsParams) {
if (assistant.enableGenerateImage) {
await this.generateImageExp({ messages, assistant, onFilterMessages, onChunk })
} else {
const defaultModel = getDefaultModel()
const model = assistant.model || defaultModel
const { contextCount, maxTokens, streamOutput } = getAssistantSettings(assistant)
const userMessages = filterUserRoleStartMessages(
filterEmptyMessages(filterContextMessages(takeRight(messages, contextCount + 2)))
)
onFilterMessages(userMessages)
const userLastMessage = userMessages.pop()
const history: Content[] = []
for (const message of userMessages) {
history.push(await this.getMessageContents(message))
}
const tools = mcpToolsToGeminiTools(mcpTools)
const toolResponses: MCPToolResponse[] = []
if (assistant.enableWebSearch && isWebSearchModel(model)) {
tools.push({
// @ts-ignore googleSearch is not a valid tool for Gemini
googleSearch: {}
})
}
const geminiModel = this.sdk.getGenerativeModel(
{
model: model.id,
...(isGemmaModel(model) ? {} : { systemInstruction: assistant.prompt }),
safetySettings: this.getSafetySettings(model.id),
tools: tools,
generationConfig: {
maxOutputTokens: maxTokens,
temperature: assistant?.settings?.temperature,
topP: assistant?.settings?.topP,
...this.getCustomParameters(assistant)
}
},
this.requestOptions
)
const chat = geminiModel.startChat({ history })
const messageContents = await this.getMessageContents(userLastMessage!)
if (isGemmaModel(model) && assistant.prompt) {
const isFirstMessage = history.length === 0
if (isFirstMessage) {
const systemMessage = {
role: 'user',
parts: [
{
text:
'<start_of_turn>user\n' +
assistant.prompt +
'<end_of_turn>\n' +
'<start_of_turn>user\n' +
messageContents.parts[0].text +
'<end_of_turn>'
}
]
}
messageContents.parts = systemMessage.parts
}
}
const start_time_millsec = new Date().getTime()
const { abortController, cleanup } = this.createAbortController(userLastMessage?.id)
const { signal } = abortController
if (!streamOutput) {
const { response } = await chat.sendMessage(messageContents.parts, { signal })
const time_completion_millsec = new Date().getTime() - start_time_millsec
onChunk({
text: response.candidates?.[0].content.parts[0].text,
usage: {
prompt_tokens: response.usageMetadata?.promptTokenCount || 0,
completion_tokens: response.usageMetadata?.candidatesTokenCount || 0,
total_tokens: response.usageMetadata?.totalTokenCount || 0
},
metrics: {
completion_tokens: response.usageMetadata?.candidatesTokenCount,
time_completion_millsec,
time_first_token_millsec: 0
},
search: response.candidates?.[0]?.groundingMetadata
})
return
}
const userMessagesStream = await chat.sendMessageStream(messageContents.parts, { signal })
let time_first_token_millsec = 0
const processStream = async (stream: GenerateContentStreamResult, idx: number) => {
for await (const chunk of stream.stream) {
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) break
if (time_first_token_millsec == 0) {
time_first_token_millsec = new Date().getTime() - start_time_millsec
}
const time_completion_millsec = new Date().getTime() - start_time_millsec
const functionCalls = chunk.functionCalls()
if (functionCalls) {
const fcallParts: FunctionCallPart[] = []
const fcRespParts: FunctionResponsePart[] = []
for (const call of functionCalls) {
console.log('Function call:', call)
fcallParts.push({ functionCall: call } as FunctionCallPart)
const mcpTool = geminiFunctionCallToMcpTool(mcpTools, call)
if (mcpTool) {
upsertMCPToolResponse(
toolResponses,
{
tool: mcpTool,
status: 'invoking',
id: `${call.name}-${idx}`
},
onChunk
)
const toolCallResponse = await callMCPTool(mcpTool)
fcRespParts.push({
functionResponse: {
name: mcpTool.id,
response: toolCallResponse
}
})
upsertMCPToolResponse(
toolResponses,
{
tool: mcpTool,
status: 'done',
response: toolCallResponse,
id: `${call.name}-${idx}`
},
onChunk
)
}
}
if (fcRespParts) {
history.push(messageContents)
history.push({
role: 'model',
parts: fcallParts
})
const newChat = geminiModel.startChat({ history })
const newStream = await newChat.sendMessageStream(fcRespParts, { signal })
await processStream(newStream, idx + 1)
}
}
onChunk({
text: chunk.text(),
usage: {
prompt_tokens: chunk.usageMetadata?.promptTokenCount || 0,
completion_tokens: chunk.usageMetadata?.candidatesTokenCount || 0,
total_tokens: chunk.usageMetadata?.totalTokenCount || 0
},
metrics: {
completion_tokens: chunk.usageMetadata?.candidatesTokenCount,
time_completion_millsec,
time_first_token_millsec
},
search: chunk.candidates?.[0]?.groundingMetadata,
mcpToolResponse: toolResponses
})
}
}
await processStream(userMessagesStream, 0).finally(cleanup)
}
}
/**
* Translate a message
* @param message - The message
* @param assistant - The assistant
* @param onResponse - The onResponse callback
* @returns The translated message
*/
async translate(message: Message, assistant: Assistant, onResponse?: (text: string) => void) {
const defaultModel = getDefaultModel()
const { maxTokens } = getAssistantSettings(assistant)
const model = assistant.model || defaultModel
const geminiModel = this.sdk.getGenerativeModel(
{
model: model.id,
...(isGemmaModel(model) ? {} : { systemInstruction: assistant.prompt }),
generationConfig: {
maxOutputTokens: maxTokens,
temperature: assistant?.settings?.temperature
}
},
this.requestOptions
)
const content =
isGemmaModel(model) && assistant.prompt
? `<start_of_turn>user\n${assistant.prompt}<end_of_turn>\n<start_of_turn>user\n${message.content}<end_of_turn>`
: message.content
if (!onResponse) {
const { response } = await geminiModel.generateContent(content)
return response.text()
}
const response = await geminiModel.generateContentStream(content)
let text = ''
for await (const chunk of response.stream) {
text += chunk.text()
onResponse(text)
}
return text
}
/**
* Summarize a message
* @param messages - The messages
* @param assistant - The assistant
* @returns The summary
*/
public async summaries(messages: Message[], assistant: Assistant): Promise<string> {
const model = getTopNamingModel() || assistant.model || getDefaultModel()
const userMessages = takeRight(messages, 5)
.filter((message) => !message.isPreset)
.map((message) => ({
role: message.role,
content: message.content
}))
const userMessageContent = userMessages.reduce((prev, curr) => {
const content = curr.role === 'user' ? `User: ${curr.content}` : `Assistant: ${curr.content}`
return prev + (prev ? '\n' : '') + content
}, '')
const systemMessage = {
role: 'system',
content: (getStoreSetting('topicNamingPrompt') as string) || i18n.t('prompts.title')
}
const userMessage = {
role: 'user',
content: userMessageContent
}
const geminiModel = this.sdk.getGenerativeModel(
{
model: model.id,
...(isGemmaModel(model) ? {} : { systemInstruction: systemMessage.content }),
generationConfig: {
temperature: assistant?.settings?.temperature
}
},
this.requestOptions
)
const chat = await geminiModel.startChat()
const content = isGemmaModel(model)
? `<start_of_turn>user\n${systemMessage.content}<end_of_turn>\n<start_of_turn>user\n${userMessage.content}<end_of_turn>`
: userMessage.content
const { response } = await chat.sendMessage(content)
return removeSpecialCharactersForTopicName(response.text())
}
/**
* Generate text
* @param prompt - The prompt
* @param content - The content
* @returns The generated text
*/
public async generateText({ prompt, content }: { prompt: string; content: string }): Promise<string> {
const model = getDefaultModel()
const systemMessage = { role: 'system', content: prompt }
const geminiModel = this.sdk.getGenerativeModel(
{
model: model.id,
...(isGemmaModel(model) ? {} : { systemInstruction: systemMessage.content })
},
this.requestOptions
)
const chat = await geminiModel.startChat()
const messageContent = isGemmaModel(model)
? `<start_of_turn>user\n${prompt}<end_of_turn>\n<start_of_turn>user\n${content}<end_of_turn>`
: content
const { response } = await chat.sendMessage(messageContent)
return response.text()
}
/**
* Generate suggestions
* @returns The suggestions
*/
public async suggestions(): Promise<Suggestion[]> {
return []
}
/**
* Summarize a message for search
* @param messages - The messages
* @param assistant - The assistant
* @returns The summary
*/
public async summaryForSearch(messages: Message[], assistant: Assistant): Promise<string> {
const model = assistant.model || getDefaultModel()
const systemMessage = {
role: 'system',
content: assistant.prompt
}
const userMessage = {
role: 'user',
content: messages.map((m) => m.content).join('\n')
}
const geminiModel = this.sdk.getGenerativeModel(
{
model: model.id,
systemInstruction: systemMessage.content,
generationConfig: {
temperature: assistant?.settings?.temperature
}
},
{
...this.requestOptions,
timeout: 20 * 1000
}
)
const chat = await geminiModel.startChat()
const { response } = await chat.sendMessage(userMessage.content)
return response.text()
}
/**
* Generate an image
* @returns The generated image
*/
public async generateImage(): Promise<string[]> {
return []
}
/**
* 生成图像
* @param messages - 消息列表
* @param assistant - 助手配置
* @param onChunk - 处理生成块的回调
* @param onFilterMessages - 过滤消息的回调
* @returns Promise<void>
*/
private async generateImageExp({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams): Promise<void> {
const defaultModel = getDefaultModel()
const model = assistant.model || defaultModel
const { contextCount, streamOutput, maxTokens } = getAssistantSettings(assistant)
const userMessages = filterUserRoleStartMessages(filterContextMessages(takeRight(messages, contextCount + 2)))
onFilterMessages(userMessages)
const userLastMessage = userMessages.pop()
if (!userLastMessage) {
throw new Error('No user message found')
}
const history: Content[] = []
for (const message of userMessages) {
history.push(await this.getMessageContents(message))
}
const userLastMessageContent = await this.getMessageContents(userLastMessage)
const allContents = [...history, userLastMessageContent]
let contents: ContentListUnion = allContents.length > 0 ? (allContents as ContentListUnion) : []
contents = await this.addImageFileToContents(userLastMessage, contents)
if (!streamOutput) {
const response = await this.callGeminiGenerateContent(model.id, contents, maxTokens)
const { isValid, message } = this.isValidGeminiResponse(response)
if (!isValid) {
throw new Error(`Gemini API error: ${message}`)
}
this.processGeminiImageResponse(response, onChunk)
return
}
const response = await this.callGeminiGenerateContentStream(model.id, contents, maxTokens)
for await (const chunk of response) {
this.processGeminiImageResponse(chunk, onChunk)
}
}
/**
* 添加图片文件到内容列表
* @param message - 用户消息
* @param contents - 内容列表
* @returns 更新后的内容列表
*/
private async addImageFileToContents(message: Message, contents: ContentListUnion): Promise<ContentListUnion> {
if (message.files && message.files.length > 0) {
const file = message.files[0]
const fileContent = await window.api.file.base64Image(file.id + file.ext)
if (fileContent && fileContent.base64) {
const contentsArray = Array.isArray(contents) ? contents : [contents]
return [...contentsArray, createPartFromBase64(fileContent.base64, fileContent.mime)]
}
}
return contents
}
/**
* 调用Gemini API生成内容
* @param modelId - 模型ID
* @param contents - 内容列表
* @returns 生成结果
*/
private async callGeminiGenerateContent(
modelId: string,
contents: ContentListUnion,
maxTokens?: number
): Promise<GenerateContentResponse> {
try {
return await this.imageSdk.models.generateContent({
model: modelId,
contents: contents,
config: {
responseModalities: ['Text', 'Image'],
responseMimeType: 'text/plain',
maxOutputTokens: maxTokens
}
})
} catch (error) {
console.error('Gemini API error:', error)
throw error
}
}
private async callGeminiGenerateContentStream(
modelId: string,
contents: ContentListUnion,
maxTokens?: number
): Promise<AsyncGenerator<GenerateContentResponse>> {
try {
return await this.imageSdk.models.generateContentStream({
model: modelId,
contents: contents,
config: {
responseModalities: ['Text', 'Image'],
responseMimeType: 'text/plain',
maxOutputTokens: maxTokens
}
})
} catch (error) {
console.error('Gemini API error:', error)
throw error
}
}
/**
* 检查Gemini响应是否有效
* @param response - Gemini响应
* @returns 是否有效
*/
private isValidGeminiResponse(response: GenerateContentResponse): { isValid: boolean; message: string } {
return {
isValid: response?.candidates?.[0]?.finishReason === FinishReason.STOP ? true : false,
message: response?.candidates?.[0]?.finishReason || ''
}
}
/**
* 处理Gemini图像响应
* @param response - Gemini响应
* @param onChunk - 处理生成块的回调
*/
private processGeminiImageResponse(response: any, onChunk: (chunk: ChunkCallbackData) => void): void {
const parts = response.candidates[0].content.parts
if (!parts) {
return
}
// 提取图像数据
const images = parts
.filter((part: Part) => part.inlineData)
.map((part: Part) => {
if (!part.inlineData) {
return null
}
const dataPrefix = `data:${part.inlineData.mimeType || 'image/png'};base64,`
return part.inlineData.data.startsWith('data:') ? part.inlineData.data : dataPrefix + part.inlineData.data
})
// 提取文本数据
const text = parts
.filter((part: Part) => part.text !== undefined)
.map((part: Part) => part.text)
.join('')
// 返回结果
onChunk({
text,
generateImage: {
images
},
usage: {
prompt_tokens: response.usageMetadata?.promptTokenCount || 0,
completion_tokens: response.usageMetadata?.candidatesTokenCount || 0,
total_tokens: response.usageMetadata?.totalTokenCount || 0
},
metrics: {
completion_tokens: response.usageMetadata?.candidatesTokenCount
}
})
}
/**
* Check if the model is valid
* @param model - The model
* @returns The validity of the model
*/
public async check(model: Model): Promise<{ valid: boolean; error: Error | null }> {
if (!model) {
return { valid: false, error: new Error('No model found') }
}
const body = {
model: model.id,
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 100,
stream: false
}
try {
const geminiModel = this.sdk.getGenerativeModel({ model: body.model }, this.requestOptions)
const result = await geminiModel.generateContent(body.messages[0].content)
return {
valid: !isEmpty(result.response.text()),
error: null
}
} catch (error: any) {
return {
valid: false,
error
}
}
}
/**
* Get the models
* @returns The models
*/
public async models(): Promise<OpenAI.Models.Model[]> {
try {
const api = this.provider.apiHost + '/v1beta/models'
const { data } = await axios.get(api, { params: { key: this.apiKey } })
return data.models.map(
(m) =>
({
id: m.name.replace('models/', ''),
name: m.displayName,
description: m.description,
object: 'model',
created: Date.now(),
owned_by: 'gemini'
}) as OpenAI.Models.Model
)
} catch (error) {
return []
}
}
/**
* Get the embedding dimensions
* @param model - The model
* @returns The embedding dimensions
*/
public async getEmbeddingDimensions(model: Model): Promise<number> {
const data = await this.sdk.getGenerativeModel({ model: model.id }, this.requestOptions).embedContent('hi')
return data.embedding.values.length
}
}
+39 -41
View File
@@ -1110,11 +1110,9 @@ const migrateConfig = {
} catch (error) {
console.error(error)
return state
},
}
return state
},
'87': (state: RootState) => {
try {
state.settings.maxKeepAliveMinapps = 3
@@ -1257,47 +1255,47 @@ const migrateConfig = {
'99': (state: RootState) => {
try {
if (!state.ocr) {
state.ocr = {
defaultProvider: '',
providers: []
}
}
if (state.ocr.providers.length === 0) {
state.ocr.providers = [
{
id: 'doc2x',
name: 'Doc2x',
apiKey: '',
apiHost: 'https://v2.doc2x.noedgeai.com'
},
{
id: 'mistral',
name: 'Mistral',
model: 'mistral-ocr-latest',
apiKey: '',
apiHost: 'https://api.mistral.ai'
state.ocr = {
defaultProvider: '',
providers: []
}
]
}
if (!state.ocr.providers.find((provider) => provider.id === 'system')) {
state.ocr.providers.push({
id: 'system',
name: 'System(Mac Only)',
options: {
recognitionLevel: 0,
minConfidence: 0.5
}
if (state.ocr.providers.length === 0) {
state.ocr.providers = [
{
id: 'doc2x',
name: 'Doc2x',
apiKey: '',
apiHost: 'https://v2.doc2x.noedgeai.com'
},
{
id: 'mistral',
name: 'Mistral',
model: 'mistral-ocr-latest',
apiKey: '',
apiHost: 'https://api.mistral.ai'
}
]
}
if (!state.ocr.providers.find((provider) => provider.id === 'system')) {
state.ocr.providers.push({
id: 'system',
name: 'System(Mac Only)',
options: {
recognitionLevel: 0,
minConfidence: 0.5
}
})
}
state.llm.providers.forEach((provider) => {
if (provider.id === 'mistral') {
provider.type = 'mistral'
}
})
}
state.llm.providers.forEach((provider) => {
if (provider.id === 'mistral') {
provider.type = 'mistral'
}
})
return state
} catch(error) {
return state
} catch (error) {
return state
}
}
+92
View File
@@ -0,0 +1,92 @@
import type { File } from '@google/genai'
import type { FileSchema } from '@mistralai/mistralai/models/components'
interface BaseFileSource {
id: string
name: string
type: FileTypes
size: number
ext: string
source: 'local' | 'remote'
}
export interface RemoteFileSource extends BaseFileSource {
source: 'remote'
url: string
status: 'pending' | 'downloading' | 'downloaded' | 'error'
downloadProgress?: number
localPath?: string // 下载后的本地路径
}
export interface RemoteFile {
type: 'gemini' | 'mistral'
file: File | FileSchema
}
/**
* Type guard to check if a RemoteFile is a Gemini file
* @param file - The RemoteFile to check
* @returns True if the file is a Gemini file (file property is of type File)
*/
export const isGeminiFile = (file: RemoteFile): file is RemoteFile & { type: 'gemini'; file: File } => {
return file.type === 'gemini'
}
/**
* Type guard to check if a RemoteFile is a Mistral file
* @param file - The RemoteFile to check
* @returns True if the file is a Mistral file (file property is of type FileSchema)
*/
export const isMistralFile = (file: RemoteFile): file is RemoteFile & { type: 'mistral'; file: FileSchema } => {
return file.type === 'mistral'
}
export interface FileUploadResponse {
fileId: string
displayName: string
status: 'success' | 'processing' | 'failed' | 'unknown'
originalFile?: RemoteFile // 保留原始响应,以备需要
}
export interface FileListResponse {
files: Array<{
id: string
displayName: string
size?: number
status: 'success' | 'processing' | 'failed' | 'unknown'
originalFile: RemoteFile // 保留原始文件对象
}>
}
export interface LocalFileSource extends BaseFileSource {
origin_name: string
path: string
created_at: string
count: number
tokens?: number
source: 'local'
}
// 联合类型,表示一个文件可以是本地的或远程的
export type FileSource = LocalFileSource | RemoteFileSource
// 为了保持向后兼容
export type FileType = LocalFileSource
// 类型保护函数,用于区分文件类型
export const isLocalFile = (file: FileSource): file is LocalFileSource & { source: 'local' } => {
return file.source === 'local'
}
export const isRemoteFile = (file: FileSource): file is RemoteFileSource & { source: 'remote' } => {
return file.source === 'remote'
}
export enum FileTypes {
IMAGE = 'image',
VIDEO = 'video',
AUDIO = 'audio',
TEXT = 'text',
DOCUMENT = 'document',
OTHER = 'other'
}
+10 -68
View File
@@ -4,6 +4,8 @@ import type OpenAI from 'openai'
import React from 'react'
import { BuiltinTheme } from 'shiki'
export * from './file'
import type { FileType } from './file'
import type { Message } from './newMessage'
export type Assistant = {
@@ -161,7 +163,14 @@ export type Provider = {
notes?: string
}
export type ProviderType = 'openai' | 'openai-compatible' | 'anthropic' | 'gemini' | 'qwenlm' | 'azure-openai' | 'mistral'
export type ProviderType =
| 'openai'
| 'openai-compatible'
| 'anthropic'
| 'gemini'
| 'qwenlm'
| 'azure-openai'
| 'mistral'
export type ModelType = 'text' | 'vision' | 'embedding' | 'reasoning' | 'function_calling' | 'web_search'
@@ -264,73 +273,6 @@ export type MinAppType = {
type?: 'Custom' | 'Default' // Added the 'type' property
}
interface BaseFileSource {
id: string
name: string
type: FileTypes
size: number
ext: string
source: 'local' | 'remote'
}
export interface RemoteFileSource extends BaseFileSource {
source: 'remote'
url: string
status: 'pending' | 'downloading' | 'downloaded' | 'error'
downloadProgress?: number
localPath?: string // 下载后的本地路径
}
export interface FileUploadResponse {
fileId: string
displayName: string
status: 'success' | 'processing' | 'failed' | 'unknown'
originalFile?: any // 保留原始响应,以备需要
}
export interface FileListResponse {
files: Array<{
id: string
displayName: string
size?: number
status: 'success' | 'processing' | 'failed' | 'unknown'
originalFile?: any // 保留原始文件对象
}>
}
export interface LocalFileSource extends BaseFileSource {
origin_name: string
path: string
created_at: string
count: number
tokens?: number
source: 'local'
}
// 联合类型,表示一个文件可以是本地的或远程的
export type FileSource = LocalFileSource | RemoteFileSource
// 为了保持向后兼容
export type FileType = LocalFileSource
// 类型保护函数,用于区分文件类型
export const isLocalFile = (file: FileSource): file is LocalFileSource => {
return file.source === 'local'
}
export const isRemoteFile = (file: FileSource): file is RemoteFileSource => {
return file.source === 'remote'
}
export enum FileTypes {
IMAGE = 'image',
VIDEO = 'video',
AUDIO = 'audio',
TEXT = 'text',
DOCUMENT = 'document',
OTHER = 'other'
}
export enum ThemeMode {
light = 'light',
dark = 'dark',
-7
View File
@@ -55,10 +55,3 @@ export function removeSpecialCharactersForFileName(str: string) {
.replace(/[\r\n]+/g, ' ')
.trim()
}
export const fileToBase64 = async (filePath: string) => {
const result = await window.api.file.base64File(filePath)
return {
data: result.data,
mimeType: result.mime
}
}
+38 -49
View File
@@ -637,6 +637,17 @@ __metadata:
languageName: node
linkType: hard
"@cherrystudio/mac-system-ocr@npm:^0.2.1":
version: 0.2.1
resolution: "@cherrystudio/mac-system-ocr@npm:0.2.1"
dependencies:
bindings: "npm:^1.5.0"
node-api-headers: "npm:^1.0.1"
node-gyp: "npm:latest"
checksum: 10c0/fbba8f85c2244799f281b100fa6fbaf134f742a6f4d0a15f465ed4f0f048edc0acb60ed74908c462f0de424c2e3476892127f25d10ab805c123233e4922d415c
languageName: node
linkType: hard
"@csstools/color-helpers@npm:^5.0.2":
version: 5.0.2
resolution: "@csstools/color-helpers@npm:5.0.2"
@@ -683,17 +694,6 @@ __metadata:
languageName: node
linkType: hard
"@cherrystudio/mac-system-ocr@npm:^0.2.1":
version: 0.2.1
resolution: "@cherrystudio/mac-system-ocr@npm:0.2.1"
dependencies:
bindings: "npm:^1.5.0"
node-api-headers: "npm:^1.0.1"
node-gyp: "npm:latest"
checksum: 10c0/fbba8f85c2244799f281b100fa6fbaf134f742a6f4d0a15f465ed4f0f048edc0acb60ed74908c462f0de424c2e3476892127f25d10ab805c123233e4922d415c
languageName: node
linkType: hard
"@develar/schema-utils@npm:~2.6.5":
version: 2.6.5
resolution: "@develar/schema-utils@npm:2.6.5"
@@ -2671,14 +2671,14 @@ __metadata:
languageName: node
linkType: hard
"@mistralai/mistralai@npm:^1.5.2":
version: 1.5.2
resolution: "@mistralai/mistralai@npm:1.5.2"
"@mistralai/mistralai@npm:^1.6.0":
version: 1.6.0
resolution: "@mistralai/mistralai@npm:1.6.0"
dependencies:
zod-to-json-schema: "npm:^3.24.1"
peerDependencies:
zod: ">= 3"
checksum: 10c0/d33a8a71adac4d2074ea4bfa09605b1c2158b5ffeaa9a78f3d9602c822cf0885df660b09f2372f17d8a81e78fa64795f31d9fad0cc40a1fab57ca0a4df9dc009
checksum: 10c0/2d1778f58eb3586801d3baaf9f814c455a32e9113f941160a36cbf2a0946f307929a2ba3e34fadfd198f703cb58fef5fea1c03e255ce4dcc65af6734acf36d08
languageName: node
linkType: hard
@@ -4436,7 +4436,7 @@ __metadata:
"@iconify-json/svg-spinners": "npm:^1.2.2"
"@kangfenmao/keyv-storage": "npm:^0.1.0"
"@langchain/community": "npm:^0.3.36"
"@mistralai/mistralai": "npm:^1.5.2"
"@mistralai/mistralai": "npm:^1.6.0"
"@modelcontextprotocol/sdk": "npm:^1.10.2"
"@mozilla/readability": "npm:^0.6.0"
"@notionhq/client": "npm:^2.2.15"
@@ -4474,8 +4474,8 @@ __metadata:
babel-plugin-styled-components: "npm:^2.1.4"
browser-image-compression: "npm:^2.0.2"
bufferutil: "npm:^4.0.9"
color: "npm:^5.0.0"
canvas: "npm:3.1.0"
color: "npm:^5.0.0"
dayjs: "npm:^1.11.11"
dexie: "npm:^4.0.8"
dexie-react-hooks: "npm:^1.1.7"
@@ -5633,17 +5633,6 @@ __metadata:
languageName: node
linkType: hard
"canvas@npm:3.1.0":
version: 3.1.0
resolution: "canvas@npm:3.1.0"
dependencies:
node-addon-api: "npm:^7.0.0"
node-gyp: "npm:latest"
prebuild-install: "npm:^7.1.1"
checksum: 10c0/28da5184c1d7e97049ba6a24f10690b9ed4b303bbd25517d95c892fa3a6331417791657a3a7467068e40af0dda2dcc9120d062f7426a3d796131e69a30e3cbf1
languageName: node
linkType: hard
"caseless@npm:~0.12.0":
version: 0.12.0
resolution: "caseless@npm:0.12.0"
@@ -12615,6 +12604,13 @@ __metadata:
languageName: node
linkType: hard
"node-api-headers@npm:^1.0.1":
version: 1.5.0
resolution: "node-api-headers@npm:1.5.0"
checksum: 10c0/e8dfe99e8e3ca92cd5d37989413dfc96551e8f7883110b948917dad07e554cfbf1119130e96d0167f5cb5a05f651a4ac735402a305ff25d9ace422a2e429ae3b
languageName: node
linkType: hard
"node-api-version@npm:^0.2.0":
version: 0.2.1
resolution: "node-api-version@npm:0.2.1"
@@ -12624,13 +12620,6 @@ __metadata:
languageName: node
linkType: hard
"node-api-headers@npm:^1.0.1":
version: 1.5.0
resolution: "node-api-headers@npm:1.5.0"
checksum: 10c0/e8dfe99e8e3ca92cd5d37989413dfc96551e8f7883110b948917dad07e554cfbf1119130e96d0167f5cb5a05f651a4ac735402a305ff25d9ace422a2e429ae3b
languageName: node
linkType: hard
"node-domexception@npm:1.0.0, node-domexception@npm:^1.0.0":
version: 1.0.0
resolution: "node-domexception@npm:1.0.0"
@@ -13512,6 +13501,13 @@ __metadata:
languageName: node
linkType: hard
"path2d@npm:^0.2.0":
version: 0.2.2
resolution: "path2d@npm:0.2.2"
checksum: 10c0/1bb76c7f275d07f1bc7ca12171d828e91bf8a12596f0765a52e9d4d47fe1a428455dc1dd4c9002924a9bc554f6ac25e09a6c22eaecf32e5e33fba2985b5168f8
languageName: node
linkType: hard
"pathe@npm:^2.0.3":
version: 2.0.3
resolution: "pathe@npm:2.0.3"
@@ -13526,13 +13522,6 @@ __metadata:
languageName: node
linkType: hard
"path2d@npm:^0.2.0":
version: 0.2.2
resolution: "path2d@npm:0.2.2"
checksum: 10c0/1bb76c7f275d07f1bc7ca12171d828e91bf8a12596f0765a52e9d4d47fe1a428455dc1dd4c9002924a9bc554f6ac25e09a6c22eaecf32e5e33fba2985b5168f8
languageName: node
linkType: hard
"pdf-parse@npm:1.1.1":
version: 1.1.1
resolution: "pdf-parse@npm:1.1.1"
@@ -13553,13 +13542,6 @@ __metadata:
languageName: node
linkType: hard
"pe-library@npm:^0.4.1":
version: 0.4.1
resolution: "pe-library@npm:0.4.1"
checksum: 10c0/75c772e74c75d9710a2bf6b7e88fb57e4c26788422abd3b38c8100c796e311c72102ef71159b9e0b56f05f616a968e11b8ec218bcd625c896df067235af8da77
languageName: node
linkType: hard
"pdf-to-img@npm:^4.4.0":
version: 4.4.0
resolution: "pdf-to-img@npm:4.4.0"
@@ -13587,6 +13569,13 @@ __metadata:
languageName: node
linkType: hard
"pe-library@npm:^0.4.1":
version: 0.4.1
resolution: "pe-library@npm:0.4.1"
checksum: 10c0/75c772e74c75d9710a2bf6b7e88fb57e4c26788422abd3b38c8100c796e311c72102ef71159b9e0b56f05f616a968e11b8ec218bcd625c896df067235af8da77
languageName: node
linkType: hard
"peberminta@npm:^0.9.0":
version: 0.9.0
resolution: "peberminta@npm:0.9.0"