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:
+2
-2
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,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')
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
+43
-25
@@ -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 })
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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) {
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-2
@@ -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)
|
||||
|
||||
+8
-1
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user