mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-24 18:50:56 +08:00
* feat: add isTextFile functionality and improve file selection handling - Introduced a new IPC channel for checking if a file is a text file. - Implemented isTextFile method in FileStorage service to determine file type based on content. - Enhanced AttachmentButton to filter selected files based on text file validation. - Updated translations to include support for displaying unsupported file counts across multiple languages. - Added utility functions for text file validation and filtering in file utilities. * refactor(FileStorage): replace hardcoded buffer size with constant for improved readability * restore yarn lock * add isbinaryfile dep * refactor: 整理导入顺序 * fix(preload): 为isTextFile方法添加返回类型Promise<boolean> * refactor(FileManager): update getSafePath to use file metadata for path retrieval - Modified getSafePath method to utilize the path from file metadata instead of a hardcoded file path. - Enhanced handling for files not stored in the file storage system. * refactor(FileUtilities): rename text file functions for clarity - Updated function names from isTextFile to isSupportedFile and filterTextFiles to filterSupportedFiles to better reflect their purpose. - Adjusted related imports and usages in AttachmentButton and PasteService components to align with the new naming conventions. * fix drop files * refactor(MarkdownStyles): remove last-child margin override; adjust MessageFooter margin and clean up unused code in MessageAttachments * feat(Sidebar): add 'code_tools' icon and route; enhance CodeToolsPage layout with Navbar and improved provider filtering * feat(CodeTools): add environment variable support for CLI tools; update UI to manage environment variables and enhance localization for related strings * refactor(Sidebar): remove unused imports and code related to documentation; streamline sidebar functionality * refactor(SvgPreview): use transparent container for SVG (#9294) * refactor(SvgPreview): use transparent container for SVG * test: fix snapshot * refactor(CodeToolsService): replace npm package version fetching with direct API call; simplify command construction for installation * chore: release v1.5.7-rc.1 * refactor(CodeToolsService): adjust command construction for Windows compatibility; streamline installation command handling * refactor(Markdown): update disallowed elements to include 'script' for enhanced security * feat: quick model (#9290) * refactor(i18n): 将话题命名模型相关文案更新为摘要模型 更新所有语言文件中关于话题命名模型的文案,统一改为摘要模型,以反映功能的扩展和更通用的用途 * refactor(设置页面): 优化主题命名弹窗组件性能 使用useCallback和useMemo优化回调函数和渲染性能 将重复的JSX代码提取为独立组件 * feat(设置): 在模型设置中添加话题命名折叠面板 将话题命名设置从直接显示改为折叠面板形式,提升界面整洁度 * refactor(i18n): 重构话题命名相关翻译字段结构 * docs(i18n): 添加生成图像的高度、宽度和安全容忍度翻译占位符 * fix(settings): 修正主题命名弹窗中的翻译键名 * style(ui): 调整主题命名弹窗的间距和文本区域高度 移除多余的上下边距,并使用自适应高度的文本区域 * refactor(llm): 将 topicNamingModel 重命名为 summaryModel 更新相关函数、状态和测试用例以反映命名变更 增加迁移逻辑处理旧状态数据 更新持久化版本号至133 * fix(ApiService): 优先使用摘要模型替代默认模型 当获取摘要时,优先使用getSummaryModel()返回的模型,其次才是助手指定的模型或默认模型,以确保摘要生成的一致性 * docs(i18n): 更新摘要模型描述中的搜索关键词提炼 将"搜索结果摘要"修改为"搜索关键字提炼"以更准确描述功能 * fix(i18n): 更新多语言翻译文件中的摘要模型相关文本 * feat(i18n): 为摘要模型设置添加工具提示说明 添加摘要模型设置的工具提示,建议用户选择轻量模型而非思考模型 * refactor(i18n): 将摘要模型相关文案更新为快速模型 更新国际化文案和组件引用,将"摘要模型"统一改为"快速模型"以更准确描述功能用途 * feat(i18n): 将摘要模型重命名为快速模型并更新相关描述 * refactor(llm): 将summaryModel重命名为quickModel以提升语义清晰度 * test(api): 在ApiService测试中添加LlmState类型和awsBedrock配置 添加LlmState类型以满足类型检查要求,并补充awsBedrock的mock配置以完善测试覆盖 * Revert "feat(设置): 在模型设置中添加话题命名折叠面板" This reverts commit4d58c053da. * refactor(settings): 重命名并移动 TopicNamingModalPopup 组件文件 将 TopicNamingModalPopup.tsx 重命名为 QuickModelPopup.tsx 并移动到相应目录 * refactor(QuickModelPopup): 优化主题命名设置布局和样式 移除 TopicNamingSettings 组件内联实现,直接整合到 Modal 中 调整间距和样式,提升视觉一致性 修复文本区域 onChange 去除换行的逻辑 * feat(模型设置): 在快速模型弹窗中添加重置按钮图标并调整布局 将重置按钮改为图标形式并内联显示,同时调整输入区域的高度样式 * docs(i18n): 更新快速模型相关翻译文本 * fix: 将迁移错误日志从133更新为134 * style(settings): 替换模型设置中的图标为Rocket图标以提升视觉一致性 * fix: unexpected quitting full screen mode (#9200) * fix(Inputbar): 修正拼写错误,将expend改为expand * fix: 修复Escape键事件冒泡问题并改进全屏处理 修复多个组件中Escape键事件未阻止冒泡的问题 添加全屏控制IPC通道 将全屏退出逻辑移至渲染进程处理 移除主进程中冗余的全屏退出处理代码 * fix(SelectModelPopup): 修复键盘事件处理并移除无效的useEffect 将键盘事件监听从window移动到Modal容器,避免事件冒泡问题 移除无效的useEffect并更新键盘事件类型定义 * fix(QuickPanel): 拦截window上的keydown事件 * fix(QuickPanel): 修复事件监听器移除时未使用相同参数的问题 * fix(TopView): 修复左侧导航栏布局崩坏问题 * fix: 修正变量名拼写错误,将expended改为expanded * Revert "fix(SelectModelPopup): 修复键盘事件处理并移除无效的useEffect" This reverts commit4211780b95. * feat: use quick model to detect translate language (#9315) * refactor(语言检测): 移除翻译模型依赖,改用快速或默认模型 * feat(i18n): 添加希腊语翻译支持 * fix(i18n): 更新i18n 统一将翻译模型提示改为快速模型提示,优化多语言文件中的描述 * Revert "feat(i18n): 添加希腊语翻译支持" This reverts commit42613cb2e2. * feat: add 'code_tools' to sidebar icons and update related components * fix: KaTeX math engine render * feat: 同步百炼服务器功能 (#9205) * 同步百炼服务器功能 * cr修改 --------- Co-authored-by: yunze <yunze.wyz@alibaba-inc.com> * fix(SelectionHook): improve validation for selected text range to handle empty strings and ensure valid extraction (#9329) chore: update selection-hook dependency to version 1.0.10 in package.json and yarn.lock * fix: web search references missing caused by early reset (#9328) * feat(openai): handle special tokens for zhipu api (#9323) * feat(openai): 添加对智谱特殊token的过滤处理 在OpenAIAPIClient中添加对智谱AI特殊token的过滤逻辑,避免不需要的token被输出 * docs(OpenAIApiClient): 添加注释 * refactor(zhipu): 重命名并更新智谱特殊token处理逻辑 将 ZHIPU_SPECIAL_TOKENS_TO_FILTER 重命名为 ZHIPU_RESULT_TOKENS 以更准确描述用途 修改智谱API特殊token处理逻辑,不再过滤而是用**标记结果token * feat: support openai codex (#9332) * support openai codex * lint * refactor: remove unused codeTools enum from constant.ts * fix build * fix lin * fix: add support for qwenCode CLI tool and improve error handling in CodeToolsService * fix: timeout memory leak (#9312) * fix(MinappPopupContainer): 修复内存泄漏问题,清理未使用的定时器 在组件卸载时清理setTimeout定时器,避免潜在的内存泄漏 * fix(SelectModelButton): 修复模型选择后更新导致的卡顿问题 使用useRef存储定时器并在组件卸载时清理,避免内存泄漏 * fix(QuickPanel): 修复定时器未清理导致的内存泄漏问题 添加 clearSearchTimerRef 和 focusTimerRef 来管理定时器 在组件清理和状态变化时清理所有定时器 * fix(useInPlaceEdit): 修复编辑模式下定时器未清理的问题 添加清理定时器的逻辑,避免组件卸载时内存泄漏 * refactor(useKnowledge): 使用ref管理定时器并统一检查知识库逻辑 将分散的setTimeout调用统一为checkAllBases方法 使用useRef管理定时器并在组件卸载时清理 * fix(useScrollPosition): 修复滚动位置恢复时的内存泄漏问题 添加清理函数以清除未完成的定时器,防止组件卸载时内存泄漏 * fix(WebSearchProviderSetting): 清理定时器防止内存泄漏 在组件卸载时清理检查API有效性的定时器,避免潜在的内存泄漏问题 * fix(selection-toolbar): 修复选中文本时定时器未清理的问题 * fix(translate): 修复复制文本时定时器未清理的问题 添加 copyTimerRef 来管理复制操作的定时器,并在组件卸载时清理定时器 * fix(WebSearchSettings): 使用useRef管理订阅验证定时器以避免内存泄漏 * fix(MCPSettings): 修复定时器未清理导致的内存泄漏问题 添加 useRef 来存储定时器引用,并在组件卸载时清理定时器 * refactor(ThinkingBlock): 使用 useTemporaryValue 替换手动 setTimeout 移除手动设置的 setTimeout 来重置 copied 状态,改用 useTemporaryValue hook 自动处理 * refactor(ChatNavigation): 使用 useRef 替代 useState 管理定时器 简化定时器管理逻辑,避免不必要的状态更新 * fix(AddAssistantPopup): 清理创建助手时的定时器以避免内存泄漏 添加useEffect清理定时器,防止组件卸载时内存泄漏 * feat(hooks): 添加useTimer钩子管理定时器 实现一个自定义hook来集中管理setTimeout和setInterval定时器 自动在组件卸载时清理所有定时器防止内存泄漏 * refactor(Inputbar): 使用 useTimer 替换 setTimeout 实现延迟更新 将 setTimeout 替换为 useTimer 的自定义 setTimeoutTimer 方法,提高代码可维护性并统一计时器管理 * refactor(WindowFooter): 使用 useTimer 替换 setTimeout 以管理定时器 * docs(useTimer): 更新定时器hook的注释格式和描述 * feat(hooks): 为useTimer添加返回清理函数的功能 允许从setTimeoutTimer和setIntervalTimer返回清理函数,便于手动清除定时器 * refactor(ImportAgentPopup): 使用useTimer替换setTimeout以管理定时器 * refactor: 使用useTimer替代setTimeout以优化定时器管理 * refactor(SearchResults): 使用useTimer替换setTimeout以管理定时器 * refactor(消息组件): 使用useTimer替换setTimeout以管理定时器 * refactor: 使用useTimer替换setTimeout以优化定时器管理 * refactor(AssistantsDrawer): 使用useTimer替换setTimeout以优化定时器管理 * refactor(Inputbar): 使用useTimer替换setTimeout以优化定时器管理 * refactor(MCPToolsButton): 使用useTimer优化定时器管理 * refactor(QuickPhrasesButton): 使用useTimer替换setTimeout以优化定时器管理 * refactor(ChatFlowHistory): 使用useTimer替换setTimeout以管理定时器 * refactor(Message): 使用useTimer替换setTimeout以管理定时器 * refactor(MessageAnchorLine): 使用useTimer替换setTimeout以优化定时器管理 * refactor(MessageGroup): 使用useTimer替换setTimeout以优化定时器管理 * refactor(MessageMenubar): 使用 useTemporaryValue 替换手动 setTimeout 逻辑 * refactor(Messages): 使用 useTimer 优化定时器管理 * refactor(MessageTools): 使用useTimer替换setTimeout以管理定时器 * fix(SelectionBox): 修复鼠标移动时未清除定时器导致的内存泄漏 在鼠标移动事件处理中增加定时器清理逻辑,避免组件卸载时未清除定时器导致的内存泄漏问题 * refactor(ErrorBlock): 使用自定义hook替换setTimeout 使用useTimer中的setTimeoutTimer替代原生setTimeout,便于统一管理定时器 * refactor(GeneralSettings): 使用useTimer替换setTimeout以实现更好的定时器管理 * refactor(ShortcutSettings): 使用useTimer替换setTimeout以优化定时器管理 * refactor(AssistantModelSettings): 使用useTimer替换setTimeout以优化定时器管理 * refactor(DataSettings): 使用useTimer替换setTimeout以增强定时器管理 统一使用useTimer hook管理所有定时器操作,提高代码可维护性 * refactor(NutstoreSettings): 使用useTimer优化setTimeout管理 替换直接使用setTimeout为useTimer hook的setTimeoutTimer方法,提升定时器管理的可维护性 * refactor(MCPSettings): 使用useTimer替换setTimeout以提升代码可维护性 * refactor(ProviderSetting): 使用useTimer优化setTimeout管理 * refactor(ProviderSettings): 使用 useTimer 替换 setTimeout 以优化定时器管理 * refactor(InputBar): 使用useTimer替换setTimeout以实现更好的定时器管理 * refactor(MessageEditor): 使用useTimer替换setTimeout以管理定时器 使用自定义hook useTimer来替代原生setTimeout,便于统一管理和清理定时器 * docs(useTimer): 添加 useTimer hook 的使用示例和详细说明 * refactor(MinappPopupContainer): 使用useTimer替换setTimeout实现 替换直接使用setTimeout为自定义hook useTimer,简化组件清理逻辑 * refactor(AddAssistantPopup): 使用useTimer替换手动定时器管理 用useTimer钩子替代手动管理定时器,简化代码并提高可维护性 * refactor(WebSearchSettings): 使用 useTimer 替换手动定时器管理 移除手动管理的定时器逻辑,改用 useTimer hook 统一处理 * refactor(WebSearchProviderSetting): 使用自定义hook替代原生定时器 用useTimer hook替换原有的setTimeout和clearTimeout逻辑,提高代码可维护性 * refactor(translate): 使用 useTemporaryValue 替换手动实现的复制状态定时器 * refactor(SelectionToolbar): 使用 useTimer 钩子替换 setTimeout 和 clearTimeout 重构 SelectionToolbar 组件,使用自定义的 useTimer 钩子来管理定时器,提升代码可维护性 清理隐藏时的定时器逻辑,避免内存泄漏 * fix(Translate): update settings into db (#9305) * fix(翻译): 修复设置没有储存到db的错误 * fix(translate): 修复自动检测方法设置更新失败的问题 添加错误处理逻辑,当更新自动检测方法设置失败时显示错误信息 * Fix AWS Bedrock models not receiving uploaded document content (#9337) * Initial plan * Add file content processing to AWS Bedrock client convertMessageToSdkParam method Co-authored-by: caozhiyuan <3415285+caozhiyuan@users.noreply.github.com> * Fix file content format to match other AI clients and update tests Co-authored-by: caozhiyuan <3415285+caozhiyuan@users.noreply.github.com> * Update src/renderer/src/aiCore/clients/aws/AwsBedrockAPIClient.ts --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: caozhiyuan <3415285+caozhiyuan@users.noreply.github.com> Co-authored-by: Phantom <59059173+EurFelux@users.noreply.github.com> * feat(migrate): initialize default assistant settings if not present (#9303) * feat(migrate): update migration logic for version 134; initialize default assistant settings if not present * Update src/renderer/src/store/migrate.ts Co-authored-by: Phantom <59059173+EurFelux@users.noreply.github.com> --------- Co-authored-by: Phantom <59059173+EurFelux@users.noreply.github.com> * feat: support language aliases for code editor (#9336) * feat(CodeEditor): support language aliases * fix: mermaid * refactor: lookup * chore: sort package.json * fix(SelectionHook): [macOS] add type safety to prevent crashes (#9354) chore: update selection-hook dependency to version 1.0.11 in package.json and yarn.lock * fix: sidebar code icon reset bug (#9307) (#9333) * fix: 修复侧边栏重置时 Code 图标消失的问题 (#9307) 问题原因: - types/index.ts 中的 SidebarIcon 类型定义缺少 'code_tools' - 存在重复的类型定义和常量定义导致不一致 修复内容: - 在 types/index.ts 的 SidebarIcon 类型中添加 'code_tools' - 删除 minapps.ts 中重复的 DEFAULT_SIDEBAR_ICONS 常量 - 统一从 @renderer/types 导入 SidebarIcon 类型 - 删除 settings.ts 中重复的 SidebarIcon 类型定义 这确保了在导航栏设置为左侧时,点击侧边栏设置的重置按钮后, Code 图标能够正确显示。 * refactor: 将侧边栏配置移至 config 目录 根据 code review 建议,将侧边栏相关配置从 store/settings.ts 移动到 config/sidebar.ts,使配置管理更加清晰。 改动内容: - 创建 config/sidebar.ts 存放侧边栏配置常量 - 更新相关文件的导入路径 - 在 settings.ts 中重新导出以保持向后兼容 - 添加 REQUIRED_SIDEBAR_ICONS 常量便于未来扩展 这个改动保持了最小化原则,不影响现有功能。 * refactor: improve locate highlight animation (#9345) * feat(utils): show weekday in date and datetime prompt variables (#9362) * feat(utils): 优化日期时间变量替换格式 为 {{date}} 和 {{datetime}} 变量替换添加更详细的格式选项,包括星期、年月日和时间信息 * test(prompt): 更新测试中日期时间的本地化格式 * refactor(CodeToolsPage): simplify CLI tool change handling and optimize provider filtering logic * fix(newMessage): reduce default display count from 20 to 10 * feat(AssistantService): introduce DEFAULT_ASSISTANT_SETTINGS for consistent assistant configuration and update migration logic for version 136 * chore: release v1.5.7-rc.2 * fix(Markdown/Link): set href to undefined when it's empty (#9343) fix(Markdown/Link): 处理空链接时设置href为undefined * fix(Inputbar): update file handling to use functional state update for setFiles * refactor(file): update isSupportedFile function to accept filePath instead of FileMetadata for improved clarity and consistency in file handling --------- Co-authored-by: icarus <eurfelux@gmail.com> Co-authored-by: kangfenmao <kangfenmao@qq.com> Co-authored-by: one <wangan.cs@gmail.com> Co-authored-by: Phantom <59059173+EurFelux@users.noreply.github.com> Co-authored-by: alickreborn0 <i@guyi.me> Co-authored-by: yunze <yunze.wyz@alibaba-inc.com> Co-authored-by: fullex <106392080+0xfullex@users.noreply.github.com> Co-authored-by: caozhiyuan <568022847@qq.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: caozhiyuan <3415285+caozhiyuan@users.noreply.github.com> Co-authored-by: SuYao <sy20010504@gmail.com> Co-authored-by: Jason Young <44939412+farion1231@users.noreply.github.com>
666 lines
20 KiB
TypeScript
666 lines
20 KiB
TypeScript
import { loggerService } from '@logger'
|
||
import { getFilesDir, getFileType, getTempDir, readTextFileWithAutoEncoding } from '@main/utils/file'
|
||
import { documentExts, imageExts, KB, MB } from '@shared/config/constant'
|
||
import { FileMetadata } from '@types'
|
||
import chardet from 'chardet'
|
||
import * as crypto from 'crypto'
|
||
import {
|
||
dialog,
|
||
net,
|
||
OpenDialogOptions,
|
||
OpenDialogReturnValue,
|
||
SaveDialogOptions,
|
||
SaveDialogReturnValue,
|
||
shell
|
||
} from 'electron'
|
||
import * as fs from 'fs'
|
||
import { writeFileSync } from 'fs'
|
||
import { readFile } from 'fs/promises'
|
||
import { isBinaryFile } from 'isbinaryfile'
|
||
import officeParser from 'officeparser'
|
||
import * as path from 'path'
|
||
import { PDFDocument } from 'pdf-lib'
|
||
import { chdir } from 'process'
|
||
import { v4 as uuidv4 } from 'uuid'
|
||
import WordExtractor from 'word-extractor'
|
||
|
||
const logger = loggerService.withContext('FileStorage')
|
||
|
||
class FileStorage {
|
||
private storageDir = getFilesDir()
|
||
private tempDir = getTempDir()
|
||
|
||
constructor() {
|
||
this.initStorageDir()
|
||
}
|
||
|
||
private initStorageDir = (): void => {
|
||
try {
|
||
if (!fs.existsSync(this.storageDir)) {
|
||
fs.mkdirSync(this.storageDir, { recursive: true })
|
||
}
|
||
if (!fs.existsSync(this.tempDir)) {
|
||
fs.mkdirSync(this.tempDir, { recursive: true })
|
||
}
|
||
} catch (error) {
|
||
logger.error('Failed to initialize storage directories:', error as Error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// @TraceProperty({ spanName: 'getFileHash', tag: 'FileStorage' })
|
||
private getFileHash = async (filePath: string): Promise<string> => {
|
||
return new Promise((resolve, reject) => {
|
||
const hash = crypto.createHash('md5')
|
||
const stream = fs.createReadStream(filePath)
|
||
stream.on('data', (data) => hash.update(data))
|
||
stream.on('end', () => resolve(hash.digest('hex')))
|
||
stream.on('error', reject)
|
||
})
|
||
}
|
||
|
||
findDuplicateFile = async (filePath: string): Promise<FileMetadata | null> => {
|
||
const stats = fs.statSync(filePath)
|
||
logger.debug(`stats: ${stats}, filePath: ${filePath}`)
|
||
const fileSize = stats.size
|
||
|
||
const files = await fs.promises.readdir(this.storageDir)
|
||
for (const file of files) {
|
||
const storedFilePath = path.join(this.storageDir, file)
|
||
const storedStats = fs.statSync(storedFilePath)
|
||
|
||
if (storedStats.size === fileSize) {
|
||
const [originalHash, storedHash] = await Promise.all([
|
||
this.getFileHash(filePath),
|
||
this.getFileHash(storedFilePath)
|
||
])
|
||
|
||
if (originalHash === storedHash) {
|
||
const ext = path.extname(file)
|
||
const id = path.basename(file, ext)
|
||
return {
|
||
id,
|
||
origin_name: file,
|
||
name: file + ext,
|
||
path: storedFilePath,
|
||
created_at: storedStats.birthtime.toISOString(),
|
||
size: storedStats.size,
|
||
ext,
|
||
type: getFileType(ext),
|
||
count: 2
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
public selectFile = async (
|
||
_: Electron.IpcMainInvokeEvent,
|
||
options?: OpenDialogOptions
|
||
): Promise<FileMetadata[] | null> => {
|
||
const defaultOptions: OpenDialogOptions = {
|
||
properties: ['openFile']
|
||
}
|
||
|
||
const dialogOptions = { ...defaultOptions, ...options }
|
||
|
||
const result = await dialog.showOpenDialog(dialogOptions)
|
||
|
||
if (result.canceled || result.filePaths.length === 0) {
|
||
return null
|
||
}
|
||
|
||
const fileMetadataPromises = result.filePaths.map(async (filePath) => {
|
||
const stats = fs.statSync(filePath)
|
||
const ext = path.extname(filePath)
|
||
const fileType = getFileType(ext)
|
||
|
||
return {
|
||
id: uuidv4(),
|
||
origin_name: path.basename(filePath),
|
||
name: path.basename(filePath),
|
||
path: filePath,
|
||
created_at: stats.birthtime.toISOString(),
|
||
size: stats.size,
|
||
ext: ext,
|
||
type: fileType,
|
||
count: 1
|
||
}
|
||
})
|
||
|
||
return Promise.all(fileMetadataPromises)
|
||
}
|
||
|
||
private async compressImage(sourcePath: string, destPath: string): Promise<void> {
|
||
try {
|
||
const stats = fs.statSync(sourcePath)
|
||
const fileSizeInMB = stats.size / MB
|
||
|
||
// 如果图片大于1MB才进行压缩
|
||
if (fileSizeInMB > 1) {
|
||
try {
|
||
await fs.promises.copyFile(sourcePath, destPath)
|
||
logger.debug(`Image compressed successfully: ${sourcePath}`)
|
||
} catch (jimpError) {
|
||
logger.error('Image compression failed:', jimpError as Error)
|
||
await fs.promises.copyFile(sourcePath, destPath)
|
||
}
|
||
} else {
|
||
// 小图片直接复制
|
||
await fs.promises.copyFile(sourcePath, destPath)
|
||
}
|
||
} catch (error) {
|
||
logger.error('Image handling failed:', error as Error)
|
||
// 错误情况下直接复制原文件
|
||
await fs.promises.copyFile(sourcePath, destPath)
|
||
}
|
||
}
|
||
|
||
public uploadFile = async (_: Electron.IpcMainInvokeEvent, file: FileMetadata): Promise<FileMetadata> => {
|
||
const filePath = file.path
|
||
const duplicateFile = await this.findDuplicateFile(filePath)
|
||
|
||
if (duplicateFile) {
|
||
return duplicateFile
|
||
}
|
||
|
||
const uuid = uuidv4()
|
||
const origin_name = path.basename(file.path)
|
||
const ext = path.extname(origin_name).toLowerCase()
|
||
const destPath = path.join(this.storageDir, uuid + ext)
|
||
|
||
logger.info(`[FileStorage] Uploading file: ${filePath}`)
|
||
|
||
// 根据文件类型选择处理方式
|
||
if (imageExts.includes(ext)) {
|
||
await this.compressImage(filePath, destPath)
|
||
} else {
|
||
await fs.promises.copyFile(filePath, destPath)
|
||
}
|
||
|
||
const stats = await fs.promises.stat(destPath)
|
||
const fileType = getFileType(ext)
|
||
|
||
const fileMetadata: FileMetadata = {
|
||
id: uuid,
|
||
origin_name,
|
||
name: uuid + ext,
|
||
path: destPath,
|
||
created_at: stats.birthtime.toISOString(),
|
||
size: stats.size,
|
||
ext: ext,
|
||
type: fileType,
|
||
count: 1
|
||
}
|
||
|
||
logger.debug(`File uploaded: ${fileMetadata}`)
|
||
|
||
return fileMetadata
|
||
}
|
||
|
||
public getFile = async (_: Electron.IpcMainInvokeEvent, filePath: string): Promise<FileMetadata | null> => {
|
||
if (!fs.existsSync(filePath)) {
|
||
return null
|
||
}
|
||
|
||
const stats = fs.statSync(filePath)
|
||
const ext = path.extname(filePath)
|
||
const fileType = getFileType(ext)
|
||
|
||
const fileInfo: FileMetadata = {
|
||
id: uuidv4(),
|
||
origin_name: path.basename(filePath),
|
||
name: path.basename(filePath),
|
||
path: filePath,
|
||
created_at: stats.birthtime.toISOString(),
|
||
size: stats.size,
|
||
ext: ext,
|
||
type: fileType,
|
||
count: 1
|
||
}
|
||
|
||
return fileInfo
|
||
}
|
||
|
||
// @TraceProperty({ spanName: 'deleteFile', tag: 'FileStorage' })
|
||
public deleteFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<void> => {
|
||
if (!fs.existsSync(path.join(this.storageDir, id))) {
|
||
return
|
||
}
|
||
await fs.promises.unlink(path.join(this.storageDir, id))
|
||
}
|
||
|
||
public deleteDir = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<void> => {
|
||
if (!fs.existsSync(path.join(this.storageDir, id))) {
|
||
return
|
||
}
|
||
await fs.promises.rm(path.join(this.storageDir, id), { recursive: true })
|
||
}
|
||
|
||
public readFile = async (
|
||
_: Electron.IpcMainInvokeEvent,
|
||
id: string,
|
||
detectEncoding: boolean = false
|
||
): Promise<string> => {
|
||
const filePath = path.join(this.storageDir, id)
|
||
|
||
const fileExtension = path.extname(filePath)
|
||
|
||
if (documentExts.includes(fileExtension)) {
|
||
const originalCwd = process.cwd()
|
||
try {
|
||
chdir(this.tempDir)
|
||
|
||
if (fileExtension === '.doc') {
|
||
const extractor = new WordExtractor()
|
||
const extracted = await extractor.extract(filePath)
|
||
chdir(originalCwd)
|
||
return extracted.getBody()
|
||
}
|
||
|
||
const data = await officeParser.parseOfficeAsync(filePath)
|
||
chdir(originalCwd)
|
||
return data
|
||
} catch (error) {
|
||
chdir(originalCwd)
|
||
logger.error('Failed to read file:', error as Error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
try {
|
||
if (detectEncoding) {
|
||
return readTextFileWithAutoEncoding(filePath)
|
||
} else {
|
||
return fs.readFileSync(filePath, 'utf-8')
|
||
}
|
||
} catch (error) {
|
||
logger.error('Failed to read file:', error as Error)
|
||
throw new Error(`Failed to read file: ${filePath}.`)
|
||
}
|
||
}
|
||
|
||
public createTempFile = async (_: Electron.IpcMainInvokeEvent, fileName: string): Promise<string> => {
|
||
if (!fs.existsSync(this.tempDir)) {
|
||
fs.mkdirSync(this.tempDir, { recursive: true })
|
||
}
|
||
|
||
return path.join(this.tempDir, `temp_file_${uuidv4()}_${fileName}`)
|
||
}
|
||
|
||
public writeFile = async (
|
||
_: Electron.IpcMainInvokeEvent,
|
||
filePath: string,
|
||
data: Uint8Array | string
|
||
): Promise<void> => {
|
||
await fs.promises.writeFile(filePath, data)
|
||
}
|
||
|
||
public base64Image = async (
|
||
_: Electron.IpcMainInvokeEvent,
|
||
id: string
|
||
): Promise<{ mime: string; base64: string; data: string }> => {
|
||
const filePath = path.join(this.storageDir, id)
|
||
const data = await fs.promises.readFile(filePath)
|
||
const base64 = data.toString('base64')
|
||
const ext = path.extname(filePath).slice(1) == 'jpg' ? 'jpeg' : path.extname(filePath).slice(1)
|
||
const mime = `image/${ext}`
|
||
return {
|
||
mime,
|
||
base64,
|
||
data: `data:${mime};base64,${base64}`
|
||
}
|
||
}
|
||
|
||
public saveBase64Image = async (_: Electron.IpcMainInvokeEvent, base64Data: string): Promise<FileMetadata> => {
|
||
try {
|
||
if (!base64Data) {
|
||
throw new Error('Base64 data is required')
|
||
}
|
||
|
||
// 移除 base64 头部信息(如果存在)
|
||
const base64String = base64Data.replace(/^data:.*;base64,/, '')
|
||
const buffer = Buffer.from(base64String, 'base64')
|
||
const uuid = uuidv4()
|
||
const ext = '.png'
|
||
const destPath = path.join(this.storageDir, uuid + ext)
|
||
|
||
logger.debug('Saving base64 image:', {
|
||
storageDir: this.storageDir,
|
||
destPath,
|
||
bufferSize: buffer.length
|
||
})
|
||
|
||
// 确保目录存在
|
||
if (!fs.existsSync(this.storageDir)) {
|
||
fs.mkdirSync(this.storageDir, { recursive: true })
|
||
}
|
||
|
||
await fs.promises.writeFile(destPath, buffer)
|
||
|
||
const fileMetadata: FileMetadata = {
|
||
id: uuid,
|
||
origin_name: uuid + ext,
|
||
name: uuid + ext,
|
||
path: destPath,
|
||
created_at: new Date().toISOString(),
|
||
size: buffer.length,
|
||
ext: ext.slice(1),
|
||
type: getFileType(ext),
|
||
count: 1
|
||
}
|
||
|
||
return fileMetadata
|
||
} catch (error) {
|
||
logger.error('Failed to save base64 image:', error as Error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
public base64File = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<{ data: string; mime: string }> => {
|
||
const filePath = path.join(this.storageDir, id)
|
||
const buffer = await fs.promises.readFile(filePath)
|
||
const base64 = buffer.toString('base64')
|
||
const mime = `application/${path.extname(filePath).slice(1)}`
|
||
return { data: base64, mime }
|
||
}
|
||
|
||
public pdfPageCount = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<number> => {
|
||
const filePath = path.join(this.storageDir, id)
|
||
const buffer = await fs.promises.readFile(filePath)
|
||
|
||
const pdfDoc = await PDFDocument.load(buffer)
|
||
return pdfDoc.getPageCount()
|
||
}
|
||
|
||
public binaryImage = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<{ data: Buffer; mime: string }> => {
|
||
const filePath = path.join(this.storageDir, id)
|
||
const data = await fs.promises.readFile(filePath)
|
||
const mime = `image/${path.extname(filePath).slice(1)}`
|
||
return { data, mime }
|
||
}
|
||
|
||
public clear = async (): Promise<void> => {
|
||
await fs.promises.rm(this.storageDir, { recursive: true })
|
||
await this.initStorageDir()
|
||
}
|
||
|
||
public clearTemp = async (): Promise<void> => {
|
||
await fs.promises.rm(this.tempDir, { recursive: true })
|
||
await fs.promises.mkdir(this.tempDir, { recursive: true })
|
||
}
|
||
|
||
public open = async (
|
||
_: Electron.IpcMainInvokeEvent,
|
||
options: OpenDialogOptions
|
||
): Promise<{ fileName: string; filePath: string; content?: Buffer; size: number } | null> => {
|
||
try {
|
||
const result: OpenDialogReturnValue = await dialog.showOpenDialog({
|
||
title: '打开文件',
|
||
properties: ['openFile'],
|
||
filters: [{ name: '所有文件', extensions: ['*'] }],
|
||
...options
|
||
})
|
||
|
||
if (!result.canceled && result.filePaths.length > 0) {
|
||
const filePath = result.filePaths[0]
|
||
const fileName = filePath.split('/').pop() || ''
|
||
const stats = await fs.promises.stat(filePath)
|
||
|
||
// If the file is less than 2GB, read the content
|
||
if (stats.size < 2 * 1024 * 1024 * 1024) {
|
||
const content = await readFile(filePath)
|
||
return { fileName, filePath, content, size: stats.size }
|
||
}
|
||
|
||
// For large files, only return file information, do not read content
|
||
return { fileName, filePath, size: stats.size }
|
||
}
|
||
|
||
return null
|
||
} catch (err) {
|
||
logger.error('[IPC - Error] An error occurred opening the file:', err as Error)
|
||
return null
|
||
}
|
||
}
|
||
|
||
public openPath = async (_: Electron.IpcMainInvokeEvent, path: string): Promise<void> => {
|
||
shell.openPath(path).catch((err) => logger.error('[IPC - Error] Failed to open file:', err))
|
||
}
|
||
|
||
/**
|
||
* 通过相对路径打开文件,跨设备时使用
|
||
* @param file
|
||
*/
|
||
public openFileWithRelativePath = async (_: Electron.IpcMainInvokeEvent, file: FileMetadata): Promise<void> => {
|
||
const filePath = path.join(this.storageDir, file.name)
|
||
if (fs.existsSync(filePath)) {
|
||
shell.openPath(filePath).catch((err) => logger.error('[IPC - Error] Failed to open file:', err))
|
||
} else {
|
||
logger.warn(`[IPC - Warning] File does not exist: ${filePath}`)
|
||
}
|
||
}
|
||
|
||
public save = async (
|
||
_: Electron.IpcMainInvokeEvent,
|
||
fileName: string,
|
||
content: string,
|
||
options?: SaveDialogOptions
|
||
): Promise<string> => {
|
||
try {
|
||
const result: SaveDialogReturnValue = await dialog.showSaveDialog({
|
||
title: '保存文件',
|
||
defaultPath: fileName,
|
||
...options
|
||
})
|
||
|
||
if (result.canceled) {
|
||
return Promise.reject(new Error('User canceled the save dialog'))
|
||
}
|
||
|
||
if (!result.canceled && result.filePath) {
|
||
await writeFileSync(result.filePath, content, { encoding: 'utf-8' })
|
||
}
|
||
|
||
return result.filePath
|
||
} catch (err: any) {
|
||
logger.error('[IPC - Error] An error occurred saving the file:', err as Error)
|
||
return Promise.reject('An error occurred saving the file: ' + err?.message)
|
||
}
|
||
}
|
||
|
||
public saveImage = async (_: Electron.IpcMainInvokeEvent, name: string, data: string): Promise<void> => {
|
||
try {
|
||
const filePath = dialog.showSaveDialogSync({
|
||
defaultPath: `${name}.png`,
|
||
filters: [{ name: 'PNG Image', extensions: ['png'] }]
|
||
})
|
||
|
||
if (filePath) {
|
||
const base64Data = data.replace(/^data:image\/png;base64,/, '')
|
||
fs.writeFileSync(filePath, base64Data, 'base64')
|
||
}
|
||
} catch (error) {
|
||
logger.error('[IPC - Error] An error occurred saving the image:', error as Error)
|
||
}
|
||
}
|
||
|
||
public selectFolder = async (_: Electron.IpcMainInvokeEvent, options: OpenDialogOptions): Promise<string | null> => {
|
||
try {
|
||
const result: OpenDialogReturnValue = await dialog.showOpenDialog({
|
||
title: '选择文件夹',
|
||
properties: ['openDirectory'],
|
||
...options
|
||
})
|
||
|
||
if (!result.canceled && result.filePaths.length > 0) {
|
||
return result.filePaths[0]
|
||
}
|
||
|
||
return null
|
||
} catch (err) {
|
||
logger.error('[IPC - Error] An error occurred selecting the folder:', err as Error)
|
||
return null
|
||
}
|
||
}
|
||
|
||
public downloadFile = async (
|
||
_: Electron.IpcMainInvokeEvent,
|
||
url: string,
|
||
isUseContentType?: boolean
|
||
): Promise<FileMetadata> => {
|
||
try {
|
||
const response = await net.fetch(url)
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`)
|
||
}
|
||
|
||
// 尝试从Content-Disposition获取文件名
|
||
const contentDisposition = response.headers.get('Content-Disposition')
|
||
let filename = 'download'
|
||
|
||
if (contentDisposition) {
|
||
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i)
|
||
if (filenameMatch) {
|
||
filename = filenameMatch[1]
|
||
}
|
||
}
|
||
|
||
// 如果URL中有文件名,使用URL中的文件名
|
||
const urlFilename = url.split('/').pop()?.split('?')[0]
|
||
if (urlFilename && urlFilename.includes('.')) {
|
||
filename = urlFilename
|
||
}
|
||
|
||
// 如果文件名没有后缀,根据Content-Type添加后缀
|
||
if (isUseContentType || !filename.includes('.')) {
|
||
const contentType = response.headers.get('Content-Type')
|
||
const ext = this.getExtensionFromMimeType(contentType)
|
||
filename += ext
|
||
}
|
||
|
||
const uuid = uuidv4()
|
||
const ext = path.extname(filename)
|
||
const destPath = path.join(this.storageDir, uuid + ext)
|
||
|
||
// 将响应内容写入文件
|
||
const buffer = Buffer.from(await response.arrayBuffer())
|
||
await fs.promises.writeFile(destPath, buffer)
|
||
|
||
const stats = await fs.promises.stat(destPath)
|
||
const fileType = getFileType(ext)
|
||
|
||
const fileMetadata: FileMetadata = {
|
||
id: uuid,
|
||
origin_name: filename,
|
||
name: uuid + ext,
|
||
path: destPath,
|
||
created_at: stats.birthtime.toISOString(),
|
||
size: stats.size,
|
||
ext: ext,
|
||
type: fileType,
|
||
count: 1
|
||
}
|
||
|
||
return fileMetadata
|
||
} catch (error) {
|
||
logger.error('Download file error:', error as Error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
private getExtensionFromMimeType(mimeType: string | null): string {
|
||
if (!mimeType) return '.bin'
|
||
|
||
const mimeToExtension: { [key: string]: string } = {
|
||
'image/jpeg': '.jpg',
|
||
'image/png': '.png',
|
||
'image/gif': '.gif',
|
||
'application/pdf': '.pdf',
|
||
'text/plain': '.txt',
|
||
'application/msword': '.doc',
|
||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
|
||
'application/zip': '.zip',
|
||
'application/x-zip-compressed': '.zip',
|
||
'application/octet-stream': '.bin'
|
||
}
|
||
|
||
return mimeToExtension[mimeType] || '.bin'
|
||
}
|
||
|
||
// @TraceProperty({ spanName: 'copyFile', tag: 'FileStorage' })
|
||
public copyFile = async (_: Electron.IpcMainInvokeEvent, id: string, destPath: string): Promise<void> => {
|
||
try {
|
||
const sourcePath = path.join(this.storageDir, id)
|
||
|
||
// 确保目标目录存在
|
||
const destDir = path.dirname(destPath)
|
||
if (!fs.existsSync(destDir)) {
|
||
await fs.promises.mkdir(destDir, { recursive: true })
|
||
}
|
||
|
||
// 复制文件
|
||
await fs.promises.copyFile(sourcePath, destPath)
|
||
logger.debug(`File copied successfully: ${sourcePath} to ${destPath}`)
|
||
} catch (error) {
|
||
logger.error('Copy file failed:', error as Error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
public writeFileWithId = async (_: Electron.IpcMainInvokeEvent, id: string, content: string): Promise<void> => {
|
||
try {
|
||
const filePath = path.join(this.storageDir, id)
|
||
logger.debug(`Writing file: ${filePath}`)
|
||
|
||
// 确保目录存在
|
||
if (!fs.existsSync(this.storageDir)) {
|
||
logger.debug(`Creating storage directory: ${this.storageDir}`)
|
||
fs.mkdirSync(this.storageDir, { recursive: true })
|
||
}
|
||
|
||
await fs.promises.writeFile(filePath, content, 'utf8')
|
||
logger.debug(`File written successfully: ${filePath}`)
|
||
} catch (error) {
|
||
logger.error('Failed to write file:', error as Error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
public getFilePathById(file: FileMetadata): string {
|
||
return path.join(this.storageDir, file.id + file.ext)
|
||
}
|
||
|
||
public isTextFile = async (_: Electron.IpcMainInvokeEvent, filePath: string): Promise<boolean> => {
|
||
try {
|
||
const isBinary = await isBinaryFile(filePath)
|
||
if (isBinary) {
|
||
return false
|
||
}
|
||
|
||
const length = 8 * KB
|
||
const fileHandle = await fs.promises.open(filePath, 'r')
|
||
const buffer = Buffer.alloc(length)
|
||
const { bytesRead } = await fileHandle.read(buffer, 0, length, 0)
|
||
await fileHandle.close()
|
||
|
||
const sampleBuffer = buffer.subarray(0, bytesRead)
|
||
const matches = chardet.analyse(sampleBuffer)
|
||
|
||
// 如果检测到的编码置信度较高,认为是文本文件
|
||
if (matches.length > 0 && matches[0].confidence > 0.8) {
|
||
return true
|
||
}
|
||
|
||
return false
|
||
} catch (error) {
|
||
logger.error('Failed to check if file is text:', error as Error)
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
|
||
export const fileStorage = new FileStorage()
|