From c14dd778619e3d68d2b8751da4924b605bed26a0 Mon Sep 17 00:00:00 2001 From: suyao Date: Sat, 10 May 2025 11:59:34 +0800 Subject: [PATCH] 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. --- package.json | 4 +- packages/shared/IpcChannel.ts | 6 + src/main/ipc.ts | 14 +- src/main/ocr/MistralOcrProvider.ts | 2 +- .../{FileService.ts => FileSystemService.ts} | 2 +- src/main/services/GeminiService.ts | 68 -- .../{file => remotefile}/BaseFileService.ts | 0 .../FileServiceManager.ts | 0 .../{file => remotefile}/GeminiService.ts | 68 +- .../{file => remotefile}/MistralService.ts | 5 +- src/preload/index.ts | 17 +- src/renderer/src/hooks/useKnowledge.ts | 28 - src/renderer/src/i18n/locales/zh-cn.json | 26 +- src/renderer/src/pages/files/FilesPage.tsx | 2 + src/renderer/src/pages/files/GeminiFiles.tsx | 144 +-- src/renderer/src/pages/files/MistralFiles.tsx | 100 +-- .../src/pages/files/RemoteFileList.tsx | 152 ++++ .../src/pages/knowledge/KnowledgeContent.tsx | 6 +- .../components/KnowledgeSettingsPopup.tsx | 343 -------- .../src/pages/settings/SettingsPage.tsx | 8 +- .../WebSearchSettings/AddSubscribePopup.tsx | 4 +- .../WebSearchSettings/BasicSettings.tsx | 84 +- .../WebSearchSettings/BlacklistSettings.tsx | 3 +- .../WebSearchProviderSetting.tsx | 9 +- .../ToolSettings/WebSearchSettings/index.tsx | 2 +- .../WebSearchSettings/BasicSettings.tsx | 67 -- .../providers/AiProvider/GeminiProvider.ts | 10 +- src/renderer/src/providers/GeminiProvider.ts | 827 ------------------ src/renderer/src/store/migrate.ts | 80 +- src/renderer/src/types/file.ts | 92 ++ src/renderer/src/types/index.ts | 78 +- src/renderer/src/utils/file.ts | 7 - yarn.lock | 87 +- 33 files changed, 514 insertions(+), 1831 deletions(-) rename src/main/services/{FileService.ts => FileSystemService.ts} (76%) delete mode 100644 src/main/services/GeminiService.ts rename src/main/services/{file => remotefile}/BaseFileService.ts (100%) rename src/main/services/{file => remotefile}/FileServiceManager.ts (100%) rename src/main/services/{file => remotefile}/GeminiService.ts (66%) rename src/main/services/{file => remotefile}/MistralService.ts (96%) create mode 100644 src/renderer/src/pages/files/RemoteFileList.tsx delete mode 100644 src/renderer/src/pages/knowledge/components/KnowledgeSettingsPopup.tsx delete mode 100644 src/renderer/src/pages/settings/WebSearchSettings/BasicSettings.tsx delete mode 100644 src/renderer/src/providers/GeminiProvider.ts create mode 100644 src/renderer/src/types/file.ts diff --git a/package.json b/package.json index ae384b569..0c3494567 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 38aadf923..be9b0ba46 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -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', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index c77bfe3ae..6a2544d24 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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) diff --git a/src/main/ocr/MistralOcrProvider.ts b/src/main/ocr/MistralOcrProvider.ts index f694ec884..a8db6b4eb 100644 --- a/src/main/ocr/MistralOcrProvider.ts +++ b/src/main/ocr/MistralOcrProvider.ts @@ -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' diff --git a/src/main/services/FileService.ts b/src/main/services/FileSystemService.ts similarity index 76% rename from src/main/services/FileService.ts rename to src/main/services/FileSystemService.ts index 39255e15f..afb3794df 100644 --- a/src/main/services/FileService.ts +++ b/src/main/services/FileSystemService.ts @@ -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') } } diff --git a/src/main/services/GeminiService.ts b/src/main/services/GeminiService.ts deleted file mode 100644 index 8427e1304..000000000 --- a/src/main/services/GeminiService.ts +++ /dev/null @@ -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 { - 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 { - const sdk = new GoogleGenAI({ vertexai: false, apiKey }) - const cachedResponse = CacheService.get(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: 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 { - 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 }) - } -} diff --git a/src/main/services/file/BaseFileService.ts b/src/main/services/remotefile/BaseFileService.ts similarity index 100% rename from src/main/services/file/BaseFileService.ts rename to src/main/services/remotefile/BaseFileService.ts diff --git a/src/main/services/file/FileServiceManager.ts b/src/main/services/remotefile/FileServiceManager.ts similarity index 100% rename from src/main/services/file/FileServiceManager.ts rename to src/main/services/remotefile/FileServiceManager.ts diff --git a/src/main/services/file/GeminiService.ts b/src/main/services/remotefile/GeminiService.ts similarity index 66% rename from src/main/services/file/GeminiService.ts rename to src/main/services/remotefile/GeminiService.ts index f3251a552..acc64bde1 100644 --- a/src/main/services/file/GeminiService.ts +++ b/src/main/services/remotefile/GeminiService.ts @@ -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 { - 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 { - await this.fileManager.deleteFile(fileId) + await this.fileManager.delete({ name: fileId }) } } diff --git a/src/main/services/file/MistralService.ts b/src/main/services/remotefile/MistralService.ts similarity index 96% rename from src/main/services/file/MistralService.ts rename to src/main/services/remotefile/MistralService.ts index ab8086ddc..938e7360d 100644 --- a/src/main/services/file/MistralService.ts +++ b/src/main/services/remotefile/MistralService.ts @@ -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) { diff --git a/src/preload/index.ts b/src/preload/index.ts index 8ab419225..6ca1dcaa5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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) diff --git a/src/renderer/src/hooks/useKnowledge.ts b/src/renderer/src/hooks/useKnowledge.ts index 2dc9661f8..fbe88cee5 100644 --- a/src/renderer/src/hooks/useKnowledge.ts +++ b/src/renderer/src/hooks/useKnowledge.ts @@ -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(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 })) diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 62598edd4..5c17b0a13 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -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": "左侧", diff --git a/src/renderer/src/pages/files/FilesPage.tsx b/src/renderer/src/pages/files/FilesPage.tsx index 055f5e60f..a510dc74f 100644 --- a/src/renderer/src/pages/files/FilesPage.tsx +++ b/src/renderer/src/pages/files/FilesPage.tsx @@ -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('document') const [sortField, setSortField] = useState('created_at') const [sortOrder, setSortOrder] = useState('desc') + const { providers } = useProviders() const mistralProviders = providers.filter((provider) => provider.type === 'mistral') const tempFilesSort = (files: FileType[]) => { return files.sort((a, b) => { diff --git a/src/renderer/src/pages/files/GeminiFiles.tsx b/src/renderer/src/pages/files/GeminiFiles.tsx index d9a5ed4ce..e25a32a92 100644 --- a/src/renderer/src/pages/files/GeminiFiles.tsx +++ b/src/renderer/src/pages/files/GeminiFiles.tsx @@ -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 = ({ id }) => { - const { provider } = useProvider(id) - const [files, setFiles] = useState([]) - 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 = [ - { - 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 ( - { - 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 ( - - - - - - ) + const formatFileDate = (date: string | number): string => { + return dayjs(date).format('MM-DD HH:mm') } - return ( - - - {files.map((file) => ( - { - 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]) - }) - }} - /> - ) - }} - /> - ))} - - - ) + const formatFileSize = (size: string | number): string => { + const sizeNumber = typeof size === 'string' ? parseInt(size) : size + return `${(sizeNumber / MB).toFixed(2)} MB` + } + + return } -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 diff --git a/src/renderer/src/pages/files/MistralFiles.tsx b/src/renderer/src/pages/files/MistralFiles.tsx index 2eed190e2..91920ff10 100644 --- a/src/renderer/src/pages/files/MistralFiles.tsx +++ b/src/renderer/src/pages/files/MistralFiles.tsx @@ -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 = ({ id }) => { - const { provider } = useProvider(id) - const { t } = useTranslation() - const [files, setFiles] = useState([]) - 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 = [ - { - 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 ( - { - 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 ( - - - - ) + return } -const Container = styled.div`` - export default MistralFiles diff --git a/src/renderer/src/pages/files/RemoteFileList.tsx b/src/renderer/src/pages/files/RemoteFileList.tsx new file mode 100644 index 000000000..2a05a36e4 --- /dev/null +++ b/src/renderer/src/pages/files/RemoteFileList.tsx @@ -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 = ({ id, formatFileDate, formatFileSize }) => { + const { provider } = useProvider(id) + const [files, setFiles] = useState([]) + 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 ( + + + + ) + } + + return ( + + + {files.map((file) => { + const fileName = getFileNameFn(file) + const fileExt = `.${fileName?.split('.').pop()}` + + return ( + handleDeleteFile(file)} + /> + ) + }} + /> + ) + })} + + + ) +} + +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 diff --git a/src/renderer/src/pages/knowledge/KnowledgeContent.tsx b/src/renderer/src/pages/knowledge/KnowledgeContent.tsx index 14934c9e5..c71e416f5 100644 --- a/src/renderer/src/pages/knowledge/KnowledgeContent.tsx +++ b/src/renderer/src/pages/knowledge/KnowledgeContent.tsx @@ -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 = ({ selectedBase }) => {