diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 0a993f796..e1687a6c5 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -52,6 +52,8 @@ jobs: steps: - name: Check out Git repository uses: actions/checkout@v4 + with: + ref: develop - name: Install Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index df32ff713..3200140f7 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -5,6 +5,7 @@ on: pull_request: branches: - main + - develop jobs: build: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b8b1731d0..d7fa9142f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,6 +26,8 @@ jobs: steps: - name: Check out Git repository uses: actions/checkout@v4 + with: + ref: main - name: Get release tag id: get-tag diff --git a/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch b/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch index cf4219719..d79275839 100644 --- a/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch +++ b/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch @@ -37,3 +37,49 @@ index 7d19f5578040afa004bc887aab1725e8703d2bac..59ec725b6142299a62798ac4bdedb63b if (status === 400) { return new BadRequestError(status, error, message, headers); } +diff --git a/resources/embeddings.js b/resources/embeddings.js +index aae578404cb2d09a39ac33fc416f1c215c45eecd..25c54b05bdae64d5c3b36fbb30dc7c8221b14034 100644 +--- a/resources/embeddings.js ++++ b/resources/embeddings.js +@@ -36,6 +36,9 @@ class Embeddings extends resource_1.APIResource { + // No encoding_format specified, defaulting to base64 for performance reasons + // See https://github.com/openai/openai-node/pull/1312 + let encoding_format = hasUserProvidedEncodingFormat ? body.encoding_format : 'base64'; ++ if (body.model.includes('jina')) { ++ encoding_format = undefined; ++ } + if (hasUserProvidedEncodingFormat) { + Core.debug('Request', 'User defined encoding_format:', body.encoding_format); + } +@@ -47,7 +50,7 @@ class Embeddings extends resource_1.APIResource { + ...options, + }); + // if the user specified an encoding_format, return the response as-is +- if (hasUserProvidedEncodingFormat) { ++ if (hasUserProvidedEncodingFormat || body.model.includes('jina')) { + return response; + } + // in this stage, we are sure the user did not specify an encoding_format +diff --git a/resources/embeddings.mjs b/resources/embeddings.mjs +index 0df3c6cc79a520e54acb4c2b5f77c43b774035ff..aa488b8a11b2c413c0a663d9a6059d286d7b5faf 100644 +--- a/resources/embeddings.mjs ++++ b/resources/embeddings.mjs +@@ -10,6 +10,9 @@ export class Embeddings extends APIResource { + // No encoding_format specified, defaulting to base64 for performance reasons + // See https://github.com/openai/openai-node/pull/1312 + let encoding_format = hasUserProvidedEncodingFormat ? body.encoding_format : 'base64'; ++ if (body.model.includes('jina')) { ++ encoding_format = undefined; ++ } + if (hasUserProvidedEncodingFormat) { + Core.debug('Request', 'User defined encoding_format:', body.encoding_format); + } +@@ -21,7 +24,7 @@ export class Embeddings extends APIResource { + ...options, + }); + // if the user specified an encoding_format, return the response as-is +- if (hasUserProvidedEncodingFormat) { ++ if (hasUserProvidedEncodingFormat || body.model.includes('jina')) { + return response; + } + // in this stage, we are sure the user did not specify an encoding_format diff --git a/README.md b/README.md index b5d7c2528..efcc7db72 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Cherry Studio is a desktop client that supports for multiple LLM providers, avai # 📖 Guide -https://docs.cherry-ai.com + # 🌠 Screenshot @@ -82,11 +82,11 @@ https://docs.cherry-ai.com # 🌈 Theme -- Theme Gallery: https://cherrycss.com -- Aero Theme: https://github.com/hakadao/CherryStudio-Aero -- PaperMaterial Theme: https://github.com/rainoffallingstar/CherryStudio-PaperMaterial -- Claude dynamic-style: https://github.com/bjl101501/CherryStudio-Claudestyle-dynamic -- Maple Neon Theme: https://github.com/BoningtonChen/CherryStudio_themes +- Theme Gallery: +- Aero Theme: +- PaperMaterial Theme: +- Claude dynamic-style: +- Maple Neon Theme: Welcome PR for more themes @@ -94,6 +94,10 @@ Welcome PR for more themes Refer to the [development documentation](docs/dev.md) +Refer to the [Architecture overview documentation](https://deepwiki.com/CherryHQ/cherry-studio) + +Refer to the [Branching Strategy](docs/branching-strategy.md) for contribution guidelines + # 🤝 Contributing We welcome contributions to Cherry Studio! Here are some ways you can contribute: @@ -144,7 +148,7 @@ Thank you for your support and contributions! # ✉️ Contact -yinsenho@cherry-ai.com + # ⭐️ Star History diff --git a/docs/branching-strategy.md b/docs/branching-strategy.md new file mode 100644 index 000000000..3196d09fe --- /dev/null +++ b/docs/branching-strategy.md @@ -0,0 +1,51 @@ +# 🌿 Branching Strategy + +Cherry Studio follows a structured branching strategy to maintain code quality and streamline the development process: + +## Main Branches + +- `main`: Production-ready branch containing stable releases + + - All code here is thoroughly tested and ready for production + - Direct commits are not allowed - changes must come through pull requests + - Each merge to main represents a new release + +- `develop` (default): Primary development branch + - Contains the latest delivered development changes for the next release + - Relatively stable but may contain features in progress + - This is the default branch for development + +## Contributing Branches + +When contributing to Cherry Studio, please follow these guidelines: + +1. **For bug fixes:** + + - Create a branch from `develop` + - Name format: `fix/issue-number-brief-description` + - Submit pull request back to `develop` + +2. **For new features:** + + - Create a branch from `develop` + - Name format: `feature/issue-number-brief-description` + - Submit pull request back to `develop` + +3. **For documentation:** + + - Create a branch from `develop` + - Name format: `docs/brief-description` + - Submit pull request back to `develop` + +4. **For critical hotfixes:** + - Create a branch from `main` + - Name format: `hotfix/issue-number-brief-description` + - Submit pull request to both `main` and `develop` + +## Pull Request Guidelines + +- Always create pull requests against the `develop` branch unless fixing a critical production issue +- Ensure your branch is up to date with the latest `develop` changes before submitting +- Include relevant issue numbers in your PR description +- Make sure all tests pass and code meets our quality standards +- Critical hotfixes may be submitted against `main` but must also be merged into `develop` diff --git a/electron-builder.yml b/electron-builder.yml index 1c97700af..f3a9c4833 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -96,9 +96,9 @@ afterSign: scripts/notarize.js artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | - 新增对 grok-2-image 和 gpt-4o-image 图像支持 - 支持 Windows 便携版使用 data 目录存储数据 - MCP 界面改版,新增描述信息显示 - Mermaid 渲染逻辑优化 - 支持关闭公示渲染 - 修复 OpenAI 类型渲染错误 + 重构消息结构,支持不同类型消息按时间顺序显示 + 智能体支持导入和导出 + 快捷面板增加网络搜索引擎选择 + 显示设置增加缩放控制按钮 + 支持添加自定义小程序 + 性能优化和错误修复 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index a9d47b180..8afd92508 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -75,6 +75,14 @@ export default defineConfig({ }, optimizeDeps: { exclude: [] + }, + build: { + rollupOptions: { + input: { + index: resolve(__dirname, 'src/renderer/index.html'), + miniWindow: resolve(__dirname, 'src/renderer/miniWindow.html') + } + } } } }) diff --git a/package.json b/package.json index 0c3494567..90c717012 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.2.10", + "version": "1.3.0", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", @@ -77,7 +77,6 @@ "adm-zip": "^0.5.16", "archiver": "^7.0.1", "async-mutex": "^0.5.0", - "bufferutil": "^4.0.9", "canvas": "3.1.0", "color": "^5.0.0", "diff": "^7.0.0", @@ -96,7 +95,6 @@ "markdown-it": "^14.1.0", "node-stream-zip": "^1.15.0", "officeparser": "^4.1.1", - "opendal": "^0.47.11", "os-proxy-config": "^1.1.2", "pdf-to-img": "^4.4.0", "pdfjs-dist": "4.2.67", @@ -185,6 +183,7 @@ "lru-cache": "^11.1.0", "lucide-react": "^0.487.0", "mime": "^4.0.4", + "motion": "^12.10.5", "npx-scope-finder": "^1.2.0", "openai": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch", "p-queue": "^8.1.0", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index be9b0ba46..c28b32ed7 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -13,8 +13,7 @@ export enum IpcChannel { App_RestartTray = 'app:restart-tray', App_SetTheme = 'app:set-theme', App_SetAutoUpdate = 'app:set-auto-update', - App_SetZoomFactor = 'app:set-zoom-factor', - ZoomFactorUpdated = 'app:zoom-factor-updated', + App_HandleZoomFactor = 'app:handle-zoom-factor', App_IsBinaryExist = 'app:is-binary-exist', App_GetBinaryPath = 'app:get-binary-path', diff --git a/scripts/notarize.js b/scripts/notarize.js index 671605a0b..985da3c37 100644 --- a/scripts/notarize.js +++ b/scripts/notarize.js @@ -7,7 +7,6 @@ exports.default = async function notarizing(context) { } if (!process.env.APPLE_ID || !process.env.APPLE_APP_SPECIFIC_PASSWORD || !process.env.APPLE_TEAM_ID) { - console.log('Skipping notarization') return } diff --git a/scripts/update-i18n.ts b/scripts/update-i18n.ts index 2140f4cef..3af608438 100644 --- a/scripts/update-i18n.ts +++ b/scripts/update-i18n.ts @@ -1,16 +1,16 @@ /** - * OCOOL_API_KEY=sk-abcxxxxxxxxxxxxxxxxxxxxxxx123 ts-node scripts/update-i18n.ts + * Paratera_API_KEY=sk-abcxxxxxxxxxxxxxxxxxxxxxxx123 ts-node scripts/update-i18n.ts */ // OCOOL API KEY -const OCOOL_API_KEY = process.env.OCOOL_API_KEY +const Paratera_API_KEY = process.env.Paratera_API_KEY const INDEX = [ // 语言的名称 代码 用来翻译的模型 - { name: 'France', code: 'fr-fr', model: 'qwen2.5-32b-instruct' }, - { name: 'Spanish', code: 'es-es', model: 'qwen2.5-32b-instruct' }, - { name: 'Portuguese', code: 'pt-pt', model: 'qwen2.5-72b-instruct' }, - { name: 'Greek', code: 'el-gr', model: 'qwen-turbo' } + { 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' } ] const fs = require('fs') @@ -19,8 +19,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: OCOOL_API_KEY, - baseURL: 'https://one.ocoolai.com/v1' + apiKey: Paratera_API_KEY, + baseURL: 'https://llmapi.paratera.com/v1' }) // 递归遍历翻译 diff --git a/src/main/embeddings/VoyageEmbeddings.ts b/src/main/embeddings/VoyageEmbeddings.ts index dee2695c8..fc0c8b9fe 100644 --- a/src/main/embeddings/VoyageEmbeddings.ts +++ b/src/main/embeddings/VoyageEmbeddings.ts @@ -1,5 +1,6 @@ import { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces' import { VoyageEmbeddings as _VoyageEmbeddings } from '@langchain/community/embeddings/voyage' +import Logger from 'electron-log' export default class VoyageEmbeddings extends BaseEmbeddings { private model: _VoyageEmbeddings @@ -11,7 +12,7 @@ export default class VoyageEmbeddings extends BaseEmbeddings { if (!this.configuration.outputDimension) { throw new Error('You need to pass in the optional dimensions parameter for this model') } - console.log('VoyageEmbeddings', this.configuration) + Logger.log('VoyageEmbeddings', this.configuration) this.model = new _VoyageEmbeddings(this.configuration) } override async getDimensions(): Promise { diff --git a/src/main/index.ts b/src/main/index.ts index ad6879dfa..132dff40f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -19,6 +19,8 @@ import { TrayService } from './services/TrayService' import { windowService } from './services/WindowService' import { setUserDataDir } from './utils/file' +Logger.initialize() + // Check for single instance lock if (!app.requestSingleInstanceLock()) { app.quit() diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 6c8bb73d7..efc5c0ce3 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -3,6 +3,7 @@ import { arch } from 'node:os' import { isMac, isWin } from '@main/constant' import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process' +import { handleZoomFactor } from '@main/utils/zoom' import { IpcChannel } from '@shared/IpcChannel' import { LocalFileSource, Shortcut, ThemeMode } from '@types' import { BrowserWindow, ipcMain, nativeTheme, session, shell } from 'electron' @@ -141,15 +142,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { notifyThemeChange() }) - // zoom factor - ipcMain.handle(IpcChannel.App_SetZoomFactor, (_, factor: number) => { - configManager.setZoomFactor(factor) + ipcMain.handle(IpcChannel.App_HandleZoomFactor, (_, delta: number, reset: boolean = false) => { const windows = BrowserWindow.getAllWindows() - windows.forEach((win) => { - if (!win.isDestroyed()) { - win.webContents.setZoomFactor(factor) - } - }) + handleZoomFactor(windows, delta, reset) + return configManager.getZoomFactor() }) // clear cache diff --git a/src/main/mcpServers/dify-knowledge.ts b/src/main/mcpServers/dify-knowledge.ts index c84d4fe3c..33c61820c 100644 --- a/src/main/mcpServers/dify-knowledge.ts +++ b/src/main/mcpServers/dify-knowledge.ts @@ -56,7 +56,6 @@ class DifyKnowledgeServer { private config: DifyKnowledgeServerConfig constructor(difyKey: string, args: string[]) { - console.log('DifyKnowledgeServer args', args) if (args.length === 0) { throw new Error('DifyKnowledgeServer requires at least one argument') } @@ -113,8 +112,6 @@ class DifyKnowledgeServer { const errorDetails = JSON.stringify(parsed.error.format(), null, 2) throw new Error(`无效的参数:\n${errorDetails}`) } - - console.log('DifyKnowledgeServer search_knowledge parsed', parsed.data) return await this.performSearchKnowledge( parsed.data.id, parsed.data.query, diff --git a/src/main/mcpServers/memory.ts b/src/main/mcpServers/memory.ts index 211b5f223..746670b36 100644 --- a/src/main/mcpServers/memory.ts +++ b/src/main/mcpServers/memory.ts @@ -2,6 +2,7 @@ import { getConfigDir } from '@main/utils/file' import { Server } from '@modelcontextprotocol/sdk/server/index.js' import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js' import { Mutex } from 'async-mutex' // 引入 Mutex +import Logger from 'electron-log' import { promises as fs } from 'fs' import path from 'path' @@ -355,9 +356,9 @@ class MemoryServer { private async _initializeManager(memoryPath: string): Promise { try { this.knowledgeGraphManager = await KnowledgeGraphManager.create(memoryPath) - console.log('KnowledgeGraphManager initialized successfully.') + Logger.log('KnowledgeGraphManager initialized successfully.') } catch (error) { - console.error('Failed to initialize KnowledgeGraphManager:', error) + Logger.error('Failed to initialize KnowledgeGraphManager:', error) // Server might be unusable, consider how to handle this state // Maybe set a flag and return errors for all tool calls? this.knowledgeGraphManager = null // Ensure it's null if init fails diff --git a/src/main/reranker/BaseReranker.ts b/src/main/reranker/BaseReranker.ts index 4109f5398..5a8bd6ee2 100644 --- a/src/main/reranker/BaseReranker.ts +++ b/src/main/reranker/BaseReranker.ts @@ -17,6 +17,10 @@ export default abstract class BaseReranker { * Get Rerank Request Url */ protected getRerankUrl() { + if (this.base.rerankModelProvider === 'dashscope') { + return 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank' + } + let baseURL = this.base?.rerankBaseURL?.endsWith('/') ? this.base.rerankBaseURL.slice(0, -1) : this.base.rerankBaseURL @@ -28,6 +32,56 @@ export default abstract class BaseReranker { return `${baseURL}/rerank` } + /** + * Get Rerank Request Body + */ + protected getRerankRequestBody(query: string, searchResults: ExtractChunkData[]) { + const provider = this.base.rerankModelProvider + const documents = searchResults.map((doc) => doc.pageContent) + const topN = this.base.topN || 5 + + if (provider === 'voyageai') { + return { + model: this.base.rerankModel, + query, + documents, + top_k: topN + } + } else if (provider === 'dashscope') { + return { + model: this.base.rerankModel, + input: { + query, + documents + }, + parameters: { + top_n: topN + } + } + } else { + return { + model: this.base.rerankModel, + query, + documents, + top_n: topN + } + } + } + + /** + * Extract Rerank Result + */ + protected extractRerankResult(data: any) { + const provider = this.base.rerankModelProvider + if (provider === 'dashscope') { + return data.output.results + } else if (provider === 'voyageai') { + return data.data + } else { + return data.results + } + } + /** * Get Rerank Result * @param searchResults diff --git a/src/main/reranker/DashscopeReranker.ts b/src/main/reranker/DashscopeReranker.ts deleted file mode 100644 index ac96092a1..000000000 --- a/src/main/reranker/DashscopeReranker.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' -import axiosProxy from '@main/services/AxiosProxy' -import { KnowledgeBaseParams } from '@types' - -import BaseReranker from './BaseReranker' - -interface DashscopeRerankResultItem { - document: { - text: string - } - index: number - relevance_score: number -} - -interface DashscopeRerankResponse { - output: { - results: DashscopeRerankResultItem[] - } - usage: { - total_tokens: number - } - request_id: string -} - -export default class DashscopeReranker extends BaseReranker { - constructor(base: KnowledgeBaseParams) { - super(base) - } - - public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise => { - const url = 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank' - - const requestBody = { - model: this.base.rerankModel, - input: { - query, - documents: searchResults.map((doc) => doc.pageContent) - }, - parameters: { - return_documents: true, // Recommended to be true to get document details if needed, though scores are primary - top_n: this.base.topN || 5 // Default to 5 if topN is not specified, as per API example - } - } - - try { - const { data } = await axiosProxy.axios.post(url, requestBody, { - headers: this.defaultHeaders() - }) - - const rerankResults = data.output.results - return this.getRerankResult(searchResults, rerankResults) - } catch (error: any) { - const errorDetails = this.formatErrorMessage(url, error, requestBody) - console.error('Dashscope Reranker API 错误:', errorDetails) - throw new Error(`重排序请求失败: ${error.message}\n请求详情: ${errorDetails}`) - } - } -} diff --git a/src/main/reranker/DefaultReranker.ts b/src/main/reranker/DefaultReranker.ts deleted file mode 100644 index 70a4d05ac..000000000 --- a/src/main/reranker/DefaultReranker.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' -import { KnowledgeBaseParams } from '@types' - -import BaseReranker from './BaseReranker' - -export default class DefaultReranker extends BaseReranker { - constructor(base: KnowledgeBaseParams) { - super(base) - } - - async rerank(): Promise { - throw new Error('Method not implemented.') - } -} diff --git a/src/main/reranker/JinaReranker.ts b/src/main/reranker/GeneralReranker.ts similarity index 70% rename from src/main/reranker/JinaReranker.ts rename to src/main/reranker/GeneralReranker.ts index 88350a5e6..185e2132c 100644 --- a/src/main/reranker/JinaReranker.ts +++ b/src/main/reranker/GeneralReranker.ts @@ -4,7 +4,7 @@ import { KnowledgeBaseParams } from '@types' import BaseReranker from './BaseReranker' -export default class JinaReranker extends BaseReranker { +export default class GeneralReranker extends BaseReranker { constructor(base: KnowledgeBaseParams) { super(base) } @@ -12,21 +12,15 @@ export default class JinaReranker extends BaseReranker { public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise => { const url = this.getRerankUrl() - const requestBody = { - model: this.base.rerankModel, - query, - documents: searchResults.map((doc) => doc.pageContent), - top_n: this.base.topN - } + const requestBody = this.getRerankRequestBody(query, searchResults) try { const { data } = await AxiosProxy.axios.post(url, requestBody, { headers: this.defaultHeaders() }) - const rerankResults = data.results + const rerankResults = this.extractRerankResult(data) return this.getRerankResult(searchResults, rerankResults) } catch (error: any) { const errorDetails = this.formatErrorMessage(url, error, requestBody) - console.error('Jina Reranker API Error:', errorDetails) throw new Error(`重排序请求失败: ${error.message}\n请求详情: ${errorDetails}`) } } diff --git a/src/main/reranker/Reranker.ts b/src/main/reranker/Reranker.ts index f9f37cfca..d42376ea2 100644 --- a/src/main/reranker/Reranker.ts +++ b/src/main/reranker/Reranker.ts @@ -1,13 +1,12 @@ import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' import { KnowledgeBaseParams } from '@types' -import BaseReranker from './BaseReranker' -import RerankerFactory from './RerankerFactory' +import GeneralReranker from './GeneralReranker' export default class Reranker { - private sdk: BaseReranker + private sdk: GeneralReranker constructor(base: KnowledgeBaseParams) { - this.sdk = RerankerFactory.create(base) + this.sdk = new GeneralReranker(base) } public async rerank(query: string, searchResults: ExtractChunkData[]): Promise { return this.sdk.rerank(query, searchResults) diff --git a/src/main/reranker/RerankerFactory.ts b/src/main/reranker/RerankerFactory.ts deleted file mode 100644 index d1ae18d78..000000000 --- a/src/main/reranker/RerankerFactory.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { KnowledgeBaseParams } from '@types' - -import BaseReranker from './BaseReranker' -import DashscopeReranker from './DashscopeReranker' -import DefaultReranker from './DefaultReranker' -import JinaReranker from './JinaReranker' -import SiliconFlowReranker from './SiliconFlowReranker' -import VoyageReranker from './VoyageReranker' - -export default class RerankerFactory { - static create(base: KnowledgeBaseParams): BaseReranker { - if (base.rerankModelProvider === 'silicon') { - return new SiliconFlowReranker(base) - } else if (base.rerankModelProvider === 'jina') { - return new JinaReranker(base) - } else if (base.rerankModelProvider === 'voyageai') { - return new VoyageReranker(base) - } else if (base.rerankModelProvider === 'dashscope') { - return new DashscopeReranker(base) - } - return new DefaultReranker(base) - } -} diff --git a/src/main/reranker/SiliconFlowReranker.ts b/src/main/reranker/SiliconFlowReranker.ts deleted file mode 100644 index 78a213561..000000000 --- a/src/main/reranker/SiliconFlowReranker.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' -import axiosProxy from '@main/services/AxiosProxy' -import { KnowledgeBaseParams } from '@types' - -import BaseReranker from './BaseReranker' - -export default class SiliconFlowReranker extends BaseReranker { - constructor(base: KnowledgeBaseParams) { - super(base) - } - - public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise => { - const url = this.getRerankUrl() - - const requestBody = { - model: this.base.rerankModel, - query, - documents: searchResults.map((doc) => doc.pageContent), - top_n: this.base.topN, - max_chunks_per_doc: this.base.chunkSize, - overlap_tokens: this.base.chunkOverlap - } - - try { - const { data } = await axiosProxy.axios.post(url, requestBody, { headers: this.defaultHeaders() }) - - const rerankResults = data.results - return this.getRerankResult(searchResults, rerankResults) - } catch (error: any) { - const errorDetails = this.formatErrorMessage(url, error, requestBody) - - console.error('SiliconFlow Reranker API 错误:', errorDetails) - throw new Error(`重排序请求失败: ${error.message}\n请求详情: ${errorDetails}`) - } - } -} diff --git a/src/main/reranker/VoyageReranker.ts b/src/main/reranker/VoyageReranker.ts deleted file mode 100644 index 44c800b6d..000000000 --- a/src/main/reranker/VoyageReranker.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' -import axiosProxy from '@main/services/AxiosProxy' -import { KnowledgeBaseParams } from '@types' - -import BaseReranker from './BaseReranker' - -export default class VoyageReranker extends BaseReranker { - constructor(base: KnowledgeBaseParams) { - super(base) - } - - public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise => { - const url = this.getRerankUrl() - - const requestBody = { - model: this.base.rerankModel, - query, - documents: searchResults.map((doc) => doc.pageContent), - top_k: this.base.topN, - return_documents: false, - truncation: true - } - - try { - const { data } = await axiosProxy.axios.post(url, requestBody, { - headers: { - ...this.defaultHeaders() - } - }) - - const rerankResults = data.data - return this.getRerankResult(searchResults, rerankResults) - } catch (error: any) { - const errorDetails = this.formatErrorMessage(url, error, requestBody) - - console.error('Voyage Reranker API Error:', errorDetails) - throw new Error(`重排序请求失败: ${error.message}\n请求详情: ${errorDetails}`) - } - } -} diff --git a/src/main/services/CopilotService.ts b/src/main/services/CopilotService.ts index bc3f2c4af..0be9ee8a5 100644 --- a/src/main/services/CopilotService.ts +++ b/src/main/services/CopilotService.ts @@ -1,5 +1,6 @@ import { AxiosRequestConfig } from 'axios' import { app, safeStorage } from 'electron' +import Logger from 'electron-log' import fs from 'fs/promises' import path from 'path' @@ -227,10 +228,10 @@ class CopilotService { try { await fs.access(this.tokenFilePath) await fs.unlink(this.tokenFilePath) - console.log('Successfully logged out from Copilot') + Logger.log('Successfully logged out from Copilot') } catch (error) { // 文件不存在不是错误,只是记录一下 - console.log('Token file not found, nothing to delete') + Logger.log('Token file not found, nothing to delete') } } catch (error) { console.error('Failed to logout:', error) diff --git a/src/main/services/KnowledgeService.ts b/src/main/services/KnowledgeService.ts index a87500b2d..a61255846 100644 --- a/src/main/services/KnowledgeService.ts +++ b/src/main/services/KnowledgeService.ts @@ -465,7 +465,7 @@ class KnowledgeService { { uniqueId, uniqueIds, base }: { uniqueId: string; uniqueIds: string[]; base: KnowledgeBaseParams } ): Promise => { const ragApplication = await this.getRagApplication(base) - console.log(`[ KnowledgeService Remove Item UniqueId: ${uniqueId}]`) + Logger.log(`[ KnowledgeService Remove Item UniqueId: ${uniqueId}]`) for (const id of uniqueIds) { await ragApplication.deleteLoader(id) } diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts index 0ba1df8f3..237e709de 100644 --- a/src/main/services/MCPService.ts +++ b/src/main/services/MCPService.ts @@ -593,7 +593,7 @@ class McpService { const pathSeparator = process.platform === 'win32' ? ';' : ':' const cherryBinPath = path.join(os.homedir(), '.cherrystudio', 'bin') loginEnv.PATH = `${loginEnv.PATH}${pathSeparator}${cherryBinPath}` - Logger.info('[MCP] Successfully fetched login shell environment variables:', loginEnv) + Logger.info('[MCP] Successfully fetched login shell environment variables:') return loginEnv } catch (error) { Logger.error('[MCP] Failed to fetch login shell environment variables:', error) diff --git a/src/main/services/ObsidianVaultService.ts b/src/main/services/ObsidianVaultService.ts index 544c9c07b..0f9b33c47 100644 --- a/src/main/services/ObsidianVaultService.ts +++ b/src/main/services/ObsidianVaultService.ts @@ -1,4 +1,5 @@ import { app } from 'electron' +import Logger from 'electron-log' import fs from 'fs' import path from 'path' @@ -155,7 +156,7 @@ class ObsidianVaultService { return [] } - console.log('获取Vault文件结构:', vault.name, vault.path) + Logger.log('获取Vault文件结构:', vault.name, vault.path) return this.getVaultStructure(vault.path) } catch (error) { console.error('获取Vault文件结构时发生错误:', error) diff --git a/src/main/services/ProtocolClient.ts b/src/main/services/ProtocolClient.ts index 1d98f31c9..f37c61bb3 100644 --- a/src/main/services/ProtocolClient.ts +++ b/src/main/services/ProtocolClient.ts @@ -25,7 +25,6 @@ export function handleProtocolUrl(url: string) { if (!url) return // Process the URL that was used to open the app // The url will be in the format: cherrystudio://data?param1=value1¶m2=value2 - console.log('Received URL:', url) // Parse the URL and extract parameters const urlObj = new URL(url) diff --git a/src/main/services/ReduxService.ts b/src/main/services/ReduxService.ts index ec9b1c087..3cddd0e94 100644 --- a/src/main/services/ReduxService.ts +++ b/src/main/services/ReduxService.ts @@ -191,7 +191,7 @@ export const reduxService = new ReduxService() try { // 读取状态 const settings = await reduxService.select('state.settings') - console.log('settings', settings) + Logger.log('settings', settings) // 派发 action await reduxService.dispatch({ @@ -201,7 +201,7 @@ export const reduxService = new ReduxService() // 订阅状态变化 const unsubscribe = await reduxService.subscribe('state.settings.apiKey', (newValue) => { - console.log('API key changed:', newValue) + Logger.log('API key changed:', newValue) }) // 批量执行 actions @@ -212,16 +212,16 @@ export const reduxService = new ReduxService() // 同步方法虽然可能不是最新的数据,但响应更快 const apiKey = reduxService.selectSync('state.settings.apiKey') - console.log('apiKey', apiKey) + Logger.log('apiKey', apiKey) // 处理保证是最新的数据 const apiKey1 = await reduxService.select('state.settings.apiKey') - console.log('apiKey1', apiKey1) + Logger.log('apiKey1', apiKey1) // 取消订阅 unsubscribe() } catch (error) { - console.error('Error:', error) + Logger.error('Error:', error) } } */ diff --git a/src/main/services/RemoteStorage.ts b/src/main/services/RemoteStorage.ts index 373f7fc31..b62489bbb 100644 --- a/src/main/services/RemoteStorage.ts +++ b/src/main/services/RemoteStorage.ts @@ -1,57 +1,57 @@ -import Logger from 'electron-log' -import { Operator } from 'opendal' +// import Logger from 'electron-log' +// import { Operator } from 'opendal' -export default class RemoteStorage { - public instance: Operator | 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 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) +// /** +// * +// * @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 - } - } -} +// 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/ShortcutService.ts b/src/main/services/ShortcutService.ts index c384f57fa..d69c80b32 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -1,5 +1,4 @@ -import { ZOOM_LEVELS } from '@shared/config/constant' -import { IpcChannel } from '@shared/IpcChannel' +import { handleZoomFactor } from '@main/utils/zoom' import { Shortcut } from '@types' import { BrowserWindow, globalShortcut } from 'electron' import Logger from 'electron-log' @@ -16,14 +15,11 @@ const windowOnHandlers = new Map void; on function getShortcutHandler(shortcut: Shortcut) { switch (shortcut.key) { case 'zoom_in': - return (window: BrowserWindow) => handleZoom(1)(window) + return (window: BrowserWindow) => handleZoomFactor([window], 0.1) case 'zoom_out': - return (window: BrowserWindow) => handleZoom(-1)(window) + return (window: BrowserWindow) => handleZoomFactor([window], -0.1) case 'zoom_reset': - return (window: BrowserWindow) => { - window.webContents.setZoomFactor(1) - configManager.setZoomFactor(1) - } + return (window: BrowserWindow) => handleZoomFactor([window], 0, true) case 'show_app': return () => { windowService.toggleMainWindow() @@ -41,46 +37,6 @@ function formatShortcutKey(shortcut: string[]): string { return shortcut.join('+') } -function handleZoom(delta: number) { - return (window: BrowserWindow) => { - const currentZoom = configManager.getZoomFactor() - let currentIndex = ZOOM_LEVELS.indexOf(currentZoom) - - // 如果当前缩放比例不在预设列表中,找到最接近的 - if (currentIndex === -1) { - let closestIndex = 0 - let minDiff = Math.abs(ZOOM_LEVELS[0] - currentZoom) - for (let i = 1; i < ZOOM_LEVELS.length; i++) { - const diff = Math.abs(ZOOM_LEVELS[i] - currentZoom) - if (diff < minDiff) { - minDiff = diff - closestIndex = i - } - } - currentIndex = closestIndex - } - - let nextIndex = currentIndex + delta - - // 边界检查 - if (nextIndex < 0) { - nextIndex = 0 // 已经是最小值 - } else if (nextIndex >= ZOOM_LEVELS.length) { - nextIndex = ZOOM_LEVELS.length - 1 // 已经是最大值 - } - - const newZoom = ZOOM_LEVELS[nextIndex] - - if (newZoom !== currentZoom) { - // 只有在实际改变时才更新 - configManager.setZoomFactor(newZoom) - // 通知所有渲染进程更新 zoomFactor - window.webContents.setZoomFactor(newZoom) - window.webContents.send(IpcChannel.ZoomFactorUpdated, newZoom) - } - } -} - const convertShortcutRecordedByKeyboardEventKeyValueToElectronGlobalShortcutFormat = ( shortcut: string | string[] ): string => { diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index efbaae5fc..bac15cca0 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -468,9 +468,9 @@ export class WindowService { }) if (is.dev && process.env['ELECTRON_RENDERER_URL']) { - this.miniWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/src/windows/mini/index.html') + this.miniWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/miniWindow.html') } else { - this.miniWindow.loadFile(join(__dirname, '../renderer/src/windows/mini/index.html')) + this.miniWindow.loadFile(join(__dirname, '../renderer/miniWindow.html')) } return this.miniWindow diff --git a/src/main/utils/zoom.ts b/src/main/utils/zoom.ts new file mode 100644 index 000000000..8e7145313 --- /dev/null +++ b/src/main/utils/zoom.ts @@ -0,0 +1,26 @@ +import { BrowserWindow } from 'electron' + +import { configManager } from '../services/ConfigManager' + +export function handleZoomFactor(wins: BrowserWindow[], delta: number, reset: boolean = false) { + if (reset) { + wins.forEach((win) => { + win.webContents.setZoomFactor(1) + }) + configManager.setZoomFactor(1) + return + } + + if (delta === 0) { + return + } + + const currentZoom = configManager.getZoomFactor() + const newZoom = Number((currentZoom + delta).toFixed(1)) + if (newZoom >= 0.5 && newZoom <= 2.0) { + wins.forEach((win) => { + win.webContents.setZoomFactor(newZoom) + }) + configManager.setZoomFactor(newZoom) + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 6ca1dcaa5..56b02d437 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -19,7 +19,8 @@ const api = { setTrayOnClose: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTrayOnClose, isActive), restartTray: () => ipcRenderer.invoke(IpcChannel.App_RestartTray), setTheme: (theme: 'light' | 'dark' | 'auto') => ipcRenderer.invoke(IpcChannel.App_SetTheme, theme), - setZoomFactor: (factor: number) => ipcRenderer.invoke(IpcChannel.App_SetZoomFactor, factor), + handleZoomFactor: (delta: number, reset: boolean = false) => + ipcRenderer.invoke(IpcChannel.App_HandleZoomFactor, delta, reset), setAutoUpdate: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetAutoUpdate, isActive), openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url), clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache), @@ -198,17 +199,6 @@ const api = { subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe), unsubscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Unsubscribe), onUpdate: (action: any) => ipcRenderer.invoke(IpcChannel.StoreSync_OnUpdate, action) - }, - // 新增:监听主进程的 zoom factor 更新 - onZoomFactorUpdate: (callback: (factor: number) => void) => { - const listener = (_event: Electron.IpcRendererEvent, factor: number) => { - callback(factor) - } - ipcRenderer.on(IpcChannel.ZoomFactorUpdated, listener) - // 返回一个移除监听器的函数 - return () => { - ipcRenderer.removeListener(IpcChannel.ZoomFactorUpdated, listener) - } } } diff --git a/src/renderer/__tests__/setup.ts b/src/renderer/__tests__/setup.ts new file mode 100644 index 000000000..f847a4082 --- /dev/null +++ b/src/renderer/__tests__/setup.ts @@ -0,0 +1,20 @@ +import { vi } from 'vitest' + +vi.mock('electron-log/renderer', () => { + return { + default: { + info: console.log, + error: console.error, + warn: console.warn, + debug: console.debug, + verbose: console.log, + silly: console.log, + log: console.log, + transports: { + console: { + level: 'info' + } + } + } + } +}) diff --git a/src/renderer/src/windows/mini/index.html b/src/renderer/miniWindow.html similarity index 88% rename from src/renderer/src/windows/mini/index.html rename to src/renderer/miniWindow.html index 224ae9027..c2748618f 100644 --- a/src/renderer/src/windows/mini/index.html +++ b/src/renderer/miniWindow.html @@ -18,7 +18,7 @@
- + \ No newline at end of file diff --git a/src/renderer/src/assets/images/search/bocha.webp b/src/renderer/src/assets/images/search/bocha.webp new file mode 100644 index 000000000..ee21dc16e Binary files /dev/null and b/src/renderer/src/assets/images/search/bocha.webp differ diff --git a/src/renderer/src/components/CustomTag.tsx b/src/renderer/src/components/CustomTag.tsx index d16b29c73..76334ae6c 100644 --- a/src/renderer/src/components/CustomTag.tsx +++ b/src/renderer/src/components/CustomTag.tsx @@ -1,6 +1,6 @@ import { CloseOutlined } from '@ant-design/icons' import { Tooltip } from 'antd' -import { FC, memo, useEffect, useMemo, useState } from 'react' +import { FC, memo } from 'react' import styled from 'styled-components' interface CustomTagProps { @@ -14,29 +14,13 @@ interface CustomTagProps { } const CustomTag: FC = ({ children, icon, color, size = 12, tooltip, closable = false, onClose }) => { - const [showTooltip, setShowTooltip] = useState(false) - - useEffect(() => { - const timer = setTimeout(() => setShowTooltip(true), 300) - return () => clearTimeout(timer) - }, []) - - const tagContent = useMemo( - () => ( + return ( + {icon && icon} {children} {closable && } - ), - [children, color, closable, icon, onClose, size] - ) - - return tooltip && showTooltip ? ( - - {tagContent} - ) : ( - tagContent ) } diff --git a/src/renderer/src/components/Icons/FallbackFavicon.tsx b/src/renderer/src/components/Icons/FallbackFavicon.tsx index f6c8369f3..df4567321 100644 --- a/src/renderer/src/components/Icons/FallbackFavicon.tsx +++ b/src/renderer/src/components/Icons/FallbackFavicon.tsx @@ -1,3 +1,4 @@ +import Logger from '@renderer/config/logger' import { useEffect, useState } from 'react' import styled from 'styled-components' @@ -120,7 +121,7 @@ const FallbackFavicon: React.FC = ({ hostname, alt }) => { setFaviconState({ status: 'loaded', src: url }) }) .catch((error) => { - console.log('All favicon requests failed:', error) + Logger.log('All favicon requests failed:', error) setFaviconState({ status: 'loaded', src: faviconUrls[0] }) }) diff --git a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx index b1fda028c..3b728fa30 100644 --- a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx +++ b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx @@ -24,9 +24,9 @@ import { Avatar, Drawer, Tooltip } from 'antd' import { WebviewTag } from 'electron' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import BeatLoader from 'react-spinners/BeatLoader' import styled from 'styled-components' -import SvgSpinners180Ring from '../Icons/SvgSpinners180Ring' import WebviewContainer from './WebviewContainer' interface AppExtraInfo { @@ -375,7 +375,7 @@ const MinappPopupContainer: React.FC = () => { size={80} style={{ border: '1px solid var(--color-border)', marginTop: -150 }} /> - + )} {WebviewContainerGroup} diff --git a/src/renderer/src/components/ObsidianExportDialog.tsx b/src/renderer/src/components/ObsidianExportDialog.tsx index 739b51003..267473f48 100644 --- a/src/renderer/src/components/ObsidianExportDialog.tsx +++ b/src/renderer/src/components/ObsidianExportDialog.tsx @@ -312,6 +312,7 @@ const ObsidianExportDialog: React.FC = ({ closable maskClosable centered + transitionName="animation-move-down" okButtonProps={{ type: 'primary', disabled: vaults.length === 0 || loading || !!error diff --git a/src/renderer/src/components/Popups/AddAssistantPopup.tsx b/src/renderer/src/components/Popups/AddAssistantPopup.tsx index 2efecdf07..8215ee0ae 100644 --- a/src/renderer/src/components/Popups/AddAssistantPopup.tsx +++ b/src/renderer/src/components/Popups/AddAssistantPopup.tsx @@ -150,7 +150,7 @@ const PopupContainer: React.FC = ({ resolve }) => { open={open} onCancel={onCancel} afterClose={onClose} - transitionName="ant-move-up" + transitionName="animation-move-down" styles={{ content: { borderRadius: 20, diff --git a/src/renderer/src/components/Popups/BackupPopup.tsx b/src/renderer/src/components/Popups/BackupPopup.tsx index a6e8d03e8..41eb268a1 100644 --- a/src/renderer/src/components/Popups/BackupPopup.tsx +++ b/src/renderer/src/components/Popups/BackupPopup.tsx @@ -70,6 +70,7 @@ const PopupContainer: React.FC = ({ resolve }) => { cancelButtonProps={{ disabled: isDisabled }} okText={t('backup.confirm.button')} maskClosable={false} + transitionName="animation-move-down" centered> {!progressData &&
{t('backup.content')}
} {progressData && ( diff --git a/src/renderer/src/components/Popups/PromptPopup.tsx b/src/renderer/src/components/Popups/PromptPopup.tsx index 473a50e95..413aeff1e 100644 --- a/src/renderer/src/components/Popups/PromptPopup.tsx +++ b/src/renderer/src/components/Popups/PromptPopup.tsx @@ -63,6 +63,7 @@ const PromptPopupContainer: React.FC = ({ onCancel={onCancel} afterClose={onClose} afterOpenChange={handleAfterOpenChange} + transitionName="animation-move-down" centered> {message} = ({ resolve }) => { okButtonProps={{ disabled: isDisabled }} cancelButtonProps={{ disabled: isDisabled }} maskClosable={false} + transitionName="animation-move-down" centered> {!progressData &&
{t('restore.content')}
} {progressData && ( diff --git a/src/renderer/src/components/Popups/SelectModelPopup/hook.ts b/src/renderer/src/components/Popups/SelectModelPopup/hook.ts new file mode 100644 index 000000000..93441acb2 --- /dev/null +++ b/src/renderer/src/components/Popups/SelectModelPopup/hook.ts @@ -0,0 +1,41 @@ +import { useMemo, useReducer } from 'react' + +import { initialScrollState, scrollReducer } from './reducer' +import { FlatListItem, ScrollTrigger } from './types' + +/** + * 管理滚动和焦点状态的 hook + */ +export function useScrollState() { + const [state, dispatch] = useReducer(scrollReducer, initialScrollState) + + const actions = useMemo( + () => ({ + setFocusedItemKey: (key: string) => dispatch({ type: 'SET_FOCUSED_ITEM_KEY', payload: key }), + setScrollTrigger: (trigger: ScrollTrigger) => dispatch({ type: 'SET_SCROLL_TRIGGER', payload: trigger }), + setLastScrollOffset: (offset: number) => dispatch({ type: 'SET_LAST_SCROLL_OFFSET', payload: offset }), + setStickyGroup: (group: FlatListItem | null) => dispatch({ type: 'SET_STICKY_GROUP', payload: group }), + setIsMouseOver: (isMouseOver: boolean) => dispatch({ type: 'SET_IS_MOUSE_OVER', payload: isMouseOver }), + focusNextItem: (modelItems: FlatListItem[], step: number) => + dispatch({ type: 'FOCUS_NEXT_ITEM', payload: { modelItems, step } }), + focusPage: (modelItems: FlatListItem[], currentIndex: number, step: number) => + dispatch({ type: 'FOCUS_PAGE', payload: { modelItems, currentIndex, step } }), + searchChanged: (searchText: string) => dispatch({ type: 'SEARCH_CHANGED', payload: { searchText } }), + updateOnListChange: (modelItems: FlatListItem[]) => + dispatch({ type: 'UPDATE_ON_LIST_CHANGE', payload: { modelItems } }), + initScroll: () => dispatch({ type: 'INIT_SCROLL' }) + }), + [] + ) + + return { + // 状态 + focusedItemKey: state.focusedItemKey, + scrollTrigger: state.scrollTrigger, + lastScrollOffset: state.lastScrollOffset, + stickyGroup: state.stickyGroup, + isMouseOver: state.isMouseOver, + // 操作 + ...actions + } +} diff --git a/src/renderer/src/components/Popups/SelectModelPopup/index.ts b/src/renderer/src/components/Popups/SelectModelPopup/index.ts new file mode 100644 index 000000000..7a0b7fa86 --- /dev/null +++ b/src/renderer/src/components/Popups/SelectModelPopup/index.ts @@ -0,0 +1,3 @@ +import { SelectModelPopup } from './popup' + +export default SelectModelPopup diff --git a/src/renderer/src/components/Popups/SelectModelPopup.tsx b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx similarity index 72% rename from src/renderer/src/components/Popups/SelectModelPopup.tsx rename to src/renderer/src/components/Popups/SelectModelPopup/popup.tsx index c19c5def9..febf76818 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup.tsx +++ b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx @@ -1,7 +1,9 @@ import { PushpinOutlined } from '@ant-design/icons' +import { HStack } from '@renderer/components/Layout' +import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel' import { TopView } from '@renderer/components/TopView' import { getModelLogo, isEmbeddingModel, isRerankModel } from '@renderer/config/models' -import db from '@renderer/databases' +import { usePinnedModels } from '@renderer/hooks/usePinnedModels' import { useProviders } from '@renderer/hooks/useProvider' import { getModelUniqId } from '@renderer/services/ModelService' import { Model } from '@renderer/types' @@ -15,101 +17,61 @@ import { useTranslation } from 'react-i18next' import { FixedSizeList } from 'react-window' import styled from 'styled-components' -import { HStack } from '../Layout' -import ModelTagsWithLabel from '../ModelTagsWithLabel' +import { useScrollState } from './hook' +import { FlatListItem } from './types' -const PAGE_SIZE = 9 +const PAGE_SIZE = 10 const ITEM_HEIGHT = 36 -// 列表项类型,组名也作为列表项 -type ListItemType = 'group' | 'model' - -// 滚动触发来源类型 -type ScrollTrigger = 'initial' | 'search' | 'keyboard' | 'none' - -// 扁平化列表项接口 -interface FlatListItem { - key: string - type: ListItemType - icon?: React.ReactNode - name: React.ReactNode - tags?: React.ReactNode - model?: Model - isPinned?: boolean - isSelected?: boolean -} - -interface Props { +interface PopupParams { model?: Model } -interface PopupContainerProps extends Props { +interface Props extends PopupParams { resolve: (value: Model | undefined) => void } -const PopupContainer: React.FC = ({ model, resolve }) => { +const PopupContainer: React.FC = ({ model, resolve }) => { const { t } = useTranslation() const { providers } = useProviders() + const { pinnedModels, togglePinnedModel, loading: loadingPinnedModels } = usePinnedModels() const [open, setOpen] = useState(true) const inputRef = useRef(null) const listRef = useRef(null) const [_searchText, setSearchText] = useState('') const searchText = useDeferredValue(_searchText) - const [isMouseOver, setIsMouseOver] = useState(false) - const [pinnedModels, setPinnedModels] = useState([]) - const [_focusedItemKey, setFocusedItemKey] = useState('') - const focusedItemKey = useDeferredValue(_focusedItemKey) - const [_stickyGroup, setStickyGroup] = useState(null) - const stickyGroup = useDeferredValue(_stickyGroup) - const firstGroupRef = useRef(null) - const scrollTriggerRef = useRef('initial') - const lastScrollOffsetRef = useRef(0) // 当前选中的模型ID const currentModelId = model ? getModelUniqId(model) : '' - // 加载置顶模型列表 - useEffect(() => { - const loadPinnedModels = async () => { - const setting = await db.settings.get('pinned:models') - const savedPinnedModels = setting?.value || [] + // 管理滚动和焦点状态 + const { + focusedItemKey, + scrollTrigger, + lastScrollOffset, + stickyGroup: _stickyGroup, + isMouseOver, + setFocusedItemKey, + setScrollTrigger, + setLastScrollOffset, + setStickyGroup, + setIsMouseOver, + focusNextItem, + focusPage, + searchChanged, + updateOnListChange, + initScroll + } = useScrollState() - // Filter out invalid pinned models - const allModelIds = providers.flatMap((p) => p.models || []).map((m) => getModelUniqId(m)) - const validPinnedModels = savedPinnedModels.filter((id) => allModelIds.includes(id)) - - // Update storage if there were invalid models - if (validPinnedModels.length !== savedPinnedModels.length) { - await db.settings.put({ id: 'pinned:models', value: validPinnedModels }) - } - - setPinnedModels(sortBy(validPinnedModels)) - } - - try { - loadPinnedModels() - } catch (error) { - console.error('Failed to load pinned models', error) - setPinnedModels([]) - } - }, [providers]) + const stickyGroup = useDeferredValue(_stickyGroup) + const firstGroupRef = useRef(null) const togglePin = useCallback( async (modelId: string) => { - const newPinnedModels = pinnedModels.includes(modelId) - ? pinnedModels.filter((id) => id !== modelId) - : [...pinnedModels, modelId] - - try { - await db.settings.put({ id: 'pinned:models', value: newPinnedModels }) - setPinnedModels(sortBy(newPinnedModels)) - // Pin操作不触发滚动 - scrollTriggerRef.current = 'none' - } catch (error) { - console.error('Failed to update pinned models', error) - } + await togglePinnedModel(modelId) + setScrollTrigger('none') // pin操作不触发滚动 }, - [pinnedModels] + [togglePinnedModel, setScrollTrigger] ) // 根据输入的文本筛选模型 @@ -222,6 +184,16 @@ const PopupContainer: React.FC = ({ model, resolve }) => { return items }, [providers, getFilteredModels, pinnedModels, searchText, t, createModelItem]) + // 获取可选择的模型项(过滤掉分组标题) + const modelItems = useMemo(() => { + return listItems.filter((item) => item.type === 'model') + }, [listItems]) + + // 当搜索文本变化时更新滚动触发器 + useEffect(() => { + searchChanged(searchText) + }, [searchText, searchChanged]) + // 基于滚动位置更新sticky分组标题 const updateStickyGroup = useCallback( (scrollOffset?: number) => { @@ -231,7 +203,7 @@ const PopupContainer: React.FC = ({ model, resolve }) => { } // 基于滚动位置计算当前可见的第一个项的索引 - const estimatedIndex = Math.floor((scrollOffset ?? lastScrollOffsetRef.current) / ITEM_HEIGHT) + const estimatedIndex = Math.floor((scrollOffset ?? lastScrollOffset) / ITEM_HEIGHT) // 从该索引向前查找最近的分组标题 for (let i = estimatedIndex - 1; i >= 0; i--) { @@ -242,9 +214,9 @@ const PopupContainer: React.FC = ({ model, resolve }) => { } // 找不到则使用第一个分组标题 - setStickyGroup(firstGroupRef.current ?? null) + setStickyGroup(firstGroupRef.current) }, - [listItems] + [listItems, lastScrollOffset, setStickyGroup] ) // 在listItems变化时更新sticky group @@ -255,72 +227,47 @@ const PopupContainer: React.FC = ({ model, resolve }) => { // 处理列表滚动事件,更新lastScrollOffset并更新sticky分组 const handleScroll = useCallback( ({ scrollOffset }) => { - lastScrollOffsetRef.current = scrollOffset + setLastScrollOffset(scrollOffset) updateStickyGroup(scrollOffset) }, - [updateStickyGroup] + [updateStickyGroup, setLastScrollOffset] ) - // 获取可选择的模型项(过滤掉分组标题) - const modelItems = useMemo(() => { - return listItems.filter((item) => item.type === 'model') - }, [listItems]) - - // 搜索文本变化时设置滚动来源 + // 在列表项更新时,更新焦点项 useEffect(() => { - if (searchText.trim() !== '') { - scrollTriggerRef.current = 'search' - setFocusedItemKey('') - } - }, [searchText]) - - // 设置初始聚焦项以触发滚动 - useEffect(() => { - if (scrollTriggerRef.current === 'initial' || scrollTriggerRef.current === 'search') { - const selectedItem = modelItems.find((item) => item.isSelected) - if (selectedItem) { - setFocusedItemKey(selectedItem.key) - } else if (scrollTriggerRef.current === 'initial' && modelItems.length > 0) { - setFocusedItemKey(modelItems[0].key) - } - // 其余情况不设置focusedItemKey - } - }, [modelItems]) + updateOnListChange(modelItems) + }, [modelItems, updateOnListChange]) // 滚动到聚焦项 useEffect(() => { - if (scrollTriggerRef.current === 'none' || !focusedItemKey) return + if (scrollTrigger === 'none' || !focusedItemKey) return const index = listItems.findIndex((item) => item.key === focusedItemKey) if (index < 0) return // 根据触发源决定滚动对齐方式 - const alignment = scrollTriggerRef.current === 'keyboard' ? 'auto' : 'center' + const alignment = scrollTrigger === 'keyboard' ? 'auto' : 'center' listRef.current?.scrollToItem(index, alignment) // 滚动后重置触发器 - scrollTriggerRef.current = 'none' - }, [focusedItemKey, listItems]) + setScrollTrigger('none') + }, [focusedItemKey, scrollTrigger, listItems, setScrollTrigger]) const handleItemClick = useCallback( (item: FlatListItem) => { if (item.type === 'model') { - scrollTriggerRef.current = 'none' + setScrollTrigger('initial') resolve(item.model) setOpen(false) } }, - [resolve] + [resolve, setScrollTrigger] ) // 处理键盘导航 - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (!open) return - - if (modelItems.length === 0) { - return - } + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!open || modelItems.length === 0 || e.isComposing) return // 键盘操作时禁用鼠标 hover if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Enter', 'Escape'].includes(e.key)) { @@ -329,43 +276,21 @@ const PopupContainer: React.FC = ({ model, resolve }) => { setIsMouseOver(false) } - const getCurrentIndex = (currentKey: string) => { - const currentIndex = modelItems.findIndex((item) => item.key === currentKey) - return currentIndex < 0 ? 0 : currentIndex - } + const currentIndex = modelItems.findIndex((item) => item.key === focusedItemKey) + const normalizedIndex = currentIndex < 0 ? 0 : currentIndex switch (e.key) { case 'ArrowUp': - scrollTriggerRef.current = 'keyboard' - setFocusedItemKey((prev) => { - const currentIndex = getCurrentIndex(prev) - const nextIndex = (currentIndex - 1 + modelItems.length) % modelItems.length - return modelItems[nextIndex].key - }) + focusNextItem(modelItems, -1) break case 'ArrowDown': - scrollTriggerRef.current = 'keyboard' - setFocusedItemKey((prev) => { - const currentIndex = getCurrentIndex(prev) - const nextIndex = (currentIndex + 1) % modelItems.length - return modelItems[nextIndex].key - }) + focusNextItem(modelItems, 1) break case 'PageUp': - scrollTriggerRef.current = 'keyboard' - setFocusedItemKey((prev) => { - const currentIndex = getCurrentIndex(prev) - const nextIndex = Math.max(currentIndex - PAGE_SIZE, 0) - return modelItems[nextIndex].key - }) + focusPage(modelItems, normalizedIndex, -PAGE_SIZE) break case 'PageDown': - scrollTriggerRef.current = 'keyboard' - setFocusedItemKey((prev) => { - const currentIndex = getCurrentIndex(prev) - const nextIndex = Math.min(currentIndex + PAGE_SIZE, modelItems.length - 1) - return modelItems[nextIndex].key - }) + focusPage(modelItems, normalizedIndex, PAGE_SIZE) break case 'Enter': if (focusedItemKey) { @@ -377,34 +302,47 @@ const PopupContainer: React.FC = ({ model, resolve }) => { break case 'Escape': e.preventDefault() - scrollTriggerRef.current = 'none' + setScrollTrigger('none') setOpen(false) resolve(undefined) break } - } - - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) - }, [focusedItemKey, modelItems, handleItemClick, open, resolve]) - - const onCancel = useCallback(() => { - scrollTriggerRef.current = 'none' - setOpen(false) - }, []) - - const onClose = useCallback(async () => { - scrollTriggerRef.current = 'none' - resolve(undefined) - SelectModelPopup.hide() - }, [resolve]) + }, + [ + focusedItemKey, + modelItems, + handleItemClick, + open, + resolve, + setIsMouseOver, + focusNextItem, + focusPage, + setScrollTrigger + ] + ) useEffect(() => { - if (!open) return + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [handleKeyDown]) + + const onCancel = useCallback(() => { + setScrollTrigger('initial') + setOpen(false) + }, [setScrollTrigger]) + + const onClose = useCallback(async () => { + setScrollTrigger('initial') + resolve(undefined) + SelectModelPopup.hide() + }, [resolve, setScrollTrigger]) + + // 初始化焦点和滚动位置 + useEffect(() => { + if (!open || loadingPinnedModels) return setTimeout(() => inputRef.current?.focus(), 0) - scrollTriggerRef.current = 'initial' - lastScrollOffsetRef.current = 0 - }, [open]) + initScroll() + }, [open, initScroll, loadingPinnedModels]) const RowData = useMemo( (): VirtualizedRowData => ({ @@ -415,7 +353,7 @@ const PopupContainer: React.FC = ({ model, resolve }) => { handleItemClick, togglePin }), - [stickyGroup, focusedItemKey, handleItemClick, listItems, togglePin] + [stickyGroup, focusedItemKey, handleItemClick, listItems, togglePin, setFocusedItemKey] ) const listHeight = useMemo(() => { @@ -470,7 +408,7 @@ const PopupContainer: React.FC = ({ model, resolve }) => { {listItems.length > 0 ? ( - setIsMouseOver(true)}> + !isMouseOver && setIsMouseOver(true)}> {/* Sticky Group Banner,它会替换第一个分组名称 */} {stickyGroup?.name} ((resolve) => { - TopView.show(, 'SelectModelPopup') + TopView.show( + { + resolve(v) + TopView.hide(TopViewKey) + }} + />, + TopViewKey + ) }) } } diff --git a/src/renderer/src/components/Popups/SelectModelPopup/reducer.ts b/src/renderer/src/components/Popups/SelectModelPopup/reducer.ts new file mode 100644 index 000000000..45e3390ea --- /dev/null +++ b/src/renderer/src/components/Popups/SelectModelPopup/reducer.ts @@ -0,0 +1,109 @@ +import { ScrollAction, ScrollState } from './types' + +/** + * 初始状态 + */ +export const initialScrollState: ScrollState = { + focusedItemKey: '', + scrollTrigger: 'initial', + lastScrollOffset: 0, + stickyGroup: null, + isMouseOver: false +} + +/** + * 滚动状态的 reducer,用于避免复杂依赖可能带来的状态更新问题 + * @param state 当前状态 + * @param action 动作 + * @returns 新的状态 + */ +export const scrollReducer = (state: ScrollState, action: ScrollAction): ScrollState => { + switch (action.type) { + case 'SET_FOCUSED_ITEM_KEY': + return { ...state, focusedItemKey: action.payload } + + case 'SET_SCROLL_TRIGGER': + return { ...state, scrollTrigger: action.payload } + + case 'SET_LAST_SCROLL_OFFSET': + return { ...state, lastScrollOffset: action.payload } + + case 'SET_STICKY_GROUP': + return { ...state, stickyGroup: action.payload } + + case 'SET_IS_MOUSE_OVER': + return { ...state, isMouseOver: action.payload } + + case 'FOCUS_NEXT_ITEM': { + const { modelItems, step } = action.payload + + if (modelItems.length === 0) { + return { + ...state, + focusedItemKey: '', + scrollTrigger: 'keyboard' + } + } + + const currentIndex = modelItems.findIndex((item) => item.key === state.focusedItemKey) + const nextIndex = (currentIndex < 0 ? 0 : currentIndex + step + modelItems.length) % modelItems.length + + return { + ...state, + focusedItemKey: modelItems[nextIndex].key, + scrollTrigger: 'keyboard' + } + } + + case 'FOCUS_PAGE': { + const { modelItems, currentIndex, step } = action.payload + const nextIndex = Math.max(0, Math.min(currentIndex + step, modelItems.length - 1)) + + return { + ...state, + focusedItemKey: modelItems.length > 0 ? modelItems[nextIndex].key : '', + scrollTrigger: 'keyboard' + } + } + + case 'SEARCH_CHANGED': + return { + ...state, + scrollTrigger: action.payload.searchText ? 'search' : 'initial' + } + + case 'UPDATE_ON_LIST_CHANGE': { + const { modelItems } = action.payload + + // 在列表变化时尝试聚焦一个模型: + // - 如果是 initial 状态,先尝试聚焦当前选中的模型 + // - 如果是 search 状态,尝试聚焦第一个模型 + let newFocusedKey = '' + if (state.scrollTrigger === 'initial' || state.scrollTrigger === 'search') { + const selectedItem = modelItems.find((item) => item.isSelected) + if (selectedItem && state.scrollTrigger === 'initial') { + newFocusedKey = selectedItem.key + } else if (modelItems.length > 0) { + newFocusedKey = modelItems[0].key + } + } else { + newFocusedKey = state.focusedItemKey + } + + return { + ...state, + focusedItemKey: newFocusedKey + } + } + + case 'INIT_SCROLL': + return { + ...state, + scrollTrigger: 'initial', + lastScrollOffset: 0 + } + + default: + return state + } +} diff --git a/src/renderer/src/components/Popups/SelectModelPopup/types.ts b/src/renderer/src/components/Popups/SelectModelPopup/types.ts new file mode 100644 index 000000000..41ec04c58 --- /dev/null +++ b/src/renderer/src/components/Popups/SelectModelPopup/types.ts @@ -0,0 +1,42 @@ +import { Model } from '@renderer/types' +import { ReactNode } from 'react' + +// 列表项类型,组名也作为列表项 +export type ListItemType = 'group' | 'model' + +// 滚动触发来源类型 +export type ScrollTrigger = 'initial' | 'search' | 'keyboard' | 'none' + +// 扁平化列表项接口 +export interface FlatListItem { + key: string + type: ListItemType + icon?: ReactNode + name: ReactNode + tags?: ReactNode + model?: Model + isPinned?: boolean + isSelected?: boolean +} + +// 滚动和焦点相关的状态类型 +export interface ScrollState { + focusedItemKey: string + scrollTrigger: ScrollTrigger + lastScrollOffset: number + stickyGroup: FlatListItem | null + isMouseOver: boolean +} + +// 滚动和焦点相关的 action 类型 +export type ScrollAction = + | { type: 'SET_FOCUSED_ITEM_KEY'; payload: string } + | { type: 'SET_SCROLL_TRIGGER'; payload: ScrollTrigger } + | { type: 'SET_LAST_SCROLL_OFFSET'; payload: number } + | { type: 'SET_STICKY_GROUP'; payload: FlatListItem | null } + | { type: 'SET_IS_MOUSE_OVER'; payload: boolean } + | { type: 'FOCUS_NEXT_ITEM'; payload: { modelItems: FlatListItem[]; step: number } } + | { type: 'FOCUS_PAGE'; payload: { modelItems: FlatListItem[]; currentIndex: number; step: number } } + | { type: 'SEARCH_CHANGED'; payload: { searchText: string } } + | { type: 'UPDATE_ON_LIST_CHANGE'; payload: { modelItems: FlatListItem[] } } + | { type: 'INIT_SCROLL'; payload?: void } diff --git a/src/renderer/src/components/Spinner.tsx b/src/renderer/src/components/Spinner.tsx index fb8e3d35e..74408fc50 100644 --- a/src/renderer/src/components/Spinner.tsx +++ b/src/renderer/src/components/Spinner.tsx @@ -1,41 +1,68 @@ import { Search } from 'lucide-react' +import { motion } from 'motion/react' import { useTranslation } from 'react-i18next' -import BarLoader from 'react-spinners/BarLoader' -import styled, { css } from 'styled-components' +import styled from 'styled-components' interface Props { text: string } +// Define variants for the spinner animation +const spinnerVariants = { + defaultColor: { + color: '#2a2a2a' + }, + dimmed: { + color: '#8C9296' + } +} + export default function Spinner({ text }: Props) { const { t } = useTranslation() return ( - - - {t(text)} - - + + + {t(text)} + ) } -const baseContainer = css` +// const baseContainer = css` +// display: flex; +// flex-direction: row; +// align-items: center; +// ` + +// const Container = styled.div` +// ${baseContainer} +// background-color: var(--color-background-mute); +// padding: 10px; +// border-radius: 10px; +// margin-bottom: 10px; +// gap: 10px; +// ` + +// const StatusText = styled.div` +// font-size: 14px; +// line-height: 1.6; +// text-decoration: none; +// color: var(--color-text-1); +// ` +const SearchWrapper = styled.div` display: flex; - flex-direction: row; align-items: center; -` - -const Container = styled.div` - ${baseContainer} - background-color: var(--color-background-mute); - padding: 10px; - border-radius: 10px; - margin-bottom: 10px; - gap: 10px; -` - -const StatusText = styled.div` + gap: 4px; font-size: 14px; - line-height: 1.6; - text-decoration: none; - color: var(--color-text-1); + padding: 10px; + padding-left: 0; ` +const Searching = motion.create(SearchWrapper) diff --git a/src/renderer/src/components/TranslateButton.tsx b/src/renderer/src/components/TranslateButton.tsx index 010e15248..78dd1b7d3 100644 --- a/src/renderer/src/components/TranslateButton.tsx +++ b/src/renderer/src/components/TranslateButton.tsx @@ -35,7 +35,6 @@ const TranslateButton: FC = ({ text, onTranslated, disabled, style, isLoa } const handleTranslate = async () => { - console.log('handleTranslate', text) if (!text?.trim()) return if (!(await translateConfirm())) { diff --git a/src/renderer/src/components/WebdavBackupManager.tsx b/src/renderer/src/components/WebdavBackupManager.tsx index fd42449fe..1c589736b 100644 --- a/src/renderer/src/components/WebdavBackupManager.tsx +++ b/src/renderer/src/components/WebdavBackupManager.tsx @@ -254,6 +254,8 @@ export function WebdavBackupManager({ visible, onClose, webdavConfig, restoreMet open={visible} onCancel={onClose} width={800} + centered + transitionName="animation-move-down" footer={[