diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..f530d6e3bf --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,86 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "monthly" + open-pull-requests-limit: 7 + target-branch: "main" + commit-message: + prefix: "chore" + include: "scope" + groups: + # 核心框架 + core-framework: + 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" + + # 测试工具 + testing-tools: + patterns: + - "vitest" + - "@vitest/*" + - "playwright" + - "@playwright/*" + - "eslint*" + - "@eslint*" + - "prettier" + - "husky" + - "lint-staged" + update-types: + - "minor" + - "patch" + + # CherryStudio 自定义包 + cherrystudio-packages: + patterns: + - "@cherrystudio/*" + update-types: + - "minor" + - "patch" + + # 兜底其他 dependencies + other-dependencies: + dependency-type: "production" + + # 兜底其他 devDependencies + other-dev-dependencies: + dependency-type: "development" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 3 + commit-message: + prefix: "ci" + include: "scope" + groups: + github-actions: + patterns: + - "*" + update-types: + - "minor" + - "patch" diff --git a/.gitignore b/.gitignore index 68ea0f203f..23d8a8531a 100644 --- a/.gitignore +++ b/.gitignore @@ -47,8 +47,13 @@ local .cursorrules .cursor/rules -# test +# vitest coverage .vitest-cache vitest.config.*.timestamp-* + +# playwright +playwright-report +test-results + YOUR_MEMORY_FILE_PATH diff --git a/README.md b/README.md index e5315f5ce9..3cf67d8368 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,11 @@ Cherry Studio is a desktop client that supports for multiple LLM providers, avai # 🌠 Screenshot -![](https://github.com/user-attachments/assets/082efa42-c4df-4863-a9cb-80435cecce0f) -![](https://github.com/user-attachments/assets/f8411a65-c51f-47d3-9273-62ae384cc6f1) -![](https://github.com/user-attachments/assets/0d235b3e-65ae-45ab-987f-8dbe003c52be) +![](https://github.com/user-attachments/assets/36dddb2c-e0fb-4a5f-9411-91447bab6e18) + +![](https://github.com/user-attachments/assets/f549e8a0-2385-40b4-b52b-2039e39f2930) + +![](https://github.com/user-attachments/assets/58e0237c-4d36-40de-b428-53051d982026) # 🌟 Key Features @@ -65,20 +67,42 @@ Cherry Studio is a desktop client that supports for multiple LLM providers, avai - 📝 Complete Markdown Rendering - 🤲 Easy Content Sharing -# 📝 TODO +# 📝 Roadmap -- [x] Quick popup (read clipboard, quick question, explain, translate, summarize) -- [x] Comparison of multi-model answers -- [x] Support login using SSO provided by service providers -- [x] All models support networking -- [x] Launch of the first official version -- [x] Bug fixes and improvements (In progress...) -- [ ] Plugin functionality (JavaScript) -- [ ] Browser extension (highlight text to translate, summarize, add to knowledge base) -- [ ] iOS & Android client -- [ ] AI notes -- [ ] Voice input and output (AI call) -- [ ] Data backup supports custom backup content +We're actively working on the following features and improvements: + +1. 🎯 **Core Features** + +- Selection Assistant - Smart content selection enhancement +- Deep Research - Advanced research capabilities +- Memory System - Global context awareness +- Document Preprocessing - Improved document handling +- MCP Marketplace - Model Context Protocol ecosystem + +2. 🗂 **Knowledge Management** + +- Notes and Collections +- Dynamic Canvas visualization +- OCR capabilities +- TTS (Text-to-Speech) support + +3. 📱 **Platform Support** + +- HarmonyOS Edition (PC) +- Android App (Phase 1) +- iOS App (Phase 1) +- Multi-Window support +- Window Pinning functionality + +4. 🔌 **Advanced Features** + +- Plugin System +- ASR (Automatic Speech Recognition) +- Assistant and Topic Interaction Refactoring + +Track our progress and contribute on our [project board](https://github.com/orgs/CherryHQ/projects/7). + +Want to influence our roadmap? Join our [GitHub Discussions](https://github.com/CherryHQ/cherry-studio/discussions) to share your ideas and feedback! # 🌈 Theme @@ -121,7 +145,7 @@ For more detailed guidelines, please refer to our [Contributing Guide](./CONTRIB Thank you for your support and contributions! -## Related Projects +# 🔗 Related Projects - [one-api](https://github.com/songquanpeng/one-api):LLM API management and distribution system, supporting mainstream models like OpenAI, Azure, and Anthropic. Features unified API interface, suitable for key management and secondary distribution. diff --git a/docs/README.ja.md b/docs/README.ja.md index ce5d2f6ef7..2a88cf8e5b 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -26,9 +26,11 @@ https://docs.cherry-ai.com # 🌠 スクリーンショット -![](https://github.com/user-attachments/assets/082efa42-c4df-4863-a9cb-80435cecce0f) -![](https://github.com/user-attachments/assets/f8411a65-c51f-47d3-9273-62ae384cc6f1) -![](https://github.com/user-attachments/assets/0d235b3e-65ae-45ab-987f-8dbe003c52be) +![](https://github.com/user-attachments/assets/36dddb2c-e0fb-4a5f-9411-91447bab6e18) + +![](https://github.com/user-attachments/assets/f549e8a0-2385-40b4-b52b-2039e39f2930) + +![](https://github.com/user-attachments/assets/58e0237c-4d36-40de-b428-53051d982026) # 🌟 主な機能 @@ -68,20 +70,42 @@ https://docs.cherry-ai.com - 📝 完全な Markdown レンダリング - 🤲 簡単な共有機能 -# 📝 TODO +# 📝 開発計画 -- [x] クイックポップアップ(クリップボードの読み取り、簡単な質問、説明、翻訳、要約) -- [x] 複数モデルの回答の比較 -- [x] サービスプロバイダーが提供する SSO を使用したログイン対応 -- [x] すべてのモデルのネットワーク対応 -- [x] 最初の公式バージョンのリリース -- [x] バグ修正と改善(進行中...) -- [ ] プラグイン機能(JavaScript) -- [ ] ブラウザ拡張機能(テキストをハイライトして翻訳、要約、ナレッジベースに追加) -- [ ] iOS & Android クライアント -- [ ] AI ノート -- [ ] 音声入出力(AI コール) -- [ ] データバックアップのカスタマイズ対応 +以下の機能と改善に積極的に取り組んでいます: + +1. 🎯 **コア機能** + +- 選択アシスタント - スマートな内容選択の強化 +- ディープリサーチ - 高度な研究能力 +- メモリーシステム - グローバルコンテキスト認識 +- ドキュメント前処理 - 文書処理の改善 +- MCP マーケットプレイス - モデルコンテキストプロトコルエコシステム + +2. 🗂 **ナレッジ管理** + +- ノートとコレクション +- ダイナミックキャンバス可視化 +- OCR 機能 +- TTS(テキスト読み上げ)サポート + +3. 📱 **プラットフォーム対応** + +- HarmonyOS エディション +- Android アプリ(フェーズ1) +- iOS アプリ(フェーズ1) +- マルチウィンドウ対応 +- ウィンドウピン留め機能 + +4. 🔌 **高度な機能** + +- プラグインシステム +- ASR(音声認識) +- アシスタントとトピックの対話機能リファクタリング + +[プロジェクトボード](https://github.com/orgs/CherryHQ/projects/7)で進捗を確認し、貢献することができます。 + +開発計画に影響を与えたいですか?[GitHub ディスカッション](https://github.com/CherryHQ/cherry-studio/discussions)に参加して、アイデアやフィードバックを共有してください! # 🌈 テーマ @@ -124,7 +148,7 @@ Cherry Studio への貢献を歓迎します!以下の方法で貢献できま ご支援と貢献に感謝します! -## 関連プロジェクト +# 🔗 関連プロジェクト - [one-api](https://github.com/songquanpeng/one-api):LLM API の管理・配信システム。OpenAI、Azure、Anthropic などの主要モデルに対応し、統一 API インターフェースを提供。API キー管理と再配布に利用可能。 diff --git a/docs/README.zh.md b/docs/README.zh.md index ca85959dab..f4a8feda66 100644 --- a/docs/README.zh.md +++ b/docs/README.zh.md @@ -33,9 +33,11 @@ https://docs.cherry-ai.com # 🌠 界面 -![](https://github.com/user-attachments/assets/082efa42-c4df-4863-a9cb-80435cecce0f) -![](https://github.com/user-attachments/assets/f8411a65-c51f-47d3-9273-62ae384cc6f1) -![](https://github.com/user-attachments/assets/0d235b3e-65ae-45ab-987f-8dbe003c52be) +![](https://github.com/user-attachments/assets/36dddb2c-e0fb-4a5f-9411-91447bab6e18) + +![](https://github.com/user-attachments/assets/f549e8a0-2385-40b4-b52b-2039e39f2930) + +![](https://github.com/user-attachments/assets/58e0237c-4d36-40de-b428-53051d982026) # 🌟 主要特性 @@ -75,20 +77,42 @@ https://docs.cherry-ai.com - 📝 完整的 Markdown 渲染 - 🤲 便捷的内容分享功能 -# 📝 待办事项 +# 📝 开发计划 -- [x] 快捷弹窗(读取剪贴板、快速提问、解释、翻译、总结) -- [x] 多模型回答对比 -- [x] 支持使用服务供应商提供的 SSO 进行登录 -- [x] 所有模型支持联网 -- [x] 推出第一个正式版 -- [x] 错误修复和改进(开发中...) -- [ ] 插件功能(JavaScript) -- [ ] 浏览器插件(划词翻译、总结、新增至知识库) -- [ ] iOS & Android 客户端 -- [ ] AI 笔记 -- [ ] 语音输入输出(AI 通话) -- [ ] 数据备份支持自定义备份内容 +我们正在积极开发以下功能和改进: + +1. 🎯 **核心功能** + +- 选择助手 - 智能内容选择增强 +- 深度研究 - 高级研究能力 +- 全局记忆 - 全局上下文感知 +- 文档预处理 - 改进文档处理能力 +- MCP 市场 - 模型上下文协议生态系统 + +2. 🗂 **知识管理** + +- 笔记与收藏功能 +- 动态画布可视化 +- OCR 光学字符识别 +- TTS 文本转语音支持 + +3. 📱 **平台支持** + +- 鸿蒙版本 (PC) +- Android 应用(第一期) +- iOS 应用(第一期) +- 多窗口支持 +- 窗口置顶功能 + +4. 🔌 **高级特性** + +- 插件系统 +- ASR 语音识别 +- 助手与话题交互重构 + +在我们的[项目面板](https://github.com/orgs/CherryHQ/projects/7)上跟踪进展并参与贡献。 + +想要影响开发计划?欢迎加入我们的 [GitHub 讨论区](https://github.com/CherryHQ/cherry-studio/discussions) 分享您的想法和反馈! # 🌈 主题 @@ -131,7 +155,7 @@ https://docs.cherry-ai.com 感谢您的支持和贡献! -## 相关项目 +# 🔗 相关项目 - [one-api](https://github.com/songquanpeng/one-api):LLM API 管理及分发系统,支持 OpenAI、Azure、Anthropic 等主流模型,统一 API 接口,可用于密钥管理与二次分发。 diff --git a/docs/dev.md b/docs/dev.md index 22a0eb9086..9a781314a9 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -37,6 +37,14 @@ yarn install yarn dev ``` +### Debug + +```bash +yarn debug +``` + +Then input chrome://inspect in browser + ### Test ```bash diff --git a/electron-builder.yml b/electron-builder.yml index f95bbcfed2..6a5a1b3b94 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -12,9 +12,10 @@ electronLanguages: directories: buildResources: build files: - - '!{.vscode,.yarn,.github}' + - '**/*' + - '!{.vscode,.yarn,.yarn-lock,.github,.cursorrules,.prettierrc}' - '!electron.vite.config.{js,ts,mjs,cjs}' - - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}' + - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,eslint.config.mjs,dev-app-update.yml,CHANGELOG.md,README.md}' - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}' - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' - '!src' @@ -22,20 +23,28 @@ files: - '!local' - '!docs' - '!packages' + - '!.swc' + - '!.bin' + - '!._*' + - '!*.log' - '!stats.html' - '!*.md' + - '!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}' - '!**/*.{map,ts,tsx,jsx,less,scss,sass,css.d.ts,d.cts,d.mts,md,markdown,yaml,yml}' - - '!**/{test,tests,__tests__,coverage}/**' + - '!**/{test,tests,__tests__,powered-test,coverage}/**' + - '!**/{example,examples}/**' - '!**/*.{spec,test}.{js,jsx,ts,tsx}' - '!**/*.min.*.map' - '!**/*.d.ts' - - '!**/{.DS_Store,Thumbs.db}' - - '!**/{LICENSE,LICENSE.txt,LICENSE-MIT.txt,*.LICENSE.txt,NOTICE.txt,README.md,CHANGELOG.md}' + - '!**/{.DS_Store,Thumbs.db,thumbs.db,__pycache__}' + - '!**/{LICENSE,LICENSE.txt,LICENSE-MIT.txt,*.LICENSE.txt,NOTICE.txt,README.md,readme.md,CHANGELOG.md}' - '!node_modules/rollup-plugin-visualizer' - '!node_modules/js-tiktoken' - '!node_modules/@tavily/core/node_modules/js-tiktoken' - '!node_modules/pdf-parse/lib/pdf.js/{v1.9.426,v1.10.88,v2.0.550}' - '!node_modules/mammoth/{mammoth.browser.js,mammoth.browser.min.js}' + - '!node_modules/selection-hook/prebuilds/**/*' # we rebuild .node, don't use prebuilds + - '!**/*.{h,iobj,ipdb,tlog,recipe,vcxproj,vcxproj.filters}' # filter .node build files asarUnpack: - resources/** - '**/*.{metal,exp,lib}' @@ -50,6 +59,7 @@ win: - target: portable signtoolOptions: sign: scripts/win-sign.js + verifyUpdateCodeSignature: false nsis: artifactName: ${productName}-${version}-${arch}-setup.${ext} shortcutName: ${productName} @@ -97,10 +107,9 @@ artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | ⚠️ 注意:升级前请备份数据,否则将无法降级 - 增加消息通知功能 - 增加 Google 小程序 - MCP 支持运行 Python 代码 - 修复 MCP SSE 连接问题 - 修复消息编辑和消息多选相关问题 - 修复消息显示问题 - 修复话题提示词无效问题 + 文生图新增服务商 DMXAPI(限时免费) + 输入框按钮支持拖拽排序 + 修复知识库搜索结果 100% 问题 + 修复拖拽多选消息相关问题 + 修复翻译回复内容导致内存异常问题 + 常规错误修复和优化 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 050a64fd28..a56379d8ae 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -38,8 +38,12 @@ export default defineConfig({ }, build: { rollupOptions: { - external: ['@libsql/client'] - } + external: ['@libsql/client', 'bufferutil', 'utf-8-validate'] + }, + sourcemap: process.env.NODE_ENV === 'development' + }, + optimizeDeps: { + noDiscovery: process.env.NODE_ENV === 'development' } }, preload: { @@ -48,6 +52,9 @@ export default defineConfig({ alias: { '@shared': resolve('packages/shared') } + }, + build: { + sourcemap: process.env.NODE_ENV === 'development' } }, renderer: { @@ -83,24 +90,9 @@ export default defineConfig({ rollupOptions: { input: { index: resolve(__dirname, 'src/renderer/index.html'), - miniWindow: resolve(__dirname, 'src/renderer/miniWindow.html') - }, - output: { - manualChunks: (id: string) => { - // 检测所有 worker 文件,提取 worker 名称作为 chunk 名 - if (id.includes('.worker') && id.endsWith('?worker')) { - const workerName = id.split('/').pop()?.split('.')[0] || 'worker' - return `workers/${workerName}` - } - - // All node_modules are in the vendor chunk - if (id.includes('node_modules')) { - return 'vendor' - } - - // Other modules use default chunk splitting strategy - return undefined - } + miniWindow: resolve(__dirname, 'src/renderer/miniWindow.html'), + selectionToolbar: resolve(__dirname, 'src/renderer/selectionToolbar.html'), + selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html') } } } diff --git a/package.json b/package.json index aeb61e6fb6..b9de498ce3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.3.9", + "version": "1.3.12", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", @@ -20,6 +20,7 @@ "scripts": { "start": "electron-vite preview", "dev": "electron-vite dev", + "debug": "electron-vite -- --inspect --sourcemap --remote-debugging-port=9222", "build": "npm run typecheck && electron-vite build", "build:check": "yarn test && yarn typecheck && yarn check:i18n", "build:unpack": "dotenv npm run build && electron-builder --dir", @@ -44,16 +45,16 @@ "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", "check:i18n": "node scripts/check-i18n.js", - "test": "yarn test:renderer", - "test:coverage": "yarn test:renderer:coverage", - "test:node": "npx -y tsx --test src/**/*.test.ts", - "test:renderer": "vitest run", - "test:renderer:ui": "vitest --ui", - "test:renderer:coverage": "vitest run --coverage", + "test": "vitest run --silent", + "test:main": "vitest run --project main", + "test:renderer": "vitest run --project renderer", + "test:coverage": "vitest run --coverage --silent", + "test:ui": "vitest --ui", + "test:watch": "vitest", + "test:e2e": "yarn playwright test", "test:lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts", "format": "prettier --write .", "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", - "postinstall": "electron-builder install-app-deps", "prepare": "husky", "migrations:generate": "drizzle-kit generate --config ./migrations/sqlite-drizzle.config.ts" }, @@ -70,7 +71,6 @@ "@cherrystudio/embedjs-loader-xml": "^0.1.31", "@cherrystudio/embedjs-openai": "^0.1.31", "@electron-toolkit/utils": "^3.0.0", - "@electron/notarize": "^2.5.0", "@langchain/community": "^0.3.36", "@libsql/client": "^0.15.7", "@strongtz/win32-arm64-msvc": "^0.4.7", @@ -78,7 +78,6 @@ "@types/react-infinite-scroll-component": "^5.0.0", "archiver": "^7.0.1", "async-mutex": "^0.5.0", - "color": "^5.0.0", "diff": "^7.0.0", "docx": "^9.0.2", "drizzle-orm": "^0.43.1", @@ -87,22 +86,18 @@ "electron-updater": "6.6.4", "electron-window-state": "^5.0.3", "epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch", - "fast-diff": "^1.3.0", "fast-xml-parser": "^5.2.0", - "fetch-socks": "^1.3.2", "fs-extra": "^11.2.0", - "got-scraping": "^4.1.1", "jsdom": "^26.0.0", "markdown-it": "^14.1.0", "node-stream-zip": "^1.15.0", "officeparser": "^4.1.1", "os-proxy-config": "^1.1.2", "proxy-agent": "^6.5.0", + "selection-hook": "^0.9.14", "tar": "^7.4.3", "turndown": "^7.2.0", - "turndown-plugin-gfm": "^1.0.2", "webdav": "^5.8.0", - "ws": "^8.18.1", "zipread": "^1.3.3" }, "devDependencies": { @@ -115,6 +110,7 @@ "@electron-toolkit/eslint-config-ts": "^3.0.0", "@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/tsconfig": "^1.0.1", + "@electron/notarize": "^2.5.0", "@emotion/is-prop-valid": "^1.3.1", "@eslint-react/eslint-plugin": "^1.36.1", "@eslint/js": "^9.22.0", @@ -124,9 +120,13 @@ "@modelcontextprotocol/sdk": "^1.11.4", "@mozilla/readability": "^0.6.0", "@notionhq/client": "^2.2.15", + "@playwright/test": "^1.52.0", "@reduxjs/toolkit": "^2.2.5", "@shikijs/markdown-it": "^3.4.2", "@swc/plugin-styled-components": "^7.1.5", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", "@tryfabric/martian": "^1.2.4", "@types/diff": "^7", "@types/fs-extra": "^11", @@ -145,18 +145,21 @@ "@uiw/codemirror-themes-all": "^4.23.12", "@uiw/react-codemirror": "^4.23.12", "@vitejs/plugin-react-swc": "^3.9.0", - "@vitest/ui": "^3.1.1", - "@vitest/web-worker": "^3.1.3", + "@vitest/browser": "^3.1.4", + "@vitest/coverage-v8": "^3.1.4", + "@vitest/ui": "^3.1.4", + "@vitest/web-worker": "^3.1.4", "@xyflow/react": "^12.4.4", "antd": "^5.22.5", "axios": "^1.7.3", "browser-image-compression": "^2.0.2", + "color": "^5.0.0", "dayjs": "^1.11.11", "dexie": "^4.0.8", "dexie-react-hooks": "^1.1.7", "dotenv-cli": "^7.4.2", "drizzle-kit": "^0.31.1", - "electron": "35.2.2", + "electron": "35.4.0", "electron-builder": "26.0.15", "electron-devtools-installer": "^3.2.0", "electron-icon-builder": "^2.0.1", @@ -167,9 +170,11 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-unused-imports": "^4.1.4", + "fast-diff": "^1.3.0", "html-to-image": "^1.11.13", "husky": "^9.1.7", "i18next": "^23.11.5", + "jest-styled-components": "^7.2.0", "lint-staged": "^15.5.0", "lodash": "^4.17.21", "lru-cache": "^11.1.0", @@ -180,6 +185,7 @@ "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", + "playwright": "^1.52.0", "prettier": "^3.5.3", "rc-virtual-list": "^3.18.6", "react": "^19.0.0", @@ -211,7 +217,7 @@ "typescript": "^5.6.2", "uuid": "^10.0.0", "vite": "6.2.6", - "vitest": "^3.1.1" + "vitest": "^3.1.4" }, "resolutions": { "pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 7ba4164969..528b64c4e4 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -173,5 +173,23 @@ export enum IpcChannel { StoreSync_Subscribe = 'store-sync:subscribe', StoreSync_Unsubscribe = 'store-sync:unsubscribe', StoreSync_OnUpdate = 'store-sync:on-update', - StoreSync_BroadcastSync = 'store-sync:broadcast-sync' + StoreSync_BroadcastSync = 'store-sync:broadcast-sync', + + // Provider + Provider_AddKey = 'provider:add-key', + + //Selection Assistant + Selection_TextSelected = 'selection:text-selected', + Selection_ToolbarHide = 'selection:toolbar-hide', + Selection_ToolbarVisibilityChange = 'selection:toolbar-visibility-change', + Selection_ToolbarDetermineSize = 'selection:toolbar-determine-size', + Selection_WriteToClipboard = 'selection:write-to-clipboard', + Selection_SetEnabled = 'selection:set-enabled', + Selection_SetTriggerMode = 'selection:set-trigger-mode', + Selection_SetFollowToolbar = 'selection:set-follow-toolbar', + Selection_ActionWindowClose = 'selection:action-window-close', + Selection_ActionWindowMinimize = 'selection:action-window-minimize', + Selection_ActionWindowPin = 'selection:action-window-pin', + Selection_ProcessAction = 'selection:process-action', + Selection_UpdateActionData = 'selection:update-action-data' } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000000..e12ce7ab6d --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,42 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + // Look for test files, relative to this configuration file. + testDir: './tests/e2e', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://localhost:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry' + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] } + } + ] + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://localhost:3000', + // reuseExistingServer: !process.env.CI, + // }, +}) diff --git a/resources/scripts/install-bun.js b/resources/scripts/install-bun.js index b7784fa58f..9637c60f3a 100644 --- a/resources/scripts/install-bun.js +++ b/resources/scripts/install-bun.js @@ -15,6 +15,8 @@ const BUN_PACKAGES = { 'darwin-x64': 'bun-darwin-x64.zip', 'win32-x64': 'bun-windows-x64.zip', 'win32-x64-baseline': 'bun-windows-x64-baseline.zip', + 'win32-arm64': 'bun-windows-x64.zip', + 'win32-arm64-baseline': 'bun-windows-x64-baseline.zip', 'linux-x64': 'bun-linux-x64.zip', 'linux-x64-baseline': 'bun-linux-x64-baseline.zip', 'linux-arm64': 'bun-linux-aarch64.zip', diff --git a/src/main/embeddings/EmbeddingsFactory.ts b/src/main/embeddings/EmbeddingsFactory.ts index 69de15171e..5924d00d7d 100644 --- a/src/main/embeddings/EmbeddingsFactory.ts +++ b/src/main/embeddings/EmbeddingsFactory.ts @@ -23,14 +23,14 @@ export default class EmbeddingsFactory { azureOpenAIApiVersion: apiVersion, azureOpenAIApiDeploymentName: model, azureOpenAIApiInstanceName: getInstanceName(baseURL), - // dimensions, + dimensions, batchSize }) } return new OpenAiEmbeddings({ model, apiKey, - // dimensions, + dimensions, batchSize, configuration: { baseURL } }) diff --git a/src/main/index.ts b/src/main/index.ts index 175776cbae..b6efc687cc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -17,6 +17,7 @@ import { registerProtocolClient, setupAppImageDeepLink } from './services/ProtocolClient' +import selectionService, { initSelectionService } from './services/SelectionService' import { registerShortcuts } from './services/ShortcutService' import { TrayService } from './services/TrayService' import { windowService } from './services/WindowService' @@ -87,6 +88,9 @@ if (!app.requestSingleInstanceLock()) { .then((name) => console.log(`Added Extension: ${name}`)) .catch((err) => console.log('An error occurred: ', err)) } + + //start selection assistant service + initSelectionService() }) registerProtocolClient(app) @@ -113,6 +117,11 @@ if (!app.requestSingleInstanceLock()) { app.on('before-quit', () => { app.isQuitting = true + + // quit selection service + if (selectionService) { + selectionService.quit() + } }) app.on('will-quit', async () => { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 45e9d7b72d..9c75b514c1 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -26,6 +26,7 @@ import * as NutstoreService from './services/NutstoreService' import ObsidianVaultService from './services/ObsidianVaultService' import { ProxyConfig, proxyManager } from './services/ProxyManager' import { searchService } from './services/SearchService' +import { SelectionService } from './services/SelectionService' import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService' import storeSyncService from './services/StoreSyncService' import { TrayService } from './services/TrayService' @@ -200,7 +201,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { // check for update ipcMain.handle(IpcChannel.App_CheckForUpdate, async () => { - await appUpdater.checkForUpdates() + return await appUpdater.checkForUpdates() }) // notification @@ -379,4 +380,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { // store sync storeSyncService.registerIpcHandler() + + // selection assistant + SelectionService.registerIpcHandler() } diff --git a/src/main/services/ConfigManager.ts b/src/main/services/ConfigManager.ts index 6242709385..996b976f0c 100644 --- a/src/main/services/ConfigManager.ts +++ b/src/main/services/ConfigManager.ts @@ -5,7 +5,7 @@ import Store from 'electron-store' import { locales } from '../utils/locales' -enum ConfigKeys { +export enum ConfigKeys { Language = 'language', Theme = 'theme', LaunchToTray = 'launchToTray', @@ -16,7 +16,10 @@ enum ConfigKeys { ClickTrayToShowQuickAssistant = 'clickTrayToShowQuickAssistant', EnableQuickAssistant = 'enableQuickAssistant', AutoUpdate = 'autoUpdate', - EnableDataCollection = 'enableDataCollection' + EnableDataCollection = 'enableDataCollection', + SelectionAssistantEnabled = 'selectionAssistantEnabled', + SelectionAssistantTriggerMode = 'selectionAssistantTriggerMode', + SelectionAssistantFollowToolbar = 'selectionAssistantFollowToolbar' } export class ConfigManager { @@ -146,6 +149,36 @@ export class ConfigManager { this.set(ConfigKeys.EnableDataCollection, value) } + // Selection Assistant: is enabled the selection assistant + getSelectionAssistantEnabled(): boolean { + return this.get(ConfigKeys.SelectionAssistantEnabled, true) + } + + setSelectionAssistantEnabled(value: boolean) { + this.set(ConfigKeys.SelectionAssistantEnabled, value) + this.notifySubscribers(ConfigKeys.SelectionAssistantEnabled, value) + } + + // Selection Assistant: trigger mode (selected, ctrlkey) + getSelectionAssistantTriggerMode(): string { + return this.get(ConfigKeys.SelectionAssistantTriggerMode, 'selected') + } + + setSelectionAssistantTriggerMode(value: string) { + this.set(ConfigKeys.SelectionAssistantTriggerMode, value) + this.notifySubscribers(ConfigKeys.SelectionAssistantTriggerMode, value) + } + + // Selection Assistant: if action window position follow toolbar + getSelectionAssistantFollowToolbar(): boolean { + return this.get(ConfigKeys.SelectionAssistantFollowToolbar, true) + } + + setSelectionAssistantFollowToolbar(value: boolean) { + this.set(ConfigKeys.SelectionAssistantFollowToolbar, value) + this.notifySubscribers(ConfigKeys.SelectionAssistantFollowToolbar, value) + } + set(key: string, value: unknown) { this.store.set(key, value) } diff --git a/src/main/services/ExportService.ts b/src/main/services/ExportService.ts index 7f58ccafa1..b17acc9bde 100644 --- a/src/main/services/ExportService.ts +++ b/src/main/services/ExportService.ts @@ -47,6 +47,8 @@ export class ExportService { let linkText = '' let linkUrl = '' let insideLink = false + let boldStack = 0 // 跟踪嵌套的粗体标记 + let italicStack = 0 // 跟踪嵌套的斜体标记 for (let i = 0; i < tokens.length; i++) { const token = tokens[i] @@ -82,17 +84,37 @@ export class ExportService { insideLink = false } break + case 'strong_open': + boldStack++ + break + case 'strong_close': + boldStack-- + break + case 'em_open': + italicStack++ + break + case 'em_close': + italicStack-- + break case 'text': - runs.push(new TextRun({ text: token.content, bold: isHeaderRow })) - break - case 'strong': - runs.push(new TextRun({ text: token.content, bold: true })) - break - case 'em': - runs.push(new TextRun({ text: token.content, italics: true })) + runs.push( + new TextRun({ + text: token.content, + bold: isHeaderRow || boldStack > 0, + italics: italicStack > 0 + }) + ) break case 'code_inline': - runs.push(new TextRun({ text: token.content, font: 'Consolas', size: 20 })) + runs.push( + new TextRun({ + text: token.content, + font: 'Consolas', + size: 20, + bold: isHeaderRow || boldStack > 0, + italics: italicStack > 0 + }) + ) break } } diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts index f055bdc5fb..9b097e96ef 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -386,7 +386,11 @@ class FileStorage { } } - public downloadFile = async (_: Electron.IpcMainInvokeEvent, url: string): Promise => { + public downloadFile = async ( + _: Electron.IpcMainInvokeEvent, + url: string, + isUseContentType?: boolean + ): Promise => { try { const response = await fetch(url) if (!response.ok) { @@ -411,7 +415,7 @@ class FileStorage { } // 如果文件名没有后缀,根据Content-Type添加后缀 - if (!filename.includes('.')) { + if (isUseContentType || !filename.includes('.')) { const contentType = response.headers.get('Content-Type') const ext = this.getExtensionFromMimeType(contentType) filename += ext diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts index bb6fc2835a..2515c91416 100644 --- a/src/main/services/MCPService.ts +++ b/src/main/services/MCPService.ts @@ -91,7 +91,7 @@ class McpService { return JSON.stringify({ baseUrl: server.baseUrl, command: server.command, - args: server.args, + args: Array.isArray(server.args) ? server.args : [], registryUrl: server.registryUrl, env: server.env, id: server.id @@ -245,7 +245,7 @@ class McpService { const loginShellEnv = await this.getLoginShellEnv() // Bun not support proxy https://github.com/oven-sh/bun/issues/16812 - if (cmd.endsWith('bun')) { + if (cmd.includes('bun')) { this.removeProxyEnv(loginShellEnv) } @@ -567,12 +567,11 @@ class McpService { try { const result = await client.listResources() const resources = result.resources || [] - const serverResources = (Array.isArray(resources) ? resources : []).map((resource: any) => ({ + return (Array.isArray(resources) ? resources : []).map((resource: any) => ({ ...resource, serverId: server.id, serverName: server.name })) - return serverResources } catch (error: any) { // -32601 is the code for the method not found if (error?.code !== -32601) { diff --git a/src/main/services/ProtocolClient.ts b/src/main/services/ProtocolClient.ts index f37c61bb39..7e0b274816 100644 --- a/src/main/services/ProtocolClient.ts +++ b/src/main/services/ProtocolClient.ts @@ -6,6 +6,7 @@ import { promisify } from 'node:util' import { app } from 'electron' import Logger from 'electron-log' +import { handleProvidersProtocolUrl } from './urlschema/handle-providers' import { handleMcpProtocolUrl } from './urlschema/mcp-install' import { windowService } from './WindowService' @@ -34,6 +35,9 @@ export function handleProtocolUrl(url: string) { case 'mcp': handleMcpProtocolUrl(urlObj) return + case 'providers': + handleProvidersProtocolUrl(urlObj) + return } // You can send the data to your renderer process diff --git a/src/main/services/SelectionService.ts b/src/main/services/SelectionService.ts new file mode 100644 index 0000000000..512240e564 --- /dev/null +++ b/src/main/services/SelectionService.ts @@ -0,0 +1,1022 @@ +import { isDev, isWin } from '@main/constant' +import { IpcChannel } from '@shared/IpcChannel' +import { BrowserWindow, ipcMain, screen } from 'electron' +import Logger from 'electron-log' +import { join } from 'path' +import type { + KeyboardEventData, + MouseEventData, + SelectionHookConstructor, + SelectionHookInstance, + TextSelectionData +} from 'selection-hook' + +import type { ActionItem } from '../../renderer/src/types/selectionTypes' +import { ConfigKeys, configManager } from './ConfigManager' + +let SelectionHook: SelectionHookConstructor | null = null +try { + if (isWin) { + SelectionHook = require('selection-hook') + } +} catch (error) { + Logger.error('Failed to load selection-hook:', error) +} + +// Type definitions +type Point = { x: number; y: number } +type RelativeOrientation = + | 'topLeft' + | 'topRight' + | 'topMiddle' + | 'bottomLeft' + | 'bottomRight' + | 'bottomMiddle' + | 'middleLeft' + | 'middleRight' + | 'center' + +/** SelectionService is a singleton class that manages the selection hook and the toolbar window + * + * Features: + * - Text selection detection and processing + * - Floating toolbar management + * - Action window handling + * - Multiple trigger modes (selection/alt-key) + * - Screen boundary-aware positioning + * + * Usage: + * import selectionService from '/src/main/services/SelectionService' + * selectionService?.start() + */ +export class SelectionService { + private static instance: SelectionService | null = null + private selectionHook: SelectionHookInstance | null = null + + private static isIpcHandlerRegistered = false + + private initStatus: boolean = false + private started: boolean = false + + private triggerMode = 'selected' + private isFollowToolbar = true + + private toolbarWindow: BrowserWindow | null = null + private actionWindows = new Set() + private preloadedActionWindows: BrowserWindow[] = [] + private readonly PRELOAD_ACTION_WINDOW_COUNT = 1 + + private isHideByMouseKeyListenerActive: boolean = false + private isCtrlkeyListenerActive: boolean = false + /** + * Ctrlkey action states: + * 0 - Ready to monitor ctrlkey action + * >0 - Currently monitoring ctrlkey action + * -1 - Ctrlkey action triggered, no need to process again + */ + private lastCtrlkeyDownTime: number = 0 + + private zoomFactor: number = 1 + + private TOOLBAR_WIDTH = 350 + private TOOLBAR_HEIGHT = 43 + + private readonly ACTION_WINDOW_WIDTH = 500 + private readonly ACTION_WINDOW_HEIGHT = 400 + + private constructor() { + try { + if (!SelectionHook) { + throw new Error('module selection-hook not exists') + } + + this.selectionHook = new SelectionHook() + if (this.selectionHook) { + this.initZoomFactor() + + this.initStatus = true + } + } catch (error) { + this.logError('Failed to initialize SelectionService:', error as Error) + } + } + + public static getInstance(): SelectionService | null { + if (!isWin) return null + + if (!SelectionService.instance) { + SelectionService.instance = new SelectionService() + } + + if (SelectionService.instance.initStatus) { + return SelectionService.instance + } + return null + } + + public getSelectionHook(): SelectionHookInstance | null { + return this.selectionHook + } + + /** + * Initialize zoom factor from config and subscribe to changes + * Ensures UI elements scale properly with system DPI settings + */ + private initZoomFactor() { + const zoomFactor = configManager.getZoomFactor() + if (zoomFactor) { + this.setZoomFactor(zoomFactor) + } + + configManager.subscribe('ZoomFactor', this.setZoomFactor) + } + + public setZoomFactor = (zoomFactor: number) => { + this.zoomFactor = zoomFactor + } + + private initConfig() { + this.triggerMode = configManager.getSelectionAssistantTriggerMode() + this.isFollowToolbar = configManager.getSelectionAssistantFollowToolbar() + + configManager.subscribe(ConfigKeys.SelectionAssistantTriggerMode, (triggerMode: string) => { + this.triggerMode = triggerMode + this.processTriggerMode() + }) + + configManager.subscribe(ConfigKeys.SelectionAssistantFollowToolbar, (isFollowToolbar: boolean) => { + this.isFollowToolbar = isFollowToolbar + }) + } + + /** + * Start the selection service and initialize required windows + * @returns {boolean} Success status of service start + */ + public start(): boolean { + if (!this.selectionHook || this.started) { + this.logError(new Error('SelectionService start(): instance is null or already started')) + return false + } + + try { + //init basic configs + this.initConfig() + //make sure the toolbar window is ready + this.createToolbarWindow() + // Initialize preloaded windows + this.initPreloadedActionWindows() + // Handle errors + this.selectionHook.on('error', (error: { message: string }) => { + this.logError('Error in SelectionHook:', error as Error) + }) + // Handle text selection events + this.selectionHook.on('text-selection', this.processTextSelection) + + // Start the hook + if (this.selectionHook.start({ debug: isDev })) { + //init trigger mode configs + this.processTriggerMode() + + this.started = true + this.logInfo('SelectionService Started') + return true + } + + this.logError(new Error('Failed to start text selection hook.')) + return false + } catch (error) { + this.logError('Failed to set up text selection hook:', error as Error) + return false + } + } + + /** + * Stop the selection service and cleanup resources + * Called when user disables selection assistant + * @returns {boolean} Success status of service stop + */ + public stop(): boolean { + if (!this.selectionHook) return false + + this.selectionHook.stop() + this.selectionHook.cleanup() + if (this.toolbarWindow) { + this.toolbarWindow.close() + this.toolbarWindow = null + } + this.started = false + this.logInfo('SelectionService Stopped') + return true + } + + /** + * Completely quit the selection service + * Called when the app is closing + */ + public quit(): void { + if (!this.selectionHook) return + + this.stop() + + this.selectionHook = null + this.initStatus = false + SelectionService.instance = null + this.logInfo('SelectionService Quitted') + } + + /** + * Create and configure the toolbar window + * Sets up window properties, event handlers, and loads the toolbar UI + * @param readyCallback Optional callback when window is ready to show + */ + private createToolbarWindow(readyCallback?: () => void) { + if (this.isToolbarAlive()) return + + const { toolbarWidth, toolbarHeight } = this.getToolbarRealSize() + + this.toolbarWindow = new BrowserWindow({ + width: toolbarWidth, + height: toolbarHeight, + frame: false, + transparent: true, + alwaysOnTop: true, + skipTaskbar: true, + resizable: false, + minimizable: false, + maximizable: false, + movable: true, + focusable: false, + hasShadow: false, + thickFrame: false, + roundedCorners: true, + backgroundMaterial: 'none', + type: 'toolbar', + show: false, + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + devTools: isDev ? true : false + } + }) + + // Hide when losing focus + this.toolbarWindow.on('blur', () => { + this.hideToolbar() + }) + + // Clean up when closed + this.toolbarWindow.on('closed', () => { + this.toolbarWindow = null + }) + + // Add show/hide event listeners + this.toolbarWindow.on('show', () => { + this.toolbarWindow?.webContents.send(IpcChannel.Selection_ToolbarVisibilityChange, true) + }) + + this.toolbarWindow.on('hide', () => { + this.toolbarWindow?.webContents.send(IpcChannel.Selection_ToolbarVisibilityChange, false) + }) + + /** uncomment to open dev tools in dev mode */ + // if (isDev) { + // this.toolbarWindow.once('ready-to-show', () => { + // this.toolbarWindow!.webContents.openDevTools({ mode: 'detach' }) + // }) + // } + + if (readyCallback) { + this.toolbarWindow.once('ready-to-show', readyCallback) + } + + /** get ready to load the toolbar window */ + + if (isDev && process.env['ELECTRON_RENDERER_URL']) { + this.toolbarWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/selectionToolbar.html') + } else { + this.toolbarWindow.loadFile(join(__dirname, '../renderer/selectionToolbar.html')) + } + } + + /** + * Show toolbar at specified position with given orientation + * @param point Reference point for positioning, logical coordinates + * @param orientation Preferred position relative to reference point + */ + private showToolbarAtPosition(point: Point, orientation: RelativeOrientation) { + if (!this.isToolbarAlive()) { + this.createToolbarWindow(() => { + this.showToolbarAtPosition(point, orientation) + }) + return + } + + const { x: posX, y: posY } = this.calculateToolbarPosition(point, orientation) + + const { toolbarWidth, toolbarHeight } = this.getToolbarRealSize() + this.toolbarWindow!.setPosition(posX, posY, false) + // Prevent window resize + this.toolbarWindow!.setBounds({ + width: toolbarWidth, + height: toolbarHeight, + x: posX, + y: posY + }) + this.toolbarWindow!.show() + this.toolbarWindow!.setOpacity(1) + this.startHideByMouseKeyListener() + } + + /** + * Hide the toolbar window and cleanup listeners + */ + public hideToolbar(): void { + if (!this.isToolbarAlive()) return + + this.toolbarWindow!.setOpacity(0) + this.toolbarWindow!.hide() + + this.stopHideByMouseKeyListener() + } + + /** + * Check if toolbar window exists and is not destroyed + * @returns {boolean} Toolbar window status + */ + private isToolbarAlive() { + return this.toolbarWindow && !this.toolbarWindow.isDestroyed() + } + + /** + * Update toolbar size based on renderer feedback + * Only updates width if it has changed + * @param width New toolbar width + * @param height New toolbar height + */ + public determineToolbarSize(width: number, height: number) { + const toolbarWidth = Math.ceil(width) + + // only update toolbar width if it's changed + if (toolbarWidth > 0 && toolbarWidth !== this.TOOLBAR_WIDTH && height > 0) { + this.TOOLBAR_WIDTH = toolbarWidth + } + } + + /** + * Get actual toolbar dimensions accounting for zoom factor + * @returns Object containing toolbar width and height + */ + private getToolbarRealSize() { + return { + toolbarWidth: this.TOOLBAR_WIDTH * this.zoomFactor, + toolbarHeight: this.TOOLBAR_HEIGHT * this.zoomFactor + } + } + + /** + * Calculate optimal toolbar position based on selection context + * Ensures toolbar stays within screen boundaries and follows selection direction + * @param point Reference point for positioning, must be INTEGER + * @param orientation Preferred position relative to reference point + * @returns Calculated screen coordinates for toolbar, INTEGER + */ + private calculateToolbarPosition(point: Point, orientation: RelativeOrientation): Point { + // Calculate initial position based on the specified anchor + let posX: number, posY: number + + const { toolbarWidth, toolbarHeight } = this.getToolbarRealSize() + + switch (orientation) { + case 'topLeft': + posX = point.x - toolbarWidth + posY = point.y - toolbarHeight + break + case 'topRight': + posX = point.x + posY = point.y - toolbarHeight + break + case 'topMiddle': + posX = point.x - toolbarWidth / 2 + posY = point.y - toolbarHeight + break + case 'bottomLeft': + posX = point.x - toolbarWidth + posY = point.y + break + case 'bottomRight': + posX = point.x + posY = point.y + break + case 'bottomMiddle': + posX = point.x - toolbarWidth / 2 + posY = point.y + break + case 'middleLeft': + posX = point.x - toolbarWidth + posY = point.y - toolbarHeight / 2 + break + case 'middleRight': + posX = point.x + posY = point.y - toolbarHeight / 2 + break + case 'center': + posX = point.x - toolbarWidth / 2 + posY = point.y - toolbarHeight / 2 + break + default: + // Default to 'topMiddle' if invalid position + posX = point.x - toolbarWidth / 2 + posY = point.y - toolbarHeight / 2 + } + + //use original point to get the display + const display = screen.getDisplayNearestPoint({ x: point.x, y: point.y }) + + // Ensure toolbar stays within screen boundaries + posX = Math.round( + Math.max(display.workArea.x, Math.min(posX, display.workArea.x + display.workArea.width - toolbarWidth)) + ) + posY = Math.round( + Math.max(display.workArea.y, Math.min(posY, display.workArea.y + display.workArea.height - toolbarHeight)) + ) + + return { x: posX, y: posY } + } + + private isSamePoint(point1: Point, point2: Point): boolean { + return point1.x === point2.x && point1.y === point2.y + } + + private isSameLineWithRectPoint(startTop: Point, startBottom: Point, endTop: Point, endBottom: Point): boolean { + return startTop.y === endTop.y && startBottom.y === endBottom.y + } + + /** + * Process text selection data and show toolbar + * Handles different selection scenarios: + * - Single click (cursor position) + * - Mouse selection (single/double line) + * - Keyboard selection (full/detailed) + * @param selectionData Text selection information and coordinates + */ + private processTextSelection = (selectionData: TextSelectionData) => { + // Skip if no text or toolbar already visible + if (!selectionData.text || (this.isToolbarAlive() && this.toolbarWindow!.isVisible())) { + return + } + + // Determine reference point and position for toolbar + let refPoint: { x: number; y: number } = { x: 0, y: 0 } + let isLogical = false + let refOrientation: RelativeOrientation = 'bottomRight' + + switch (selectionData.posLevel) { + case SelectionHook?.PositionLevel.NONE: + { + const cursorPoint = screen.getCursorScreenPoint() + refPoint = { x: cursorPoint.x, y: cursorPoint.y } + refOrientation = 'bottomMiddle' + isLogical = true + } + break + case SelectionHook?.PositionLevel.MOUSE_SINGLE: + { + refOrientation = 'bottomMiddle' + refPoint = { x: selectionData.mousePosEnd.x, y: selectionData.mousePosEnd.y + 16 } + } + break + case SelectionHook?.PositionLevel.MOUSE_DUAL: + { + const yDistance = selectionData.mousePosEnd.y - selectionData.mousePosStart.y + const xDistance = selectionData.mousePosEnd.x - selectionData.mousePosStart.x + + // not in the same line + if (Math.abs(yDistance) > 14) { + if (yDistance > 0) { + refOrientation = 'bottomLeft' + refPoint = { + x: selectionData.mousePosEnd.x, + y: selectionData.mousePosEnd.y + 16 + } + } else { + refOrientation = 'topRight' + refPoint = { + x: selectionData.mousePosEnd.x, + y: selectionData.mousePosEnd.y - 16 + } + } + } else { + // in the same line + if (xDistance > 0) { + refOrientation = 'bottomLeft' + refPoint = { + x: selectionData.mousePosEnd.x, + y: Math.max(selectionData.mousePosEnd.y, selectionData.mousePosStart.y) + 16 + } + } else { + refOrientation = 'bottomRight' + refPoint = { + x: selectionData.mousePosEnd.x, + y: Math.min(selectionData.mousePosEnd.y, selectionData.mousePosStart.y) + 16 + } + } + } + } + break + case SelectionHook?.PositionLevel.SEL_FULL: + case SelectionHook?.PositionLevel.SEL_DETAILED: + { + //some case may not have mouse position, so use the endBottom point as reference + const isNoMouse = + selectionData.mousePosStart.x === 0 && + selectionData.mousePosStart.y === 0 && + selectionData.mousePosEnd.x === 0 && + selectionData.mousePosEnd.y === 0 + + if (isNoMouse) { + refOrientation = 'bottomLeft' + refPoint = { x: selectionData.endBottom.x, y: selectionData.endBottom.y + 4 } + break + } + + const isDoubleClick = this.isSamePoint(selectionData.mousePosStart, selectionData.mousePosEnd) + + const isSameLine = this.isSameLineWithRectPoint( + selectionData.startTop, + selectionData.startBottom, + selectionData.endTop, + selectionData.endBottom + ) + + if (isDoubleClick && isSameLine) { + refOrientation = 'bottomMiddle' + refPoint = { x: selectionData.mousePosEnd.x, y: selectionData.endBottom.y + 4 } + break + } + + if (isSameLine) { + const direction = selectionData.mousePosEnd.x - selectionData.mousePosStart.x + + if (direction > 0) { + refOrientation = 'bottomLeft' + refPoint = { x: selectionData.endBottom.x, y: selectionData.endBottom.y + 4 } + } else { + refOrientation = 'bottomRight' + refPoint = { x: selectionData.startBottom.x, y: selectionData.startBottom.y + 4 } + } + break + } + + const direction = selectionData.mousePosEnd.y - selectionData.mousePosStart.y + + if (direction > 0) { + refOrientation = 'bottomLeft' + refPoint = { x: selectionData.endBottom.x, y: selectionData.endBottom.y + 4 } + } else { + refOrientation = 'topRight' + refPoint = { x: selectionData.startTop.x, y: selectionData.startTop.y - 4 } + } + } + break + } + + if (!isLogical) { + //screenToDipPoint can be float, so we need to round it + refPoint = screen.screenToDipPoint(refPoint) + refPoint = { x: Math.round(refPoint.x), y: Math.round(refPoint.y) } + } + + this.showToolbarAtPosition(refPoint, refOrientation) + this.toolbarWindow?.webContents.send(IpcChannel.Selection_TextSelected, selectionData) + } + + /** + * Global Mouse Event Handling + */ + + // Start monitoring global mouse clicks + private startHideByMouseKeyListener() { + try { + // Register event handlers + this.selectionHook!.on('mouse-down', this.handleMouseDownHide) + this.selectionHook!.on('mouse-wheel', this.handleMouseWheelHide) + this.selectionHook!.on('key-down', this.handleKeyDownHide) + this.isHideByMouseKeyListenerActive = true + } catch (error) { + this.logError('Failed to start global mouse event listener:', error as Error) + } + } + + // Stop monitoring global mouse clicks + private stopHideByMouseKeyListener() { + if (!this.isHideByMouseKeyListenerActive) return + + try { + this.selectionHook!.off('mouse-down', this.handleMouseDownHide) + this.selectionHook!.off('mouse-wheel', this.handleMouseWheelHide) + this.selectionHook!.off('key-down', this.handleKeyDownHide) + this.isHideByMouseKeyListenerActive = false + } catch (error) { + this.logError('Failed to stop global mouse event listener:', error as Error) + } + } + + /** + * Handle mouse wheel events to hide toolbar + * Hides toolbar when user scrolls + * @param data Mouse wheel event data + */ + private handleMouseWheelHide = () => { + this.hideToolbar() + } + + /** + * Handle mouse down events to hide toolbar + * Hides toolbar when clicking outside of it + * @param data Mouse event data + */ + private handleMouseDownHide = (data: MouseEventData) => { + if (!this.isToolbarAlive()) { + return + } + + //data point is physical coordinates, convert to logical coordinates + const mousePoint = screen.screenToDipPoint({ x: data.x, y: data.y }) + + const bounds = this.toolbarWindow!.getBounds() + + // Check if click is outside toolbar + const isInsideToolbar = + mousePoint.x >= bounds.x && + mousePoint.x <= bounds.x + bounds.width && + mousePoint.y >= bounds.y && + mousePoint.y <= bounds.y + bounds.height + + if (!isInsideToolbar) { + this.hideToolbar() + } + } + + /** + * Handle key down events to hide toolbar + * Hides toolbar on any key press except alt key in ctrlkey mode + * @param data Keyboard event data + */ + private handleKeyDownHide = (data: KeyboardEventData) => { + //dont hide toolbar when ctrlkey is pressed + if (this.triggerMode === 'ctrlkey' && this.isCtrlkey(data.vkCode)) { + return + } + + this.hideToolbar() + } + + /** + * Handle key down events in ctrlkey trigger mode + * Processes alt key presses to trigger selection toolbar + * @param data Keyboard event data + */ + private handleKeyDownCtrlkeyMode = (data: KeyboardEventData) => { + if (!this.isCtrlkey(data.vkCode)) { + // reset the lastCtrlkeyDownTime if any other key is pressed + if (this.lastCtrlkeyDownTime > 0) { + this.lastCtrlkeyDownTime = -1 + } + return + } + + if (this.lastCtrlkeyDownTime === -1) { + return + } + + //ctrlkey pressed + if (this.lastCtrlkeyDownTime === 0) { + this.lastCtrlkeyDownTime = Date.now() + return + } + + if (Date.now() - this.lastCtrlkeyDownTime < 350) { + return + } + + this.lastCtrlkeyDownTime = -1 + + const selectionData = this.selectionHook!.getCurrentSelection() + + if (selectionData) { + this.processTextSelection(selectionData) + } + } + + /** + * Handle key up events in ctrlkey trigger mode + * Resets alt key state when key is released + * @param data Keyboard event data + */ + private handleKeyUpCtrlkeyMode = (data: KeyboardEventData) => { + if (!this.isCtrlkey(data.vkCode)) return + this.lastCtrlkeyDownTime = 0 + } + + //check if the key is ctrl key + private isCtrlkey(vkCode: number) { + return vkCode === 162 || vkCode === 163 + } + + /** + * Create a preloaded action window for quick response + * Action windows handle specific operations on selected text + * @returns Configured BrowserWindow instance + */ + private createPreloadedActionWindow(): BrowserWindow { + const preloadedActionWindow = new BrowserWindow({ + width: this.ACTION_WINDOW_WIDTH, + height: this.ACTION_WINDOW_HEIGHT, + minWidth: 300, + minHeight: 200, + frame: false, + transparent: true, + autoHideMenuBar: true, + titleBarStyle: 'hidden', + hasShadow: false, + thickFrame: false, + show: false, + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + devTools: true + } + }) + + // Load the base URL without action data + if (isDev && process.env['ELECTRON_RENDERER_URL']) { + preloadedActionWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/selectionAction.html') + } else { + preloadedActionWindow.loadFile(join(__dirname, '../renderer/selectionAction.html')) + } + + return preloadedActionWindow + } + + /** + * Initialize preloaded action windows + * Creates a pool of windows at startup for faster response + */ + private async initPreloadedActionWindows() { + try { + // Create initial pool of preloaded windows + for (let i = 0; i < this.PRELOAD_ACTION_WINDOW_COUNT; i++) { + await this.pushNewActionWindow() + } + } catch (error) { + this.logError('Failed to initialize preloaded windows:', error as Error) + } + } + + /** + * Preload a new action window asynchronously + * This method is called after popping a window to ensure we always have windows ready + */ + private async pushNewActionWindow() { + try { + const actionWindow = this.createPreloadedActionWindow() + this.preloadedActionWindows.push(actionWindow) + } catch (error) { + this.logError('Failed to push new action window:', error as Error) + } + } + + /** + * Pop an action window from the preloadedActionWindows queue + * Immediately returns a window and asynchronously creates a new one + * @returns {BrowserWindow} The action window + */ + private popActionWindow() { + // Get a window from the preloaded queue or create a new one if empty + const actionWindow = this.preloadedActionWindows.pop() || this.createPreloadedActionWindow() + + // Set up event listeners for this instance + actionWindow.on('closed', () => { + this.actionWindows.delete(actionWindow) + if (!actionWindow.isDestroyed()) { + actionWindow.destroy() + } + }) + + this.actionWindows.add(actionWindow) + + // Asynchronously create a new preloaded window + this.pushNewActionWindow() + + return actionWindow + } + + public processAction(actionItem: ActionItem): void { + const actionWindow = this.popActionWindow() + + actionWindow.webContents.send(IpcChannel.Selection_UpdateActionData, actionItem) + + this.showActionWindow(actionWindow) + } + + /** + * Show action window with proper positioning relative to toolbar + * Ensures window stays within screen boundaries + * @param actionWindow Window to position and show + */ + private showActionWindow(actionWindow: BrowserWindow) { + if (!this.isFollowToolbar || !this.toolbarWindow) { + actionWindow.show() + this.hideToolbar() + return + } + + const toolbarBounds = this.toolbarWindow!.getBounds() + const display = screen.getDisplayNearestPoint({ x: toolbarBounds.x, y: toolbarBounds.y }) + const workArea = display.workArea + const GAP = 6 // 6px gap from screen edges + + // Calculate initial position to center action window horizontally below toolbar + let posX = Math.round(toolbarBounds.x + (toolbarBounds.width - this.ACTION_WINDOW_WIDTH) / 2) + let posY = Math.round(toolbarBounds.y) + + // Ensure action window stays within screen boundaries with a small gap + if (posX + this.ACTION_WINDOW_WIDTH > workArea.x + workArea.width) { + posX = workArea.x + workArea.width - this.ACTION_WINDOW_WIDTH - GAP + } else if (posX < workArea.x) { + posX = workArea.x + GAP + } + if (posY + this.ACTION_WINDOW_HEIGHT > workArea.y + workArea.height) { + // If window would go below screen, try to position it above toolbar + posY = workArea.y + workArea.height - this.ACTION_WINDOW_HEIGHT - GAP + } else if (posY < workArea.y) { + posY = workArea.y + GAP + } + + actionWindow.setPosition(posX, posY, false) + //KEY to make window not resize + actionWindow.setBounds({ + width: this.ACTION_WINDOW_WIDTH, + height: this.ACTION_WINDOW_HEIGHT, + x: posX, + y: posY + }) + + actionWindow.show() + } + + public closeActionWindow(actionWindow: BrowserWindow): void { + actionWindow.close() + } + + public minimizeActionWindow(actionWindow: BrowserWindow): void { + actionWindow.minimize() + } + + public pinActionWindow(actionWindow: BrowserWindow, isPinned: boolean): void { + actionWindow.setAlwaysOnTop(isPinned) + } + + /** + * Update trigger mode behavior + * Switches between selection-based and alt-key based triggering + * Manages appropriate event listeners for each mode + */ + private processTriggerMode() { + if (this.triggerMode === 'selected') { + if (this.isCtrlkeyListenerActive) { + this.selectionHook!.off('key-down', this.handleKeyDownCtrlkeyMode) + this.selectionHook!.off('key-up', this.handleKeyUpCtrlkeyMode) + + this.isCtrlkeyListenerActive = false + } + + this.selectionHook!.enableClipboard() + this.selectionHook!.setSelectionPassiveMode(false) + } else if (this.triggerMode === 'ctrlkey') { + if (!this.isCtrlkeyListenerActive) { + this.selectionHook!.on('key-down', this.handleKeyDownCtrlkeyMode) + this.selectionHook!.on('key-up', this.handleKeyUpCtrlkeyMode) + + this.isCtrlkeyListenerActive = true + } + + this.selectionHook!.disableClipboard() + this.selectionHook!.setSelectionPassiveMode(true) + } + } + + public writeToClipboard(text: string): boolean { + return this.selectionHook?.writeToClipboard(text) ?? false + } + + /** + * Register IPC handlers for communication with renderer process + * Handles toolbar, action window, and selection-related commands + */ + public static registerIpcHandler(): void { + if (this.isIpcHandlerRegistered) return + + ipcMain.handle(IpcChannel.Selection_ToolbarHide, () => { + selectionService?.hideToolbar() + }) + + ipcMain.handle(IpcChannel.Selection_WriteToClipboard, (_, text: string) => { + return selectionService?.writeToClipboard(text) ?? false + }) + + ipcMain.handle(IpcChannel.Selection_ToolbarDetermineSize, (_, width: number, height: number) => { + selectionService?.determineToolbarSize(width, height) + }) + + ipcMain.handle(IpcChannel.Selection_SetEnabled, (_, enabled: boolean) => { + configManager.setSelectionAssistantEnabled(enabled) + }) + + ipcMain.handle(IpcChannel.Selection_SetTriggerMode, (_, triggerMode: string) => { + configManager.setSelectionAssistantTriggerMode(triggerMode) + }) + + ipcMain.handle(IpcChannel.Selection_SetFollowToolbar, (_, isFollowToolbar: boolean) => { + configManager.setSelectionAssistantFollowToolbar(isFollowToolbar) + }) + + ipcMain.handle(IpcChannel.Selection_ProcessAction, (_, actionItem: ActionItem) => { + selectionService?.processAction(actionItem) + }) + + ipcMain.handle(IpcChannel.Selection_ActionWindowClose, (event) => { + const actionWindow = BrowserWindow.fromWebContents(event.sender) + if (actionWindow) { + selectionService?.closeActionWindow(actionWindow) + } + }) + + ipcMain.handle(IpcChannel.Selection_ActionWindowMinimize, (event) => { + const actionWindow = BrowserWindow.fromWebContents(event.sender) + if (actionWindow) { + selectionService?.minimizeActionWindow(actionWindow) + } + }) + + ipcMain.handle(IpcChannel.Selection_ActionWindowPin, (event, isPinned: boolean) => { + const actionWindow = BrowserWindow.fromWebContents(event.sender) + if (actionWindow) { + selectionService?.pinActionWindow(actionWindow, isPinned) + } + }) + + this.isIpcHandlerRegistered = true + } + + private logInfo(message: string) { + isDev && Logger.info('[SelectionService] Info: ', message) + } + + private logError(...args: [...string[], Error]) { + Logger.error('[SelectionService] Error: ', ...args) + } +} + +/** + * Initialize selection service when app starts + * Sets up config subscription and starts service if enabled + * @returns {boolean} Success status of initialization + */ +export function initSelectionService(): boolean { + if (!isWin) return false + + configManager.subscribe(ConfigKeys.SelectionAssistantEnabled, (enabled: boolean) => { + //avoid closure + const ss = SelectionService.getInstance() + if (!ss) { + Logger.error('SelectionService not initialized: instance is null') + return + } + + if (enabled) { + ss.start() + } else { + ss.stop() + } + }) + + if (!configManager.getSelectionAssistantEnabled()) return false + + const ss = SelectionService.getInstance() + if (!ss) { + Logger.error('SelectionService not initialized: instance is null') + return false + } + + return ss.start() +} + +const selectionService = SelectionService.getInstance() + +export default selectionService diff --git a/src/main/services/mcp/shell-env.ts b/src/main/services/mcp/shell-env.ts index 9901417024..a4128b3651 100644 --- a/src/main/services/mcp/shell-env.ts +++ b/src/main/services/mcp/shell-env.ts @@ -85,7 +85,7 @@ function getLoginShellEnvironment(): Promise> { Logger.warn(`Shell process stderr output (even with exit code 0):\n${errorOutput.trim()}`) } - const env = {} + const env: Record = {} const lines = output.split('\n') lines.forEach((line) => { @@ -110,6 +110,8 @@ function getLoginShellEnvironment(): Promise> { Logger.warn('Raw output from shell:\n', output) } + env.PATH = env.Path || env.PATH || '' + resolve(env) }) }) diff --git a/src/main/services/urlschema/handle-providers.ts b/src/main/services/urlschema/handle-providers.ts new file mode 100644 index 0000000000..bc109437e6 --- /dev/null +++ b/src/main/services/urlschema/handle-providers.ts @@ -0,0 +1,37 @@ +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) + switch (url.pathname) { + case '/api-keys': { + // jsonConfig example: + // { + // "id": "tokenflux", + // "baseUrl": "https://tokenflux.ai/v1", + // "apiKey": "sk-xxxx" + // } + // cherrystudio://providers/api-keys?data={base64Encode(JSON.stringify(jsonConfig))} + 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}')`) + } + } else { + Logger.error('No data found in URL') + } + break + } + default: + console.error(`Unknown MCP protocol URL: ${url}`) + break + } +} diff --git a/src/main/utils/__tests__/aes.test.ts b/src/main/utils/__tests__/aes.test.ts new file mode 100644 index 0000000000..59fb1d42d3 --- /dev/null +++ b/src/main/utils/__tests__/aes.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' + +import { decrypt, encrypt } from '../aes' + +const key = '12345678901234567890123456789012' // 32字节 +const iv = '1234567890abcdef1234567890abcdef' // 32字节hex,实际应16字节hex + +function getIv16() { + // 取前16字节作为 hex + return iv.slice(0, 32) +} + +describe('aes utils', () => { + it('should encrypt and decrypt normal string', () => { + const text = 'hello world' + const { iv: outIv, encryptedData } = encrypt(text, key, getIv16()) + expect(typeof encryptedData).toBe('string') + expect(outIv).toBe(getIv16()) + const decrypted = decrypt(encryptedData, getIv16(), key) + expect(decrypted).toBe(text) + }) + + it('should support unicode and special chars', () => { + const text = '你好,世界!🌟🚀' + const { encryptedData } = encrypt(text, key, getIv16()) + const decrypted = decrypt(encryptedData, getIv16(), key) + expect(decrypted).toBe(text) + }) + + it('should handle empty string', () => { + const text = '' + const { encryptedData } = encrypt(text, key, getIv16()) + const decrypted = decrypt(encryptedData, getIv16(), key) + expect(decrypted).toBe(text) + }) + + it('should encrypt and decrypt long string', () => { + const text = 'a'.repeat(100_000) + const { encryptedData } = encrypt(text, key, getIv16()) + const decrypted = decrypt(encryptedData, getIv16(), key) + expect(decrypted).toBe(text) + }) + + it('should throw error for wrong key', () => { + const text = 'test' + const { encryptedData } = encrypt(text, key, getIv16()) + expect(() => decrypt(encryptedData, getIv16(), 'wrongkeywrongkeywrongkeywrongkey')).toThrow() + }) + + it('should throw error for wrong iv', () => { + const text = 'test' + const { encryptedData } = encrypt(text, key, getIv16()) + expect(() => decrypt(encryptedData, 'abcdefabcdefabcdefabcdefabcdefab', key)).toThrow() + }) + + it('should throw error for invalid key/iv length', () => { + expect(() => encrypt('test', 'shortkey', getIv16())).toThrow() + expect(() => encrypt('test', key, 'shortiv')).toThrow() + }) + + it('should throw error for invalid encrypted data', () => { + expect(() => decrypt('nothexdata', getIv16(), key)).toThrow() + }) + + it('should throw error for non-string input', () => { + // @ts-expect-error purposely pass wrong type to test error branch + expect(() => encrypt(null, key, getIv16())).toThrow() + // @ts-expect-error purposely pass wrong type to test error branch + expect(() => decrypt(null, getIv16(), key)).toThrow() + }) +}) diff --git a/src/main/utils/__tests__/file.test.ts b/src/main/utils/__tests__/file.test.ts new file mode 100644 index 0000000000..aae00e85d4 --- /dev/null +++ b/src/main/utils/__tests__/file.test.ts @@ -0,0 +1,243 @@ +import * as fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { FileTypes } from '@types' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { getAllFiles, getAppConfigDir, getConfigDir, getFilesDir, getFileType, getTempDir } from '../file' + +// Mock dependencies +vi.mock('node:fs') +vi.mock('node:os') +vi.mock('node:path') +vi.mock('uuid', () => ({ + v4: () => 'mock-uuid' +})) +vi.mock('electron', () => ({ + app: { + getPath: vi.fn((key) => { + if (key === 'temp') return '/mock/temp' + if (key === 'userData') return '/mock/userData' + return '/mock/unknown' + }) + } +})) + +describe('file', () => { + beforeEach(() => { + vi.clearAllMocks() + + // Mock path.extname + vi.mocked(path.extname).mockImplementation((file) => { + const parts = file.split('.') + return parts.length > 1 ? `.${parts[parts.length - 1]}` : '' + }) + + // Mock path.basename + vi.mocked(path.basename).mockImplementation((file) => { + const parts = file.split('/') + return parts[parts.length - 1] + }) + + // Mock path.join + vi.mocked(path.join).mockImplementation((...args) => args.join('/')) + + // Mock os.homedir + vi.mocked(os.homedir).mockReturnValue('/mock/home') + }) + + afterEach(() => { + vi.resetAllMocks() + }) + + describe('getFileType', () => { + it('should return IMAGE for image extensions', () => { + expect(getFileType('.jpg')).toBe(FileTypes.IMAGE) + expect(getFileType('.jpeg')).toBe(FileTypes.IMAGE) + expect(getFileType('.png')).toBe(FileTypes.IMAGE) + expect(getFileType('.gif')).toBe(FileTypes.IMAGE) + expect(getFileType('.webp')).toBe(FileTypes.IMAGE) + expect(getFileType('.bmp')).toBe(FileTypes.IMAGE) + }) + + it('should return VIDEO for video extensions', () => { + expect(getFileType('.mp4')).toBe(FileTypes.VIDEO) + expect(getFileType('.avi')).toBe(FileTypes.VIDEO) + expect(getFileType('.mov')).toBe(FileTypes.VIDEO) + expect(getFileType('.mkv')).toBe(FileTypes.VIDEO) + expect(getFileType('.flv')).toBe(FileTypes.VIDEO) + }) + + it('should return AUDIO for audio extensions', () => { + expect(getFileType('.mp3')).toBe(FileTypes.AUDIO) + expect(getFileType('.wav')).toBe(FileTypes.AUDIO) + expect(getFileType('.ogg')).toBe(FileTypes.AUDIO) + expect(getFileType('.flac')).toBe(FileTypes.AUDIO) + expect(getFileType('.aac')).toBe(FileTypes.AUDIO) + }) + + it('should return TEXT for text extensions', () => { + expect(getFileType('.txt')).toBe(FileTypes.TEXT) + expect(getFileType('.md')).toBe(FileTypes.TEXT) + expect(getFileType('.html')).toBe(FileTypes.TEXT) + expect(getFileType('.json')).toBe(FileTypes.TEXT) + expect(getFileType('.js')).toBe(FileTypes.TEXT) + expect(getFileType('.ts')).toBe(FileTypes.TEXT) + expect(getFileType('.css')).toBe(FileTypes.TEXT) + expect(getFileType('.java')).toBe(FileTypes.TEXT) + expect(getFileType('.py')).toBe(FileTypes.TEXT) + }) + + it('should return DOCUMENT for document extensions', () => { + expect(getFileType('.pdf')).toBe(FileTypes.DOCUMENT) + expect(getFileType('.pptx')).toBe(FileTypes.DOCUMENT) + expect(getFileType('.docx')).toBe(FileTypes.DOCUMENT) + expect(getFileType('.xlsx')).toBe(FileTypes.DOCUMENT) + expect(getFileType('.odt')).toBe(FileTypes.DOCUMENT) + }) + + it('should return OTHER for unknown extensions', () => { + expect(getFileType('.unknown')).toBe(FileTypes.OTHER) + expect(getFileType('')).toBe(FileTypes.OTHER) + expect(getFileType('.')).toBe(FileTypes.OTHER) + expect(getFileType('...')).toBe(FileTypes.OTHER) + expect(getFileType('.123')).toBe(FileTypes.OTHER) + }) + + it('should handle case-insensitive extensions', () => { + expect(getFileType('.JPG')).toBe(FileTypes.IMAGE) + expect(getFileType('.PDF')).toBe(FileTypes.DOCUMENT) + expect(getFileType('.Mp3')).toBe(FileTypes.AUDIO) + expect(getFileType('.HtMl')).toBe(FileTypes.TEXT) + expect(getFileType('.Xlsx')).toBe(FileTypes.DOCUMENT) + }) + + it('should handle extensions without leading dot', () => { + expect(getFileType('jpg')).toBe(FileTypes.OTHER) + expect(getFileType('pdf')).toBe(FileTypes.OTHER) + expect(getFileType('mp3')).toBe(FileTypes.OTHER) + }) + + it('should handle extreme cases', () => { + expect(getFileType('.averylongfileextensionname')).toBe(FileTypes.OTHER) + expect(getFileType('.tar.gz')).toBe(FileTypes.OTHER) + expect(getFileType('.文件')).toBe(FileTypes.OTHER) + expect(getFileType('.файл')).toBe(FileTypes.OTHER) + }) + }) + + describe('getAllFiles', () => { + it('should return all valid files recursively', () => { + // Mock file system + // @ts-ignore - override type for testing + vi.spyOn(fs, 'readdirSync').mockImplementation((dirPath) => { + if (dirPath === '/test') { + return ['file1.txt', 'file2.pdf', 'subdir'] + } else if (dirPath === '/test/subdir') { + return ['file3.md', 'file4.docx'] + } + return [] + }) + + vi.mocked(fs.statSync).mockImplementation((filePath) => { + const isDir = String(filePath).endsWith('subdir') + return { + isDirectory: () => isDir, + size: 1024 + } as fs.Stats + }) + + const result = getAllFiles('/test') + + expect(result).toHaveLength(4) + expect(result[0].id).toBe('mock-uuid') + expect(result[0].name).toBe('file1.txt') + expect(result[0].type).toBe(FileTypes.TEXT) + expect(result[1].name).toBe('file2.pdf') + expect(result[1].type).toBe(FileTypes.DOCUMENT) + }) + + it('should skip hidden files', () => { + // @ts-ignore - override type for testing + vi.spyOn(fs, 'readdirSync').mockReturnValue(['.hidden', 'visible.txt']) + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => false, + size: 1024 + } as fs.Stats) + + const result = getAllFiles('/test') + + expect(result).toHaveLength(1) + expect(result[0].name).toBe('visible.txt') + }) + + it('should skip unsupported file types', () => { + // @ts-ignore - override type for testing + vi.spyOn(fs, 'readdirSync').mockReturnValue(['image.jpg', 'video.mp4', 'audio.mp3', 'document.pdf']) + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => false, + size: 1024 + } as fs.Stats) + + const result = getAllFiles('/test') + + // Should only include document.pdf as the others are excluded types + expect(result).toHaveLength(1) + expect(result[0].name).toBe('document.pdf') + expect(result[0].type).toBe(FileTypes.DOCUMENT) + }) + + it('should return empty array for empty directory', () => { + // @ts-ignore - override type for testing + vi.spyOn(fs, 'readdirSync').mockReturnValue([]) + + const result = getAllFiles('/empty') + + expect(result).toHaveLength(0) + }) + + it('should handle file system errors', () => { + // @ts-ignore - override type for testing + vi.spyOn(fs, 'readdirSync').mockImplementation(() => { + throw new Error('Directory not found') + }) + + // Since the function doesn't have error handling, we expect it to propagate + expect(() => getAllFiles('/nonexistent')).toThrow('Directory not found') + }) + }) + + describe('getTempDir', () => { + it('should return correct temp directory path', () => { + const tempDir = getTempDir() + expect(tempDir).toBe('/mock/temp/CherryStudio') + }) + }) + + describe('getFilesDir', () => { + it('should return correct files directory path', () => { + const filesDir = getFilesDir() + expect(filesDir).toBe('/mock/userData/Data/Files') + }) + }) + + describe('getConfigDir', () => { + it('should return correct config directory path', () => { + const configDir = getConfigDir() + expect(configDir).toBe('/mock/home/.cherrystudio/config') + }) + }) + + describe('getAppConfigDir', () => { + it('should return correct app config directory path', () => { + const appConfigDir = getAppConfigDir('test-app') + expect(appConfigDir).toBe('/mock/home/.cherrystudio/config/test-app') + }) + + it('should handle empty app name', () => { + const appConfigDir = getAppConfigDir('') + expect(appConfigDir).toBe('/mock/home/.cherrystudio/config/') + }) + }) +}) diff --git a/src/main/utils/__tests__/zip.test.ts b/src/main/utils/__tests__/zip.test.ts new file mode 100644 index 0000000000..6c84b16e93 --- /dev/null +++ b/src/main/utils/__tests__/zip.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' + +import { compress, decompress } from '../zip' + +const jsonStr = JSON.stringify({ foo: 'bar', num: 42, arr: [1, 2, 3] }) + +// 辅助函数:生成大字符串 +function makeLargeString(size: number) { + return 'a'.repeat(size) +} + +describe('zip', () => { + describe('compress & decompress', () => { + it('should compress and decompress a normal JSON string', async () => { + const compressed = await compress(jsonStr) + expect(compressed).toBeInstanceOf(Buffer) + + const decompressed = await decompress(compressed) + expect(decompressed).toBe(jsonStr) + }) + + it('should handle empty string', async () => { + const compressed = await compress('') + expect(compressed).toBeInstanceOf(Buffer) + const decompressed = await decompress(compressed) + expect(decompressed).toBe('') + }) + + it('should handle large string', async () => { + const largeStr = makeLargeString(100_000) + const compressed = await compress(largeStr) + expect(compressed).toBeInstanceOf(Buffer) + expect(compressed.length).toBeLessThan(largeStr.length) + const decompressed = await decompress(compressed) + expect(decompressed).toBe(largeStr) + }) + + it('should throw error when decompressing invalid buffer', async () => { + const invalidBuffer = Buffer.from('not a valid gzip', 'utf-8') + await expect(decompress(invalidBuffer)).rejects.toThrow() + }) + + it('should throw error when compress input is not string', async () => { + // @ts-expect-error purposely pass wrong type to test error branch + await expect(compress(null)).rejects.toThrow() + // @ts-expect-error purposely pass wrong type to test error branch + await expect(compress(undefined)).rejects.toThrow() + // @ts-expect-error purposely pass wrong type to test error branch + await expect(compress(123)).rejects.toThrow() + }) + + it('should throw error when decompress input is not buffer', async () => { + // @ts-expect-error purposely pass wrong type to test error branch + await expect(decompress(null)).rejects.toThrow() + // @ts-expect-error purposely pass wrong type to test error branch + await expect(decompress(undefined)).rejects.toThrow() + // @ts-expect-error purposely pass wrong type to test error branch + await expect(decompress('string')).rejects.toThrow() + }) + }) +}) diff --git a/src/main/utils/zip.ts b/src/main/utils/zip.ts index 177ccba7fe..b2762f7a98 100644 --- a/src/main/utils/zip.ts +++ b/src/main/utils/zip.ts @@ -9,10 +9,10 @@ const gunzipPromise = util.promisify(zlib.gunzip) /** * 压缩字符串 + * @param {string} str 要压缩的 JSON 字符串 * @returns {Promise} 压缩后的 Buffer - * @param str */ -export async function compress(str) { +export async function compress(str: string): Promise { try { const buffer = Buffer.from(str, 'utf-8') return await gzipPromise(buffer) @@ -27,7 +27,7 @@ export async function compress(str) { * @param {Buffer} compressedBuffer - 压缩的 Buffer * @returns {Promise} 解压缩后的 JSON 字符串 */ -export async function decompress(compressedBuffer) { +export async function decompress(compressedBuffer: Buffer): Promise { try { const buffer = await gunzipPromise(compressedBuffer) return buffer.toString('utf-8') diff --git a/src/preload/index.ts b/src/preload/index.ts index 81174d22d0..2a69ae908e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -6,6 +6,8 @@ import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from ' import { Notification } from 'src/renderer/src/types/notification' import { CreateDirectoryOptions } from 'webdav' +import type { ActionItem } from '../renderer/src/types/selectionTypes' + // Custom APIs for renderer const api = { getAppInfo: () => ipcRenderer.invoke(IpcChannel.App_Info), @@ -74,7 +76,7 @@ const api = { selectFolder: () => ipcRenderer.invoke(IpcChannel.File_SelectFolder), saveImage: (name: string, data: string) => ipcRenderer.invoke(IpcChannel.File_SaveImage, name, data), base64Image: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64Image, fileId), - download: (url: string) => ipcRenderer.invoke(IpcChannel.File_Download, url), + download: (url: string, isUseContentType?: boolean) => ipcRenderer.invoke(IpcChannel.File_Download, url, isUseContentType), copy: (fileId: string, destPath: string) => ipcRenderer.invoke(IpcChannel.File_Copy, fileId, destPath), binaryImage: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryImage, fileId), base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId), @@ -204,6 +206,20 @@ const api = { subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe), unsubscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Unsubscribe), onUpdate: (action: any) => ipcRenderer.invoke(IpcChannel.StoreSync_OnUpdate, action) + }, + selection: { + hideToolbar: () => ipcRenderer.invoke(IpcChannel.Selection_ToolbarHide), + writeToClipboard: (text: string) => ipcRenderer.invoke(IpcChannel.Selection_WriteToClipboard, text), + determineToolbarSize: (width: number, height: number) => + ipcRenderer.invoke(IpcChannel.Selection_ToolbarDetermineSize, width, height), + setEnabled: (enabled: boolean) => ipcRenderer.invoke(IpcChannel.Selection_SetEnabled, enabled), + setTriggerMode: (triggerMode: string) => ipcRenderer.invoke(IpcChannel.Selection_SetTriggerMode, triggerMode), + setFollowToolbar: (isFollowToolbar: boolean) => + ipcRenderer.invoke(IpcChannel.Selection_SetFollowToolbar, isFollowToolbar), + processAction: (actionItem: ActionItem) => ipcRenderer.invoke(IpcChannel.Selection_ProcessAction, actionItem), + closeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowClose), + minimizeActionWindow: () => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowMinimize), + pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned) } } diff --git a/src/renderer/__tests__/setup.ts b/src/renderer/__tests__/setup.ts deleted file mode 100644 index 70b9cd70b0..0000000000 --- a/src/renderer/__tests__/setup.ts +++ /dev/null @@ -1,49 +0,0 @@ -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' - } - } - } - } -}) - -vi.stubGlobal('window', { - electron: { - ipcRenderer: { - on: vi.fn(), // Mocking ipcRenderer.on - send: vi.fn() // Mocking ipcRenderer.send - } - }, - api: { - file: { - read: vi.fn().mockResolvedValue('[]'), // Mock file.read to return an empty array (you can customize this) - writeWithId: vi.fn().mockResolvedValue(undefined) // Mock file.writeWithId to do nothing - } - } -}) - -vi.mock('axios', () => ({ - default: { - get: vi.fn().mockResolvedValue({ data: {} }), // Mocking axios GET request - post: vi.fn().mockResolvedValue({ data: {} }) // Mocking axios POST request - // You can add other axios methods like put, delete etc. as needed - } -})) - -vi.stubGlobal('window', { - ...global.window, // Copy other global properties - addEventListener: vi.fn(), // Mock addEventListener - removeEventListener: vi.fn() // You can also mock removeEventListener if needed -}) diff --git a/src/renderer/selectionAction.html b/src/renderer/selectionAction.html new file mode 100644 index 0000000000..1dd3fa616c --- /dev/null +++ b/src/renderer/selectionAction.html @@ -0,0 +1,41 @@ + + + + + + + + Cherry Studio Selection Assistant + + + + +
+ + + + + \ No newline at end of file diff --git a/src/renderer/selectionToolbar.html b/src/renderer/selectionToolbar.html new file mode 100644 index 0000000000..1a219f6472 --- /dev/null +++ b/src/renderer/selectionToolbar.html @@ -0,0 +1,43 @@ + + + + + + + + Cherry Studio Selection Toolbar + + + + +
+ + + + + \ No newline at end of file diff --git a/src/renderer/src/assets/images/models/tokenflux.png b/src/renderer/src/assets/images/models/tokenflux.png new file mode 100644 index 0000000000..e3a8497b6c Binary files /dev/null and b/src/renderer/src/assets/images/models/tokenflux.png differ diff --git a/src/renderer/src/assets/images/models/tokenflux_dark.png b/src/renderer/src/assets/images/models/tokenflux_dark.png new file mode 100644 index 0000000000..e3a8497b6c Binary files /dev/null and b/src/renderer/src/assets/images/models/tokenflux_dark.png differ diff --git a/src/renderer/src/assets/images/providers/DMXAPI-to-img.webp b/src/renderer/src/assets/images/providers/DMXAPI-to-img.webp new file mode 100644 index 0000000000..6d18ed8dbe Binary files /dev/null and b/src/renderer/src/assets/images/providers/DMXAPI-to-img.webp differ diff --git a/src/renderer/src/assets/images/providers/dmxapi-logo-dark.webp b/src/renderer/src/assets/images/providers/dmxapi-logo-dark.webp new file mode 100644 index 0000000000..baad605a00 Binary files /dev/null and b/src/renderer/src/assets/images/providers/dmxapi-logo-dark.webp differ diff --git a/src/renderer/src/assets/images/providers/dmxapi-logo.webp b/src/renderer/src/assets/images/providers/dmxapi-logo.webp new file mode 100644 index 0000000000..422774d2e5 Binary files /dev/null and b/src/renderer/src/assets/images/providers/dmxapi-logo.webp differ diff --git a/src/renderer/src/assets/images/providers/tokenflux.png b/src/renderer/src/assets/images/providers/tokenflux.png new file mode 100644 index 0000000000..e3a8497b6c Binary files /dev/null and b/src/renderer/src/assets/images/providers/tokenflux.png differ diff --git a/src/renderer/src/assets/styles/color.scss b/src/renderer/src/assets/styles/color.scss index c142e8e270..897715b2b6 100644 --- a/src/renderer/src/assets/styles/color.scss +++ b/src/renderer/src/assets/styles/color.scss @@ -60,6 +60,7 @@ --assistants-width: 275px; --topic-list-width: 275px; --settings-width: 250px; + --scrollbar-width: 5px; --chat-background: #111111; --chat-background-user: #28b561; diff --git a/src/renderer/src/assets/styles/container.scss b/src/renderer/src/assets/styles/container.scss index 8be4027981..aa05ce010c 100644 --- a/src/renderer/src/assets/styles/container.scss +++ b/src/renderer/src/assets/styles/container.scss @@ -4,3 +4,9 @@ border-top-left-radius: 10px; border-left: 0.5px solid var(--color-border); } + +.group-container { + .context-menu-container { + width: 100%; + } +} diff --git a/src/renderer/src/assets/styles/markdown.scss b/src/renderer/src/assets/styles/markdown.scss index aed4919e85..40c0255468 100644 --- a/src/renderer/src/assets/styles/markdown.scss +++ b/src/renderer/src/assets/styles/markdown.scss @@ -321,6 +321,7 @@ mjx-container { .cm-lineWrapping * { word-wrap: break-word; + white-space: pre-wrap; } } } diff --git a/src/renderer/src/assets/styles/scrollbar.scss b/src/renderer/src/assets/styles/scrollbar.scss index f25039f556..8e73054c38 100644 --- a/src/renderer/src/assets/styles/scrollbar.scss +++ b/src/renderer/src/assets/styles/scrollbar.scss @@ -18,7 +18,8 @@ body[theme-mode='light'] { height: 6px; } -::-webkit-scrollbar-track { +::-webkit-scrollbar-track, +::-webkit-scrollbar-corner { background: transparent; } @@ -30,7 +31,7 @@ body[theme-mode='light'] { } } -pre::-webkit-scrollbar-thumb { +pre:not(.shiki)::-webkit-scrollbar-thumb { border-radius: 0; background: rgba(0, 0, 0, 0.08); &:hover { diff --git a/src/renderer/src/assets/styles/selection-toolbar.scss b/src/renderer/src/assets/styles/selection-toolbar.scss new file mode 100644 index 0000000000..dfbb6bbd59 --- /dev/null +++ b/src/renderer/src/assets/styles/selection-toolbar.scss @@ -0,0 +1,26 @@ +@use './font.scss'; + +html { + font-family: var(--font-family); +} + +:root { + --color-selection-toolbar-background: rgba(20, 20, 20, 0.95); + --color-selection-toolbar-border: rgba(55, 55, 55, 0.5); + --color-selection-toolbar-shadow: rgba(50, 50, 50, 0.3); + + --color-selection-toolbar-text: rgba(255, 255, 245, 0.9); + --color-selection-toolbar-hover-bg: #222222; + + --color-primary: #00b96b; + --color-error: #f44336; +} + +[theme-mode='light'] { + --color-selection-toolbar-background: rgba(245, 245, 245, 0.95); + --color-selection-toolbar-border: rgba(200, 200, 200, 0.5); + --color-selection-toolbar-shadow: rgba(50, 50, 50, 0.3); + + --color-selection-toolbar-text: rgba(0, 0, 0, 1); + --color-selection-toolbar-hover-bg: rgba(0, 0, 0, 0.04); +} diff --git a/src/renderer/src/components/CodeBlockView/CodePreview.tsx b/src/renderer/src/components/CodeBlockView/CodePreview.tsx index d17a146112..1e24800789 100644 --- a/src/renderer/src/components/CodeBlockView/CodePreview.tsx +++ b/src/renderer/src/components/CodeBlockView/CodePreview.tsx @@ -1,4 +1,4 @@ -import { TOOL_SPECS, useCodeToolbar } from '@renderer/components/CodeToolbar' +import { CodeTool, TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useSettings } from '@renderer/hooks/useSettings' import { uuid } from '@renderer/utils' @@ -12,6 +12,7 @@ import styled from 'styled-components' interface CodePreviewProps { children: string language: string + setTools?: (value: React.SetStateAction) => void } /** @@ -20,7 +21,7 @@ interface CodePreviewProps { * - 通过 shiki tokenizer 处理流式响应 * - 为了正确执行语法高亮,必须保证流式响应都依次到达 tokenizer,不能跳过 */ -const CodePreview = ({ children, language }: CodePreviewProps) => { +const CodePreview = ({ children, language, setTools }: CodePreviewProps) => { const { codeShowLineNumbers, fontSize, codeCollapsible, codeWrappable } = useSettings() const { activeShikiTheme, highlightCodeChunk, cleanupTokenizers } = useCodeStyle() const [isExpanded, setIsExpanded] = useState(!codeCollapsible) @@ -35,7 +36,7 @@ const CodePreview = ({ children, language }: CodePreviewProps) => { const { t } = useTranslation() - const { registerTool, removeTool } = useCodeToolbar() + const { registerTool, removeTool } = useCodeTool(setTools) // 展开/折叠工具 useEffect(() => { @@ -171,14 +172,13 @@ const CodePreview = ({ children, language }: CodePreviewProps) => { ref={codeContentRef} $lineNumbers={codeShowLineNumbers} $wrap={codeWrappable && !isUnwrapped} + $fadeIn={hasHighlightedCode} style={{ fontSize: fontSize - 1, maxHeight: codeCollapsible && !isExpanded ? '350px' : 'none' }}> {hasHighlightedCode ? ( -
- -
+ ) : ( {children} )} @@ -229,26 +229,22 @@ const ShikiTokensRenderer: React.FC<{ language: string; tokenLines: ThemedToken[ const ContentContainer = styled.div<{ $lineNumbers: boolean $wrap: boolean + $fadeIn: boolean }>` + display: block; position: relative; overflow: auto; - display: flex; - flex-direction: column; border: 0.5px solid transparent; border-radius: 5px; margin-top: 0; - ::-webkit-scrollbar-thumb { - border-radius: 10px; - } - .shiki { + display: flex; + min-width: 100%; padding: 1em; code { - display: flex; - flex-direction: column; - width: 100%; + display: block; .line { display: block; @@ -256,7 +252,7 @@ const ContentContainer = styled.div<{ padding-left: ${(props) => (props.$lineNumbers ? '2rem' : '0')}; * { - word-wrap: ${(props) => (props.$wrap ? 'break-word' : undefined)}; + overflow-wrap: ${(props) => (props.$wrap ? 'break-word' : 'normal')}; white-space: ${(props) => (props.$wrap ? 'pre-wrap' : 'pre')}; } } @@ -292,18 +288,15 @@ const ContentContainer = styled.div<{ } } - .fade-in-effect { - animation: contentFadeIn 0.3s ease-in-out forwards; - } + animation: ${(props) => (props.$fadeIn ? 'contentFadeIn 0.3s ease-in-out forwards' : 'none')}; ` const CodePlaceholder = styled.div` + display: block; opacity: 0.1; - flex-direction: column; white-space: pre-wrap; word-break: break-all; overflow-x: hidden; - display: block; min-height: 1.3rem; ` diff --git a/src/renderer/src/components/CodeBlockView/MermaidPreview.tsx b/src/renderer/src/components/CodeBlockView/MermaidPreview.tsx index f02261c466..cf00802f6a 100644 --- a/src/renderer/src/components/CodeBlockView/MermaidPreview.tsx +++ b/src/renderer/src/components/CodeBlockView/MermaidPreview.tsx @@ -1,5 +1,5 @@ import { nanoid } from '@reduxjs/toolkit' -import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar' +import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar' import { useMermaid } from '@renderer/hooks/useMermaid' import { Flex } from 'antd' import React, { memo, startTransition, useCallback, useEffect, useRef, useState } from 'react' @@ -7,9 +7,10 @@ import styled from 'styled-components' interface Props { children: string + setTools?: (value: React.SetStateAction) => void } -const MermaidPreview: React.FC = ({ children }) => { +const MermaidPreview: React.FC = ({ children, setTools }) => { const { mermaid, isLoading, error: mermaidError } = useMermaid() const mermaidRef = useRef(null) const [error, setError] = useState(null) @@ -25,6 +26,7 @@ const MermaidPreview: React.FC = ({ children }) => { // 使用工具栏 usePreviewTools({ + setTools, handleZoom, handleCopyImage, handleDownload diff --git a/src/renderer/src/components/CodeBlockView/PlantUmlPreview.tsx b/src/renderer/src/components/CodeBlockView/PlantUmlPreview.tsx index 9af10a5aa7..35ef90e12e 100644 --- a/src/renderer/src/components/CodeBlockView/PlantUmlPreview.tsx +++ b/src/renderer/src/components/CodeBlockView/PlantUmlPreview.tsx @@ -1,5 +1,5 @@ import { LoadingOutlined } from '@ant-design/icons' -import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar' +import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar' import { Spin } from 'antd' import pako from 'pako' import React, { memo, useCallback, useRef, useState } from 'react' @@ -134,9 +134,10 @@ const PlantUMLServerImage: React.FC = ({ format, diagr interface PlantUMLProps { children: string + setTools?: (value: React.SetStateAction) => void } -const PlantUmlPreview: React.FC = ({ children }) => { +const PlantUmlPreview: React.FC = ({ children, setTools }) => { const { t } = useTranslation() const containerRef = useRef(null) @@ -165,6 +166,7 @@ const PlantUmlPreview: React.FC = ({ children }) => { // 使用工具栏 usePreviewTools({ + setTools, handleZoom, handleCopyImage, handleDownload: customDownload diff --git a/src/renderer/src/components/CodeBlockView/SvgPreview.tsx b/src/renderer/src/components/CodeBlockView/SvgPreview.tsx index 1e1f20b60e..f7b23b4faf 100644 --- a/src/renderer/src/components/CodeBlockView/SvgPreview.tsx +++ b/src/renderer/src/components/CodeBlockView/SvgPreview.tsx @@ -1,12 +1,13 @@ -import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar' +import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar' import { memo, useRef } from 'react' import styled from 'styled-components' interface Props { children: string + setTools?: (value: React.SetStateAction) => void } -const SvgPreview: React.FC = ({ children }) => { +const SvgPreview: React.FC = ({ children, setTools }) => { const svgContainerRef = useRef(null) // 使用通用图像工具 @@ -17,6 +18,7 @@ const SvgPreview: React.FC = ({ children }) => { // 使用工具栏 usePreviewTools({ + setTools, handleCopyImage, handleDownload }) diff --git a/src/renderer/src/components/CodeBlockView/index.tsx b/src/renderer/src/components/CodeBlockView/index.tsx index 19b8796d09..6d404dadab 100644 --- a/src/renderer/src/components/CodeBlockView/index.tsx +++ b/src/renderer/src/components/CodeBlockView/index.tsx @@ -1,6 +1,6 @@ import { LoadingOutlined } from '@ant-design/icons' import CodeEditor from '@renderer/components/CodeEditor' -import { CodeToolbar, CodeToolContext, TOOL_SPECS, useCodeToolbar } from '@renderer/components/CodeToolbar' +import { CodeTool, CodeToolbar, TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar' import { useSettings } from '@renderer/hooks/useSettings' import { pyodideService } from '@renderer/services/PyodideService' import { extractTitle } from '@renderer/utils/formats' @@ -49,6 +49,9 @@ const CodeBlockView: React.FC = ({ children, language, onSave }) => { const [isRunning, setIsRunning] = useState(false) const [output, setOutput] = useState('') + const [tools, setTools] = useState([]) + const { registerTool, removeTool } = useCodeTool(setTools) + const isExecutable = useMemo(() => { return codeExecution.enabled && language === 'python' }, [codeExecution.enabled, language]) @@ -59,33 +62,17 @@ const CodeBlockView: React.FC = ({ children, language, onSave }) => { return hasSpecialView && viewMode === 'special' }, [hasSpecialView, viewMode]) - const { updateContext, registerTool, removeTool } = useCodeToolbar() + const handleCopySource = useCallback(() => { + navigator.clipboard.writeText(children) + window.message.success({ content: t('code_block.copy.success'), key: 'copy-code' }) + }, [children, t]) - useEffect(() => { - updateContext({ - code: children, - language - }) - }, [children, language, updateContext]) - - const handleCopySource = useCallback( - (ctx?: CodeToolContext) => { - if (!ctx) return - navigator.clipboard.writeText(ctx.code) - window.message.success({ content: t('code_block.copy.success'), key: 'copy-code' }) - }, - [t] - ) - - const handleDownloadSource = useCallback((ctx?: CodeToolContext) => { - if (!ctx) return - - const { code, language } = ctx + const handleDownloadSource = useCallback(() => { let fileName = '' // 尝试提取标题 - if (language === 'html' && code.includes('')) { - const title = extractTitle(code) + if (language === 'html' && children.includes('')) { + const title = extractTitle(children) if (title) { fileName = `${title}.html` } @@ -96,31 +83,26 @@ const CodeBlockView: React.FC = ({ children, language, onSave }) => { fileName = `${dayjs().format('YYYYMMDDHHmm')}.${language}` } - window.api.file.save(fileName, code) - }, []) + window.api.file.save(fileName, children) + }, [children, language]) - const handleRunScript = useCallback( - (ctx?: CodeToolContext) => { - if (!ctx) return + const handleRunScript = useCallback(() => { + setIsRunning(true) + setOutput('') - setIsRunning(true) - setOutput('') - - pyodideService - .runScript(ctx.code, {}, codeExecution.timeoutMinutes * 60000) - .then((formattedOutput) => { - setOutput(formattedOutput) - }) - .catch((error) => { - console.error('Unexpected error:', error) - setOutput(`Unexpected error: ${error.message || 'Unknown error'}`) - }) - .finally(() => { - setIsRunning(false) - }) - }, - [codeExecution.timeoutMinutes] - ) + pyodideService + .runScript(children, {}, codeExecution.timeoutMinutes * 60000) + .then((formattedOutput) => { + setOutput(formattedOutput) + }) + .catch((error) => { + console.error('Unexpected error:', error) + setOutput(`Unexpected error: ${error.message || 'Unknown error'}`) + }) + .finally(() => { + setIsRunning(false) + }) + }, [children, codeExecution.timeoutMinutes]) useEffect(() => { // 复制按钮 @@ -191,7 +173,7 @@ const CodeBlockView: React.FC = ({ children, language, onSave }) => { ...TOOL_SPECS.run, icon: isRunning ? : , tooltip: t('code_block.run'), - onClick: (ctx) => !isRunning && handleRunScript(ctx) + onClick: () => !isRunning && handleRunScript() }) return () => isExecutable && removeTool(TOOL_SPECS.run.id) @@ -200,20 +182,32 @@ const CodeBlockView: React.FC = ({ children, language, onSave }) => { // 源代码视图组件 const sourceView = useMemo(() => { if (codeEditor.enabled) { - return + return ( + + ) } else { - return {children} + return ( + + {children} + + ) } - }, [children, codeEditor.enabled, language, onSave]) + }, [children, codeEditor.enabled, language, onSave, setTools]) // 特殊视图组件映射 const specialView = useMemo(() => { if (language === 'mermaid') { - return {children} + return {children} } else if (language === 'plantuml' && isValidPlantUML(children)) { - return {children} + return {children} } else if (language === 'svg') { - return {children} + return {children} } return null }, [children, language]) @@ -246,7 +240,7 @@ const CodeBlockView: React.FC = ({ children, language, onSave }) => { return ( {renderHeader} - + {renderContent} {renderArtifacts} {isExecutable && output && {output}} @@ -255,10 +249,10 @@ const CodeBlockView: React.FC = ({ children, language, onSave }) => { } const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>` + /* FIXME: 在 bubble style 中撑开一些宽度*/ position: relative; .code-toolbar { - margin-top: ${(props) => (props.$isInSpecialView ? '20px' : '0')}; background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')}; border-radius: ${(props) => (props.$isInSpecialView ? '0' : '4px')}; opacity: 0; @@ -279,13 +273,13 @@ const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>` const CodeHeader = styled.div<{ $isInSpecialView: boolean }>` display: flex; align-items: center; - justify-content: space-between; color: var(--color-text); font-size: 14px; font-weight: bold; padding: 0 10px; border-top-left-radius: 8px; border-top-right-radius: 8px; + margin-top: ${(props) => (props.$isInSpecialView ? '6px' : '0')}; height: ${(props) => (props.$isInSpecialView ? '16px' : '34px')}; ` diff --git a/src/renderer/src/components/CodeEditor/index.tsx b/src/renderer/src/components/CodeEditor/index.tsx index 6000e91b49..956a66b0f1 100644 --- a/src/renderer/src/components/CodeEditor/index.tsx +++ b/src/renderer/src/components/CodeEditor/index.tsx @@ -1,4 +1,4 @@ -import { TOOL_SPECS, useCodeToolbar } from '@renderer/components/CodeToolbar' +import { CodeTool, TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useSettings } from '@renderer/hooks/useSettings' import CodeMirror, { Annotation, BasicSetupOptions, EditorView, Extension, keymap } from '@uiw/react-codemirror' @@ -25,6 +25,7 @@ interface Props { language: string onSave?: (newContent: string) => void onChange?: (newContent: string) => void + setTools?: (value: React.SetStateAction) => void minHeight?: string maxHeight?: string /** 用于覆写编辑器的某些设置 */ @@ -52,6 +53,7 @@ const CodeEditor = ({ language, onSave, onChange, + setTools, minHeight, maxHeight, options, @@ -88,7 +90,7 @@ const CodeEditor = ({ const langExtensions = useLanguageExtensions(language, options?.lint) - const { registerTool, removeTool } = useCodeToolbar() + const { registerTool, removeTool } = useCodeTool(setTools) // 展开/折叠工具 useEffect(() => { diff --git a/src/renderer/src/components/CodeToolbar/context.tsx b/src/renderer/src/components/CodeToolbar/context.tsx deleted file mode 100644 index 32be179d85..0000000000 --- a/src/renderer/src/components/CodeToolbar/context.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import React, { createContext, use, useCallback, useMemo, useState } from 'react' - -import { CodeTool, CodeToolContext } from './types' - -// 定义上下文默认值 -const defaultContext: CodeToolContext = { - code: '', - language: '' -} - -export interface CodeToolbarContextType { - tools: CodeTool[] - context: CodeToolContext - registerTool: (tool: CodeTool) => void - removeTool: (id: string) => void - updateContext: (newContext: Partial) => void -} - -const defaultCodeToolbarContext: CodeToolbarContextType = { - tools: [], - context: defaultContext, - registerTool: () => {}, - removeTool: () => {}, - updateContext: () => {} -} - -const CodeToolbarContext = createContext(defaultCodeToolbarContext) - -export const CodeToolbarProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const [tools, setTools] = useState([]) - const [context, setContext] = useState(defaultContext) - - // 注册工具,如果已存在同ID工具则替换 - const registerTool = useCallback((tool: CodeTool) => { - setTools((prev) => { - const filtered = prev.filter((t) => t.id !== tool.id) - return [...filtered, tool].sort((a, b) => b.order - a.order) - }) - }, []) - - // 移除工具 - const removeTool = useCallback((id: string) => { - setTools((prev) => prev.filter((tool) => tool.id !== id)) - }, []) - - // 更新上下文 - const updateContext = useCallback((newContext: Partial) => { - setContext((prev) => ({ ...prev, ...newContext })) - }, []) - - const value: CodeToolbarContextType = useMemo( - () => ({ - tools, - context, - registerTool, - removeTool, - updateContext - }), - [tools, context, registerTool, removeTool, updateContext] - ) - - return {children} -} - -export const useCodeToolbar = () => { - const context = use(CodeToolbarContext) - if (!context) { - throw new Error('useCodeToolbar must be used within a CodeToolbarProvider') - } - return context -} diff --git a/src/renderer/src/components/CodeToolbar/hook.ts b/src/renderer/src/components/CodeToolbar/hook.ts new file mode 100644 index 0000000000..5b5d6b338f --- /dev/null +++ b/src/renderer/src/components/CodeToolbar/hook.ts @@ -0,0 +1,26 @@ +import { useCallback } from 'react' + +import { CodeTool } from './types' + +export const useCodeTool = (setTools?: (value: React.SetStateAction) => void) => { + // 注册工具,如果已存在同ID工具则替换 + const registerTool = useCallback( + (tool: CodeTool) => { + setTools?.((prev) => { + const filtered = prev.filter((t) => t.id !== tool.id) + return [...filtered, tool].sort((a, b) => b.order - a.order) + }) + }, + [setTools] + ) + + // 移除工具 + const removeTool = useCallback( + (id: string) => { + setTools?.((prev) => prev.filter((tool) => tool.id !== id)) + }, + [setTools] + ) + + return { registerTool, removeTool } +} diff --git a/src/renderer/src/components/CodeToolbar/index.ts b/src/renderer/src/components/CodeToolbar/index.ts index 63d28e27f8..96434b97e9 100644 --- a/src/renderer/src/components/CodeToolbar/index.ts +++ b/src/renderer/src/components/CodeToolbar/index.ts @@ -1,5 +1,5 @@ export * from './constants' -export * from './context' +export * from './hook' export * from './toolbar' export * from './types' export * from './usePreviewTools' diff --git a/src/renderer/src/components/CodeToolbar/toolbar.tsx b/src/renderer/src/components/CodeToolbar/toolbar.tsx index 9a2f282bc3..cd615afcb4 100644 --- a/src/renderer/src/components/CodeToolbar/toolbar.tsx +++ b/src/renderer/src/components/CodeToolbar/toolbar.tsx @@ -5,7 +5,6 @@ import React, { memo, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -import { useCodeToolbar } from './context' import { CodeTool } from './types' interface CodeToolButtonProps { @@ -13,22 +12,19 @@ interface CodeToolButtonProps { } const CodeToolButton: React.FC = memo(({ tool }) => { - const { context } = useCodeToolbar() - return ( - - tool.onClick(context)}>{tool.icon} + + tool.onClick()}>{tool.icon} ) }) -export const CodeToolbar: React.FC = memo(() => { - const { tools, context } = useCodeToolbar() +export const CodeToolbar: React.FC<{ tools: CodeTool[] }> = memo(({ tools }) => { const [showQuickTools, setShowQuickTools] = useState(false) const { t } = useTranslation() // 根据条件显示工具 - const visibleTools = tools.filter((tool) => !tool.visible || tool.visible(context)) + const visibleTools = tools.filter((tool) => !tool.visible || tool.visible()) // 按类型分组 const coreTools = visibleTools.filter((tool) => tool.type === 'core') diff --git a/src/renderer/src/components/CodeToolbar/types.ts b/src/renderer/src/components/CodeToolbar/types.ts index 83db869371..d1181650fe 100644 --- a/src/renderer/src/components/CodeToolbar/types.ts +++ b/src/renderer/src/components/CodeToolbar/types.ts @@ -20,16 +20,6 @@ export interface CodeToolSpec { export interface CodeTool extends CodeToolSpec { icon: React.ReactNode tooltip: string - visible?: (ctx?: CodeToolContext) => boolean - onClick: (ctx?: CodeToolContext) => void -} - -/** - * 工具上下文接口 - * @param code 代码内容 - * @param language 语言类型 - */ -export interface CodeToolContext { - code: string - language: string + visible?: () => boolean + onClick: () => void } diff --git a/src/renderer/src/components/CodeToolbar/usePreviewTools.tsx b/src/renderer/src/components/CodeToolbar/usePreviewTools.tsx index d1af8f49f2..f5ce914e44 100644 --- a/src/renderer/src/components/CodeToolbar/usePreviewTools.tsx +++ b/src/renderer/src/components/CodeToolbar/usePreviewTools.tsx @@ -5,7 +5,8 @@ import { useTranslation } from 'react-i18next' import { DownloadPngIcon, DownloadSvgIcon } from '../Icons/DownloadIcons' import { TOOL_SPECS } from './constants' -import { useCodeToolbar } from './context' +import { useCodeTool } from './hook' +import { CodeTool } from './types' // 预编译正则表达式用于查询位置 const TRANSFORM_REGEX = /translate\((-?\d+\.?\d*)px,\s*(-?\d+\.?\d*)px\)/ @@ -272,6 +273,7 @@ export const usePreviewToolHandlers = ( } export interface PreviewToolsOptions { + setTools?: (value: React.SetStateAction) => void handleZoom?: (delta: number) => void handleCopyImage?: () => Promise handleDownload?: (format: 'svg' | 'png') => void @@ -280,9 +282,9 @@ export interface PreviewToolsOptions { /** * 提供预览组件通用工具栏功能的自定义Hook */ -export const usePreviewTools = ({ handleZoom, handleCopyImage, handleDownload }: PreviewToolsOptions) => { +export const usePreviewTools = ({ setTools, handleZoom, handleCopyImage, handleDownload }: PreviewToolsOptions) => { const { t } = useTranslation() - const { registerTool, removeTool } = useCodeToolbar() + const { registerTool, removeTool } = useCodeTool(setTools) useEffect(() => { // 根据提供的功能有选择性地注册工具 diff --git a/src/renderer/src/components/ContextMenu/index.tsx b/src/renderer/src/components/ContextMenu/index.tsx index d0eace1dbf..61d51f3701 100644 --- a/src/renderer/src/components/ContextMenu/index.tsx +++ b/src/renderer/src/components/ContextMenu/index.tsx @@ -74,7 +74,7 @@ const ContextMenu: React.FC = ({ children, onContextMenu }) => ] return ( - + {contextMenuPosition && ( = ({ + tooltip, + textToCopy, + label, + color = 'var(--color-text-2)', + hoverColor = 'var(--color-primary)', + size = 14 +}) => { + const { t } = useTranslation() + + const handleCopy = () => { + navigator.clipboard + .writeText(textToCopy) + .then(() => { + window.message?.success(t('message.copy.success')) + }) + .catch(() => { + window.message?.error(t('message.copy.failed')) + }) + } + + const button = ( + + + {label && {label}} + + ) + + if (tooltip) { + return {button} + } + + return button +} + +const ButtonContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; + cursor: pointer; + color: ${(props) => props.$color}; + transition: color 0.2s; + + .copy-icon { + color: ${(props) => props.$color}; + transition: color 0.2s; + } + + &:hover { + color: ${(props) => props.$hoverColor}; + + .copy-icon { + color: ${(props) => props.$hoverColor}; + } + } +` + +const RightText = styled.span<{ size: number }>` + font-size: ${(props) => props.size}px; +` + +export default CopyButton diff --git a/src/renderer/src/components/CustomCollapse.tsx b/src/renderer/src/components/CustomCollapse.tsx index 2828379399..062af0a7ec 100644 --- a/src/renderer/src/components/CustomCollapse.tsx +++ b/src/renderer/src/components/CustomCollapse.tsx @@ -1,6 +1,6 @@ import { Collapse } from 'antd' import { merge } from 'lodash' -import { FC, memo } from 'react' +import { FC, memo, useMemo, useState } from 'react' interface CustomCollapseProps { label: React.ReactNode @@ -28,28 +28,45 @@ const CustomCollapse: FC = ({ style, styles }) => { + const [activeKeys, setActiveKeys] = useState(activeKey || defaultActiveKey) + const defaultCollapseStyle = { width: '100%', background: 'transparent', border: '0.5px solid var(--color-border)' } + const defaultCollpaseHeaderStyle = { + padding: '3px 16px', + alignItems: 'center', + justifyContent: 'space-between', + background: 'var(--color-background-soft)' + } + + const getHeaderStyle = () => { + return activeKeys && activeKeys.length > 0 + ? { + ...defaultCollpaseHeaderStyle, + borderTopLeftRadius: '8px', + borderTopRightRadius: '8px' + } + : { + ...defaultCollpaseHeaderStyle, + borderRadius: '8px' + } + } + const defaultCollapseItemStyles = { - header: { - padding: '3px 16px', - alignItems: 'center', - justifyContent: 'space-between', - background: 'var(--color-background-soft)', - borderTopLeftRadius: '8px', - borderTopRightRadius: '8px' - }, + header: getHeaderStyle(), body: { borderTop: 'none' } } const collapseStyle = merge({}, defaultCollapseStyle, style) - const collapseItemStyles = merge({}, defaultCollapseItemStyles, styles) + const collapseItemStyles = useMemo(() => { + return merge({}, defaultCollapseItemStyles, styles) + }, [activeKeys]) return ( = ({ activeKey={activeKey} destroyInactivePanel={destroyInactivePanel} collapsible={collapsible} + onChange={setActiveKeys} items={[ { styles: collapseItemStyles, diff --git a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx index a2dd31cfab..bd360c8a30 100644 --- a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx +++ b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx @@ -1,4 +1,6 @@ import { + ArrowLeftOutlined, + ArrowRightOutlined, CloseOutlined, CodeOutlined, CopyOutlined, @@ -241,6 +243,22 @@ const MinappPopupContainer: React.FC = () => { dispatch(setMinappsOpenLinkExternal(!minappsOpenLinkExternal)) } + /** navigate back in webview history */ + const handleGoBack = (appid: string) => { + const webview = webviewRefs.current.get(appid) + if (webview && webview.canGoBack()) { + webview.goBack() + } + } + + /** navigate forward in webview history */ + const handleGoForward = (appid: string) => { + const webview = webviewRefs.current.get(appid) + if (webview && webview.canGoForward()) { + webview.goForward() + } + } + /** Title bar of the popup */ const Title = ({ appInfo, url }: { appInfo: AppInfo | null; url: string | null }) => { if (!appInfo) return null @@ -286,6 +304,16 @@ const MinappPopupContainer: React.FC = () => { )} + + + + + + - - - )} - - - - - - + + + + {isLast ? t('settings.miniapps.custom.title') : app.name} + + ) } @@ -254,25 +109,4 @@ const AppTitle = styled.div` white-space: nowrap; ` -const AddButton = styled.div<{ size?: number }>` - width: ${({ size }) => size || 60}px; - height: ${({ size }) => size || 60}px; - border-radius: 12px; - display: flex; - align-items: center; - justify-content: center; - background: var(--color-background-soft); - border: 1px dashed var(--color-border); - color: var(--color-text-soft); - font-size: 24px; - cursor: pointer; - transition: all 0.2s; - - &:hover { - background: var(--color-background); - border-color: var(--color-primary); - color: var(--color-primary); - } -` - export default App diff --git a/src/renderer/src/pages/apps/AppsPage.tsx b/src/renderer/src/pages/apps/AppsPage.tsx index 099649fade..5348ac4367 100644 --- a/src/renderer/src/pages/apps/AppsPage.tsx +++ b/src/renderer/src/pages/apps/AppsPage.tsx @@ -1,14 +1,13 @@ import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' -import { Center } from '@renderer/components/Layout' import { useMinapps } from '@renderer/hooks/useMinapps' import { Input } from 'antd' -import { isEmpty } from 'lodash' import { Search } from 'lucide-react' import React, { FC, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' import App from './App' +import NewAppButton from './NewAppButton' const AppsPage: FC = () => { const { t } = useTranslation() @@ -51,18 +50,12 @@ const AppsPage: FC = () => { - {isEmpty(filteredApps) ? ( -
- -
- ) : ( - - {filteredApps.map((app) => ( - - ))} - - - )} + + {filteredApps.map((app) => ( + + ))} + +
) diff --git a/src/renderer/src/pages/apps/NewAppButton.tsx b/src/renderer/src/pages/apps/NewAppButton.tsx new file mode 100644 index 0000000000..a09f4c86f3 --- /dev/null +++ b/src/renderer/src/pages/apps/NewAppButton.tsx @@ -0,0 +1,197 @@ +import { PlusOutlined, UploadOutlined } from '@ant-design/icons' +import { loadCustomMiniApp, ORIGIN_DEFAULT_MIN_APPS, updateDefaultMinApps } from '@renderer/config/minapps' +import { useMinapps } from '@renderer/hooks/useMinapps' +import { MinAppType } from '@renderer/types' +import { Button, Form, Input, message, Modal, Radio, Upload } from 'antd' +import type { UploadFile } from 'antd/es/upload/interface' +import { FC, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface Props { + size?: number +} + +const NewAppButton: FC = ({ size = 60 }) => { + const { t } = useTranslation() + const [isModalVisible, setIsModalVisible] = useState(false) + const [fileList, setFileList] = useState([]) + const [logoType, setLogoType] = useState<'url' | 'file'>('url') + const [form] = Form.useForm() + const { minapps, updateMinapps } = useMinapps() + + const handleLogoTypeChange = (e: any) => { + setLogoType(e.target.value) + form.setFieldValue('logo', '') + setFileList([]) + } + + const handleAddCustomApp = async (values: any) => { + try { + const content = await window.api.file.read('custom-minapps.json') + const customApps = JSON.parse(content) + + // Check for duplicate ID + if (customApps.some((app: MinAppType) => app.id === values.id)) { + message.error(t('settings.miniapps.custom.duplicate_ids', { ids: values.id })) + return + } + if (ORIGIN_DEFAULT_MIN_APPS.some((app: MinAppType) => app.id === values.id)) { + message.error(t('settings.miniapps.custom.conflicting_ids', { ids: values.id })) + return + } + + const newApp: MinAppType = { + id: values.id, + name: values.name, + url: values.url, + logo: form.getFieldValue('logo') || '', + type: 'Custom', + addTime: new Date().toISOString() + } + customApps.push(newApp) + await window.api.file.writeWithId('custom-minapps.json', JSON.stringify(customApps, null, 2)) + message.success(t('settings.miniapps.custom.save_success')) + setIsModalVisible(false) + form.resetFields() + setFileList([]) + const reloadedApps = [...ORIGIN_DEFAULT_MIN_APPS, ...(await loadCustomMiniApp())] + updateDefaultMinApps(reloadedApps) + updateMinapps([...minapps, newApp]) + } catch (error) { + message.error(t('settings.miniapps.custom.save_error')) + console.error('Failed to save custom mini app:', error) + } + } + + const handleFileChange = async (info: any) => { + const file = info.fileList[info.fileList.length - 1]?.originFileObj + setFileList(info.fileList.slice(-1)) + + if (file) { + try { + const reader = new FileReader() + reader.onload = (event) => { + const base64Data = event.target?.result + if (typeof base64Data === 'string') { + message.success(t('settings.miniapps.custom.logo_upload_success')) + form.setFieldValue('logo', base64Data) + } + } + reader.readAsDataURL(file) + } catch (error) { + console.error('Failed to read file:', error) + message.error(t('settings.miniapps.custom.logo_upload_error')) + } + } + } + + return ( + <> + setIsModalVisible(true)}> + + + + {t('settings.miniapps.custom.title')} + + { + setIsModalVisible(false) + setFileList([]) + }} + footer={null} + transitionName="animation-move-down" + centered> +
+ + + + + + + + + + + + {t('settings.miniapps.custom.logo_url')} + {t('settings.miniapps.custom.logo_file')} + + + {logoType === 'url' ? ( + + + + ) : ( + + false}> + + + + )} + + + +
+
+ + ) +} + +const Container = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + cursor: pointer; +` + +const AddButton = styled.div<{ size?: number }>` + width: ${({ size }) => size || 60}px; + height: ${({ size }) => size || 60}px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-background-soft); + border: 1px dashed var(--color-border); + color: var(--color-text-soft); + font-size: 24px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: var(--color-background); + border-color: var(--color-primary); + color: var(--color-primary); + } +` + +const AppTitle = styled.div` + font-size: 12px; + margin-top: 5px; + color: var(--color-text-soft); + text-align: center; + user-select: none; + white-space: nowrap; +` + +export default NewAppButton diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index 1c82a46b03..c2ee47d43c 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -36,9 +36,9 @@ const Chat: FC = (props) => { const maxWidth = useMemo(() => { const showRightTopics = showTopics && topicPosition === 'right' - const minusAssistantsWidth = showAssistants ? '- var(--assistants-width)' : '' - const minusRightTopicsWidth = showRightTopics ? '- var(--assistants-width)' : '' - return `calc(100vw - var(--sidebar-width) ${minusAssistantsWidth} ${minusRightTopicsWidth} - 5px)` + const minusAssistantsWidth = showAssistants ? `- var(--assistants-width) - var(--scrollbar-width)` : '' + const minusRightTopicsWidth = showRightTopics ? `- var(--assistants-width) - var(--scrollbar-width)` : '' + return `calc(100vw - var(--sidebar-width) ${minusAssistantsWidth} ${minusRightTopicsWidth})` }, [showAssistants, showTopics, topicPosition]) useHotkeys('esc', () => { diff --git a/src/renderer/src/pages/home/Inputbar/GenerateImageButton.tsx b/src/renderer/src/pages/home/Inputbar/GenerateImageButton.tsx index 297ebc97f4..889919b7f5 100644 --- a/src/renderer/src/pages/home/Inputbar/GenerateImageButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/GenerateImageButton.tsx @@ -15,10 +15,6 @@ interface Props { const GenerateImageButton: FC = ({ model, ToolbarButton, assistant, onEnableGenerateImage }) => { const { t } = useTranslation() - if (!isGenerateImageModel(model)) { - return null - } - return ( = ({ assistant: _assistant, setActiveTopic, topic }) = const [tokenCount, setTokenCount] = useState(0) - const quickPhrasesButtonRef = useRef(null) - const mentionModelsButtonRef = useRef(null) - const knowledgeBaseButtonRef = useRef(null) - const mcpToolsButtonRef = useRef(null) - const attachmentButtonRef = useRef(null) - const webSearchButtonRef = useRef(null) - const thinkingButtonRef = useRef(null) + const inputbarToolsRef = useRef(null) // eslint-disable-next-line react-hooks/exhaustive-deps const debouncedEstimate = useCallback( @@ -314,7 +284,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = description: '', icon: , action: () => { - attachmentButtonRef.current?.openQuickPanel() + inputbarToolsRef.current?.openQuickPanel() } }, ...knowledgeBases.map((base) => { @@ -333,92 +303,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = ], symbol: 'file' }) - }, [knowledgeBases, openKnowledgeFileList, quickPanel, t]) - - const quickPanelMenu = useMemo(() => { - return [ - { - label: t('settings.quickPhrase.title'), - description: '', - icon: , - isMenu: true, - action: () => { - quickPhrasesButtonRef.current?.openQuickPanel() - } - }, - { - label: t('agents.edit.model.select.title'), - description: '', - icon: , - isMenu: true, - action: () => { - mentionModelsButtonRef.current?.openQuickPanel() - } - }, - { - label: t('chat.input.knowledge_base'), - description: '', - icon: , - isMenu: true, - disabled: files.length > 0, - action: () => { - knowledgeBaseButtonRef.current?.openQuickPanel() - } - }, - { - label: t('settings.mcp.title'), - description: t('settings.mcp.not_support'), - icon: , - isMenu: true, - action: () => { - mcpToolsButtonRef.current?.openQuickPanel() - } - }, - { - label: `MCP ${t('settings.mcp.tabs.prompts')}`, - description: '', - icon: , - isMenu: true, - action: () => { - mcpToolsButtonRef.current?.openPromptList() - } - }, - { - label: `MCP ${t('settings.mcp.tabs.resources')}`, - description: '', - icon: , - isMenu: true, - action: () => { - mcpToolsButtonRef.current?.openResourcesList() - } - }, - { - label: t('chat.input.web_search'), - description: '', - icon: , - isMenu: true, - action: () => { - webSearchButtonRef.current?.openQuickPanel() - } - }, - { - label: isVisionModel(model) ? t('chat.input.upload') : t('chat.input.upload.document'), - description: '', - icon: , - isMenu: true, - action: openSelectFileMenu - }, - { - label: t('translate.title'), - description: t('translate.menu.description'), - icon: , - action: () => { - if (!text) return - translate() - } - } - ] - }, [files.length, model, openSelectFileMenu, t, text, translate]) + }, [knowledgeBases, openKnowledgeFileList, quickPanel, t, inputbarToolsRef]) const handleKeyDown = (event: React.KeyboardEvent) => { const isEnterPressed = event.keyCode == 13 @@ -566,6 +451,16 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const lastSymbol = newText[cursorPosition - 1] if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '/') { + const quickPanelMenu = + inputbarToolsRef.current?.getQuickPanelMenu({ + t, + files, + model, + text: newText, + openSelectFileMenu, + translate + }) || [] + quickPanel.open({ title: t('settings.quickPanel.title'), list: quickPanelMenu, @@ -574,7 +469,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = } if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '@') { - mentionModelsButtonRef.current?.openQuickPanel() + inputbarToolsRef.current?.openMentionModelsPanel() } } @@ -936,75 +831,30 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = + - - - - - - - {showThinkingButton && ( - - )} - - {showKnowledgeIcon && ( - 0} - /> - )} - - - - - - - - - - - - - {isExpended ? : } - - - = ({ assistant: _assistant, setActiveTopic, topic }) = ToolbarButton={ToolbarButton} onClick={onNewContext} /> - - {loading && ( @@ -1118,7 +966,8 @@ const Toolbar = styled.div` padding: 0 8px; padding-bottom: 0; margin-bottom: 4px; - height: 36px; + height: 30px; + gap: 16px; ` const ToolbarMenu = styled.div` diff --git a/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx b/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx new file mode 100644 index 0000000000..248b182f4d --- /dev/null +++ b/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx @@ -0,0 +1,639 @@ +import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd' +import { QuickPanelListItem } from '@renderer/components/QuickPanel' +import { isGenerateImageModel, isVisionModel } from '@renderer/config/models' +import { useAppDispatch, useAppSelector } from '@renderer/store' +import { setIsCollapsed, setToolOrder } from '@renderer/store/inputTools' +import { Assistant, FileType, KnowledgeBase, Model } from '@renderer/types' +import { classNames } from '@renderer/utils' +import { Divider, Dropdown, Tooltip } from 'antd' +import { ItemType } from 'antd/es/menu/interface' +import { + AtSign, + Check, + CircleChevronRight, + FileSearch, + Globe, + Languages, + LucideSquareTerminal, + Maximize, + MessageSquareDiff, + Minimize, + PaintbrushVertical, + Paperclip, + Zap +} from 'lucide-react' +import { Dispatch, ReactNode, SetStateAction, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import AttachmentButton, { AttachmentButtonRef } from './AttachmentButton' +import GenerateImageButton from './GenerateImageButton' +import { ToolbarButton } from './Inputbar' +import KnowledgeBaseButton, { KnowledgeBaseButtonRef } from './KnowledgeBaseButton' +import MCPToolsButton, { MCPToolsButtonRef } from './MCPToolsButton' +import MentionModelsButton, { MentionModelsButtonRef } from './MentionModelsButton' +import NewContextButton from './NewContextButton' +import QuickPhrasesButton, { QuickPhrasesButtonRef } from './QuickPhrasesButton' +import ThinkingButton, { ThinkingButtonRef } from './ThinkingButton' +import WebSearchButton, { WebSearchButtonRef } from './WebSearchButton' + +export interface InputbarToolsRef { + getQuickPanelMenu: (params: { + t: (key: string, options?: any) => string + files: FileType[] + model: Model + text: string + openSelectFileMenu: () => void + translate: () => void + }) => QuickPanelListItem[] + openMentionModelsPanel: () => void + openQuickPanel: () => void +} + +export interface InputbarToolsProps { + assistant: Assistant + model: Model + + files: FileType[] + setFiles: (files: FileType[]) => void + showThinkingButton: boolean + showKnowledgeIcon: boolean + selectedKnowledgeBases: KnowledgeBase[] + handleKnowledgeBaseSelect: (bases?: KnowledgeBase[]) => void + setText: Dispatch> + resizeTextArea: () => void + mentionModels: Model[] + onMentionModel: (model: Model) => void + onEnableGenerateImage: () => void + isExpended: boolean + onToggleExpended: () => void + + addNewTopic: () => void + clearTopic: () => void + onNewContext: () => void + + newTopicShortcut: string + cleanTopicShortcut: string +} + +interface ToolButtonConfig { + key: string + component: ReactNode + condition?: boolean + visible?: boolean + label?: string + icon?: ReactNode +} + +const DraggablePortal = ({ children, isDragging }) => { + return isDragging ? createPortal(children, document.body) : children +} + +const InputbarTools = ({ + ref, + assistant, + model, + files, + setFiles, + showThinkingButton, + showKnowledgeIcon, + selectedKnowledgeBases, + handleKnowledgeBaseSelect, + setText, + resizeTextArea, + mentionModels, + onMentionModel, + onEnableGenerateImage, + isExpended, + onToggleExpended, + addNewTopic, + clearTopic, + onNewContext, + newTopicShortcut, + cleanTopicShortcut +}: InputbarToolsProps & { ref?: React.RefObject }) => { + const { t } = useTranslation() + const dispatch = useAppDispatch() + + const quickPhrasesButtonRef = useRef(null) + const mentionModelsButtonRef = useRef(null) + const knowledgeBaseButtonRef = useRef(null) + const mcpToolsButtonRef = useRef(null) + const attachmentButtonRef = useRef(null) + const webSearchButtonRef = useRef(null) + const thinkingButtonRef = useRef(null) + + const toolOrder = useAppSelector((state) => state.inputTools.toolOrder) + const isCollapse = useAppSelector((state) => state.inputTools.isCollapsed) + + const [targetTool, setTargetTool] = useState(null) + + const toggleToolVisibility = useCallback( + (toolKey: string, isVisible: boolean | undefined) => { + const newToolOrder = { + visible: [...toolOrder.visible], + hidden: [...toolOrder.hidden] + } + + if (isVisible === true) { + newToolOrder.visible = newToolOrder.visible.filter((key) => key !== toolKey) + newToolOrder.hidden.push(toolKey) + } else { + newToolOrder.hidden = newToolOrder.hidden.filter((key) => key !== toolKey) + newToolOrder.visible.push(toolKey) + } + + dispatch(setToolOrder(newToolOrder)) + setTargetTool(null) + }, + [dispatch, toolOrder.hidden, toolOrder.visible] + ) + + const getQuickPanelMenuImpl = (params: { + t: (key: string, options?: any) => string + files: FileType[] + model: Model + text: string + openSelectFileMenu: () => void + translate: () => void + }): QuickPanelListItem[] => { + const { t, files, model, text, openSelectFileMenu, translate } = params + + return [ + { + label: t('settings.quickPhrase.title'), + description: '', + icon: , + isMenu: true, + action: () => { + quickPhrasesButtonRef.current?.openQuickPanel() + } + }, + { + label: t('agents.edit.model.select.title'), + description: '', + icon: , + isMenu: true, + action: () => { + mentionModelsButtonRef.current?.openQuickPanel() + } + }, + { + label: t('chat.input.knowledge_base'), + description: '', + icon: , + isMenu: true, + disabled: files.length > 0, + action: () => { + knowledgeBaseButtonRef.current?.openQuickPanel() + } + }, + { + label: t('settings.mcp.title'), + description: t('settings.mcp.not_support'), + icon: , + isMenu: true, + action: () => { + mcpToolsButtonRef.current?.openQuickPanel() + } + }, + { + label: `MCP ${t('settings.mcp.tabs.prompts')}`, + description: '', + icon: , + isMenu: true, + action: () => { + mcpToolsButtonRef.current?.openPromptList() + } + }, + { + label: `MCP ${t('settings.mcp.tabs.resources')}`, + description: '', + icon: , + isMenu: true, + action: () => { + mcpToolsButtonRef.current?.openResourcesList() + } + }, + { + label: t('chat.input.web_search'), + description: '', + icon: , + isMenu: true, + action: () => { + webSearchButtonRef.current?.openQuickPanel() + } + }, + { + label: isVisionModel(model) ? t('chat.input.upload') : t('chat.input.upload.document'), + description: '', + icon: , + isMenu: true, + action: openSelectFileMenu + }, + { + label: t('translate.title'), + description: t('translate.menu.description'), + icon: , + action: () => { + if (!text) return + translate() + } + } + ] + } + + const handleDragEnd = (result: DropResult) => { + const { source, destination } = result + + if (!destination) return + + const sourceId = source.droppableId + const destinationId = destination.droppableId + + const newToolOrder = { + visible: [...toolOrder.visible], + hidden: [...toolOrder.hidden] + } + + const sourceArray = sourceId === 'inputbar-tools-visible' ? 'visible' : 'hidden' + const destArray = destinationId === 'inputbar-tools-visible' ? 'visible' : 'hidden' + + if (sourceArray === destArray) { + const items = newToolOrder[sourceArray] + const [removed] = items.splice(source.index, 1) + items.splice(destination.index, 0, removed) + } else { + const removed = newToolOrder[sourceArray][source.index] + newToolOrder[sourceArray].splice(source.index, 1) + newToolOrder[destArray].splice(destination.index, 0, removed) + } + + dispatch(setToolOrder(newToolOrder)) + } + + useImperativeHandle(ref, () => ({ + getQuickPanelMenu: getQuickPanelMenuImpl, + openMentionModelsPanel: () => mentionModelsButtonRef.current?.openQuickPanel(), + openQuickPanel: () => attachmentButtonRef.current?.openQuickPanel() + })) + + const toolButtons = useMemo(() => { + return [ + { + key: 'new_topic', + label: t('chat.input.new_topic', { Command: '' }), + component: ( + + + + + + ) + }, + { + key: 'attachment', + label: t('chat.input.upload'), + component: ( + + ) + }, + { + key: 'thinking', + label: t('chat.input.thinking'), + component: ( + + ), + condition: showThinkingButton + }, + { + key: 'web_search', + label: t('chat.input.web_search'), + component: + }, + { + key: 'knowledge_base', + label: t('chat.input.knowledge_base'), + component: ( + 0} + /> + ), + condition: showKnowledgeIcon + }, + { + key: 'mcp_tools', + label: t('settings.mcp.title'), + component: ( + + ) + }, + { + key: 'generate_image', + label: t('chat.input.generate_image'), + component: ( + + ), + condition: isGenerateImageModel(model) + }, + { + key: 'mention_models', + label: t('agents.edit.model.select.title'), + component: ( + + ) + }, + { + key: 'quick_phrases', + label: t('settings.quickPhrase.title'), + component: ( + + ) + }, + { + key: 'clear_topic', + label: t('chat.input.clear', { Command: '' }), + component: ( + + + + + + ) + }, + { + key: 'toggle_expand', + label: isExpended ? t('chat.input.collapse') : t('chat.input.expand'), + component: ( + + + {isExpended ? : } + + + ) + }, + { + key: 'new_context', + label: t('chat.input.new.context', { Command: '' }), + component: + } + ] + }, [ + addNewTopic, + assistant, + cleanTopicShortcut, + clearTopic, + files, + handleKnowledgeBaseSelect, + isExpended, + mentionModels, + model, + newTopicShortcut, + onEnableGenerateImage, + onMentionModel, + onNewContext, + onToggleExpended, + resizeTextArea, + selectedKnowledgeBases, + setFiles, + setText, + showKnowledgeIcon, + showThinkingButton, + t + ]) + + const visibleTools = useMemo(() => { + return toolOrder.visible.map((v) => ({ + ...toolButtons.find((tool) => tool.key === v), + visible: true + })) as ToolButtonConfig[] + }, [toolButtons, toolOrder]) + + const hiddenTools = useMemo(() => { + return toolOrder.hidden.map((v) => ({ + ...toolButtons.find((tool) => tool.key === v), + visible: false + })) as ToolButtonConfig[] + }, [toolButtons, toolOrder]) + + const showDivider = useMemo(() => { + return ( + hiddenTools.filter((tool) => tool.condition ?? true).length > 0 && + visibleTools.filter((tool) => tool.condition ?? true).length !== 0 + ) + }, [hiddenTools, visibleTools]) + + const showCollapseButton = useMemo(() => { + return hiddenTools.filter((tool) => tool.condition ?? true).length > 0 + }, [hiddenTools]) + + const getMenuItems = useMemo(() => { + const baseItems: ItemType[] = [...visibleTools, ...hiddenTools].map((tool) => ({ + label: tool.label, + key: tool.key, + icon: ( +
+ {tool.visible ? : undefined} +
+ ), + onClick: () => { + toggleToolVisibility(tool.key, tool.visible) + } + })) + + if (targetTool) { + baseItems.push({ + type: 'divider' + }) + baseItems.push({ + label: `${targetTool.visible ? t('chat.input.tools.collapse_in') : t('chat.input.tools.collapse_out')} "${targetTool.label}"`, + key: 'selected_' + targetTool.key, + icon:
, + onClick: () => { + toggleToolVisibility(targetTool.key, targetTool.visible) + } + }) + } + + return baseItems + }, [hiddenTools, t, targetTool, toggleToolVisibility, visibleTools]) + + return ( + + { + const target = e.target as HTMLElement + const isToolButton = target.closest('[data-key]') + if (!isToolButton) { + setTargetTool(null) + } + }}> + + + {(provided) => ( + + {visibleTools.map( + (tool, index) => + (tool.condition ?? true) && ( + + {(provided, snapshot) => ( + + setTargetTool(tool)} + ref={provided.innerRef} + {...provided.draggableProps} + {...provided.dragHandleProps} + style={{ + ...provided.draggableProps.style + }}> + {tool.component} + + + )} + + ) + )} + + {provided.placeholder} + + )} + + + {showDivider && } + + + {(provided) => ( + + {hiddenTools.map( + (tool, index) => + (tool.condition ?? true) && ( + + {(provided, snapshot) => ( + + setTargetTool(tool)} + ref={provided.innerRef} + {...provided.draggableProps} + {...provided.dragHandleProps} + style={{ + ...provided.draggableProps.style, + transitionDelay: `${index * 0.02}s` + }}> + {tool.component} + + + )} + + ) + )} + {provided.placeholder} + + )} + + + + {showCollapseButton && ( + + dispatch(setIsCollapsed(!isCollapse))}> + + + + )} + + + ) +} + +const ToolsContainer = styled.div` + min-width: 0; + display: flex; + align-items: center; + position: relative; +` + +const VisibleTools = styled.div` + height: 30px; + display: flex; + align-items: center; + overflow-x: auto; + &::-webkit-scrollbar { + display: none; + } + -ms-overflow-style: none; + scrollbar-width: none; +` + +const HiddenTools = styled.div` + height: 30px; + display: flex; + align-items: center; + overflow-x: auto; + &::-webkit-scrollbar { + display: none; + } + -ms-overflow-style: none; + scrollbar-width: none; +` + +const ToolWrapper = styled.div` + width: 30px; + margin-right: 6px; + transition: + width 0.2s, + margin-right 0.2s, + opacity 0.2s; + &.is-collapsed { + width: 0px; + margin-right: 0px; + overflow: hidden; + opacity: 0; + } +` + +export default InputbarTools diff --git a/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx b/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx index 46d08184ff..b022377f18 100644 --- a/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx @@ -4,9 +4,8 @@ import { useMCPServers } from '@renderer/hooks/useMCPServers' import { EventEmitter } from '@renderer/services/EventService' import { Assistant, MCPPrompt, MCPResource, MCPServer } from '@renderer/types' import { Form, Input, Tooltip } from 'antd' -import { Plus, SquareTerminal } from 'lucide-react' -import { FC, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' -import React from 'react' +import { CircleX, Plus, SquareTerminal } from 'lucide-react' +import React, { FC, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router' @@ -132,9 +131,6 @@ const MCPToolsButton: FC = ({ ref, setInputValue, resizeTextArea, Toolbar () => activedMcpServers.filter((server) => mcpServers.some((s) => s.id === server.id)), [activedMcpServers, mcpServers] ) - - const buttonEnabled = assistantMcpServers.length > 0 - const handleMcpServerSelect = useCallback( (server: MCPServer) => { if (assistantMcpServers.some((s) => s.id === server.id)) { @@ -156,6 +152,18 @@ const MCPToolsButton: FC = ({ ref, setInputValue, resizeTextArea, Toolbar return () => EventEmitter.off('mcp-server-select', handler) }, []) + const updateMcpEnabled = useCallback( + (enabled: boolean) => { + setTimeout(() => { + updateAssistant({ + ...assistant, + mcpServers: enabled ? assistant.mcpServers || [] : [] + }) + }, 200) + }, + [assistant, updateAssistant] + ) + const menuItems = useMemo(() => { const newList: QuickPanelListItem[] = activedMcpServers.map((server) => ({ label: server.name, @@ -171,8 +179,16 @@ const MCPToolsButton: FC = ({ ref, setInputValue, resizeTextArea, Toolbar action: () => navigate('/settings/mcp') }) + newList.unshift({ + label: t('common.close'), + description: t('settings.mcp.disable.description'), + icon: , + isSelected: !(assistant.mcpServers && assistant.mcpServers.length > 0), + action: () => updateMcpEnabled(false) + }) + return newList - }, [activedMcpServers, t, assistantMcpServers, navigate]) + }, [activedMcpServers, t, assistant.mcpServers, assistantMcpServers, navigate, updateMcpEnabled]) const openQuickPanel = useCallback(() => { quickPanel.open({ @@ -412,10 +428,9 @@ const MCPToolsButton: FC = ({ ref, setInputValue, resizeTextArea, Toolbar }, [activedMcpServers]) const openResourcesList = useCallback(async () => { - const resources = resourcesList quickPanel.open({ title: t('settings.mcp.title'), - list: resources, + list: resourcesList, symbol: 'mcp-resource', multiple: true }) @@ -435,14 +450,13 @@ const MCPToolsButton: FC = ({ ref, setInputValue, resizeTextArea, Toolbar openResourcesList })) - if (activedMcpServers.length === 0) { - return null - } - return ( - + 0 ? 'var(--color-primary)' : 'var(--color-icon)'} + /> ) diff --git a/src/renderer/src/pages/home/Inputbar/NewContextButton.tsx b/src/renderer/src/pages/home/Inputbar/NewContextButton.tsx index 2cf0ba2dab..c8c2b9fced 100644 --- a/src/renderer/src/pages/home/Inputbar/NewContextButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/NewContextButton.tsx @@ -3,7 +3,6 @@ import { Tooltip } from 'antd' import { Eraser } from 'lucide-react' import { FC } from 'react' import { useTranslation } from 'react-i18next' -import styled from 'styled-components' interface Props { onNewContext: () => void @@ -17,20 +16,12 @@ const NewContextButton: FC = ({ onNewContext, ToolbarButton }) => { useShortcut('toggle_new_context', onNewContext) return ( - - - - - - - + + + + + ) } -const Container = styled.div` - @media (max-width: 800px) { - display: none; - } -` - export default NewContextButton diff --git a/src/renderer/src/pages/home/Inputbar/TokenCount.tsx b/src/renderer/src/pages/home/Inputbar/TokenCount.tsx index d73d18b82c..0f556a1d15 100644 --- a/src/renderer/src/pages/home/Inputbar/TokenCount.tsx +++ b/src/renderer/src/pages/home/Inputbar/TokenCount.tsx @@ -62,7 +62,6 @@ const Container = styled.div` z-index: 10; padding: 3px 10px; user-select: none; - border: 0.5px solid var(--color-text-3); border-radius: 20px; display: flex; align-items: center; diff --git a/src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx b/src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx index 68838061cf..906c7aa5aa 100644 --- a/src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx @@ -6,7 +6,7 @@ import WebSearchService from '@renderer/services/WebSearchService' import { Assistant, WebSearchProvider } from '@renderer/types' import { hasObjectKey } from '@renderer/utils' import { Tooltip } from 'antd' -import { Globe, Settings } from 'lucide-react' +import { CircleX, Globe, Settings } from 'lucide-react' import { FC, memo, useCallback, useImperativeHandle, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' @@ -85,9 +85,9 @@ const WebSearchButton: FC = ({ ref, assistant, ToolbarButton }) => { }) items.unshift({ - label: t('chat.input.web_search.no_web_search'), + label: t('common.close'), description: t('chat.input.web_search.no_web_search.description'), - icon: , + icon: , isSelected: !assistant.enableWebSearch && !assistant.webSearchProviderId, action: () => { updateSelectedWebSearchProvider(undefined) diff --git a/src/renderer/src/pages/home/Markdown/CodeBlock.tsx b/src/renderer/src/pages/home/Markdown/CodeBlock.tsx index 2de19fe6c3..5692d50bf7 100644 --- a/src/renderer/src/pages/home/Markdown/CodeBlock.tsx +++ b/src/renderer/src/pages/home/Markdown/CodeBlock.tsx @@ -1,5 +1,4 @@ import CodeBlockView from '@renderer/components/CodeBlockView' -import { CodeToolbarProvider } from '@renderer/components/CodeToolbar' import React, { memo, useCallback } from 'react' interface Props { @@ -24,11 +23,9 @@ const CodeBlock: React.FC = ({ children, className, id, onSave }) => { ) return match ? ( - - - {children} - - + + {children} + ) : ( {children} diff --git a/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx index 25c6223c9d..3e12940c28 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx @@ -163,7 +163,9 @@ const MainTextBlock: React.FC = ({ block, citationBlockId, role, mentions )} {role === 'user' && !renderInputMessageAsMarkdown ? ( -

{block.content}

+

+ {block.content} +

) : ( )} diff --git a/src/renderer/src/pages/home/Messages/MessageEditor.tsx b/src/renderer/src/pages/home/Messages/MessageEditor.tsx index e2810e4aa2..827a078e61 100644 --- a/src/renderer/src/pages/home/Messages/MessageEditor.tsx +++ b/src/renderer/src/pages/home/Messages/MessageEditor.tsx @@ -255,7 +255,7 @@ const MessageBlockEditor: FC = ({ message, onSave, onResend, onCancel })
- {message.role === 'assistant' && ( + {message.role === 'user' && ( handleClick(true)}> diff --git a/src/renderer/src/pages/home/Messages/MessageGroup.tsx b/src/renderer/src/pages/home/Messages/MessageGroup.tsx index c18c5f52b7..be8346ee63 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroup.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroup.tsx @@ -190,16 +190,6 @@ const MessageGroup = ({ messages, topic, hidePresetMessages, registerMessageElem ) - const wrappedMessage = ( - - {messageContent} - - ) - if (isGridGroupMessage) { return ( triggerNode.parentNode as HTMLElement}> - {wrappedMessage} +
{messageContent}
) } - return wrappedMessage + return ( + + {messageContent} + + ) }, [ isGrid, diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index d1f575c094..ed202f9264 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -8,7 +8,7 @@ import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessag import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { getMessageTitle } from '@renderer/services/MessagesService' import { translateText } from '@renderer/services/TranslateService' -import { RootState } from '@renderer/store' +import store, { RootState } from '@renderer/store' import { messageBlocksSelectors } from '@renderer/store/messageBlock' import type { Model } from '@renderer/types' import type { Assistant, Topic } from '@renderer/types' @@ -90,13 +90,24 @@ const MessageMenubar: FC = (props) => { const onCopy = useCallback( (e: React.MouseEvent) => { e.stopPropagation() - navigator.clipboard.writeText(removeTrailingDoubleSpaces(mainTextContent.trimStart())) + + const currentMessageId = message.id // from props + const latestMessageEntity = store.getState().messages.entities[currentMessageId] + + let contentToCopy = '' + if (latestMessageEntity) { + contentToCopy = getMainTextContent(latestMessageEntity as Message) + } else { + contentToCopy = getMainTextContent(message) + } + + navigator.clipboard.writeText(removeTrailingDoubleSpaces(contentToCopy.trimStart())) window.message.success({ content: t('message.copied'), key: 'copy-message' }) setCopied(true) setTimeout(() => setCopied(false), 2000) }, - [mainTextContent, t] + [message, t] // message is needed for message.id and as a fallback. t is for translation. ) const onNewBranch = useCallback(async () => { diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index 8270c192c9..430060ba6d 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -2,11 +2,13 @@ import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring' import Scrollbar from '@renderer/components/Scrollbar' import { LOAD_MORE_COUNT } from '@renderer/config/constant' import { useAssistant } from '@renderer/hooks/useAssistant' +import { useChatContext } from '@renderer/hooks/useChatContext' import { useMessageOperations, useTopicMessages } from '@renderer/hooks/useMessageOperations' import useScrollPosition from '@renderer/hooks/useScrollPosition' import { useSettings } from '@renderer/hooks/useSettings' import { useShortcut } from '@renderer/hooks/useShortcuts' import { autoRenameTopic, getTopic } from '@renderer/hooks/useTopic' +import SelectionBox from '@renderer/pages/home/Messages/SelectionBox' import { getDefaultTopic } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { getContextCount, getGroupedMessages, getUserMessage } from '@renderer/services/MessagesService' @@ -64,7 +66,7 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o const { displayCount, clearTopicMessages, deleteMessage, createTopicBranch } = useMessageOperations(topic) const messagesRef = useRef(messages) - // const { isMultiSelectMode, handleSelectMessage } = useChatContext(topic) + const { isMultiSelectMode, handleSelectMessage } = useChatContext(topic) useEffect(() => { messagesRef.current = messages @@ -86,9 +88,9 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o const maxWidth = useMemo(() => { const showRightTopics = showTopics && topicPosition === 'right' - const minusAssistantsWidth = showAssistants ? '- var(--assistants-width)' : '' - const minusRightTopicsWidth = showRightTopics ? '- var(--assistants-width)' : '' - return `calc(100vw - var(--sidebar-width) ${minusAssistantsWidth} ${minusRightTopicsWidth} - 5px)` + const minusAssistantsWidth = showAssistants ? `- var(--assistants-width) - var(--scrollbar-width)` : '' + const minusRightTopicsWidth = showRightTopics ? `- var(--assistants-width) - var(--scrollbar-width)` : '' + return `calc(100vw - var(--sidebar-width) ${minusAssistantsWidth} ${minusRightTopicsWidth})` }, [showAssistants, showTopics, topicPosition]) const scrollToBottom = useCallback(() => { @@ -313,13 +315,13 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o {messageNavigation === 'anchor' && } {messageNavigation === 'buttons' && } - {/* TODO: 多选功能实现有问题,需要重新改改 */} - {/* */} + /> ) } diff --git a/src/renderer/src/pages/home/Messages/SelectionBox.tsx b/src/renderer/src/pages/home/Messages/SelectionBox.tsx index b8ac6206d7..ab48b69a8e 100644 --- a/src/renderer/src/pages/home/Messages/SelectionBox.tsx +++ b/src/renderer/src/pages/home/Messages/SelectionBox.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import styled from 'styled-components' interface SelectionBoxProps { @@ -18,6 +18,8 @@ const SelectionBox: React.FC = ({ const [dragStart, setDragStart] = useState({ x: 0, y: 0 }) const [dragCurrent, setDragCurrent] = useState({ x: 0, y: 0 }) + const dragSelectedIds = useRef>(new Set()) + useEffect(() => { if (!isMultiSelectMode) return @@ -25,96 +27,90 @@ const SelectionBox: React.FC = ({ const container = scrollContainerRef.current! if (!container) return { x: 0, y: 0 } const rect = container.getBoundingClientRect() - const x = e.clientX - rect.left + container.scrollLeft - const y = e.clientY - rect.top + container.scrollTop - return { x, y } + return { + x: e.clientX - rect.left + container.scrollLeft, + y: e.clientY - rect.top + container.scrollTop + } } const handleMouseDown = (e: MouseEvent) => { if ((e.target as HTMLElement).closest('.ant-checkbox-wrapper')) return if ((e.target as HTMLElement).closest('.MessageFooter')) return + + e.preventDefault() + setIsDragging(true) const pos = updateDragPos(e) setDragStart(pos) setDragCurrent(pos) + dragSelectedIds.current.clear() document.body.classList.add('no-select') } const handleMouseMove = (e: MouseEvent) => { if (!isDragging) return - setDragCurrent(updateDragPos(e)) - const container = scrollContainerRef.current! - if (container) { - const { top, bottom } = container.getBoundingClientRect() - const scrollSpeed = 15 - if (e.clientY < top + 50) { - container.scrollBy(0, -scrollSpeed) - } else if (e.clientY > bottom - 50) { - container.scrollBy(0, scrollSpeed) + + e.preventDefault() + + const pos = updateDragPos(e) + setDragCurrent(pos) + + // 计算当前框选矩形 + const left = Math.min(dragStart.x, pos.x) + const right = Math.max(dragStart.x, pos.x) + const top = Math.min(dragStart.y, pos.y) + const bottom = Math.max(dragStart.y, pos.y) + + // 创建新选中的消息ID集合 + const newSelectedIds = new Set() + + messageElements.forEach((el, id) => { + // 检查消息是否已被选中(不管是拖动选中还是手动选中) + const checkbox = el.querySelector('input[type="checkbox"]') as HTMLInputElement | null + const isAlreadySelected = checkbox?.checked || false + + // 如果已经被记录为拖动选中,跳过 + if (dragSelectedIds.current.has(id)) return + + const rect = el.getBoundingClientRect() + const container = scrollContainerRef.current! + const eTop = rect.top - container.getBoundingClientRect().top + container.scrollTop + const eLeft = rect.left - container.getBoundingClientRect().left + container.scrollLeft + const eBottom = eTop + rect.height + const eRight = eLeft + rect.width + + // 检查消息是否在当前选择框内 + const isInSelectionBox = !(eRight < left || eLeft > right || eBottom < top || eTop > bottom) + + // 只有在选择框内且未被选中的消息才需要处理 + if (isInSelectionBox && !isAlreadySelected) { + handleSelectMessage(id, true) + dragSelectedIds.current.add(id) + newSelectedIds.add(id) + el.classList.add('selection-highlight') + setTimeout(() => el.classList.remove('selection-highlight'), 300) } - } + }) } const handleMouseUp = () => { if (!isDragging) return - - const left = Math.min(dragStart.x, dragCurrent.x) - const right = Math.max(dragStart.x, dragCurrent.x) - const top = Math.min(dragStart.y, dragCurrent.y) - const bottom = Math.max(dragStart.y, dragCurrent.y) - - const MIN_SELECTION_SIZE = 5 - const isValidSelection = - Math.abs(right - left) > MIN_SELECTION_SIZE && Math.abs(bottom - top) > MIN_SELECTION_SIZE - - if (isValidSelection) { - messageElements.forEach((element, messageId) => { - try { - const rect = element.getBoundingClientRect() - const container = scrollContainerRef.current! - - const elementTop = rect.top - container.getBoundingClientRect().top + container.scrollTop - const elementLeft = rect.left - container.getBoundingClientRect().left + container.scrollLeft - const elementBottom = elementTop + rect.height - const elementRight = elementLeft + rect.width - - const isIntersecting = !( - elementRight < left || - elementLeft > right || - elementBottom < top || - elementTop > bottom - ) - - if (isIntersecting) { - handleSelectMessage(messageId, true) - element.classList.add('selection-highlight') - setTimeout(() => element.classList.remove('selection-highlight'), 300) - } - } catch (error) { - console.error('Error calculating element intersection:', error) - } - }) - } setIsDragging(false) document.body.classList.remove('no-select') } const container = scrollContainerRef.current! - if (container) { - container.addEventListener('mousedown', handleMouseDown) - window.addEventListener('mousemove', handleMouseMove) - window.addEventListener('mouseup', handleMouseUp) - } + container?.addEventListener('mousedown', handleMouseDown) + window.addEventListener('mousemove', handleMouseMove) + window.addEventListener('mouseup', handleMouseUp) return () => { - if (container) { - container.removeEventListener('mousedown', handleMouseDown) - window.removeEventListener('mousemove', handleMouseMove) - window.removeEventListener('mouseup', handleMouseUp) - document.body.classList.remove('no-select') - } + container?.removeEventListener('mousedown', handleMouseDown) + window.removeEventListener('mousemove', handleMouseMove) + window.removeEventListener('mouseup', handleMouseUp) + document.body.classList.remove('no-select') } - }, [isMultiSelectMode, isDragging, dragStart, dragCurrent, handleSelectMessage, scrollContainerRef, messageElements]) + }, [isMultiSelectMode, isDragging, dragStart, scrollContainerRef, messageElements, handleSelectMessage]) if (!isDragging || !isMultiSelectMode) return null diff --git a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx index 2df5fb5db8..f53989be0c 100644 --- a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx @@ -54,7 +54,7 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic const { assistants } = useAssistants() const { assistant, removeTopic, moveTopic, updateTopic, updateTopics } = useAssistant(_assistant.id) const { t } = useTranslation() - const { showTopicTime, topicPosition } = useSettings() + const { showTopicTime, topicPosition, pinTopicsToTop } = useSettings() const borderRadius = showTopicTime ? 12 : 'var(--list-item-border-radius)' @@ -380,10 +380,22 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic targetTopic ]) + // Sort topics based on pinned status if pinTopicsToTop is enabled + const sortedTopics = useMemo(() => { + if (pinTopicsToTop) { + return [...assistant.topics].sort((a, b) => { + if (a.pinned && !b.pinned) return -1 + if (!a.pinned && b.pinned) return 1 + return 0 + }) + } + return assistant.topics + }, [assistant.topics, pinTopicsToTop]) + return ( - + {(topic) => { const isActive = topic.id === activeTopic?.id const topicName = topic.name.replace('`', '') diff --git a/src/renderer/src/pages/paintings/Artboard.tsx b/src/renderer/src/pages/paintings/Artboard.tsx index 57de20649c..e5666801b0 100644 --- a/src/renderer/src/pages/paintings/Artboard.tsx +++ b/src/renderer/src/pages/paintings/Artboard.tsx @@ -3,7 +3,7 @@ import FileManager from '@renderer/services/FileManager' import { Painting } from '@renderer/types' import { download } from '@renderer/utils/download' import { Button, Dropdown, Spin } from 'antd' -import { FC } from 'react' +import React, { FC } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -17,6 +17,7 @@ interface ArtboardProps { onNextImage: () => void onCancel: () => void retry?: (painting: Painting) => void + imageCover?: React.ReactNode } const Artboard: FC = ({ @@ -26,7 +27,8 @@ const Artboard: FC = ({ onPrevImage, onNextImage, onCancel, - retry + retry, + imageCover }) => { const { t } = useTranslation() @@ -108,8 +110,10 @@ const Artboard: FC = ({ ) : ( -
{t('paintings.image_placeholder')}
- )} + imageCover ? + imageCover: + (
{t('paintings.image_placeholder')}
) + )} )} {isLoading && ( diff --git a/src/renderer/src/pages/paintings/DmxapiPage.tsx b/src/renderer/src/pages/paintings/DmxapiPage.tsx new file mode 100644 index 0000000000..1f0eacd707 --- /dev/null +++ b/src/renderer/src/pages/paintings/DmxapiPage.tsx @@ -0,0 +1,663 @@ +import { PlusOutlined, RedoOutlined } from '@ant-design/icons' +import DMXAPIToImg from '@renderer/assets/images/providers/DMXAPI-to-img.webp' +import { Navbar, NavbarCenter, NavbarRight } from '@renderer/components/app/Navbar' +import { VStack } from '@renderer/components/Layout' +import Scrollbar from '@renderer/components/Scrollbar' +import { isMac } from '@renderer/config/constant' +import { getProviderLogo } from '@renderer/config/providers' +import { useTheme } from '@renderer/context/ThemeProvider' +import { usePaintings } from '@renderer/hooks/usePaintings' +import { useAllProviders } from '@renderer/hooks/useProvider' +import { useRuntime } from '@renderer/hooks/useRuntime' +import FileManager from '@renderer/services/FileManager' +import { useAppDispatch } from '@renderer/store' +import { setGenerating } from '@renderer/store/runtime' +import type { FileType, PaintingsState } from '@renderer/types' +import { getErrorMessage, uuid } from '@renderer/utils' +import { DmxapiPainting, PaintingAction } from '@types' +import { Avatar, Button, Input, Radio, Select, Tooltip } from 'antd' +import TextArea from 'antd/es/input/TextArea' +import { Info } from 'lucide-react' +import React, { FC } from 'react' +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useLocation, useNavigate } from 'react-router-dom' +import styled from 'styled-components' + +import SendMessageButton from '../home/Inputbar/SendMessageButton' +import { SettingHelpLink, SettingTitle } from '../settings' +import Artboard from './Artboard' +import { + COURSE_URL, + DEFAULT_PAINTING, + IMAGE_SIZES, + STYLE_TYPE_OPTIONS, + TEXT_TO_IMAGES_MODELS +} from './config/DmxapiConfig' +import PaintingsList from './PaintingsList' + +const generateRandomSeed = () => Math.floor(Math.random() * 1000000).toString() + +const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { + const [mode] = useState('DMXAPIPaintings') + const { DMXAPIPaintings, addPainting, removePainting, updatePainting } = usePaintings() + const [painting, setPainting] = useState(DMXAPIPaintings?.[0] || DEFAULT_PAINTING) + const { theme } = useTheme() + const { t } = useTranslation() + const providers = useAllProviders() + const providerOptions = Options.map((option) => { + const provider = providers.find((p) => p.id === option) + return { + label: t(`provider.${provider?.id}`), + value: provider?.id + } + }) + + const dmxapiProvider = providers.find((p) => p.id === 'dmxapi')! + + const [currentImageIndex, setCurrentImageIndex] = useState(0) + const [isLoading, setIsLoading] = useState(false) + const [abortController, setAbortController] = useState(null) + const dispatch = useAppDispatch() + const { generating } = useRuntime() + const navigate = useNavigate() + const location = useLocation() + + const getNewPainting = () => { + return { + ...DEFAULT_PAINTING, + id: uuid(), + seed: generateRandomSeed() + } + } + + const modelOptions = TEXT_TO_IMAGES_MODELS.map((model) => ({ + label: model.name, + value: model.id + })) + + const textareaRef = useRef(null) + + const updatePaintingState = (updates: Partial) => { + const updatedPainting = { ...painting, ...updates } + setPainting(updatedPainting) + updatePainting('DMXAPIPaintings', updatedPainting) + } + + const onSelectModel = (modelId: string) => { + const model = TEXT_TO_IMAGES_MODELS.find((m) => m.id === modelId) + if (model) { + updatePaintingState({ model: modelId }) + } + } + + const onCancel = () => { + abortController?.abort() + } + + const onSelectImageSize = (v: string) => { + const size = IMAGE_SIZES.find((i) => i.value === v) + size && updatePaintingState({ image_size: size.value, aspect_ratio: size.label }) + } + + const onSelectStyleType = (v: string) => { + if (v === painting.style_type) { + updatePaintingState({ style_type: '' }) + } else { + updatePaintingState({ style_type: v }) + } + } + + const onInputSeed = (e: React.ChangeEvent) => { + const value = e.target.value + // 允许空值或合法整数,且大于等于 -1 + if (value === '' || value === '-' || /^-?\d+$/.test(value)) { + const numValue = parseInt(value, 10) + + if (numValue >= -1 || value === '' || value === '-') { + updatePaintingState({ seed: value }) + } + } + } + + // 检查提供者状态函数 + const checkProviderStatus = () => { + if (!dmxapiProvider.enabled) { + throw new Error('error.provider_disabled') + } + + if (!dmxapiProvider.apiKey) { + throw new Error('error.no_api_key') + } + + if (!painting.model) { + throw new Error('error.missing_required_fields') + } + + if (!painting.prompt) { + throw new Error('paintings.text_desc_required') + } + } + + // 准备V1生成请求函数 + const prepareV1GenerateRequest = (prompt: string, painting: DmxapiPainting) => { + const params = { + prompt, + model: painting.model, + n: painting.n + } + + if (painting.aspect_ratio) { + params['aspect_ratio'] = painting.aspect_ratio + } + + if (painting.image_size) { + params['size'] = painting.image_size + } + + if (painting.seed) { + if (Number(painting.seed) >= -1) { + params['seed'] = Number(painting.seed) + } else { + params['seed'] = -1 + } + } + + if (painting.style_type) { + params.prompt = prompt + ',风格:' + painting.style_type + } + + return { + body: JSON.stringify(params), + endpoint: `${dmxapiProvider.apiHost}/v1/images/generations` + } + } + + // API请求函数 + const callApi = async (requestConfig: { endpoint: string; body: any }, controller: AbortController) => { + const { endpoint, body } = requestConfig + const headers = {} + + // 如果是JSON数据,添加Content-Type头 + if (typeof body === 'string') { + headers['Content-Type'] = 'application/json' + headers['Authorization'] = `Bearer ${dmxapiProvider.apiKey}` + headers['User-Agent'] = 'DMXAPI/1.0.0 (https://www.dmxapi.com)' + headers['Accept'] = 'application/json' + } + + const response = await fetch(endpoint, { + method: 'POST', + headers, + body, + signal: controller.signal + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error?.message || '操作失败') + } + + const data = await response.json() + return data.data.map((item: { url: string }) => item.url) + } + + // 下载图像函数 + const downloadImages = async (urls: string[]) => { + return Promise.all( + urls.map(async (url) => { + try { + if (!url || url.trim() === '') { + window.message.warning({ + content: t('message.empty_url'), + key: 'empty-url-warning' + }) + return null + } + return await window.api.file.download(url, true) + } catch (error) { + if ( + error instanceof Error && + (error.message.includes('Failed to parse URL') || error.message.includes('Invalid URL')) + ) { + window.message.warning({ + content: t('message.empty_url'), + key: 'empty-url-warning' + }) + } + return null + } + }) + ) + } + + // 准备请求配置函数 + const prepareRequestConfig = (prompt: string, painting: PaintingAction) => { + // 根据模式和模型版本返回不同的请求配置 + return prepareV1GenerateRequest(prompt, painting) + } + + const onGenerate = async () => { + // 如果已经在生成过程中,直接返回 + if (isLoading) { + return + } + try { + // 获取提示词 + const prompt = textareaRef.current?.resizableTextArea?.textArea?.value || '' + updatePaintingState({ prompt }) + + // 检查提供者状态 + checkProviderStatus() + + // 处理已有文件 + if (painting.files.length > 0) { + const confirmed = await window.modal.confirm({ + content: t('paintings.regenerate.confirm'), + centered: true + }) + if (!confirmed) return + } + + setIsLoading(true) + + // 设置请求状态 + const controller = new AbortController() + setAbortController(controller) + dispatch(setGenerating(true)) + + // 准备请求配置 + const requestConfig = prepareRequestConfig(prompt, painting) + + // 发送API请求 + const urls = await callApi(requestConfig, controller) + + // 下载图像 + if (urls.length > 0) { + const downloadedFiles = await downloadImages(urls) + const validFiles = downloadedFiles.filter((file): file is FileType => file !== null) + + // 删除之前的图片 + await FileManager.deleteFiles(painting.files) + // 保存文件并更新状态 + await FileManager.addFiles(validFiles) + updatePaintingState({ files: validFiles, urls }) + } + } catch (error) { + // 错误处理 + if (error instanceof Error && error.name !== 'AbortError') { + window.modal.error({ + content: + error.message.startsWith('paintings.') || error.message.startsWith('error.') + ? t(error.message) + : getErrorMessage(error), + centered: true + }) + } + } finally { + // 清理状态 + setIsLoading(false) + dispatch(setGenerating(false)) + setAbortController(null) + } + } + + const nextImage = () => { + setCurrentImageIndex((prev) => (prev + 1) % painting.files.length) + } + + const prevImage = () => { + setCurrentImageIndex((prev) => (prev - 1 + painting.files.length) % painting.files.length) + } + + const onDeletePainting = (paintingToDelete: DmxapiPainting) => { + if (paintingToDelete.id === painting.id) { + const currentIndex = DMXAPIPaintings.findIndex((p) => p.id === paintingToDelete.id) + + if (currentIndex > 0) { + setPainting(DMXAPIPaintings[currentIndex - 1]) + } else if (DMXAPIPaintings.length > 1) { + setPainting(DMXAPIPaintings[1]) + } + } + + removePainting(mode, paintingToDelete).then(() => {}) + } + + const onSelectPainting = (newPainting: DmxapiPainting) => { + if (generating) return + setPainting(newPainting) + setCurrentImageIndex(0) + } + + const spaceClickTimer = useRef(null) + + const handleProviderChange = (providerId: string) => { + const routeName = location.pathname.split('/').pop() + if (providerId !== routeName) { + navigate('../' + providerId, { replace: true }) + } + } + + useEffect(() => { + if (!DMXAPIPaintings || DMXAPIPaintings.length === 0) { + const newPainting = getNewPainting() + addPainting('DMXAPIPaintings', newPainting) + setPainting(newPainting) + } + + return () => { + if (spaceClickTimer.current) { + clearTimeout(spaceClickTimer.current) + } + } + }, [DMXAPIPaintings, DMXAPIPaintings.length, addPainting, mode]) + + return ( + + + {t('paintings.title')} + {isMac && ( + + + + )} + + + + + {t('common.provider')} + + {t('paintings.paint_course')} + + + + + {t('common.model')} + onInputSeed(e)} + suffix={ + updatePaintingState({ seed: Math.floor(Math.random() * 1000000).toString() })} + style={{ cursor: 'pointer', color: 'var(--color-text-2)' }} + /> + } + /> + + {t('paintings.style_type')} + + + {STYLE_TYPE_OPTIONS.map((ele) => ( + onSelectStyleType(ele.label)}> + {ele.label} + + ))} + + + + + 0 || DMXAPIPaintings?.length > 1 ? null : ( + + + + ) + } + /> + +