diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f530d6e3bf..e2b17486db 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,38 +4,26 @@ updates: directory: "/" schedule: interval: "monthly" - open-pull-requests-limit: 7 + open-pull-requests-limit: 5 target-branch: "main" commit-message: prefix: "chore" include: "scope" + ignore: + - dependency-name: "*" + update-types: + - "version-update:semver-major" + - dependency-name: "@google/genai" + - dependency-name: "antd" + - dependency-name: "epub" + - dependency-name: "openai" groups: - # 核心框架 - core-framework: + # CherryStudio 自定义包 + cherrystudio-packages: patterns: - - "react" - - "react-dom" - - "electron" - - "typescript" - - "@types/react*" - - "@types/node" - update-types: - - "minor" - - "patch" - - # Electron 生态和构建工具 - electron-build: - patterns: - - "electron-*" - - "@electron*" - - "vite" - - "@vitejs/*" - - "dotenv-cli" - - "rollup-plugin-*" - - "@swc/*" - update-types: - - "minor" - - "patch" + - "@cherrystudio/*" + - "@kangfenmao/*" + - "selection-hook" # 测试工具 testing-tools: @@ -44,30 +32,40 @@ updates: - "@vitest/*" - "playwright" - "@playwright/*" - - "eslint*" - - "@eslint*" + - "testing-library/*" + - "jest-styled-components" + + # Lint 工具 + lint-tools: + patterns: + - "eslint" + - "eslint-plugin-*" + - "@eslint/*" + - "@eslint-react/*" + - "@electron-toolkit/eslint-config-*" - "prettier" - "husky" - "lint-staged" - update-types: - - "minor" - - "patch" - # CherryStudio 自定义包 - cherrystudio-packages: + # Markdown + markdown: patterns: - - "@cherrystudio/*" - update-types: - - "minor" - - "patch" - - # 兜底其他 dependencies - other-dependencies: - dependency-type: "production" - - # 兜底其他 devDependencies - other-dev-dependencies: - dependency-type: "development" + - "react-markdown" + - "rehype-katex" + - "rehype-mathjax" + - "rehype-raw" + - "remark-cjk-friendly" + - "remark-gfm" + - "remark-math" + - "remove-markdown" + - "markdown-it" + - "@shikijs/markdown-it" + - "shiki" + - "@uiw/codemirror-extensions-langs" + - "@uiw/codemirror-themes-all" + - "@uiw/react-codemirror" + - "fast-diff" + - "mermaid" - package-ecosystem: "github-actions" directory: "/" diff --git a/.vscode/settings.json b/.vscode/settings.json index 47640bff09..ef4dc3954a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,7 +2,7 @@ "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit", - "source.organizeImports": "explicit" + "source.organizeImports": "never" }, "search.exclude": { "**/dist/**": true, diff --git a/electron-builder.yml b/electron-builder.yml index ecbbc10057..c65f20ed32 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -11,6 +11,11 @@ electronLanguages: - en # for macOS directories: buildResources: build + +protocols: + - name: Cherry Studio + schemes: + - cherrystudio files: - '**/*' - '!**/{.vscode,.yarn,.yarn-lock,.github,.cursorrules,.prettierrc}' diff --git a/package.json b/package.json index 0bfc8c30bb..6d2e19a15b 100644 --- a/package.json +++ b/package.json @@ -62,9 +62,9 @@ "@libsql/win32-x64-msvc": "^0.4.7", "@strongtz/win32-arm64-msvc": "^0.4.7", "jsdom": "26.1.0", + "macos-release": "^3.4.0", "node-stream-zip": "^1.15.0", "notion-helper": "^1.3.22", - "opendal": "0.47.11", "os-proxy-config": "^1.1.2", "selection-hook": "^0.9.23", "turndown": "7.2.0" @@ -106,7 +106,7 @@ "@notionhq/client": "^2.2.15", "@playwright/test": "^1.52.0", "@reduxjs/toolkit": "^2.2.5", - "@shikijs/markdown-it": "^3.4.2", + "@shikijs/markdown-it": "^3.7.0", "@swc/plugin-styled-components": "^7.1.5", "@tanstack/react-query": "^5.27.0", "@testing-library/dom": "^10.4.0", @@ -126,9 +126,9 @@ "@types/react-window": "^1", "@types/tinycolor2": "^1", "@types/word-extractor": "^1", - "@uiw/codemirror-extensions-langs": "^4.23.12", - "@uiw/codemirror-themes-all": "^4.23.12", - "@uiw/react-codemirror": "^4.23.12", + "@uiw/codemirror-extensions-langs": "^4.23.14", + "@uiw/codemirror-themes-all": "^4.23.14", + "@uiw/react-codemirror": "^4.23.14", "@vitejs/plugin-react-swc": "^3.9.0", "@vitest/browser": "^3.1.4", "@vitest/coverage-v8": "^3.1.4", @@ -148,7 +148,7 @@ "diff": "^7.0.0", "docx": "^9.0.2", "dotenv-cli": "^7.4.2", - "electron": "35.4.0", + "electron": "35.6.0", "electron-builder": "26.0.15", "electron-devtools-installer": "^3.2.0", "electron-log": "^5.1.5", @@ -178,7 +178,7 @@ "lru-cache": "^11.1.0", "lucide-react": "^0.487.0", "markdown-it": "^14.1.0", - "mermaid": "^11.6.0", + "mermaid": "^11.7.0", "mime": "^4.0.4", "motion": "^12.10.5", "npx-scope-finder": "^1.2.0", @@ -211,7 +211,7 @@ "remove-markdown": "^0.6.2", "rollup-plugin-visualizer": "^5.12.0", "sass": "^1.88.0", - "shiki": "^3.4.2", + "shiki": "^3.7.0", "string-width": "^7.2.0", "styled-components": "^6.1.11", "tar": "^7.4.3", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 0d90b41c0e..57e73c9127 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -153,11 +153,6 @@ export enum IpcChannel { Backup_CheckConnection = 'backup:checkConnection', Backup_CreateDirectory = 'backup:createDirectory', Backup_DeleteWebdavFile = 'backup:deleteWebdavFile', - Backup_BackupToS3 = 'backup:backupToS3', - Backup_RestoreFromS3 = 'backup:restoreFromS3', - Backup_ListS3Files = 'backup:listS3Files', - Backup_DeleteS3File = 'backup:deleteS3File', - Backup_CheckS3Connection = 'backup:checkS3Connection', // zip Zip_Compress = 'zip:compress', diff --git a/scripts/update-i18n.ts b/scripts/update-i18n.ts index 3af6084384..9363970f74 100644 --- a/scripts/update-i18n.ts +++ b/scripts/update-i18n.ts @@ -1,16 +1,19 @@ /** - * Paratera_API_KEY=sk-abcxxxxxxxxxxxxxxxxxxxxxxx123 ts-node scripts/update-i18n.ts + * 使用 OpenAI 兼容的模型生成 i18n 文本,并更新到 translate 目录 + * + * API_KEY=sk-xxxx BASE_URL=xxxx MODEL=xxxx ts-node scripts/update-i18n.ts */ -// OCOOL API KEY -const Paratera_API_KEY = process.env.Paratera_API_KEY +const API_KEY = process.env.API_KEY +const BASE_URL = process.env.BASE_URL || 'https://llmapi.paratera.com/v1' +const MODEL = process.env.MODEL || 'Qwen3-235B-A22B' const INDEX = [ - // 语言的名称 代码 用来翻译的模型 - { name: 'France', code: 'fr-fr', model: 'Qwen3-235B-A22B' }, - { name: 'Spanish', code: 'es-es', model: 'Qwen3-235B-A22B' }, - { name: 'Portuguese', code: 'pt-pt', model: 'Qwen3-235B-A22B' }, - { name: 'Greek', code: 'el-gr', model: 'Qwen3-235B-A22B' } + // 语言的名称代码用来翻译的模型 + { name: 'France', code: 'fr-fr', model: MODEL }, + { name: 'Spanish', code: 'es-es', model: MODEL }, + { name: 'Portuguese', code: 'pt-pt', model: MODEL }, + { name: 'Greek', code: 'el-gr', model: MODEL } ] const fs = require('fs') @@ -19,8 +22,8 @@ import OpenAI from 'openai' const zh = JSON.parse(fs.readFileSync('src/renderer/src/i18n/locales/zh-cn.json', 'utf8')) as object const openai = new OpenAI({ - apiKey: Paratera_API_KEY, - baseURL: 'https://llmapi.paratera.com/v1' + apiKey: API_KEY, + baseURL: BASE_URL }) // 递归遍历翻译 diff --git a/src/main/index.ts b/src/main/index.ts index 3699335a90..46ebd7c6e6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -124,19 +124,27 @@ if (!app.requestSingleInstanceLock()) { registerProtocolClient(app) // macOS specific: handle protocol when app is already running + app.on('open-url', (event, url) => { event.preventDefault() handleProtocolUrl(url) }) + const handleOpenUrl = (args: string[]) => { + const url = args.find((arg) => arg.startsWith(CHERRY_STUDIO_PROTOCOL + '://')) + if (url) handleProtocolUrl(url) + } + + // for windows to start with url + handleOpenUrl(process.argv) + // Listen for second instance app.on('second-instance', (_event, argv) => { windowService.showMainWindow() // Protocol handler for Windows/Linux // The commandLine is an array of strings where the last item might be the URL - const url = argv.find((arg) => arg.startsWith(CHERRY_STUDIO_PROTOCOL + '://')) - if (url) handleProtocolUrl(url) + handleOpenUrl(argv) }) app.on('browser-window-created', (_, window) => { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 7d21c84f00..ef16e18aec 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -345,11 +345,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.Backup_CheckConnection, backupManager.checkConnection) ipcMain.handle(IpcChannel.Backup_CreateDirectory, backupManager.createDirectory) ipcMain.handle(IpcChannel.Backup_DeleteWebdavFile, backupManager.deleteWebdavFile) - ipcMain.handle(IpcChannel.Backup_BackupToS3, backupManager.backupToS3) - ipcMain.handle(IpcChannel.Backup_RestoreFromS3, backupManager.restoreFromS3) - ipcMain.handle(IpcChannel.Backup_ListS3Files, backupManager.listS3Files) - ipcMain.handle(IpcChannel.Backup_DeleteS3File, backupManager.deleteS3File) - ipcMain.handle(IpcChannel.Backup_CheckS3Connection, backupManager.checkS3Connection) // file ipcMain.handle(IpcChannel.File_Open, fileManager.open) diff --git a/src/main/embeddings/Embeddings.ts b/src/main/knowledage/embeddings/Embeddings.ts similarity index 100% rename from src/main/embeddings/Embeddings.ts rename to src/main/knowledage/embeddings/Embeddings.ts diff --git a/src/main/embeddings/EmbeddingsFactory.ts b/src/main/knowledage/embeddings/EmbeddingsFactory.ts similarity index 100% rename from src/main/embeddings/EmbeddingsFactory.ts rename to src/main/knowledage/embeddings/EmbeddingsFactory.ts diff --git a/src/main/embeddings/VoyageEmbeddings.ts b/src/main/knowledage/embeddings/VoyageEmbeddings.ts similarity index 100% rename from src/main/embeddings/VoyageEmbeddings.ts rename to src/main/knowledage/embeddings/VoyageEmbeddings.ts diff --git a/src/main/loader/draftsExportLoader.ts b/src/main/knowledage/loader/draftsExportLoader.ts similarity index 100% rename from src/main/loader/draftsExportLoader.ts rename to src/main/knowledage/loader/draftsExportLoader.ts diff --git a/src/main/loader/epubLoader.ts b/src/main/knowledage/loader/epubLoader.ts similarity index 100% rename from src/main/loader/epubLoader.ts rename to src/main/knowledage/loader/epubLoader.ts diff --git a/src/main/loader/index.ts b/src/main/knowledage/loader/index.ts similarity index 100% rename from src/main/loader/index.ts rename to src/main/knowledage/loader/index.ts diff --git a/src/main/loader/noteLoader.ts b/src/main/knowledage/loader/noteLoader.ts similarity index 100% rename from src/main/loader/noteLoader.ts rename to src/main/knowledage/loader/noteLoader.ts diff --git a/src/main/loader/odLoader.ts b/src/main/knowledage/loader/odLoader.ts similarity index 100% rename from src/main/loader/odLoader.ts rename to src/main/knowledage/loader/odLoader.ts diff --git a/src/main/reranker/BaseReranker.ts b/src/main/knowledage/reranker/BaseReranker.ts similarity index 100% rename from src/main/reranker/BaseReranker.ts rename to src/main/knowledage/reranker/BaseReranker.ts diff --git a/src/main/reranker/GeneralReranker.ts b/src/main/knowledage/reranker/GeneralReranker.ts similarity index 100% rename from src/main/reranker/GeneralReranker.ts rename to src/main/knowledage/reranker/GeneralReranker.ts diff --git a/src/main/reranker/Reranker.ts b/src/main/knowledage/reranker/Reranker.ts similarity index 100% rename from src/main/reranker/Reranker.ts rename to src/main/knowledage/reranker/Reranker.ts diff --git a/src/main/services/AppUpdater.ts b/src/main/services/AppUpdater.ts index 82165fd715..effcff00c5 100644 --- a/src/main/services/AppUpdater.ts +++ b/src/main/services/AppUpdater.ts @@ -1,5 +1,6 @@ import { isWin } from '@main/constant' import { locales } from '@main/utils/locales' +import { generateUserAgent } from '@main/utils/systemInfo' import { FeedUrl, UpgradeChannel } from '@shared/config/constant' import { IpcChannel } from '@shared/IpcChannel' import { CancellationToken, UpdateInfo } from 'builder-util-runtime' @@ -24,6 +25,10 @@ export default class AppUpdater { autoUpdater.forceDevUpdateConfig = !app.isPackaged autoUpdater.autoDownload = configManager.getAutoUpdate() autoUpdater.autoInstallOnAppQuit = configManager.getAutoUpdate() + autoUpdater.requestHeaders = { + ...autoUpdater.requestHeaders, + 'User-Agent': generateUserAgent() + } autoUpdater.on('error', (error) => { // 简单记录错误信息和时间戳 diff --git a/src/main/services/BackupManager.ts b/src/main/services/BackupManager.ts index 6e0c813e6d..e994e90bed 100644 --- a/src/main/services/BackupManager.ts +++ b/src/main/services/BackupManager.ts @@ -1,6 +1,5 @@ import { IpcChannel } from '@shared/IpcChannel' import { WebDavConfig } from '@types' -import { S3Config } from '@types' import archiver from 'archiver' import { exec } from 'child_process' import { app } from 'electron' @@ -11,7 +10,6 @@ import * as path from 'path' import { CreateDirectoryOptions, FileStat } from 'webdav' import { getDataPath } from '../utils' -import S3Storage from './RemoteStorage' import WebDav from './WebDav' import { windowService } from './WindowService' @@ -27,11 +25,6 @@ class BackupManager { this.restoreFromWebdav = this.restoreFromWebdav.bind(this) this.listWebdavFiles = this.listWebdavFiles.bind(this) this.deleteWebdavFile = this.deleteWebdavFile.bind(this) - this.backupToS3 = this.backupToS3.bind(this) - this.restoreFromS3 = this.restoreFromS3.bind(this) - this.listS3Files = this.listS3Files.bind(this) - this.deleteS3File = this.deleteS3File.bind(this) - this.checkS3Connection = this.checkS3Connection.bind(this) } private async setWritableRecursive(dirPath: string): Promise { @@ -92,11 +85,7 @@ class BackupManager { const onProgress = (processData: { stage: string; progress: number; total: number }) => { mainWindow?.webContents.send(IpcChannel.BackupProgress, processData) - // 只在关键阶段记录日志:开始、结束和主要阶段转换点 - const logStages = ['preparing', 'writing_data', 'preparing_compression', 'completed'] - if (logStages.includes(processData.stage) || processData.progress === 100) { - Logger.log('[BackupManager] backup progress', processData) - } + Logger.log('[BackupManager] backup progress', processData) } try { @@ -158,23 +147,18 @@ class BackupManager { let totalBytes = 0 let processedBytes = 0 - // 首先计算总文件数和总大小,但不记录详细日志 + // 首先计算总文件数和总大小 const calculateTotals = async (dirPath: string) => { - try { - const items = await fs.readdir(dirPath, { withFileTypes: true }) - for (const item of items) { - const fullPath = path.join(dirPath, item.name) - if (item.isDirectory()) { - await calculateTotals(fullPath) - } else { - totalEntries++ - const stats = await fs.stat(fullPath) - totalBytes += stats.size - } + const items = await fs.readdir(dirPath, { withFileTypes: true }) + for (const item of items) { + const fullPath = path.join(dirPath, item.name) + if (item.isDirectory()) { + await calculateTotals(fullPath) + } else { + totalEntries++ + const stats = await fs.stat(fullPath) + totalBytes += stats.size } - } catch (error) { - // 仅在出错时记录日志 - Logger.error('[BackupManager] Error calculating totals:', error) } } @@ -246,11 +230,7 @@ class BackupManager { const onProgress = (processData: { stage: string; progress: number; total: number }) => { mainWindow?.webContents.send(IpcChannel.RestoreProgress, processData) - // 只在关键阶段记录日志 - const logStages = ['preparing', 'extracting', 'extracted', 'reading_data', 'completed'] - if (logStages.includes(processData.stage) || processData.progress === 100) { - Logger.log('[BackupManager] restore progress', processData) - } + Logger.log('[BackupManager] restore progress', processData) } try { @@ -402,54 +382,21 @@ class BackupManager { destination: string, onProgress: (size: number) => void ): Promise { - // 先统计总文件数 - let totalFiles = 0 - let processedFiles = 0 - let lastProgressReported = 0 + const items = await fs.readdir(source, { withFileTypes: true }) - // 计算总文件数 - const countFiles = async (dir: string): Promise => { - let count = 0 - const items = await fs.readdir(dir, { withFileTypes: true }) - for (const item of items) { - if (item.isDirectory()) { - count += await countFiles(path.join(dir, item.name)) - } else { - count++ - } - } - return count - } + for (const item of items) { + const sourcePath = path.join(source, item.name) + const destPath = path.join(destination, item.name) - totalFiles = await countFiles(source) - - // 复制文件并更新进度 - const copyDir = async (src: string, dest: string): Promise => { - const items = await fs.readdir(src, { withFileTypes: true }) - - for (const item of items) { - const sourcePath = path.join(src, item.name) - const destPath = path.join(dest, item.name) - - if (item.isDirectory()) { - await fs.ensureDir(destPath) - await copyDir(sourcePath, destPath) - } else { - const stats = await fs.stat(sourcePath) - await fs.copy(sourcePath, destPath) - processedFiles++ - - // 只在进度变化超过5%时报告进度 - const currentProgress = Math.floor((processedFiles / totalFiles) * 100) - if (currentProgress - lastProgressReported >= 5 || processedFiles === totalFiles) { - lastProgressReported = currentProgress - onProgress(stats.size) - } - } + if (item.isDirectory()) { + await fs.ensureDir(destPath) + await this.copyDirWithProgress(sourcePath, destPath, onProgress) + } else { + const stats = await fs.stat(sourcePath) + await fs.copy(sourcePath, destPath) + onProgress(stats.size) } } - - await copyDir(source, destination) } async checkConnection(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) { @@ -476,141 +423,6 @@ class BackupManager { throw new Error(error.message || 'Failed to delete backup file') } } - - async backupToS3(_: Electron.IpcMainInvokeEvent, data: string, s3Config: S3Config) { - // 获取设备名 - const os = require('os') - const deviceName = os.hostname ? os.hostname() : 'device' - const timestamp = new Date() - .toISOString() - .replace(/[-:T.Z]/g, '') - .slice(0, 14) - const filename = s3Config.fileName || `cherry-studio.backup.${deviceName}.${timestamp}.zip` - - // 不记录详细日志,只记录开始和结束 - Logger.log(`[BackupManager] Starting S3 backup to ${filename}`) - - const backupedFilePath = await this.backup(_, filename, data, undefined, s3Config.skipBackupFile) - const s3Client = new S3Storage('s3', { - endpoint: s3Config.endpoint, - region: s3Config.region, - bucket: s3Config.bucket, - access_key_id: s3Config.access_key_id, - secret_access_key: s3Config.secret_access_key, - root: s3Config.root || '' - }) - try { - const fileBuffer = await fs.promises.readFile(backupedFilePath) - const result = await s3Client.putFileContents(filename, fileBuffer) - await fs.remove(backupedFilePath) - - Logger.log(`[BackupManager] S3 backup completed successfully: ${filename}`) - return result - } catch (error) { - Logger.error(`[BackupManager] S3 backup failed:`, error) - await fs.remove(backupedFilePath) - throw error - } - } - - async restoreFromS3(_: Electron.IpcMainInvokeEvent, s3Config: S3Config) { - const filename = s3Config.fileName || 'cherry-studio.backup.zip' - - // 只记录开始和结束或错误 - Logger.log(`[BackupManager] Starting restore from S3: ${filename}`) - - const s3Client = new S3Storage('s3', { - endpoint: s3Config.endpoint, - region: s3Config.region, - bucket: s3Config.bucket, - access_key_id: s3Config.access_key_id, - secret_access_key: s3Config.secret_access_key, - root: s3Config.root || '' - }) - try { - const retrievedFile = await s3Client.getFileContents(filename) - const backupedFilePath = path.join(this.backupDir, filename) - if (!fs.existsSync(this.backupDir)) { - fs.mkdirSync(this.backupDir, { recursive: true }) - } - await new Promise((resolve, reject) => { - const writeStream = fs.createWriteStream(backupedFilePath) - writeStream.write(retrievedFile as Buffer) - writeStream.end() - writeStream.on('finish', () => resolve()) - writeStream.on('error', (error) => reject(error)) - }) - - Logger.log(`[BackupManager] S3 restore file downloaded successfully: ${filename}`) - return await this.restore(_, backupedFilePath) - } catch (error: any) { - Logger.error('[BackupManager] Failed to restore from S3:', error) - throw new Error(error.message || 'Failed to restore backup file') - } - } - - listS3Files = async (_: Electron.IpcMainInvokeEvent, s3Config: S3Config) => { - try { - const s3Client = new S3Storage('s3', { - endpoint: s3Config.endpoint, - region: s3Config.region, - bucket: s3Config.bucket, - access_key_id: s3Config.access_key_id, - secret_access_key: s3Config.secret_access_key, - root: s3Config.root || '' - }) - const entries = await s3Client.instance?.list('/') - const files: Array<{ fileName: string; modifiedTime: string; size: number }> = [] - if (entries) { - for await (const entry of entries) { - const path = entry.path() - if (path.endsWith('.zip')) { - const meta = await s3Client.instance!.stat(path) - if (meta.isFile()) { - files.push({ - fileName: path.replace(/^\/+/, ''), - modifiedTime: meta.lastModified || '', - size: Number(meta.contentLength || 0n) - }) - } - } - } - } - return files.sort((a, b) => new Date(b.modifiedTime).getTime() - new Date(a.modifiedTime).getTime()) - } catch (error: any) { - Logger.error('Failed to list S3 files:', error) - throw new Error(error.message || 'Failed to list backup files') - } - } - - async deleteS3File(_: Electron.IpcMainInvokeEvent, fileName: string, s3Config: S3Config) { - try { - const s3Client = new S3Storage('s3', { - endpoint: s3Config.endpoint, - region: s3Config.region, - bucket: s3Config.bucket, - access_key_id: s3Config.access_key_id, - secret_access_key: s3Config.secret_access_key, - root: s3Config.root || '' - }) - return await s3Client.deleteFile(fileName) - } catch (error: any) { - Logger.error('Failed to delete S3 file:', error) - throw new Error(error.message || 'Failed to delete backup file') - } - } - - async checkS3Connection(_: Electron.IpcMainInvokeEvent, s3Config: S3Config) { - const s3Client = new S3Storage('s3', { - endpoint: s3Config.endpoint, - region: s3Config.region, - bucket: s3Config.bucket, - access_key_id: s3Config.access_key_id, - secret_access_key: s3Config.secret_access_key, - root: s3Config.root || '' - }) - return await s3Client.checkConnection() - } } export default BackupManager diff --git a/src/main/services/KnowledgeService.ts b/src/main/services/KnowledgeService.ts index d2d381c598..686e643711 100644 --- a/src/main/services/KnowledgeService.ts +++ b/src/main/services/KnowledgeService.ts @@ -21,10 +21,10 @@ import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' import { LibSqlDb } from '@cherrystudio/embedjs-libsql' import { SitemapLoader } from '@cherrystudio/embedjs-loader-sitemap' import { WebLoader } from '@cherrystudio/embedjs-loader-web' -import Embeddings from '@main/embeddings/Embeddings' -import { addFileLoader } from '@main/loader' -import { NoteLoader } from '@main/loader/noteLoader' -import Reranker from '@main/reranker/Reranker' +import Embeddings from '@main/knowledage/embeddings/Embeddings' +import { addFileLoader } from '@main/knowledage/loader' +import { NoteLoader } from '@main/knowledage/loader/noteLoader' +import Reranker from '@main/knowledage/reranker/Reranker' import { windowService } from '@main/services/WindowService' import { getDataPath } from '@main/utils' import { getAllFiles } from '@main/utils/file' diff --git a/src/main/services/ProtocolClient.ts b/src/main/services/ProtocolClient.ts index 7e0b274816..cac0983fd6 100644 --- a/src/main/services/ProtocolClient.ts +++ b/src/main/services/ProtocolClient.ts @@ -19,7 +19,7 @@ export function registerProtocolClient(app: Electron.App) { } } - app.setAsDefaultProtocolClient('cherrystudio') + app.setAsDefaultProtocolClient(CHERRY_STUDIO_PROTOCOL) } export function handleProtocolUrl(url: string) { diff --git a/src/main/services/RemoteStorage.ts b/src/main/services/RemoteStorage.ts index 4efc57b6c6..b62489bbbe 100644 --- a/src/main/services/RemoteStorage.ts +++ b/src/main/services/RemoteStorage.ts @@ -1,83 +1,57 @@ -import Logger from 'electron-log' -import type { Operator as OperatorType } from 'opendal' -const { Operator } = require('opendal') +// import Logger from 'electron-log' +// import { Operator } from 'opendal' -export default class S3Storage { - public instance: OperatorType | undefined +// export default class RemoteStorage { +// public instance: Operator | undefined - /** - * - * @param scheme is the scheme for opendal services. Available value includes "azblob", "azdls", "cos", "gcs", "obs", "oss", "s3", "webdav", "webhdfs", "aliyun-drive", "alluxio", "azfile", "dropbox", "gdrive", "onedrive", "postgresql", "mysql", "redis", "swift", "mongodb", "alluxio", "b2", "seafile", "upyun", "koofr", "yandex-disk" - * @param options is the options for given opendal services. Valid options depend on the scheme. Checkout https://docs.rs/opendal/latest/opendal/services/index.html for all valid options. - * - * For example, use minio as remote storage: - * - * ```typescript - * const storage = new S3Storage('s3', { - * endpoint: 'http://localhost:9000', - * region: 'us-east-1', - * bucket: 'testbucket', - * access_key_id: 'user', - * secret_access_key: 'password', - * root: '/path/to/basepath', - * }) - * ``` - */ - constructor(scheme: string, options?: Record | undefined | null) { - this.instance = new Operator(scheme, options) +// /** +// * +// * @param scheme is the scheme for opendal services. Available value includes "azblob", "azdls", "cos", "gcs", "obs", "oss", "s3", "webdav", "webhdfs", "aliyun-drive", "alluxio", "azfile", "dropbox", "gdrive", "onedrive", "postgresql", "mysql", "redis", "swift", "mongodb", "alluxio", "b2", "seafile", "upyun", "koofr", "yandex-disk" +// * @param options is the options for given opendal services. Valid options depend on the scheme. Checkout https://docs.rs/opendal/latest/opendal/services/index.html for all valid options. +// * +// * For example, use minio as remote storage: +// * +// * ```typescript +// * const storage = new RemoteStorage('s3', { +// * endpoint: 'http://localhost:9000', +// * region: 'us-east-1', +// * bucket: 'testbucket', +// * access_key_id: 'user', +// * secret_access_key: 'password', +// * root: '/path/to/basepath', +// * }) +// * ``` +// */ +// constructor(scheme: string, options?: Record | undefined | null) { +// this.instance = new Operator(scheme, options) - this.putFileContents = this.putFileContents.bind(this) - this.getFileContents = this.getFileContents.bind(this) - } +// this.putFileContents = this.putFileContents.bind(this) +// this.getFileContents = this.getFileContents.bind(this) +// } - public putFileContents = async (filename: string, data: string | Buffer) => { - if (!this.instance) { - return new Error('RemoteStorage client not initialized') - } +// public putFileContents = async (filename: string, data: string | Buffer) => { +// if (!this.instance) { +// return new Error('RemoteStorage client not initialized') +// } - try { - return await this.instance.write(filename, data) - } catch (error) { - Logger.error('[RemoteStorage] Error putting file contents:', error) - throw error - } - } +// try { +// return await this.instance.write(filename, data) +// } catch (error) { +// Logger.error('[RemoteStorage] Error putting file contents:', error) +// throw error +// } +// } - public getFileContents = async (filename: string) => { - if (!this.instance) { - throw new Error('RemoteStorage client not initialized') - } +// public getFileContents = async (filename: string) => { +// if (!this.instance) { +// throw new Error('RemoteStorage client not initialized') +// } - try { - return await this.instance.read(filename) - } catch (error) { - Logger.error('[RemoteStorage] Error getting file contents:', error) - throw error - } - } - - public deleteFile = async (filename: string) => { - if (!this.instance) { - throw new Error('RemoteStorage client not initialized') - } - try { - return await this.instance.delete(filename) - } catch (error) { - Logger.error('[RemoteStorage] Error deleting file:', error) - throw error - } - } - - public checkConnection = async () => { - if (!this.instance) { - throw new Error('RemoteStorage client not initialized') - } - try { - // 检查根目录是否可访问 - return await this.instance.stat('/') - } catch (error) { - Logger.error('[RemoteStorage] Error checking connection:', error) - throw error - } - } -} +// try { +// return await this.instance.read(filename) +// } catch (error) { +// Logger.error('[RemoteStorage] Error getting file contents:', error) +// throw error +// } +// } +// } diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index 29024b3ddc..7c249f7a38 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -72,7 +72,8 @@ export class WindowService { webSecurity: false, webviewTag: true, allowRunningInsecureContent: true, - zoomFactor: configManager.getZoomFactor() + zoomFactor: configManager.getZoomFactor(), + backgroundThrottling: false } }) diff --git a/src/main/services/urlschema/handle-providers.ts b/src/main/services/urlschema/handle-providers.ts index bc109437e6..d23f3749db 100644 --- a/src/main/services/urlschema/handle-providers.ts +++ b/src/main/services/urlschema/handle-providers.ts @@ -1,37 +1,47 @@ -import { IpcChannel } from '@shared/IpcChannel' import Logger from 'electron-log' import { windowService } from '../WindowService' -export function handleProvidersProtocolUrl(url: URL) { - const params = new URLSearchParams(url.search) +export async function handleProvidersProtocolUrl(url: URL) { switch (url.pathname) { case '/api-keys': { // jsonConfig example: // { // "id": "tokenflux", // "baseUrl": "https://tokenflux.ai/v1", - // "apiKey": "sk-xxxx" + // "apiKey": "sk-xxxx", + // "name": "TokenFlux", // optional + // "type": "openai" // optional // } - // cherrystudio://providers/api-keys?data={base64Encode(JSON.stringify(jsonConfig))} + // cherrystudio://providers/api-keys?v=1&data={base64Encode(JSON.stringify(jsonConfig))} + + // replace + and / to _ and - because + and / are processed by URLSearchParams + const processedSearch = url.search.replaceAll('+', '_').replaceAll('/', '-') + const params = new URLSearchParams(processedSearch) const data = params.get('data') - if (data) { - const stringify = Buffer.from(data, 'base64').toString('utf8') - Logger.info('get api keys from urlschema: ', stringify) - const jsonConfig = JSON.parse(stringify) - Logger.info('get api keys from urlschema: ', jsonConfig) - const mainWindow = windowService.getMainWindow() - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send(IpcChannel.Provider_AddKey, jsonConfig) - mainWindow.webContents.executeJavaScript(`window.navigate('/settings/provider?id=${jsonConfig.id}')`) - } + const mainWindow = windowService.getMainWindow() + const version = params.get('v') + if (version == '1') { + // TODO: handle different version + Logger.info('handleProvidersProtocolUrl', { data, version }) + } + + // add check there is window.navigate function in mainWindow + if ( + mainWindow && + !mainWindow.isDestroyed() && + (await mainWindow.webContents.executeJavaScript(`typeof window.navigate === 'function'`)) + ) { + mainWindow.webContents.executeJavaScript(`window.navigate('/settings/provider?addProviderData=${data}')`) } else { - Logger.error('No data found in URL') + setTimeout(() => { + handleProvidersProtocolUrl(url) + }, 1000) } break } default: - console.error(`Unknown MCP protocol URL: ${url}`) + Logger.error(`Unknown MCP protocol URL: ${url}`) break } } diff --git a/src/main/utils/systemInfo.ts b/src/main/utils/systemInfo.ts new file mode 100644 index 0000000000..84db4efed7 --- /dev/null +++ b/src/main/utils/systemInfo.ts @@ -0,0 +1,92 @@ +import { app } from 'electron' +import macosRelease from 'macos-release' +import os from 'os' + +/** + * System information interface + */ +export interface SystemInfo { + platform: NodeJS.Platform + arch: string + osRelease: string + appVersion: string + osString: string + archString: string +} + +/** + * Get basic system constants for quick access + * @returns {Object} Basic system constants + */ +export function getSystemConstants() { + return { + platform: process.platform, + arch: process.arch, + osRelease: os.release(), + appVersion: app.getVersion() + } +} + +/** + * Get system information + * @returns {SystemInfo} Complete system information object + */ +export function getSystemInfo(): SystemInfo { + const platform = process.platform + const arch = process.arch + const osRelease = os.release() + const appVersion = app.getVersion() + + let osString = '' + + switch (platform) { + case 'win32': { + // Get Windows version + const parts = osRelease.split('.') + const buildNumber = parseInt(parts[2], 10) + osString = buildNumber >= 22000 ? 'Windows 11' : 'Windows 10' + break + } + case 'darwin': { + // macOS version handling using macos-release for better accuracy + try { + const macVersionInfo = macosRelease() + const versionString = macVersionInfo.version.replace(/\./g, '_') // 15.0.0 -> 15_0_0 + osString = arch === 'arm64' ? `Mac OS X ${versionString}` : `Intel Mac OS X ${versionString}` // Mac OS X 15_0_0 + } catch (error) { + // Fallback to original logic if macos-release fails + const macVersion = osRelease.split('.').slice(0, 2).join('_') + osString = arch === 'arm64' ? `Mac OS X ${macVersion}` : `Intel Mac OS X ${macVersion}` + } + break + } + case 'linux': { + osString = `Linux ${arch}` + break + } + default: { + osString = `${platform} ${arch}` + } + } + + const archString = arch === 'x64' ? 'x86_64' : arch === 'arm64' ? 'arm64' : arch + + return { + platform, + arch, + osRelease, + appVersion, + osString, + archString + } +} + +/** + * Generate User-Agent string based on user system data + * @returns {string} Dynamically generated User-Agent string + */ +export function generateUserAgent(): string { + const systemInfo = getSystemInfo() + + return `Mozilla/5.0 (${systemInfo.osString}; ${systemInfo.archString}) AppleWebKit/537.36 (KHTML, like Gecko) CherryStudio/${systemInfo.appVersion} Chrome/124.0.0.0 Safari/537.36` +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 34e282ebf0..6ce6422d0c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,16 +2,7 @@ import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' import { electronAPI } from '@electron-toolkit/preload' import { UpgradeChannel } from '@shared/config/constant' import { IpcChannel } from '@shared/IpcChannel' -import { - FileType, - KnowledgeBaseParams, - KnowledgeItem, - MCPServer, - S3Config, - Shortcut, - ThemeMode, - WebDavConfig -} from '@types' +import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, ThemeMode, WebDavConfig } from '@types' import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron' import { Notification } from 'src/renderer/src/types/notification' import { CreateDirectoryOptions } from 'webdav' @@ -80,13 +71,7 @@ const api = { createDirectory: (webdavConfig: WebDavConfig, path: string, options?: CreateDirectoryOptions) => ipcRenderer.invoke(IpcChannel.Backup_CreateDirectory, webdavConfig, path, options), deleteWebdavFile: (fileName: string, webdavConfig: WebDavConfig) => - ipcRenderer.invoke(IpcChannel.Backup_DeleteWebdavFile, fileName, webdavConfig), - backupToS3: (data: string, s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_BackupToS3, data, s3Config), - restoreFromS3: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_RestoreFromS3, s3Config), - listS3Files: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_ListS3Files, s3Config), - deleteS3File: (fileName: string, s3Config: S3Config) => - ipcRenderer.invoke(IpcChannel.Backup_DeleteS3File, fileName, s3Config), - checkS3Connection: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_CheckS3Connection, s3Config) + ipcRenderer.invoke(IpcChannel.Backup_DeleteWebdavFile, fileName, webdavConfig) }, file: { select: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Select, options), diff --git a/src/renderer/src/aiCore/clients/BaseApiClient.ts b/src/renderer/src/aiCore/clients/BaseApiClient.ts index 083dbda872..d311ce2d6a 100644 --- a/src/renderer/src/aiCore/clients/BaseApiClient.ts +++ b/src/renderer/src/aiCore/clients/BaseApiClient.ts @@ -210,6 +210,7 @@ export abstract class BaseApiClient< public async getMessageContent(message: Message): Promise { const content = getContentWithTools(message) + if (isEmpty(content)) { return '' } @@ -273,6 +274,7 @@ export abstract class BaseApiClient< const webSearch: WebSearchResponse = window.keyv.get(`web-search-${message.id}`) if (webSearch) { + window.keyv.remove(`web-search-${message.id}`) return (webSearch.results as WebSearchProviderResponse).results.map( (result, index) => ({ @@ -298,6 +300,7 @@ export abstract class BaseApiClient< const knowledgeReferences: KnowledgeReference[] = window.keyv.get(`knowledge-search-${message.id}`) if (!isEmpty(knowledgeReferences)) { + window.keyv.remove(`knowledge-search-${message.id}`) // Logger.log(`Found ${knowledgeReferences.length} knowledge base references in cache for ID: ${message.id}`) return knowledgeReferences } diff --git a/src/renderer/src/aiCore/middleware/core/WebSearchMiddleware.ts b/src/renderer/src/aiCore/middleware/core/WebSearchMiddleware.ts index 97261e3d52..70915abffa 100644 --- a/src/renderer/src/aiCore/middleware/core/WebSearchMiddleware.ts +++ b/src/renderer/src/aiCore/middleware/core/WebSearchMiddleware.ts @@ -1,5 +1,5 @@ import { ChunkType } from '@renderer/types/chunk' -import { smartLinkConverter } from '@renderer/utils/linkConverter' +import { flushLinkConverterBuffer, smartLinkConverter } from '@renderer/utils/linkConverter' import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' import { CompletionsContext, CompletionsMiddleware } from '../types' @@ -42,20 +42,46 @@ export const WebSearchMiddleware: CompletionsMiddleware = const providerType = model.provider || 'openai' // 使用当前可用的Web搜索结果进行链接转换 const text = chunk.text - const processedText = smartLinkConverter(text, providerType, isFirstChunk) + const result = smartLinkConverter(text, providerType, isFirstChunk) if (isFirstChunk) { isFirstChunk = false } - controller.enqueue({ - ...chunk, - text: processedText - }) + + // - 如果有内容被缓冲,说明convertLinks正在等待后续chunk,不使用原文本避免重复 + // - 如果没有内容被缓冲且结果为空,可能是其他处理问题,使用原文本作为安全回退 + let finalText: string + if (result.hasBufferedContent) { + // 有内容被缓冲,使用处理后的结果(可能为空,等待后续chunk) + finalText = result.text + } else { + // 没有内容被缓冲,可以安全使用回退逻辑 + finalText = result.text || text + } + + // 只有当finalText不为空时才发送chunk + if (finalText) { + controller.enqueue({ + ...chunk, + text: finalText + }) + } } else if (chunk.type === ChunkType.LLM_WEB_SEARCH_COMPLETE) { // 暂存Web搜索结果用于链接完善 ctx._internal.webSearchState!.results = chunk.llm_web_search // 将Web搜索完成事件继续传递下去 controller.enqueue(chunk) + } else if (chunk.type === ChunkType.LLM_RESPONSE_COMPLETE) { + // 流结束时,清空链接转换器的buffer并处理剩余内容 + const remainingText = flushLinkConverterBuffer() + if (remainingText) { + controller.enqueue({ + type: ChunkType.TEXT_DELTA, + text: remainingText + }) + } + // 继续传递LLM_RESPONSE_COMPLETE事件 + controller.enqueue(chunk) } else { controller.enqueue(chunk) } diff --git a/src/renderer/src/components/OAuth/OAuthButton.tsx b/src/renderer/src/components/OAuth/OAuthButton.tsx index 37909041bf..aafbd81c5a 100644 --- a/src/renderer/src/components/OAuth/OAuthButton.tsx +++ b/src/renderer/src/components/OAuth/OAuthButton.tsx @@ -1,5 +1,5 @@ import { Provider } from '@renderer/types' -import { oauthWithAihubmix, oauthWithSiliconFlow, oauthWithTokenFlux } from '@renderer/utils/oauth' +import { oauthWithAihubmix, oauthWithPPIO, oauthWithSiliconFlow, oauthWithTokenFlux } from '@renderer/utils/oauth' import { Button, ButtonProps } from 'antd' import { FC } from 'react' import { useTranslation } from 'react-i18next' @@ -28,6 +28,10 @@ const OAuthButton: FC = ({ provider, onSuccess, ...buttonProps }) => { oauthWithAihubmix(handleSuccess) } + if (provider.id === 'ppio') { + oauthWithPPIO(handleSuccess) + } + if (provider.id === 'tokenflux') { oauthWithTokenFlux() } diff --git a/src/renderer/src/components/S3BackupManager.tsx b/src/renderer/src/components/S3BackupManager.tsx deleted file mode 100644 index ecc9ed88ef..0000000000 --- a/src/renderer/src/components/S3BackupManager.tsx +++ /dev/null @@ -1,298 +0,0 @@ -import { DeleteOutlined, ExclamationCircleOutlined, ReloadOutlined } from '@ant-design/icons' -import { restoreFromS3 } from '@renderer/services/BackupService' -import { formatFileSize } from '@renderer/utils' -import { Button, Modal, Table, Tooltip } from 'antd' -import dayjs from 'dayjs' -import { useCallback, useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' - -interface BackupFile { - fileName: string - modifiedTime: string - size: number -} - -interface S3Config { - endpoint: string - region: string - bucket: string - access_key_id: string - secret_access_key: string - root?: string -} - -interface S3BackupManagerProps { - visible: boolean - onClose: () => void - s3Config: { - endpoint?: string - region?: string - bucket?: string - access_key_id?: string - secret_access_key?: string - root?: string - } - restoreMethod?: (fileName: string) => Promise -} - -export function S3BackupManager({ visible, onClose, s3Config, restoreMethod }: S3BackupManagerProps) { - const [backupFiles, setBackupFiles] = useState([]) - const [loading, setLoading] = useState(false) - const [selectedRowKeys, setSelectedRowKeys] = useState([]) - const [deleting, setDeleting] = useState(false) - const [restoring, setRestoring] = useState(false) - const [pagination, setPagination] = useState({ - current: 1, - pageSize: 5, - total: 0 - }) - const { t } = useTranslation() - - const { endpoint, region, bucket, access_key_id, secret_access_key, root } = s3Config - - const fetchBackupFiles = useCallback(async () => { - if (!endpoint || !region || !bucket || !access_key_id || !secret_access_key) { - window.message.error(t('settings.data.s3.manager.config.incomplete')) - return - } - - setLoading(true) - try { - const files = await window.api.backup.listS3Files({ - endpoint, - region, - bucket, - access_key_id, - secret_access_key, - root - } as S3Config) - setBackupFiles(files) - setPagination((prev) => ({ - ...prev, - total: files.length - })) - } catch (error: any) { - window.message.error(t('settings.data.s3.manager.files.fetch.error', { message: error.message })) - } finally { - setLoading(false) - } - }, [endpoint, region, bucket, access_key_id, secret_access_key, root, t]) - - useEffect(() => { - if (visible) { - fetchBackupFiles() - setSelectedRowKeys([]) - setPagination((prev) => ({ - ...prev, - current: 1 - })) - } - }, [visible, fetchBackupFiles]) - - const handleTableChange = (pagination: any) => { - setPagination(pagination) - } - - const handleDeleteSelected = async () => { - if (selectedRowKeys.length === 0) { - window.message.warning(t('settings.data.s3.manager.select.warning')) - return - } - - if (!endpoint || !region || !bucket || !access_key_id || !secret_access_key) { - window.message.error(t('settings.data.s3.manager.config.incomplete')) - return - } - - window.modal.confirm({ - title: t('settings.data.s3.manager.delete.confirm.title'), - icon: , - content: t('settings.data.s3.manager.delete.confirm.multiple', { count: selectedRowKeys.length }), - okText: t('settings.data.s3.manager.delete.confirm.title'), - cancelText: t('common.cancel'), - centered: true, - onOk: async () => { - setDeleting(true) - try { - // 依次删除选中的文件 - for (const key of selectedRowKeys) { - await window.api.backup.deleteS3File(key.toString(), { - endpoint, - region, - bucket, - access_key_id, - secret_access_key, - root - } as S3Config) - } - window.message.success( - t('settings.data.s3.manager.delete.success.multiple', { count: selectedRowKeys.length }) - ) - setSelectedRowKeys([]) - await fetchBackupFiles() - } catch (error: any) { - window.message.error(t('settings.data.s3.manager.delete.error', { message: error.message })) - } finally { - setDeleting(false) - } - } - }) - } - - const handleDeleteSingle = async (fileName: string) => { - if (!endpoint || !region || !bucket || !access_key_id || !secret_access_key) { - window.message.error(t('settings.data.s3.manager.config.incomplete')) - return - } - - window.modal.confirm({ - title: t('settings.data.s3.manager.delete.confirm.title'), - icon: , - content: t('settings.data.s3.manager.delete.confirm.single', { fileName }), - okText: t('settings.data.s3.manager.delete.confirm.title'), - cancelText: t('common.cancel'), - centered: true, - onOk: async () => { - setDeleting(true) - try { - await window.api.backup.deleteS3File(fileName, { - endpoint, - region, - bucket, - access_key_id, - secret_access_key, - root - } as S3Config) - window.message.success(t('settings.data.s3.manager.delete.success.single')) - await fetchBackupFiles() - } catch (error: any) { - window.message.error(t('settings.data.s3.manager.delete.error', { message: error.message })) - } finally { - setDeleting(false) - } - } - }) - } - - const handleRestore = async (fileName: string) => { - if (!endpoint || !region || !bucket || !access_key_id || !secret_access_key) { - window.message.error(t('settings.data.s3.manager.config.incomplete')) - return - } - - window.modal.confirm({ - title: t('settings.data.s3.restore.confirm.title'), - icon: , - content: t('settings.data.s3.restore.confirm.content'), - okText: t('settings.data.s3.restore.confirm.ok'), - cancelText: t('settings.data.s3.restore.confirm.cancel'), - centered: true, - onOk: async () => { - setRestoring(true) - try { - await (restoreMethod || restoreFromS3)(fileName) - window.message.success(t('settings.data.s3.restore.success')) - onClose() // 关闭模态框 - } catch (error: any) { - window.message.error(t('settings.data.s3.restore.error', { message: error.message })) - } finally { - setRestoring(false) - } - } - }) - } - - const columns = [ - { - title: t('settings.data.s3.manager.columns.fileName'), - dataIndex: 'fileName', - key: 'fileName', - ellipsis: { - showTitle: false - }, - render: (fileName: string) => ( - - {fileName} - - ) - }, - { - title: t('settings.data.s3.manager.columns.modifiedTime'), - dataIndex: 'modifiedTime', - key: 'modifiedTime', - width: 180, - render: (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss') - }, - { - title: t('settings.data.s3.manager.columns.size'), - dataIndex: 'size', - key: 'size', - width: 120, - render: (size: number) => formatFileSize(size) - }, - { - title: t('settings.data.s3.manager.columns.actions'), - key: 'action', - width: 160, - render: (_: any, record: BackupFile) => ( - <> - - - - ) - } - ] - - const rowSelection = { - selectedRowKeys, - onChange: (selectedRowKeys: React.Key[]) => { - setSelectedRowKeys(selectedRowKeys) - } - } - - return ( - } onClick={fetchBackupFiles} disabled={loading}> - {t('settings.data.s3.manager.refresh')} - , - , - - ]}> - - - ) -} diff --git a/src/renderer/src/components/S3Modals.tsx b/src/renderer/src/components/S3Modals.tsx deleted file mode 100644 index a74ad2e9ca..0000000000 --- a/src/renderer/src/components/S3Modals.tsx +++ /dev/null @@ -1,258 +0,0 @@ -import { backupToS3, handleData } from '@renderer/services/BackupService' -import { formatFileSize } from '@renderer/utils' -import { Input, Modal, Select, Spin } from 'antd' -import dayjs from 'dayjs' -import { useCallback, useState } from 'react' -import { useTranslation } from 'react-i18next' - -interface BackupFile { - fileName: string - modifiedTime: string - size: number -} - -export function useS3BackupModal() { - const [customFileName, setCustomFileName] = useState('') - const [isModalVisible, setIsModalVisible] = useState(false) - const [backuping, setBackuping] = useState(false) - - const handleBackup = async () => { - setBackuping(true) - try { - await backupToS3({ customFileName, showMessage: true }) - } finally { - setBackuping(false) - setIsModalVisible(false) - } - } - - const handleCancel = () => { - setIsModalVisible(false) - } - - const showBackupModal = useCallback(async () => { - // 获取默认文件名 - const deviceType = await window.api.system.getDeviceType() - const hostname = await window.api.system.getHostname() - const timestamp = dayjs().format('YYYYMMDDHHmmss') - const defaultFileName = `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip` - setCustomFileName(defaultFileName) - setIsModalVisible(true) - }, []) - - return { - isModalVisible, - handleBackup, - handleCancel, - backuping, - customFileName, - setCustomFileName, - showBackupModal - } -} - -type S3BackupModalProps = { - isModalVisible: boolean - handleBackup: () => Promise - handleCancel: () => void - backuping: boolean - customFileName: string - setCustomFileName: (value: string) => void -} - -export function S3BackupModal({ - isModalVisible, - handleBackup, - handleCancel, - backuping, - customFileName, - setCustomFileName -}: S3BackupModalProps) { - const { t } = useTranslation() - - return ( - - setCustomFileName(e.target.value)} - placeholder={t('settings.data.s3.backup.modal.filename.placeholder')} - /> - - ) -} - -interface UseS3RestoreModalProps { - endpoint: string | undefined - region: string | undefined - bucket: string | undefined - access_key_id: string | undefined - secret_access_key: string | undefined - root?: string | undefined -} - -export function useS3RestoreModal({ - endpoint, - region, - bucket, - access_key_id, - secret_access_key, - root -}: UseS3RestoreModalProps) { - const [isRestoreModalVisible, setIsRestoreModalVisible] = useState(false) - const [restoring, setRestoring] = useState(false) - const [selectedFile, setSelectedFile] = useState(null) - const [loadingFiles, setLoadingFiles] = useState(false) - const [backupFiles, setBackupFiles] = useState([]) - const { t } = useTranslation() - - const showRestoreModal = useCallback(async () => { - if (!endpoint || !region || !bucket || !access_key_id || !secret_access_key) { - window.message.error({ content: t('settings.data.s3.manager.config.incomplete'), key: 's3-error' }) - return - } - - setIsRestoreModalVisible(true) - setLoadingFiles(true) - try { - const files = await window.api.backup.listS3Files({ - endpoint, - region, - bucket, - access_key_id, - secret_access_key, - root - }) - setBackupFiles(files) - } catch (error: any) { - window.message.error({ - content: t('settings.data.s3.manager.files.fetch.error', { message: error.message }), - key: 'list-files-error' - }) - } finally { - setLoadingFiles(false) - } - }, [endpoint, region, bucket, access_key_id, secret_access_key, root, t]) - - const handleRestore = useCallback(async () => { - if (!selectedFile || !endpoint || !region || !bucket || !access_key_id || !secret_access_key) { - window.message.error({ - content: !selectedFile - ? t('settings.data.s3.restore.file.required') - : t('settings.data.s3.restore.config.incomplete'), - key: 'restore-error' - }) - return - } - - window.modal.confirm({ - title: t('settings.data.s3.restore.confirm.title'), - content: t('settings.data.s3.restore.confirm.content'), - okText: t('settings.data.s3.restore.confirm.ok'), - cancelText: t('settings.data.s3.restore.confirm.cancel'), - centered: true, - onOk: async () => { - setRestoring(true) - try { - const data = await window.api.backup.restoreFromS3({ - endpoint, - region, - bucket, - access_key_id, - secret_access_key, - root, - fileName: selectedFile - }) - await handleData(JSON.parse(data)) - window.message.success(t('settings.data.s3.restore.success')) - setIsRestoreModalVisible(false) - } catch (error: any) { - window.message.error({ - content: t('settings.data.s3.restore.error', { message: error.message }), - key: 'restore-error' - }) - } finally { - setRestoring(false) - } - } - }) - }, [selectedFile, endpoint, region, bucket, access_key_id, secret_access_key, root, t]) - - const handleCancel = () => { - setIsRestoreModalVisible(false) - } - - return { - isRestoreModalVisible, - handleRestore, - handleCancel, - restoring, - selectedFile, - setSelectedFile, - loadingFiles, - backupFiles, - showRestoreModal - } -} - -type S3RestoreModalProps = ReturnType - -export function S3RestoreModal({ - isRestoreModalVisible, - handleRestore, - handleCancel, - restoring, - selectedFile, - setSelectedFile, - loadingFiles, - backupFiles -}: S3RestoreModalProps) { - const { t } = useTranslation() - - return ( - -
- setEndpoint(e.target.value)} - style={{ width: 250 }} - type="url" - onBlur={() => dispatch(setS3({ ...s3, endpoint: endpoint || '' }))} - /> - - - - {t('settings.data.s3.region')} - setRegion(e.target.value)} - style={{ width: 250 }} - onBlur={() => dispatch(setS3({ ...s3, region: region || '' }))} - /> - - - - {t('settings.data.s3.bucket')} - setBucket(e.target.value)} - style={{ width: 250 }} - onBlur={() => dispatch(setS3({ ...s3, bucket: bucket || '' }))} - /> - - - - {t('settings.data.s3.accessKeyId')} - setAccessKeyId(e.target.value)} - style={{ width: 250 }} - onBlur={() => dispatch(setS3({ ...s3, accessKeyId: accessKeyId || '' }))} - /> - - - - {t('settings.data.s3.secretAccessKey')} - setSecretAccessKey(e.target.value)} - style={{ width: 250 }} - onBlur={() => dispatch(setS3({ ...s3, secretAccessKey: secretAccessKey || '' }))} - /> - - - - {t('settings.data.s3.root')} - setRoot(e.target.value)} - style={{ width: 250 }} - onBlur={() => dispatch(setS3({ ...s3, root: root || '' }))} - /> - - - - {t('settings.data.s3.backup.operation')} - - - - - - - - {t('settings.data.s3.autoSync')} - - - - - {t('settings.data.s3.maxBackups')} - - - - - {t('settings.data.s3.skipBackupFile')} - - - - {t('settings.data.s3.skipBackupFile.help')} - - {syncInterval > 0 && ( - <> - - - {t('settings.data.s3.syncStatus')} - {renderSyncStatus()} - - - )} - <> - - - - - - ) -} - -export default S3Settings diff --git a/src/renderer/src/pages/settings/ModelSettings/ModelSettings.tsx b/src/renderer/src/pages/settings/ModelSettings/ModelSettings.tsx index fafa0448c6..129dc98fdf 100644 --- a/src/renderer/src/pages/settings/ModelSettings/ModelSettings.tsx +++ b/src/renderer/src/pages/settings/ModelSettings/ModelSettings.tsx @@ -3,7 +3,7 @@ import ModelAvatar from '@renderer/components/Avatar/ModelAvatar' import CustomSelect from '@renderer/components/CustomSelect' import { HStack } from '@renderer/components/Layout' import PromptPopup from '@renderer/components/Popups/PromptPopup' -import { isEmbeddingModel } from '@renderer/config/models' +import { isEmbeddingModel, isRerankModel } from '@renderer/config/models' import { TRANSLATE_PROMPT } from '@renderer/config/prompts' import { useTheme } from '@renderer/context/ThemeProvider' import { useAssistants, useDefaultAssistant, useDefaultModel } from '@renderer/hooks/useAssistant' @@ -46,7 +46,7 @@ const ModelSettings: FC = () => { label: p.isSystem ? t(`provider.${p.id}`) : p.name, title: p.name, options: sortBy(p.models, 'name') - .filter((m) => !isEmbeddingModel(m)) + .filter((m) => !isEmbeddingModel(m) && !isRerankModel(m)) .map((m) => ({ label: `${m.name} | ${p.isSystem ? t(`provider.${p.id}`) : p.name}`, value: getModelUniqId(m) @@ -238,7 +238,7 @@ const StyledButton = styled(Button)<{ selected: boolean }>` &:first-child { border-top-right-radius: 0; border-bottom-right-radius: 0; - border-right-width: 0px; // No right border for the first button when not selected + border-right-width: 0; // No right border for the first button when not selected } &:last-child { @@ -248,6 +248,7 @@ const StyledButton = styled(Button)<{ selected: boolean }>` } // Override Ant Design's default hover and focus styles for a cleaner look + &:hover, &:focus { z-index: 1; diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderOAuth.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderOAuth.tsx index cf6397f1cc..e5d70d4fee 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderOAuth.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderOAuth.tsx @@ -1,4 +1,5 @@ import AiHubMixProviderLogo from '@renderer/assets/images/providers/aihubmix.webp' +import PPIOProviderLogo from '@renderer/assets/images/providers/ppio.png' import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png' import TokenFluxProviderLogo from '@renderer/assets/images/providers/tokenflux.png' import { HStack } from '@renderer/components/Layout' @@ -21,14 +22,18 @@ interface Props { const PROVIDER_LOGO_MAP = { silicon: SiliconFlowProviderLogo, aihubmix: AiHubMixProviderLogo, + ppio: PPIOProviderLogo, tokenflux: TokenFluxProviderLogo } const ProviderOAuth: FC = ({ provider, setApiKey }) => { const { t } = useTranslation() - const providerWebsite = + let providerWebsite = PROVIDER_CONFIG[provider.id]?.api?.url.replace('https://', '').replace('api.', '') || provider.name + if (provider.id === 'ppio') { + providerWebsite = 'ppio.cn' + } return ( diff --git a/src/renderer/src/pages/settings/ProviderSettings/index.tsx b/src/renderer/src/pages/settings/ProviderSettings/index.tsx index bb06dfe995..65f0886f5f 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/index.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/index.tsx @@ -5,10 +5,10 @@ import { getProviderLogo } from '@renderer/config/providers' import { useAllProviders, useProviders } from '@renderer/hooks/useProvider' import ImageStorage from '@renderer/services/ImageStorage' import { INITIAL_PROVIDERS } from '@renderer/store/llm' -import { Provider } from '@renderer/types' +import { Provider, ProviderType } from '@renderer/types' import { droppableReorder, generateColorFromChar, getFirstCharacter, uuid } from '@renderer/utils' -import { Avatar, Button, Dropdown, Input, MenuProps, Tag } from 'antd' -import { Search, UserPen } from 'lucide-react' +import { Avatar, Button, Card, Dropdown, Input, MenuProps, Tag } from 'antd' +import { Eye, EyeOff, Search, UserPen } from 'lucide-react' import { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useSearchParams } from 'react-router-dom' @@ -61,6 +61,206 @@ const ProvidersList: FC = () => { } }, [providers, searchParams]) + // Handle provider add key from URL schema + useEffect(() => { + const handleProviderAddKey = (data: { + id: string + apiKey: string + baseUrl: string + type?: ProviderType + name?: string + }) => { + const { id, apiKey: newApiKey, baseUrl, type, name } = data + + // 查找匹配的 provider + let existingProvider = providers.find((p) => p.id === id) + const isNewProvider = !existingProvider + + if (!existingProvider) { + existingProvider = { + id, + name: name || id, + type: type || 'openai', + apiKey: '', + apiHost: baseUrl || '', + models: [], + enabled: true, + isSystem: false + } + } + + const providerDisplayName = existingProvider.isSystem + ? t(`provider.${existingProvider.id}`) + : existingProvider.name + + // 检查是否已有 API Key + const hasExistingKey = existingProvider.apiKey && existingProvider.apiKey.trim() !== '' + + // 检查新的 API Key 是否已经存在 + const existingKeys = hasExistingKey ? existingProvider.apiKey.split(',').map((k) => k.trim()) : [] + const keyAlreadyExists = existingKeys.includes(newApiKey.trim()) + + const confirmMessage = keyAlreadyExists + ? t('settings.models.provider_key_already_exists', { + provider: providerDisplayName, + key: '*********' + }) + : t('settings.models.provider_key_add_confirm', { + provider: providerDisplayName, + newKey: '*********' + }) + + const createModalContent = () => { + let showApiKey = false + + const toggleApiKey = () => { + showApiKey = !showApiKey + // 重新渲染模态框内容 + updateModalContent() + } + + const updateModalContent = () => { + const content = ( + + + + {t('settings.models.provider_name')}: + {providerDisplayName} + + + {t('settings.models.provider_id')}: + {id} + + {baseUrl && ( + + {t('settings.models.base_url')}: + {baseUrl} + + )} + + {t('settings.models.api_key')}: + + {showApiKey ? newApiKey : '*********'} + + {showApiKey ? : } + + + + + {confirmMessage} + + ) + + // 更新模态框内容 + if (modalInstance) { + modalInstance.update({ + content: content + }) + } + } + + const modalInstance = window.modal.confirm({ + title: t('settings.models.provider_key_confirm_title', { provider: providerDisplayName }), + content: ( + + + + {t('settings.models.provider_name')}: + {providerDisplayName} + + + {t('settings.models.provider_id')}: + {id} + + {baseUrl && ( + + {t('settings.models.base_url')}: + {baseUrl} + + )} + + {t('settings.models.api_key')}: + + {showApiKey ? newApiKey : '*********'} + + {showApiKey ? : } + + + + + {confirmMessage} + + ), + okText: keyAlreadyExists ? t('common.confirm') : t('common.add'), + cancelText: t('common.cancel'), + centered: true, + onCancel() { + window.navigate(`/settings/provider?id=${id}`) + }, + onOk() { + window.navigate(`/settings/provider?id=${id}`) + if (keyAlreadyExists) { + // 如果 key 已经存在,只显示消息,不做任何更改 + window.message.info(t('settings.models.provider_key_no_change', { provider: providerDisplayName })) + return + } + + // 如果 key 不存在,添加到现有 keys 的末尾 + const finalApiKey = hasExistingKey ? `${existingProvider.apiKey},${newApiKey.trim()}` : newApiKey.trim() + + const updatedProvider = { + ...existingProvider, + apiKey: finalApiKey, + ...(baseUrl && { apiHost: baseUrl }) + } + + if (isNewProvider) { + addProvider(updatedProvider) + } else { + updateProvider(updatedProvider) + } + + setSelectedProvider(updatedProvider) + window.message.success(t('settings.models.provider_key_added', { provider: providerDisplayName })) + } + }) + + return modalInstance + } + + createModalContent() + } + + // 检查 URL 参数 + const addProviderData = searchParams.get('addProviderData') + if (!addProviderData) { + return + } + + try { + const base64Decode = (base64EncodedString: string) => + new TextDecoder().decode(Uint8Array.from(atob(base64EncodedString), (m) => m.charCodeAt(0))) + const { + id, + apiKey: newApiKey, + baseUrl, + type, + name + } = JSON.parse(base64Decode(addProviderData.replaceAll('_', '+').replaceAll('-', '/'))) + + if (!id || !newApiKey || !baseUrl) { + window.message.error(t('settings.models.provider_key_add_failed_by_invalid_data')) + window.navigate('/settings/provider') + return + } + + handleProviderAddKey({ id, apiKey: newApiKey, baseUrl, type, name }) + } catch (error) { + window.message.error(t('settings.models.provider_key_add_failed_by_invalid_data')) + window.navigate('/settings/provider') + } + }, [searchParams]) + const onDragEnd = (result: DropResult) => { setDragging(false) if (result.destination) { @@ -380,4 +580,97 @@ const AddButtonWrapper = styled.div` align-items: center; padding: 10px 8px; ` + +const ProviderInfoContainer = styled.div` + color: var(--color-text); +` + +const ProviderInfoCard = styled(Card)` + margin-bottom: 16px; + background-color: var(--color-background-soft); + border: 1px solid var(--color-border); + + .ant-card-body { + padding: 12px; + } +` + +const ProviderInfoRow = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + + &:last-child { + margin-bottom: 0; + } +` + +const ProviderInfoLabel = styled.span` + font-weight: 600; + color: var(--color-text-2); + min-width: 80px; +` + +const ProviderInfoValue = styled.span` + font-family: 'Monaco', 'Menlo', 'Consolas', monospace; + background-color: var(--color-background-soft); + padding: 2px 6px; + border-radius: 4px; + border: 1px solid var(--color-border); + word-break: break-all; + flex: 1; + margin-left: 8px; +` + +const ConfirmMessage = styled.div` + color: var(--color-text); + line-height: 1.5; +` + +const ApiKeyContainer = styled.div` + display: flex; + align-items: center; + flex: 1; + margin-left: 8px; + position: relative; +` + +const ApiKeyValue = styled.span` + font-family: 'Monaco', 'Menlo', 'Consolas', monospace; + background-color: var(--color-background-soft); + padding: 2px 32px 2px 6px; + border-radius: 4px; + border: 1px solid var(--color-border); + word-break: break-all; + flex: 1; +` + +const EyeButton = styled.button` + background: none; + border: none; + cursor: pointer; + color: var(--color-text-3); + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + border-radius: 2px; + transition: all 0.2s ease; + position: absolute; + right: 4px; + top: 50%; + transform: translateY(-50%); + + &:hover { + color: var(--color-text); + background-color: var(--color-background-mute); + } + + &:focus { + outline: none; + box-shadow: 0 0 0 2px var(--color-primary-outline); + } +` + export default ProvidersList diff --git a/src/renderer/src/pages/translate/TranslatePage.tsx b/src/renderer/src/pages/translate/TranslatePage.tsx index a38d020eb9..fd209c1e40 100644 --- a/src/renderer/src/pages/translate/TranslatePage.tsx +++ b/src/renderer/src/pages/translate/TranslatePage.tsx @@ -1,17 +1,21 @@ -import { CheckOutlined, DeleteOutlined, HistoryOutlined, SendOutlined } from '@ant-design/icons' +import { CheckOutlined, DeleteOutlined, HistoryOutlined, RedoOutlined, SendOutlined } from '@ant-design/icons' import { NavbarCenter, NavbarMain } from '@renderer/components/app/Navbar' import CustomSelect from '@renderer/components/CustomSelect' import CopyIcon from '@renderer/components/Icons/CopyIcon' import { HStack } from '@renderer/components/Layout' import { isEmbeddingModel } from '@renderer/config/models' +import { TRANSLATE_PROMPT } from '@renderer/config/prompts' import { TranslateLanguageOptions } from '@renderer/config/translate' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import db from '@renderer/databases' import { useDefaultModel } from '@renderer/hooks/useAssistant' import { useProviders } from '@renderer/hooks/useProvider' +import { useSettings } from '@renderer/hooks/useSettings' import { fetchTranslate } from '@renderer/services/ApiService' import { getDefaultTranslateAssistant } from '@renderer/services/AssistantService' import { getModelUniqId, hasModel } from '@renderer/services/ModelService' +import { useAppDispatch } from '@renderer/store' +import { setTranslateModelPrompt } from '@renderer/store/settings' import type { Model, TranslateHistory } from '@renderer/types' import { runAsyncFunction, uuid } from '@renderer/utils' import { @@ -66,7 +70,11 @@ const TranslateSettings: FC<{ selectOptions }) => { const { t } = useTranslation() + const { translateModelPrompt } = useSettings() + const dispatch = useAppDispatch() const [localPair, setLocalPair] = useState<[string, string]>(bidirectionalPair) + const [showPrompt, setShowPrompt] = useState(false) + const [localPrompt, setLocalPrompt] = useState(translateModelPrompt) const defaultTranslateModel = useMemo( () => (hasModel(translateModel) ? getModelUniqId(translateModel) : undefined), @@ -75,7 +83,8 @@ const TranslateSettings: FC<{ useEffect(() => { setLocalPair(bidirectionalPair) - }, [bidirectionalPair, visible]) + setLocalPrompt(translateModelPrompt) + }, [bidirectionalPair, translateModelPrompt, visible]) const handleSave = () => { if (localPair[0] === localPair[1]) { @@ -89,6 +98,8 @@ const TranslateSettings: FC<{ db.settings.put({ id: 'translate:bidirectional:pair', value: localPair }) db.settings.put({ id: 'translate:scroll:sync', value: isScrollSyncEnabled }) db.settings.put({ id: 'translate:markdown:enabled', value: enableMarkdown }) + db.settings.put({ id: 'translate:model:prompt', value: localPrompt }) + dispatch(setTranslateModelPrompt(localPrompt)) window.message.success({ content: t('message.save.success.title'), key: 'translate-settings-save' @@ -113,7 +124,14 @@ const TranslateSettings: FC<{ width={420}>
-
{t('translate.settings.model')}
+
+ {t('translate.settings.model')} + + + + + +
)} -
- {t('translate.settings.model_desc')} -
@@ -158,7 +173,7 @@ const TranslateSettings: FC<{
- +
{t('translate.settings.bidirectional')} @@ -171,8 +186,8 @@ const TranslateSettings: FC<{
- - {isBidirectional && ( + {isBidirectional && ( +