Merge branch 'main' of https://github.com/CherryHQ/cherry-studio into wip/refactor/databases

This commit is contained in:
fullex 2025-05-26 22:52:23 +08:00
commit 610e7481b3
178 changed files with 10978 additions and 2140 deletions

86
.github/dependabot.yml vendored Normal file
View File

@ -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"

7
.gitignore vendored
View File

@ -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

View File

@ -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.

View File

@ -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 キー管理と再配布に利用可能。

View File

@ -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 接口,可用于密钥管理与二次分发。

View File

@ -37,6 +37,14 @@ yarn install
yarn dev
```
### Debug
```bash
yarn debug
```
Then input chrome://inspect in browser
### Test
```bash

View File

@ -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% 问题
修复拖拽多选消息相关问题
修复翻译回复内容导致内存异常问题
常规错误修复和优化

View File

@ -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')
}
}
}

View File

@ -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",

View File

@ -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'
}

42
playwright.config.ts Normal file
View File

@ -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,
// },
})

View File

@ -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',

View File

@ -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 }
})

View File

@ -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 () => {

View File

@ -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()
}

View File

@ -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<boolean>(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<string>(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<boolean>(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)
}

View File

@ -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
}
}

View File

@ -386,7 +386,11 @@ class FileStorage {
}
}
public downloadFile = async (_: Electron.IpcMainInvokeEvent, url: string): Promise<FileType> => {
public downloadFile = async (
_: Electron.IpcMainInvokeEvent,
url: string,
isUseContentType?: boolean
): Promise<FileType> => {
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

View File

@ -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) {

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -85,7 +85,7 @@ function getLoginShellEnvironment(): Promise<Record<string, string>> {
Logger.warn(`Shell process stderr output (even with exit code 0):\n${errorOutput.trim()}`)
}
const env = {}
const env: Record<string, string> = {}
const lines = output.split('\n')
lines.forEach((line) => {
@ -110,6 +110,8 @@ function getLoginShellEnvironment(): Promise<Record<string, string>> {
Logger.warn('Raw output from shell:\n', output)
}
env.PATH = env.Path || env.PATH || ''
resolve(env)
})
})

View File

@ -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
}
}

View File

@ -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()
})
})

View File

@ -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/')
})
})
})

View File

@ -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()
})
})
})

View File

@ -9,10 +9,10 @@ const gunzipPromise = util.promisify(zlib.gunzip)
/**
*
* @param {string} str JSON
* @returns {Promise<Buffer>} Buffer
* @param str
*/
export async function compress(str) {
export async function compress(str: string): Promise<Buffer> {
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<string>} JSON
*/
export async function decompress(compressedBuffer) {
export async function decompress(compressedBuffer: Buffer): Promise<string> {
try {
const buffer = await gunzipPromise(compressedBuffer)
return buffer.toString('utf-8')

View File

@ -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)
}
}

View File

@ -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
})

View File

@ -0,0 +1,41 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="initial-scale=1, width=device-width" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
<title>Cherry Studio Selection Assistant</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/windows/selection/action/entryPoint.tsx"></script>
<style>
html {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 100vw;
height: 100vh;
margin: 0;
padding: 0;
box-sizing: border-box;
}
#root {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
}
</style>
</body>
</html>

View File

@ -0,0 +1,43 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="initial-scale=1, width=device-width" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
<title>Cherry Studio Selection Toolbar</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/windows/selection/toolbar/entryPoint.tsx"></script>
<style>
html {
margin: 0;
}
body {
margin: 0;
padding: 0;
overflow: hidden;
width: 100vw;
height: 100vh;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
#root {
margin: 0;
padding: 0;
width: max-content !important;
height: fit-content !important;
}
</style>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -60,6 +60,7 @@
--assistants-width: 275px;
--topic-list-width: 275px;
--settings-width: 250px;
--scrollbar-width: 5px;
--chat-background: #111111;
--chat-background-user: #28b561;

View File

@ -4,3 +4,9 @@
border-top-left-radius: 10px;
border-left: 0.5px solid var(--color-border);
}
.group-container {
.context-menu-container {
width: 100%;
}
}

View File

@ -321,6 +321,7 @@ mjx-container {
.cm-lineWrapping * {
word-wrap: break-word;
white-space: pre-wrap;
}
}
}

View File

@ -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 {

View File

@ -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);
}

View File

@ -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<CodeTool[]>) => 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 ? (
<div className="fade-in-effect">
<ShikiTokensRenderer language={language} tokenLines={tokenLines} />
</div>
<ShikiTokensRenderer language={language} tokenLines={tokenLines} />
) : (
<CodePlaceholder>{children}</CodePlaceholder>
)}
@ -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;
`

View File

@ -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<CodeTool[]>) => void
}
const MermaidPreview: React.FC<Props> = ({ children }) => {
const MermaidPreview: React.FC<Props> = ({ children, setTools }) => {
const { mermaid, isLoading, error: mermaidError } = useMermaid()
const mermaidRef = useRef<HTMLDivElement>(null)
const [error, setError] = useState<string | null>(null)
@ -25,6 +26,7 @@ const MermaidPreview: React.FC<Props> = ({ children }) => {
// 使用工具栏
usePreviewTools({
setTools,
handleZoom,
handleCopyImage,
handleDownload

View File

@ -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<PlantUMLServerImageProps> = ({ format, diagr
interface PlantUMLProps {
children: string
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
}
const PlantUmlPreview: React.FC<PlantUMLProps> = ({ children }) => {
const PlantUmlPreview: React.FC<PlantUMLProps> = ({ children, setTools }) => {
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement>(null)
@ -165,6 +166,7 @@ const PlantUmlPreview: React.FC<PlantUMLProps> = ({ children }) => {
// 使用工具栏
usePreviewTools({
setTools,
handleZoom,
handleCopyImage,
handleDownload: customDownload

View File

@ -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<CodeTool[]>) => void
}
const SvgPreview: React.FC<Props> = ({ children }) => {
const SvgPreview: React.FC<Props> = ({ children, setTools }) => {
const svgContainerRef = useRef<HTMLDivElement>(null)
// 使用通用图像工具
@ -17,6 +18,7 @@ const SvgPreview: React.FC<Props> = ({ children }) => {
// 使用工具栏
usePreviewTools({
setTools,
handleCopyImage,
handleDownload
})

View File

@ -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<Props> = ({ children, language, onSave }) => {
const [isRunning, setIsRunning] = useState(false)
const [output, setOutput] = useState('')
const [tools, setTools] = useState<CodeTool[]>([])
const { registerTool, removeTool } = useCodeTool(setTools)
const isExecutable = useMemo(() => {
return codeExecution.enabled && language === 'python'
}, [codeExecution.enabled, language])
@ -59,33 +62,17 @@ const CodeBlockView: React.FC<Props> = ({ 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('</html>')) {
const title = extractTitle(code)
if (language === 'html' && children.includes('</html>')) {
const title = extractTitle(children)
if (title) {
fileName = `${title}.html`
}
@ -96,31 +83,26 @@ const CodeBlockView: React.FC<Props> = ({ 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<Props> = ({ children, language, onSave }) => {
...TOOL_SPECS.run,
icon: isRunning ? <LoadingOutlined /> : <CirclePlay className="icon" />,
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<Props> = ({ children, language, onSave }) => {
// 源代码视图组件
const sourceView = useMemo(() => {
if (codeEditor.enabled) {
return <CodeEditor value={children} language={language} onSave={onSave} options={{ stream: true }} />
return (
<CodeEditor
value={children}
language={language}
onSave={onSave}
options={{ stream: true }}
setTools={setTools}
/>
)
} else {
return <CodePreview language={language}>{children}</CodePreview>
return (
<CodePreview language={language} setTools={setTools}>
{children}
</CodePreview>
)
}
}, [children, codeEditor.enabled, language, onSave])
}, [children, codeEditor.enabled, language, onSave, setTools])
// 特殊视图组件映射
const specialView = useMemo(() => {
if (language === 'mermaid') {
return <MermaidPreview>{children}</MermaidPreview>
return <MermaidPreview setTools={setTools}>{children}</MermaidPreview>
} else if (language === 'plantuml' && isValidPlantUML(children)) {
return <PlantUmlPreview>{children}</PlantUmlPreview>
return <PlantUmlPreview setTools={setTools}>{children}</PlantUmlPreview>
} else if (language === 'svg') {
return <SvgPreview>{children}</SvgPreview>
return <SvgPreview setTools={setTools}>{children}</SvgPreview>
}
return null
}, [children, language])
@ -246,7 +240,7 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
return (
<CodeBlockWrapper className="code-block" $isInSpecialView={isInSpecialView}>
{renderHeader}
<CodeToolbar />
<CodeToolbar tools={tools} />
{renderContent}
{renderArtifacts}
{isExecutable && output && <StatusBar>{output}</StatusBar>}
@ -255,10 +249,10 @@ const CodeBlockView: React.FC<Props> = ({ 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')};
`

View File

@ -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<CodeTool[]>) => 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(() => {

View File

@ -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<CodeToolContext>) => void
}
const defaultCodeToolbarContext: CodeToolbarContextType = {
tools: [],
context: defaultContext,
registerTool: () => {},
removeTool: () => {},
updateContext: () => {}
}
const CodeToolbarContext = createContext<CodeToolbarContextType>(defaultCodeToolbarContext)
export const CodeToolbarProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [tools, setTools] = useState<CodeTool[]>([])
const [context, setContext] = useState<CodeToolContext>(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<CodeToolContext>) => {
setContext((prev) => ({ ...prev, ...newContext }))
}, [])
const value: CodeToolbarContextType = useMemo(
() => ({
tools,
context,
registerTool,
removeTool,
updateContext
}),
[tools, context, registerTool, removeTool, updateContext]
)
return <CodeToolbarContext value={value}>{children}</CodeToolbarContext>
}
export const useCodeToolbar = () => {
const context = use(CodeToolbarContext)
if (!context) {
throw new Error('useCodeToolbar must be used within a CodeToolbarProvider')
}
return context
}

View File

@ -0,0 +1,26 @@
import { useCallback } from 'react'
import { CodeTool } from './types'
export const useCodeTool = (setTools?: (value: React.SetStateAction<CodeTool[]>) => 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 }
}

View File

@ -1,5 +1,5 @@
export * from './constants'
export * from './context'
export * from './hook'
export * from './toolbar'
export * from './types'
export * from './usePreviewTools'

View File

@ -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<CodeToolButtonProps> = memo(({ tool }) => {
const { context } = useCodeToolbar()
return (
<Tooltip key={`${tool.id}-${tool.tooltip}`} title={tool.tooltip} mouseEnterDelay={0.5}>
<ToolWrapper onClick={() => tool.onClick(context)}>{tool.icon}</ToolWrapper>
<Tooltip key={tool.id} title={tool.tooltip} mouseEnterDelay={0.5}>
<ToolWrapper onClick={() => tool.onClick()}>{tool.icon}</ToolWrapper>
</Tooltip>
)
})
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')

View File

@ -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
}

View File

@ -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<CodeTool[]>) => void
handleZoom?: (delta: number) => void
handleCopyImage?: () => Promise<void>
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(() => {
// 根据提供的功能有选择性地注册工具

View File

@ -74,7 +74,7 @@ const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu }) =>
]
return (
<ContextContainer onContextMenu={handleContextMenu}>
<ContextContainer onContextMenu={handleContextMenu} className="context-menu-container">
{contextMenuPosition && (
<Dropdown
overlayStyle={{ position: 'fixed', left: contextMenuPosition.x, top: contextMenuPosition.y, zIndex: 1000 }}

View File

@ -0,0 +1,83 @@
import { Tooltip } from 'antd'
import { Copy } from 'lucide-react'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface CopyButtonProps {
tooltip?: string
textToCopy: string
label?: string
color?: string
hoverColor?: string
size?: number
}
interface ButtonContainerProps {
$color: string
$hoverColor: string
}
const CopyButton: FC<CopyButtonProps> = ({
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 = (
<ButtonContainer $color={color} $hoverColor={hoverColor} onClick={handleCopy}>
<Copy size={size} className="copy-icon" />
{label && <RightText size={size}>{label}</RightText>}
</ButtonContainer>
)
if (tooltip) {
return <Tooltip title={tooltip}>{button}</Tooltip>
}
return button
}
const ButtonContainer = styled.div<ButtonContainerProps>`
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

View File

@ -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<CustomCollapseProps> = ({
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 (
<Collapse
@ -59,6 +76,7 @@ const CustomCollapse: FC<CustomCollapseProps> = ({
activeKey={activeKey}
destroyInactivePanel={destroyInactivePanel}
collapsible={collapsible}
onChange={setActiveKeys}
items={[
{
styles: collapseItemStyles,

View File

@ -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 = () => {
)}
<Spacer />
<ButtonsGroup className={isWindows || isLinux ? 'windows' : ''}>
<Tooltip title={t('minapp.popup.goBack')} mouseEnterDelay={0.8} placement="bottom">
<Button onClick={() => handleGoBack(appInfo.id)}>
<ArrowLeftOutlined />
</Button>
</Tooltip>
<Tooltip title={t('minapp.popup.goForward')} mouseEnterDelay={0.8} placement="bottom">
<Button onClick={() => handleGoForward(appInfo.id)}>
<ArrowRightOutlined />
</Button>
</Tooltip>
<Tooltip title={t('minapp.popup.refresh')} mouseEnterDelay={0.8} placement="bottom">
<Button onClick={() => handleReload(appInfo.id)}>
<ReloadOutlined />

View File

@ -1,5 +1,5 @@
import { Provider } from '@renderer/types'
import { oauthWithAihubmix, oauthWithSiliconFlow } from '@renderer/utils/oauth'
import { oauthWithAihubmix, oauthWithSiliconFlow, oauthWithTokenFlux } from '@renderer/utils/oauth'
import { Button, ButtonProps } from 'antd'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
@ -27,6 +27,10 @@ const OAuthButton: FC<Props> = ({ provider, onSuccess, ...buttonProps }) => {
if (provider.id === 'aihubmix') {
oauthWithAihubmix(handleSuccess)
}
if (provider.id === 'tokenflux') {
oauthWithTokenFlux()
}
}
return (

View File

@ -5,8 +5,6 @@ import { FC, useEffect, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import styled from 'styled-components'
import Scrollbar from '../Scrollbar'
interface Props {
children: React.ReactNode
activeAssistant: Assistant
@ -55,7 +53,8 @@ const FloatingSidebar: FC<Props> = ({
forceToSeeAllTab={true}
style={{
background: 'transparent',
border: 'none'
border: 'none',
maxHeight: maxHeight
}}
/>
</PopoverContent>
@ -81,9 +80,8 @@ const FloatingSidebar: FC<Props> = ({
)
}
const PopoverContent = styled(Scrollbar)<{ maxHeight: number }>`
const PopoverContent = styled.div<{ maxHeight: number }>`
max-height: ${(props) => props.maxHeight}px;
overflow-y: auto;
`
export default FloatingSidebar

View File

@ -51,7 +51,6 @@ const MinAppsPopover: FC<Props> = ({ children }) => {
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Center>
)}
<App isLast app={minapps[0]} onClick={handleClose} size={50} />
</AppsContainer>
</PopoverContent>
)

View File

@ -86,7 +86,12 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
return true
}
const pattern = lowerSearchText.split('').join('.*')
const pattern = lowerSearchText
.split('')
.map((char) => {
return char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
})
.join('.*')
if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(filterText)) {
try {
const pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase()
@ -429,7 +434,8 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
$pageSize={ctx.pageSize}
$selectedColor={selectedColor}
$selectedColorHover={selectedColorHover}
className={ctx.isVisible ? 'visible' : ''}>
className={ctx.isVisible ? 'visible' : ''}
data-testid="quick-panel">
<QuickPanelBody
ref={bodyRef}
onMouseMove={() =>

View File

@ -3,12 +3,12 @@ import { FC, useCallback, useEffect, useRef, useState } from 'react'
import styled from 'styled-components'
interface Props extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onScroll'> {
right?: boolean
ref?: React.RefObject<HTMLDivElement | null>
right?: boolean
onScroll?: () => void // Custom onScroll prop for useScrollPosition's handleScroll
}
const Scrollbar: FC<Props> = ({ ref: passedRef, children, onScroll: externalOnScroll, ...htmlProps }) => {
const Scrollbar: FC<Props> = ({ ref: passedRef, right, children, onScroll: externalOnScroll, ...htmlProps }) => {
const [isScrolling, setIsScrolling] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
@ -43,7 +43,8 @@ const Scrollbar: FC<Props> = ({ ref: passedRef, children, onScroll: externalOnSc
return (
<Container
{...htmlProps} // Pass other HTML attributes
isScrolling={isScrolling}
$isScrolling={isScrolling}
$right={right}
onScroll={combinedOnScroll} // Use the combined handler
ref={passedRef}>
{children}
@ -51,15 +52,15 @@ const Scrollbar: FC<Props> = ({ ref: passedRef, children, onScroll: externalOnSc
)
}
const Container = styled.div<{ isScrolling: boolean; right?: boolean }>`
const Container = styled.div<{ $isScrolling: boolean; $right?: boolean }>`
overflow-y: auto;
&::-webkit-scrollbar-thumb {
transition: background 2s ease;
background: ${(props) =>
props.isScrolling ? `var(--color-scrollbar-thumb${props.right ? '-right' : ''})` : 'transparent'};
props.$isScrolling ? `var(--color-scrollbar-thumb${props.$right ? '-right' : ''})` : 'transparent'};
&:hover {
background: ${(props) =>
props.isScrolling ? `var(--color-scrollbar-thumb${props.right ? '-right' : ''}-hover)` : 'transparent'};
props.$isScrolling ? `var(--color-scrollbar-thumb${props.$right ? '-right' : ''}-hover)` : 'transparent'};
}
}
`

View File

@ -0,0 +1,44 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import CustomTag from '../CustomTag'
const COLOR = '#ff0000'
describe('CustomTag', () => {
it('should render children text', () => {
render(<CustomTag color={COLOR}>content</CustomTag>)
expect(screen.getByText('content')).toBeInTheDocument()
})
it('should render icon if provided', () => {
render(
<CustomTag color={COLOR} icon={<span data-testid="icon">cherry</span>}>
content
</CustomTag>
)
expect(screen.getByTestId('icon')).toBeInTheDocument()
expect(screen.getByText('content')).toBeInTheDocument()
})
it('should show tooltip if tooltip prop is set', async () => {
render(
<CustomTag color={COLOR} tooltip="reasoning model">
reasoning
</CustomTag>
)
// 鼠标悬停触发 Tooltip
await userEvent.hover(screen.getByText('reasoning'))
expect(await screen.findByText('reasoning model')).toBeInTheDocument()
})
it('should not render Tooltip when tooltip is not set', () => {
render(<CustomTag color="#ff0000">no tooltip</CustomTag>)
expect(screen.getByText('no tooltip')).toBeInTheDocument()
// 不应有 tooltip 相关内容
expect(document.querySelector('.ant-tooltip')).toBeNull()
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,282 @@
/// <reference types="@vitest/browser/context" />
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import DragableList from '../DragableList'
// mock @hello-pangea/dnd 组件
vi.mock('@hello-pangea/dnd', () => {
return {
__esModule: true,
DragDropContext: ({ children, onDragEnd }: any) => {
// 挂载到 window 以便测试用例直接调用
window.triggerOnDragEnd = (result = { source: { index: 0 }, destination: { index: 1 } }, provided = {}) => {
onDragEnd && onDragEnd(result, provided)
}
return <div data-testid="drag-drop-context">{children}</div>
},
Droppable: ({ children }: any) => (
<div data-testid="droppable">
{children({ droppableProps: {}, innerRef: () => {}, placeholder: <div data-testid="placeholder" /> })}
</div>
),
Draggable: ({ children, draggableId, index }: any) => (
<div data-testid={`draggable-${draggableId}-${index}`}>
{children({ draggableProps: {}, dragHandleProps: {}, innerRef: () => {} })}
</div>
)
}
})
// mock VirtualList 只做简单渲染
vi.mock('rc-virtual-list', () => ({
__esModule: true,
default: ({ data, itemKey, children }: any) => (
<div data-testid="virtual-list">
{data.map((item: any, idx: number) => (
<div key={item[itemKey] || item} data-testid="virtual-list-item">
{children(item, idx)}
</div>
))}
</div>
)
}))
declare global {
interface Window {
triggerOnDragEnd: (result?: any, provided?: any) => void
}
}
describe('DragableList', () => {
describe('rendering', () => {
it('should render all list items', () => {
const list = [
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' },
{ id: 'c', name: 'C' }
]
render(
<DragableList list={list} onUpdate={() => {}}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
const items = screen.getAllByTestId('item')
expect(items.length).toBe(3)
expect(items[0].textContent).toBe('A')
expect(items[1].textContent).toBe('B')
expect(items[2].textContent).toBe('C')
})
it('should render with custom style and listStyle', () => {
const list = [{ id: 'a', name: 'A' }]
const style = { background: 'red' }
const listStyle = { color: 'blue' }
render(
<DragableList list={list} style={style} listStyle={listStyle} onUpdate={() => {}}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
// 检查 style 是否传递到外层容器
const virtualList = screen.getByTestId('virtual-list')
expect(virtualList.parentElement).toHaveStyle({ background: 'red' })
})
it('should render nothing when list is empty', () => {
render(
<DragableList list={[]} onUpdate={() => {}}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
// 虚拟列表存在但无内容
const items = screen.queryAllByTestId('item')
expect(items.length).toBe(0)
})
})
describe('drag and drop', () => {
it('should call onUpdate with new order after drag end', () => {
const list = [
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' },
{ id: 'c', name: 'C' }
]
const newOrder = [list[1], list[2], list[0]]
const onUpdate = vi.fn()
render(
<DragableList list={list} onUpdate={onUpdate}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
// 直接调用 window.triggerOnDragEnd 模拟拖拽结束
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 2 } }, {})
expect(onUpdate).toHaveBeenCalledWith(newOrder)
expect(onUpdate).toHaveBeenCalledTimes(1)
})
it('should call onDragStart and onDragEnd', () => {
const list = [
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' },
{ id: 'c', name: 'C' }
]
const onDragStart = vi.fn()
const onDragEnd = vi.fn()
render(
<DragableList list={list} onUpdate={() => {}} onDragStart={onDragStart} onDragEnd={onDragEnd}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
// 先手动调用 onDragStart
onDragStart()
// 再模拟拖拽结束
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 1 } }, {})
expect(onDragStart).toHaveBeenCalledTimes(1)
expect(onDragEnd).toHaveBeenCalledTimes(1)
})
it('should not call onUpdate if dropped at same position', () => {
const list = [
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' },
{ id: 'c', name: 'C' }
]
const onUpdate = vi.fn()
render(
<DragableList list={list} onUpdate={onUpdate}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
// 模拟拖拽到自身
window.triggerOnDragEnd({ source: { index: 1 }, destination: { index: 1 } }, {})
expect(onUpdate).toHaveBeenCalledTimes(1)
expect(onUpdate.mock.calls[0][0]).toEqual(list)
})
})
describe('edge cases', () => {
it('should work with single item', () => {
const list = [{ id: 'a', name: 'A' }]
const onUpdate = vi.fn()
render(
<DragableList list={list} onUpdate={onUpdate}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
// 拖拽自身
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 0 } }, {})
expect(onUpdate).toHaveBeenCalledTimes(1)
expect(onUpdate.mock.calls[0][0]).toEqual(list)
})
it('should not crash if callbacks are undefined', () => {
const list = [
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' }
]
// 不传 onDragStart/onDragEnd
expect(() => {
render(
<DragableList list={list} onUpdate={() => {}}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 1 } }, {})
}).not.toThrow()
})
it('should handle items without id', () => {
const list = ['A', 'B', 'C']
const onUpdate = vi.fn()
render(
<DragableList list={list} onUpdate={onUpdate}>
{(item) => <div data-testid="item">{item}</div>}
</DragableList>
)
// 拖拽第0项到第2项
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 2 } }, {})
expect(onUpdate).toHaveBeenCalledTimes(1)
expect(onUpdate.mock.calls[0][0]).toEqual(['B', 'C', 'A'])
})
})
describe('interaction', () => {
it('should show placeholder during drag', () => {
const list = [
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' },
{ id: 'c', name: 'C' }
]
render(
<DragableList list={list} onUpdate={() => {}}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
// placeholder 应该在初始渲染时就存在
const placeholder = screen.getByTestId('placeholder')
expect(placeholder).toBeInTheDocument()
})
it('should reorder correctly when dragged to first/last', () => {
const list = [
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' },
{ id: 'c', name: 'C' }
]
const onUpdate = vi.fn()
render(
<DragableList list={list} onUpdate={onUpdate}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
// 拖拽第2项到第0项
window.triggerOnDragEnd({ source: { index: 2 }, destination: { index: 0 } }, {})
expect(onUpdate).toHaveBeenCalledWith([
{ id: 'c', name: 'C' },
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' }
])
// 拖拽第0项到第2项
onUpdate.mockClear()
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 2 } }, {})
expect(onUpdate).toHaveBeenCalledWith([
{ id: 'b', name: 'B' },
{ id: 'c', name: 'C' },
{ id: 'a', name: 'A' }
])
})
})
describe('snapshot', () => {
it('should match snapshot', () => {
const list = [
{ id: 'a', name: 'A' },
{ id: 'b', name: 'B' },
{ id: 'c', name: 'C' }
]
const { container } = render(
<DragableList list={list} onUpdate={() => {}}>
{(item) => <div data-testid="item">{item.name}</div>}
</DragableList>
)
expect(container).toMatchSnapshot()
})
})
})

View File

@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import ExpandableText from '../ExpandableText'
// mock i18n
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (k: string) => k })
}))
describe('ExpandableText', () => {
const TEXT = 'This is a long text for testing.'
it('should render text and expand button', () => {
render(<ExpandableText text={TEXT} />)
expect(screen.getByText(TEXT)).toBeInTheDocument()
expect(screen.getByRole('button')).toHaveTextContent('common.expand')
})
it('should toggle expand/collapse when button is clicked', async () => {
render(<ExpandableText text={TEXT} />)
const button = screen.getByRole('button')
// 初始为收起状态
expect(button).toHaveTextContent('common.expand')
// 点击展开
await userEvent.click(button)
expect(button).toHaveTextContent('common.collapse')
// 再次点击收起
await userEvent.click(button)
expect(button).toHaveTextContent('common.expand')
})
})

View File

@ -0,0 +1,188 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useEffect } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { QuickPanelListItem, QuickPanelProvider, QuickPanelView, useQuickPanel } from '../QuickPanel'
function createList(length: number, prefix = 'Item', extra: Partial<QuickPanelListItem> = {}) {
return Array.from({ length }, (_, i) => ({
label: `${prefix} ${i + 1}`,
description: `${prefix} Description ${i + 1}`,
icon: `${prefix} Icon ${i + 1}`,
action: () => {},
...extra
}))
}
type KeyStep = {
key: string
ctrlKey?: boolean
expected: string | ((text: string) => boolean)
}
const PAGE_SIZE = 7
// 用于测试 open 行为的组件
function OpenPanelOnMount({ list }: { list: QuickPanelListItem[] }) {
const quickPanel = useQuickPanel()
useEffect(() => {
quickPanel.open({
title: 'Test Panel',
list,
symbol: 'test',
pageSize: PAGE_SIZE
})
}, [list, quickPanel])
return null
}
describe('QuickPanelView', () => {
beforeEach(() => {
// 添加一个假的 .inputbar textarea 到 document.body
const inputbar = document.createElement('div')
inputbar.className = 'inputbar'
const textarea = document.createElement('textarea')
inputbar.appendChild(textarea)
document.body.appendChild(inputbar)
})
afterEach(() => {
const inputbar = document.querySelector('.inputbar')
if (inputbar) inputbar.remove()
})
describe('rendering', () => {
it('should render without crashing when wrapped in QuickPanelProvider', () => {
render(
<QuickPanelProvider>
<QuickPanelView setInputText={vi.fn()} />
</QuickPanelProvider>
)
// 检查面板容器是否存在且初始不可见
const panel = screen.getByTestId('quick-panel')
expect(panel.classList.contains('visible')).toBe(false)
})
it('should render list after open', async () => {
const list = createList(100)
render(
<QuickPanelProvider>
<QuickPanelView setInputText={vi.fn()} />
<OpenPanelOnMount list={list} />
</QuickPanelProvider>
)
// 检查面板可见
const panel = screen.getByTestId('quick-panel')
expect(panel.classList.contains('visible')).toBe(true)
// 检查第一个 item 是否渲染
expect(screen.getByText('Item 1')).toBeInTheDocument()
})
})
describe('focusing', () => {
// 执行一系列按键,检查 focused item 是否正确
async function runKeySequenceAndCheck(panel: HTMLElement, sequence: KeyStep[]) {
const user = userEvent.setup()
for (const { key, ctrlKey, expected } of sequence) {
let keyString = ''
if (ctrlKey) keyString += '{Control>}'
keyString += key.length === 1 ? key : `{${key}}`
if (ctrlKey) keyString += '{/Control}'
await user.keyboard(keyString)
// 检查是否只有一个 focused item
const focused = panel.querySelectorAll('.focused')
expect(focused.length).toBe(1)
// 检查 focused item 是否包含预期文本
const text = focused[0].textContent || ''
if (typeof expected === 'string') {
expect(text).toContain(expected)
} else {
expect(expected(text)).toBe(true)
}
}
}
it('should focus on the first item after panel open', () => {
const list = createList(100)
render(
<QuickPanelProvider>
<QuickPanelView setInputText={vi.fn()} />
<OpenPanelOnMount list={list} />
</QuickPanelProvider>
)
// 检查第一个 item 是否有 focused
const item1 = screen.getByText('Item 1')
const focused = item1.closest('.focused')
expect(focused).not.toBeNull()
expect(item1).toBeInTheDocument()
})
it('should focus on the right item using ArrowUp, ArrowDown', async () => {
const list = createList(100, 'Item')
render(
<QuickPanelProvider>
<QuickPanelView setInputText={vi.fn()} />
<OpenPanelOnMount list={list} />
</QuickPanelProvider>
)
const keySequence = [
{ key: 'ArrowUp', expected: 'Item 100' },
{ key: 'ArrowUp', expected: 'Item 99' },
{ key: 'ArrowDown', expected: 'Item 100' },
{ key: 'ArrowDown', expected: 'Item 1' }
]
await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence)
})
it('should focus on the right item using PageUp, PageDown', async () => {
const list = createList(100, 'Item')
render(
<QuickPanelProvider>
<QuickPanelView setInputText={vi.fn()} />
<OpenPanelOnMount list={list} />
</QuickPanelProvider>
)
const keySequence = [
{ key: 'PageUp', expected: 'Item 1' }, // 停留在顶部
{ key: 'ArrowUp', expected: 'Item 100' },
{ key: 'PageDown', expected: 'Item 100' }, // 停留在底部
{ key: 'PageUp', expected: `Item ${100 - PAGE_SIZE}` },
{ key: 'PageDown', expected: 'Item 100' }
]
await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence)
})
it('should focus on the right item using Ctrl+ArrowUp, Ctrl+ArrowDown', async () => {
const list = createList(100, 'Item')
render(
<QuickPanelProvider>
<QuickPanelView setInputText={vi.fn()} />
<OpenPanelOnMount list={list} />
</QuickPanelProvider>
)
const keySequence = [
{ key: 'ArrowDown', ctrlKey: true, expected: `Item ${PAGE_SIZE + 1}` },
{ key: 'ArrowUp', ctrlKey: true, expected: 'Item 1' },
{ key: 'ArrowUp', ctrlKey: true, expected: 'Item 100' },
{ key: 'ArrowDown', ctrlKey: true, expected: 'Item 1' }
]
await runKeySequenceAndCheck(screen.getByTestId('quick-panel'), keySequence)
})
})
})

View File

@ -0,0 +1,191 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { act } from 'react'
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import Scrollbar from '../Scrollbar'
// Mock lodash throttle
vi.mock('lodash', async () => {
const actual = await import('lodash')
return {
...actual,
throttle: vi.fn((fn) => {
// 简单地直接返回函数,不实际执行节流
const throttled = (...args: any[]) => fn(...args)
throttled.cancel = vi.fn()
return throttled
})
}
})
describe('Scrollbar', () => {
beforeEach(() => {
// 使用 fake timers
vi.useFakeTimers()
})
afterEach(() => {
// 恢复真实的 timers
vi.restoreAllMocks()
vi.useRealTimers()
})
describe('rendering', () => {
it('should render children correctly', () => {
render(
<Scrollbar data-testid="scrollbar">
<div data-testid="child"></div>
</Scrollbar>
)
const child = screen.getByTestId('child')
expect(child).toBeDefined()
expect(child.textContent).toBe('测试内容')
})
it('should pass custom props to container', () => {
render(
<Scrollbar data-testid="scrollbar" className="custom-class">
</Scrollbar>
)
const scrollbar = screen.getByTestId('scrollbar')
expect(scrollbar.className).toContain('custom-class')
})
it('should match default styled snapshot', () => {
const { container } = render(<Scrollbar data-testid="scrollbar"></Scrollbar>)
expect(container.firstChild).toMatchSnapshot()
})
})
describe('scrolling behavior', () => {
it('should update isScrolling state when scrolled', () => {
render(<Scrollbar data-testid="scrollbar"></Scrollbar>)
const scrollbar = screen.getByTestId('scrollbar')
// 初始状态下应该不是滚动状态
expect(scrollbar.getAttribute('isScrolling')).toBeFalsy()
// 触发滚动
fireEvent.scroll(scrollbar)
// 由于 isScrolling 是组件内部状态,不直接反映在 DOM 属性上
// 但可以检查模拟的事件处理是否被调用
expect(scrollbar).toBeDefined()
})
it('should reset isScrolling after timeout', () => {
render(<Scrollbar data-testid="scrollbar"></Scrollbar>)
const scrollbar = screen.getByTestId('scrollbar')
// 触发滚动
fireEvent.scroll(scrollbar)
// 前进时间但不超过timeout
act(() => {
vi.advanceTimersByTime(1000)
})
// 前进超过timeout
act(() => {
vi.advanceTimersByTime(600)
})
// 不测试样式,这里只检查组件是否存在
expect(scrollbar).toBeDefined()
})
it('should reset timeout on continuous scrolling', () => {
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout')
render(<Scrollbar data-testid="scrollbar"></Scrollbar>)
const scrollbar = screen.getByTestId('scrollbar')
// 第一次滚动
fireEvent.scroll(scrollbar)
// 前进一部分时间
act(() => {
vi.advanceTimersByTime(800)
})
// 再次滚动
fireEvent.scroll(scrollbar)
// clearTimeout 应该被调用,因为在第二次滚动时会清除之前的定时器
expect(clearTimeoutSpy).toHaveBeenCalled()
})
})
describe('throttling', () => {
it('should use throttled scroll handler', async () => {
const { throttle } = await import('lodash')
render(<Scrollbar data-testid="scrollbar"></Scrollbar>)
// 验证 throttle 被调用
expect(throttle).toHaveBeenCalled()
// 验证 throttle 调用时使用了 200ms 延迟
expect(throttle).toHaveBeenCalledWith(expect.any(Function), 200)
})
})
describe('cleanup', () => {
it('should clear timeout and cancel throttle on unmount', async () => {
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout')
const { unmount } = render(<Scrollbar data-testid="scrollbar"></Scrollbar>)
const scrollbar = screen.getByTestId('scrollbar')
// 触发滚动设置定时器
fireEvent.scroll(scrollbar)
// 卸载组件
unmount()
// 验证 clearTimeout 被调用
expect(clearTimeoutSpy).toHaveBeenCalled()
// 验证 throttle.cancel 被调用
const { throttle } = await import('lodash')
const throttledFunction = (throttle as unknown as Mock).mock.results[0].value
expect(throttledFunction.cancel).toHaveBeenCalled()
})
})
describe('props handling', () => {
it('should handle right prop correctly', () => {
const { container } = render(
<Scrollbar data-testid="scrollbar" right>
</Scrollbar>
)
const scrollbar = screen.getByTestId('scrollbar')
// 验证 right 属性被正确传递
expect(scrollbar).toBeDefined()
// snapshot 测试 styled-components 样式
expect(container.firstChild).toMatchSnapshot()
})
it('should handle ref forwarding', () => {
const ref = { current: null }
render(
<Scrollbar data-testid="scrollbar" ref={ref}>
</Scrollbar>
)
// 验证 ref 被正确设置
expect(ref.current).not.toBeNull()
})
})
})

View File

@ -0,0 +1,74 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`DragableList > snapshot > should match snapshot 1`] = `
<div>
<div
data-testid="drag-drop-context"
>
<div
data-testid="droppable"
>
<div>
<div
data-testid="virtual-list"
>
<div
data-testid="virtual-list-item"
>
<div
data-testid="draggable-a-0"
>
<div
style="margin-bottom: 8px;"
>
<div
data-testid="item"
>
A
</div>
</div>
</div>
</div>
<div
data-testid="virtual-list-item"
>
<div
data-testid="draggable-b-1"
>
<div
style="margin-bottom: 8px;"
>
<div
data-testid="item"
>
B
</div>
</div>
</div>
</div>
<div
data-testid="virtual-list-item"
>
<div
data-testid="draggable-c-2"
>
<div
style="margin-bottom: 8px;"
>
<div
data-testid="item"
>
C
</div>
</div>
</div>
</div>
</div>
<div
data-testid="placeholder"
/>
</div>
</div>
</div>
</div>
`;

View File

@ -0,0 +1,45 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Scrollbar > props handling > should handle right prop correctly 1`] = `
.c0 {
overflow-y: auto;
}
.c0::-webkit-scrollbar-thumb {
transition: background 2s ease;
background: transparent;
}
.c0::-webkit-scrollbar-thumb:hover {
background: transparent;
}
<div
class="c0"
data-testid="scrollbar"
>
内容
</div>
`;
exports[`Scrollbar > rendering > should match default styled snapshot 1`] = `
.c0 {
overflow-y: auto;
}
.c0::-webkit-scrollbar-thumb {
transition: background 2s ease;
background: transparent;
}
.c0::-webkit-scrollbar-thumb:hover {
background: transparent;
}
<div
class="c0"
data-testid="scrollbar"
>
内容
</div>
`;

View File

@ -10,6 +10,7 @@ export const isWindows = platform === 'win32' || platform === 'win64'
export const isLinux = platform === 'linux'
export const SILICON_CLIENT_ID = 'SFaJLLq0y6CAMoyDm81aMu'
export const TOKENFLUX_HOST = 'https://tokenflux.ai'
// Messages loading configuration
export const INITIAL_MESSAGES_COUNT = 20

View File

@ -34,8 +34,10 @@ import DianxinModelLogo from '@renderer/assets/images/models/dianxin.png'
import DianxinModelLogoDark from '@renderer/assets/images/models/dianxin_dark.png'
import DoubaoModelLogo from '@renderer/assets/images/models/doubao.png'
import DoubaoModelLogoDark from '@renderer/assets/images/models/doubao_dark.png'
import EmbeddingModelLogo from '@renderer/assets/images/models/embedding.png'
import EmbeddingModelLogoDark from '@renderer/assets/images/models/embedding.png'
import {
default as EmbeddingModelLogo,
default as EmbeddingModelLogoDark
} from '@renderer/assets/images/models/embedding.png'
import FlashaudioModelLogo from '@renderer/assets/images/models/flashaudio.png'
import FlashaudioModelLogoDark from '@renderer/assets/images/models/flashaudio_dark.png'
import FluxModelLogo from '@renderer/assets/images/models/flux.png'
@ -44,14 +46,15 @@ import GeminiModelLogo from '@renderer/assets/images/models/gemini.png'
import GeminiModelLogoDark from '@renderer/assets/images/models/gemini_dark.png'
import GemmaModelLogo from '@renderer/assets/images/models/gemma.png'
import GemmaModelLogoDark from '@renderer/assets/images/models/gemma_dark.png'
import GoogleModelLogo from '@renderer/assets/images/models/google.png'
import GoogleModelLogoDark from '@renderer/assets/images/models/google.png'
import { default as GoogleModelLogo, default as GoogleModelLogoDark } from '@renderer/assets/images/models/google.png'
import ChatGPT35ModelLogo from '@renderer/assets/images/models/gpt_3.5.png'
import ChatGPT4ModelLogo from '@renderer/assets/images/models/gpt_4.png'
import ChatGptModelLogoDakr from '@renderer/assets/images/models/gpt_dark.png'
import ChatGPT35ModelLogoDark from '@renderer/assets/images/models/gpt_dark.png'
import ChatGPT4ModelLogoDark from '@renderer/assets/images/models/gpt_dark.png'
import ChatGPTo1ModelLogoDark from '@renderer/assets/images/models/gpt_dark.png'
import {
default as ChatGPT4ModelLogoDark,
default as ChatGPT35ModelLogoDark,
default as ChatGptModelLogoDakr,
default as ChatGPTo1ModelLogoDark
} from '@renderer/assets/images/models/gpt_dark.png'
import ChatGPTo1ModelLogo from '@renderer/assets/images/models/gpt_o1.png'
import GrokModelLogo from '@renderer/assets/images/models/grok.png'
import GrokModelLogoDark from '@renderer/assets/images/models/grok_dark.png'
@ -86,22 +89,28 @@ import MicrosoftModelLogo from '@renderer/assets/images/models/microsoft.png'
import MicrosoftModelLogoDark from '@renderer/assets/images/models/microsoft_dark.png'
import MidjourneyModelLogo from '@renderer/assets/images/models/midjourney.png'
import MidjourneyModelLogoDark from '@renderer/assets/images/models/midjourney_dark.png'
import MinicpmModelLogo from '@renderer/assets/images/models/minicpm.webp'
import MinicpmModelLogoDark from '@renderer/assets/images/models/minicpm.webp'
import {
default as MinicpmModelLogo,
default as MinicpmModelLogoDark
} from '@renderer/assets/images/models/minicpm.webp'
import MinimaxModelLogo from '@renderer/assets/images/models/minimax.png'
import MinimaxModelLogoDark from '@renderer/assets/images/models/minimax_dark.png'
import MistralModelLogo from '@renderer/assets/images/models/mixtral.png'
import MistralModelLogoDark from '@renderer/assets/images/models/mixtral_dark.png'
import MoonshotModelLogo from '@renderer/assets/images/models/moonshot.png'
import MoonshotModelLogoDark from '@renderer/assets/images/models/moonshot_dark.png'
import NousResearchModelLogo from '@renderer/assets/images/models/nousresearch.png'
import NousResearchModelLogoDark from '@renderer/assets/images/models/nousresearch.png'
import {
default as NousResearchModelLogo,
default as NousResearchModelLogoDark
} from '@renderer/assets/images/models/nousresearch.png'
import NvidiaModelLogo from '@renderer/assets/images/models/nvidia.png'
import NvidiaModelLogoDark from '@renderer/assets/images/models/nvidia_dark.png'
import PalmModelLogo from '@renderer/assets/images/models/palm.png'
import PalmModelLogoDark from '@renderer/assets/images/models/palm_dark.png'
import PerplexityModelLogo from '@renderer/assets/images/models/perplexity.png'
import PerplexityModelLogoDark from '@renderer/assets/images/models/perplexity.png'
import {
default as PerplexityModelLogo,
default as PerplexityModelLogoDark
} from '@renderer/assets/images/models/perplexity.png'
import PixtralModelLogo from '@renderer/assets/images/models/pixtral.png'
import PixtralModelLogoDark from '@renderer/assets/images/models/pixtral_dark.png'
import QwenModelLogo from '@renderer/assets/images/models/qwen.png'
@ -118,6 +127,8 @@ import SunoModelLogo from '@renderer/assets/images/models/suno.png'
import SunoModelLogoDark from '@renderer/assets/images/models/suno_dark.png'
import TeleModelLogo from '@renderer/assets/images/models/tele.png'
import TeleModelLogoDark from '@renderer/assets/images/models/tele_dark.png'
import TokenFluxModelLogo from '@renderer/assets/images/models/tokenflux.png'
import TokenFluxModelLogoDark from '@renderer/assets/images/models/tokenflux_dark.png'
import UpstageModelLogo from '@renderer/assets/images/models/upstage.png'
import UpstageModelLogoDark from '@renderer/assets/images/models/upstage_dark.png'
import ViduModelLogo from '@renderer/assets/images/models/vidu.png'
@ -369,7 +380,8 @@ export function getModelLogo(modelId: string) {
perplexity: isLight ? PerplexityModelLogo : PerplexityModelLogoDark,
sonar: isLight ? PerplexityModelLogo : PerplexityModelLogoDark,
'bge-': BgeModelLogo,
'voyage-': VoyageModelLogo
'voyage-': VoyageModelLogo,
tokenflux: isLight ? TokenFluxModelLogo : TokenFluxModelLogoDark
}
for (const key in logoMap) {
@ -761,200 +773,7 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
group: 'Claude 3'
}
],
'gitee-ai': [
{
id: 'Qwen3-30B-A3B',
name: 'Qwen3-30B-A3B',
provider: 'gitee-ai',
group: 'Qwen'
},
{
id: 'Qwen3-32B',
name: 'Qwen3-32B',
provider: 'gitee-ai',
group: 'Qwen'
},
{
id: 'Qwen3-8B',
name: 'Qwen3-8B',
provider: 'gitee-ai',
group: 'Qwen'
},
{
id: 'Qwen3-4B',
name: 'Qwen3-4B',
provider: 'gitee-ai',
group: 'Qwen'
},
{
id: 'Qwen3-0.6B',
name: 'Qwen3-0.6B',
provider: 'gitee-ai',
group: 'Qwen'
},
{
id: 'Qwen2.5-72B-Instruct',
name: 'Qwen2.5-72B-Instruct',
provider: 'gitee-ai',
group: 'Qwen'
},
{
id: 'Qwen2.5-14B-Instruct',
name: 'Qwen2.5-14B-Instruct',
provider: 'gitee-ai',
group: 'Qwen'
},
{
id: 'Qwen2-7B-Instruct',
name: 'Qwen2-7B-Instruct',
provider: 'gitee-ai',
group: 'Qwen'
},
{
id: 'Qwen2.5-32B-Instruct',
name: 'Qwen2.5-32B-Instruct',
provider: 'gitee-ai',
group: 'Qwen'
},
{
id: 'Qwen2-72B-Instruct',
name: 'Qwen2-72B-Instruct',
provider: 'gitee-ai',
group: 'Qwen'
},
{
id: 'Qwen2-VL-72B',
name: 'Qwen2-VL-72B',
provider: 'gitee-ai',
group: 'Qwen'
},
{
id: 'Qwen2.5-VL-32B-Instruct',
name: 'Qwen2.5-VL-32B-Instruct',
provider: 'gitee-ai',
group: 'Qwen'
},
{
id: 'QwQ-32B',
name: 'QwQ-32B',
provider: 'gitee-ai',
group: 'Qwen'
},
{
id: 'Align-DS-V',
name: 'Align-DS-V',
provider: 'gitee-ai',
group: 'Align'
},
{
id: 'Yi-34B-Chat',
name: 'Yi-34B-Chat',
provider: 'gitee-ai',
group: '01-ai'
},
{
id: 'glm-4-9b-chat',
name: 'glm-4-9b-chat',
provider: 'gitee-ai',
group: 'THUDM'
},
{
id: 'deepseek-coder-33B-instruct',
name: 'deepseek-coder-33B-instruct',
provider: 'gitee-ai',
group: 'DeepSeek'
},
{
id: 'codegeex4-all-9b',
name: 'codegeex4-all-9b',
provider: 'gitee-ai',
group: 'THUDM'
},
{
id: 'InternVL2-8B',
name: 'InternVL2-8B',
provider: 'gitee-ai',
group: 'OpenGVLab'
},
{
id: 'InternVL2.5-26B',
name: 'InternVL2.5-26B',
provider: 'gitee-ai',
group: 'OpenGVLab'
},
{
id: 'InternVL2.5-78B',
name: 'InternVL2.5-78B',
provider: 'gitee-ai',
group: 'OpenGVLab'
},
{
id: 'DeepSeek-R1-Distill-Qwen-32B',
name: 'DeepSeek-R1-Distill-Qwen-32B',
provider: 'gitee-ai',
group: 'DeepSeek'
},
{
id: 'DeepSeek-R1-Distill-Qwen-1.5B',
name: 'DeepSeek-R1-Distill-Qwen-1.5B',
provider: 'gitee-ai',
group: 'DeepSeek'
},
{
id: 'DeepSeek-R1-Distill-Qwen-14B',
name: 'DeepSeek-R1-Distill-Qwen-14B',
provider: 'gitee-ai',
group: 'DeepSeek'
},
{
id: 'DeepSeek-R1-Distill-Qwen-7B',
name: 'DeepSeek-R1-Distill-Qwen-7B',
provider: 'gitee-ai',
group: 'DeepSeek'
},
{
id: 'DeepSeek-V3',
name: 'DeepSeek-V3',
provider: 'gitee-ai',
group: 'DeepSeek'
},
{
id: 'DeepSeek-R1',
name: 'DeepSeek-R1',
provider: 'gitee-ai',
group: 'DeepSeek'
},
{
id: 'gemma-3-27b-it',
name: 'gemma-3-27b-it',
provider: 'gitee-ai',
group: 'Gemma'
},
{
id: 'bge-large-zh-v1.5',
name: 'bge-large-zh-v1.5',
provider: 'gitee-ai',
group: 'BAAI'
},
{
id: 'bge-small-zh-v1.5',
name: 'bge-small-zh-v1.5',
provider: 'gitee-ai',
group: 'BAAI'
},
{
id: 'bge-m3',
name: 'bge-m3',
provider: 'gitee-ai',
group: 'BAAI'
},
{
id: 'bce-embedding-base_v1',
name: 'bce-embedding-base_v1',
provider: 'gitee-ai',
group: 'netease-youdao'
}
],
'gitee-ai': [],
deepseek: [
{
id: 'deepseek-chat',
@ -2160,6 +1979,68 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
name: 'Qwen2.5 72B Instruct',
group: 'Qwen'
}
],
tokenflux: [
{
id: 'gpt-4.1',
provider: 'tokenflux',
name: 'GPT-4.1',
group: 'GPT-4.1'
},
{
id: 'gpt-4.1-mini',
provider: 'tokenflux',
name: 'GPT-4.1 Mini',
group: 'GPT-4.1'
},
{
id: 'claude-sonnet-4',
provider: 'tokenflux',
name: 'Claude Sonnet 4',
group: 'Claude'
},
{
id: 'claude-3-7-sonnet',
provider: 'tokenflux',
name: 'Claude 3.7 Sonnet',
group: 'Claude'
},
{
id: 'gemini-2.5-pro',
provider: 'tokenflux',
name: 'Gemini 2.5 Pro',
group: 'Gemini'
},
{
id: 'gemini-2.5-flash',
provider: 'tokenflux',
name: 'Gemini 2.5 Flash',
group: 'Gemini'
},
{
id: 'deepseek-r1',
provider: 'tokenflux',
name: 'DeepSeek R1',
group: 'DeepSeek'
},
{
id: 'deepseek-v3',
provider: 'tokenflux',
name: 'DeepSeek V3',
group: 'DeepSeek'
},
{
id: 'qwen-max',
provider: 'tokenflux',
name: 'Qwen Max',
group: 'Qwen'
},
{
id: 'qwen-plus',
provider: 'tokenflux',
name: 'Qwen Plus',
group: 'Qwen'
}
]
}

View File

@ -38,12 +38,15 @@ import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.p
import StepProviderLogo from '@renderer/assets/images/providers/step.png'
import TencentCloudProviderLogo from '@renderer/assets/images/providers/tencent-cloud-ti.png'
import TogetherProviderLogo from '@renderer/assets/images/providers/together.png'
import TokenFluxProviderLogo from '@renderer/assets/images/providers/tokenflux.png'
import BytedanceProviderLogo from '@renderer/assets/images/providers/volcengine.png'
import VoyageAIProviderLogo from '@renderer/assets/images/providers/voyageai.png'
import XirangProviderLogo from '@renderer/assets/images/providers/xirang.png'
import ZeroOneProviderLogo from '@renderer/assets/images/providers/zero-one.png'
import ZhipuProviderLogo from '@renderer/assets/images/providers/zhipu.png'
import { TOKENFLUX_HOST } from './constant'
const PROVIDER_LOGO_MAP = {
openai: OpenAiProviderLogo,
silicon: SiliconFlowProviderLogo,
@ -90,7 +93,8 @@ const PROVIDER_LOGO_MAP = {
gpustack: GPUStackProviderLogo,
alayanew: AlayaNewProviderLogo,
voyageai: VoyageAIProviderLogo,
qiniu: QiniuProviderLogo
qiniu: QiniuProviderLogo,
tokenflux: TokenFluxProviderLogo
} as const
export function getProviderLogo(providerId: string) {
@ -99,6 +103,7 @@ export function getProviderLogo(providerId: string) {
// export const SUPPORTED_REANK_PROVIDERS = ['silicon', 'jina', 'voyageai', 'dashscope', 'aihubmix']
export const NOT_SUPPORTED_REANK_PROVIDERS = ['ollama']
export const ONLY_SUPPORTED_DIMENSION_PROVIDERS = ['ollama', 'infini']
export const PROVIDER_CONFIG = {
openai: {
@ -597,5 +602,16 @@ export const PROVIDER_CONFIG = {
docs: 'https://developer.qiniu.com/aitokenapi',
models: 'https://developer.qiniu.com/aitokenapi/12883/model-list'
}
},
tokenflux: {
api: {
url: TOKENFLUX_HOST
},
websites: {
official: TOKENFLUX_HOST,
apiKey: `${TOKENFLUX_HOST}/dashboard/api-keys`,
docs: `${TOKENFLUX_HOST}/docs`,
models: `${TOKENFLUX_HOST}/models`
}
}
}

View File

@ -80,7 +80,11 @@ export const useChatContext = (activeTopic: Topic) => {
(messageId: string, selected: boolean) => {
dispatch(
setSelectedMessageIds(
selected ? [...selectedMessageIds, messageId] : selectedMessageIds.filter((id) => id !== messageId)
selected
? selectedMessageIds.includes(messageId)
? selectedMessageIds
: [...selectedMessageIds, messageId]
: selectedMessageIds.filter((id) => id !== messageId)
)
)
},

View File

@ -23,6 +23,7 @@ import type { Assistant, Model, Topic } from '@renderer/types'
import type { Message, MessageBlock } from '@renderer/types/newMessage'
import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
import { abortCompletion } from '@renderer/utils/abortController'
import { throttle } from 'lodash'
import { useCallback } from 'react'
const selectMessagesState = (state: RootState) => state.messages
@ -243,9 +244,13 @@ export function useMessageOperations(topic: Topic) {
return null
}
return (accumulatedText: string, isComplete: boolean = false) => {
dispatch(updateTranslationBlockThunk(blockId!, accumulatedText, isComplete))
}
return throttle(
(accumulatedText: string, isComplete: boolean = false) => {
dispatch(updateTranslationBlockThunk(blockId!, accumulatedText, isComplete))
},
200,
{ leading: true, trailing: true }
)
},
[dispatch, topic.id]
)

View File

@ -9,10 +9,12 @@ export function usePaintings() {
const remix = useAppSelector((state) => state.paintings.remix)
const edit = useAppSelector((state) => state.paintings.edit)
const upscale = useAppSelector((state) => state.paintings.upscale)
const DMXAPIPaintings = useAppSelector((state) => state.paintings.DMXAPIPaintings)
const dispatch = useAppDispatch()
return {
paintings,
DMXAPIPaintings,
persistentData: {
generate,
remix,

View File

@ -1,5 +1,5 @@
import { createSelector } from '@reduxjs/toolkit'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import store, { useAppDispatch, useAppSelector } from '@renderer/store'
import {
addModel,
addProvider,
@ -10,6 +10,7 @@ import {
updateProviders
} from '@renderer/store/llm'
import { Assistant, Model, Provider } from '@renderer/types'
import { IpcChannel } from '@shared/IpcChannel'
import { useDefaultModel } from './useAssistant'
@ -63,3 +64,17 @@ export function useProviderByAssistant(assistant: Assistant) {
const { provider } = useProvider(model.provider)
return provider
}
// Listen for server changes from main process
window.electron.ipcRenderer.on(IpcChannel.Provider_AddKey, (_, data) => {
console.log('Received provider key data:', data)
const { id, apiKey } = data
// for now only suppor tokenflux, but in the future we can support more
if (id === 'tokenflux') {
if (apiKey) {
store.dispatch(updateProvider({ id, apiKey } as Provider))
window.message.success('Provider API key updated')
console.log('Provider API key updated:', apiKey)
}
}
})

View File

@ -0,0 +1,48 @@
import { useAppDispatch, useAppSelector } from '@renderer/store'
import {
setActionItems,
setActionWindowOpacity,
setIsAutoClose,
setIsAutoPin,
setIsCompact,
setIsFollowToolbar,
setSelectionEnabled,
setTriggerMode
} from '@renderer/store/selectionStore'
import { ActionItem, TriggerMode } from '@renderer/types/selectionTypes'
export function useSelectionAssistant() {
const dispatch = useAppDispatch()
const selectionStore = useAppSelector((state) => state.selectionStore)
return {
...selectionStore,
setSelectionEnabled: (enabled: boolean) => {
dispatch(setSelectionEnabled(enabled))
window.api.selection.setEnabled(enabled)
},
setTriggerMode: (mode: TriggerMode) => {
dispatch(setTriggerMode(mode))
window.api.selection.setTriggerMode(mode)
},
setIsCompact: (isCompact: boolean) => {
dispatch(setIsCompact(isCompact))
},
setIsAutoClose: (isAutoClose: boolean) => {
dispatch(setIsAutoClose(isAutoClose))
},
setIsAutoPin: (isAutoPin: boolean) => {
dispatch(setIsAutoPin(isAutoPin))
},
setIsFollowToolbar: (isFollowToolbar: boolean) => {
dispatch(setIsFollowToolbar(isFollowToolbar))
window.api.selection.setFollowToolbar(isFollowToolbar)
},
setActionWindowOpacity: (opacity: number) => {
dispatch(setActionWindowOpacity(opacity))
},
setActionItems: (items: ActionItem[]) => {
dispatch(setActionItems(items))
}
}
}

View File

@ -12,6 +12,7 @@ import {
setTheme,
SettingsState,
setTopicPosition,
setPinTopicsToTop,
setTray as _setTray,
setTrayOnClose,
setWindowStyle
@ -68,6 +69,9 @@ export function useSettings() {
setTopicPosition(topicPosition: 'left' | 'right') {
dispatch(setTopicPosition(topicPosition))
},
setPinTopicsToTop(pinTopicsToTop: boolean) {
dispatch(setPinTopicsToTop(pinTopicsToTop))
},
updateSidebarIcons(icons: { visible: SidebarIcon[]; disabled: SidebarIcon[] }) {
dispatch(setSidebarIcons(icons))
},

View File

@ -314,6 +314,10 @@
"input.web_search.builtin.disabled_content": "The current model does not support web search",
"input.web_search.no_web_search": "Disable Web Search",
"input.web_search.no_web_search.description": "Do not enable web search",
"input.tools.collapse": "Collapse",
"input.tools.expand": "Expand",
"input.tools.collapse_in": "Collapse",
"input.tools.collapse_out": "Remove from collapse",
"input.thinking": "Thinking",
"input.thinking.mode.default": "Default",
"input.thinking.mode.default.tip": "The model will automatically determine the number of tokens to think",
@ -681,6 +685,8 @@
"minapp": {
"popup": {
"refresh": "Refresh",
"goBack": "Go Back",
"goForward": "Go Forward",
"close": "Close MinApp",
"minimize": "Minimize MinApp",
"devtools": "Developer Tools",
@ -811,6 +817,7 @@
"regenerate.confirm": "This will replace your existing generated images. Do you want to continue?",
"seed": "Seed",
"seed_tip": "The same seed and prompt can produce similar images",
"seed_desc_tip": "The same seed and prompt can generate similar images, setting -1 will generate different results each time",
"title": "Images",
"magic_prompt_option": "Magic Prompt",
"model": "Model Version",
@ -818,6 +825,7 @@
"style_type": "Style",
"rendering_speed": "Rendering Speed",
"learn_more": "Learn More",
"paint_course":"tutorial",
"prompt_placeholder_edit": "Enter your image description, text drawing uses \"double quotes\" to wrap",
"proxy_required": "Currently, you need to open a proxy to view the generated images, it will be supported in the future",
"image_file_required": "Please upload an image first",
@ -883,7 +891,8 @@
"number_images_tip": "Number of upscaled results to generate",
"seed_tip": "Controls upscaling randomness",
"magic_prompt_option_tip": "Intelligently enhances upscaling prompts"
}
},
"text_desc_required": "Please enter image description first"
},
"prompts": {
"explanation": "Explain this concept to me",
@ -937,7 +946,8 @@
"zhinao": "360AI",
"zhipu": "ZHIPU AI",
"voyageai": "Voyage AI",
"qiniu": "Qiniu"
"qiniu": "Qiniu",
"tokenflux": "TokenFlux"
},
"restore": {
"confirm": "Are you sure you want to restore data?",
@ -1302,6 +1312,8 @@
"stdio": "Standard Input/Output (stdio)",
"inMemory": "Memory",
"config_description": "Configure Model Context Protocol servers",
"disable": "Disable MCP Server",
"disable.description": "Do not enable MCP server functionality",
"deleteError": "Failed to delete server",
"deleteSuccess": "Server deleted successfully",
"dependenciesInstall": "Install Dependencies",
@ -1562,6 +1574,9 @@
"rate_limit": "Rate limiting",
"tooltip": "You need to log in to Github before using Github Copilot"
},
"dmxapi": {
"select_platform": "Select the platform"
},
"delete.content": "Are you sure you want to delete this provider?",
"delete.title": "Delete Provider",
"docs_check": "Check",
@ -1635,6 +1650,7 @@
"topic.position.left": "Left",
"topic.position.right": "Right",
"topic.show.time": "Show topic time",
"topic.pin_to_top": "Pin Topics to Top",
"tray.onclose": "Minimize to Tray on Close",
"tray.show": "Show Tray Icon",
"tray.title": "Tray",
@ -1763,6 +1779,141 @@
"quit": "Quit",
"show_window": "Show Window",
"visualization": "Visualization"
},
"selection": {
"name": "Selection Assistant",
"action": {
"builtin": {
"translate": "Translate",
"explain": "Explain",
"summary": "Summarize",
"search": "Search",
"refine": "Refine",
"copy": "Copy"
},
"window": {
"pin": "Pin",
"pinned": "Pinned",
"opacity": "Window Opacity",
"original_show": "Show Original",
"original_hide": "Hide Original",
"original_copy": "Copy Original",
"esc_close": "Esc to Close",
"esc_stop": "Esc to Stop",
"c_copy": "C to Copy"
}
},
"settings": {
"experimental": "Experimental Features",
"enable": {
"title": "Enable",
"description": "Currently only supported on Windows systems"
},
"toolbar": {
"title": "Toolbar",
"trigger_mode": {
"title": "Trigger Mode",
"description": "Show toolbar immediately when text is selected, or show only when Ctrl key is held after selection.",
"description_note": "The Ctrl key may not work in some apps. If you use AHK or other tools to remap the Ctrl key, it may not work.",
"selected": "Selection",
"ctrlkey": "Ctrl Key"
},
"compact_mode": {
"title": "Compact Mode",
"description": "In compact mode, only icons are displayed without text"
}
},
"window": {
"title": "Action Window",
"follow_toolbar": {
"title": "Follow Toolbar",
"description": "Window position will follow the toolbar. When disabled, it will always be centered."
},
"auto_close": {
"title": "Auto Close",
"description": "Automatically close the window when it's not pinned and loses focus"
},
"auto_pin": {
"title": "Auto Pin",
"description": "Pin the window by default"
},
"opacity": {
"title": "Opacity",
"description": "Set the default opacity of the window, 100% is fully opaque"
}
},
"actions": {
"title": "Actions",
"reset": {
"button": "Reset",
"tooltip": "Reset to default actions. Custom actions will not be deleted.",
"confirm": "Are you sure you want to reset to default actions? Custom actions will not be deleted."
},
"add_tooltip": {
"enabled": "Add Custom Action",
"disabled": "Maximum number of custom actions reached ({{max}})"
},
"delete_confirm": "Are you sure you want to delete this custom action?",
"drag_hint": "Drag to reorder. Move above to enable action ({{enabled}}/{{max}})"
},
"user_modal": {
"title": {
"add": "Add Custom Action",
"edit": "Edit Custom Action"
},
"name": {
"label": "Name",
"hint": "Please enter action name"
},
"icon": {
"label": "Icon",
"placeholder": "Enter Lucide icon name",
"error": "Invalid icon name, please check your input",
"tooltip": "Lucide icon names are lowercase, e.g. arrow-right",
"view_all": "View All Icons",
"random": "Random Icon"
},
"model": {
"label": "Model",
"tooltip": "Using Assistant: Will use both the assistant's system prompt and model parameters",
"default": "Default Model",
"assistant": "Use Assistant"
},
"assistant": {
"label": "Select Assistant",
"default": "Default"
},
"prompt": {
"label": "User Prompt",
"tooltip": "User prompt serves as a supplement to user input and won't override the assistant's system prompt",
"placeholder": "Use placeholder {{text}} to represent selected text. When empty, selected text will be appended to this prompt",
"placeholder_text": "Placeholder",
"copy_placeholder": "Copy Placeholder"
}
},
"search_modal": {
"title": "Set Search Engine",
"engine": {
"label": "Search Engine",
"custom": "Custom"
},
"custom": {
"name": {
"label": "Custom Name",
"hint": "Please enter search engine name",
"max_length": "Name cannot exceed 16 characters"
},
"url": {
"label": "Custom Search URL",
"hint": "Use {{queryString}} to represent the search term",
"required": "Please enter search URL",
"invalid_format": "Please enter a valid URL starting with http:// or https://",
"missing_placeholder": "URL must contain {{queryString}} placeholder"
},
"test": "Test"
}
}
}
}
}
}

View File

@ -314,6 +314,10 @@
"input.web_search.builtin.disabled_content": "現在のモデルはウェブ検索をサポートしていません",
"input.web_search.no_web_search": "ウェブ検索を無効にする",
"input.web_search.no_web_search.description": "ウェブ検索を無効にする",
"input.tools.collapse": "折りたたむ",
"input.tools.expand": "展開",
"input.tools.collapse_in": "折りたたむ",
"input.tools.collapse_out": "展開",
"input.thinking": "思考",
"input.thinking.mode.default": "デフォルト",
"input.thinking.mode.custom": "カスタム",
@ -599,9 +603,8 @@
"delete.confirm.content": "選択した{{count}}件のメッセージを削除しますか?",
"delete.failed": "削除に失敗しました",
"delete.success": "削除が成功しました",
"error.chunk_overlap_too_large": "チャンクの重なりは、チャンクサイズを超えることはできません",
"empty_url": "画像をダウンロードできません。プロンプトに不適切なコンテンツや禁止用語が含まれている可能性があります",
"error.chunk_overlap_too_large": "チャンクのオーバーラップがチャンクサイズより大きくなることはできません",
"empty_url": "画像をダウンロードできません。プロンプトに不適切なコンテンツや禁止用語が含まれている可能性があります",
"error.dimension_too_large": "内容のサイズが大きすぎます",
"error.enter.api.host": "APIホストを入力してください",
"error.enter.api.key": "APIキーを入力してください",
@ -682,6 +685,8 @@
"minapp": {
"popup": {
"refresh": "更新",
"goBack": "戻る",
"goForward": "進む",
"close": "ミニアプリを閉じる",
"minimize": "ミニアプリを最小化",
"devtools": "開発者ツール",
@ -812,6 +817,7 @@
"regenerate.confirm": "これにより、既存の生成画像が置き換えられます。続行しますか?",
"seed": "シード",
"seed_tip": "同じシードとプロンプトで似た画像を生成できます",
"seed_desc_tip": "同じシードとプロンプトで類似した画像を生成できますが、-1 に設定すると毎回異なる結果が生成されます",
"title": "画像",
"magic_prompt_option": "プロンプト強化",
"model": "モデルバージョン",
@ -819,6 +825,7 @@
"style_type": "スタイル",
"learn_more": "詳しくはこちら",
"prompt_placeholder_edit": "画像の説明を入力します。テキスト描画には '二重引用符' を使用します",
"paint_course":"チュートリアル",
"proxy_required": "現在、プロキシを開く必要があります。これは、将来サポートされる予定です",
"image_file_required": "画像を先にアップロードしてください",
"image_file_retry": "画像を先にアップロードしてください",
@ -884,7 +891,8 @@
"magic_prompt_option_tip": "拡大効果を向上させるための提示詞を最適化します"
},
"rendering_speed": "レンダリング速度",
"translating": "翻訳中..."
"translating": "翻訳中...",
"text_desc_required": "画像の説明を先に入力してください"
},
"prompts": {
"explanation": "この概念を説明してください",
@ -938,7 +946,8 @@
"zhinao": "360智脳",
"zhipu": "智譜AI",
"voyageai": "Voyage AI",
"qiniu": "七牛云"
"qiniu": "七牛云",
"tokenflux": "TokenFlux"
},
"restore": {
"confirm": "データを復元しますか?",
@ -1299,6 +1308,8 @@
"stdio": "標準入力/出力 (stdio)",
"inMemory": "メモリ",
"config_description": "モデルコンテキストプロトコルサーバーの設定",
"disable": "MCPサーバーを無効にする",
"disable.description": "MCP機能を有効にしない",
"deleteError": "サーバーの削除に失敗しました",
"deleteSuccess": "サーバーが正常に削除されました",
"dependenciesInstall": "依存関係をインストール",
@ -1550,6 +1561,9 @@
"rate_limit": "レート制限",
"tooltip": "Github Copilot を使用するには、まず Github にログインする必要があります。"
},
"dmxapi": {
"select_platform": "プラットフォームを選択"
},
"delete.content": "このプロバイダーを削除してもよろしいですか?",
"delete.title": "プロバイダーを削除",
"docs_check": "チェック",
@ -1626,6 +1640,7 @@
"topic.position.left": "左",
"topic.position.right": "右",
"topic.show.time": "トピックの時間を表示",
"topic.pin_to_top": "固定トピックを上部に表示",
"tray.onclose": "閉じるときにトレイに最小化",
"tray.show": "トレイアイコンを表示",
"tray.title": "トレイ",
@ -1764,6 +1779,141 @@
"quit": "終了",
"show_window": "ウィンドウを表示",
"visualization": "可視化"
},
"selection": {
"name": "テキスト選択ツール",
"action": {
"builtin": {
"translate": "翻訳",
"explain": "解説",
"summary": "要約",
"search": "検索",
"refine": "最適化",
"copy": "コピー"
},
"window": {
"pin": "最前面に固定",
"pinned": "固定中",
"opacity": "ウィンドウの透過度",
"original_show": "原文を表示",
"original_hide": "原文を非表示",
"original_copy": "原文をコピー",
"esc_close": "Escで閉じる",
"esc_stop": "Escで停止",
"c_copy": "Cでコピー"
}
},
"settings": {
"experimental": "実験的機能",
"enable": {
"title": "有効化",
"description": "現在Windowsのみ対応"
},
"toolbar": {
"title": "ツールバー",
"trigger_mode": {
"title": "表示方法",
"description": "テキスト選択時に即時表示、またはCtrlキー押下時のみ表示",
"description_note": "一部のアプリはCtrlキーでのテキスト選択に対応していません。AHKなどでCtrlキーをリマップすると、選択できなくなる場合があります。",
"selected": "選択時",
"ctrlkey": "Ctrlキー"
},
"compact_mode": {
"title": "コンパクトモード",
"description": "アイコンのみ表示(テキスト非表示)"
}
},
"window": {
"title": "機能ウィンドウ",
"follow_toolbar": {
"title": "ツールバーに追従",
"description": "ウィンドウ位置をツールバーに連動(無効時は中央表示)"
},
"auto_close": {
"title": "自動閉じる",
"description": "最前面固定されていない場合、フォーカス喪失時に自動閉じる"
},
"auto_pin": {
"title": "自動で最前面に固定",
"description": "デフォルトで最前面表示"
},
"opacity": {
"title": "透明度",
"description": "デフォルトの透明度を設定100%は完全不透明)"
}
},
"actions": {
"title": "機能設定",
"reset": {
"button": "リセット",
"tooltip": "デフォルト機能にリセット(カスタム機能は保持)",
"confirm": "デフォルト機能にリセットしますか?\nカスタム機能は削除されません"
},
"add_tooltip": {
"enabled": "カスタム機能を追加",
"disabled": "カスタム機能の上限に達しました (最大{{max}}個)"
},
"delete_confirm": "このカスタム機能を削除しますか?",
"drag_hint": "ドラッグで並べ替え (有効{{enabled}}/最大{{max}})"
},
"user_modal": {
"title": {
"add": "カスタム機能追加",
"edit": "カスタム機能編集"
},
"name": {
"label": "機能名",
"hint": "機能名を入力"
},
"icon": {
"label": "アイコン",
"placeholder": "Lucideアイコン名を入力",
"error": "無効なアイコン名です",
"tooltip": "例: arrow-right小文字で入力",
"view_all": "全アイコンを表示",
"random": "ランダム選択"
},
"model": {
"label": "モデル",
"tooltip": "アシスタント使用時はシステムプロンプトとモデルパラメータも適用",
"default": "デフォルトモデル",
"assistant": "アシスタントを使用"
},
"assistant": {
"label": "アシスタント選択",
"default": "デフォルト"
},
"prompt": {
"label": "ユーザープロンプト",
"tooltip": "アシスタントのシステムプロンプトを上書きせず、入力補助として機能",
"placeholder": "{{text}}で選択テキストを参照(未入力時は末尾に追加)",
"placeholder_text": "プレースホルダー",
"copy_placeholder": "プレースホルダーをコピー"
}
},
"search_modal": {
"title": "検索エンジン設定",
"engine": {
"label": "検索エンジン",
"custom": "カスタム"
},
"custom": {
"name": {
"label": "表示名",
"hint": "検索エンジン名16文字以内",
"max_length": "16文字以内で入力"
},
"url": {
"label": "検索URL",
"hint": "{{queryString}}で検索語を表す",
"required": "URLを入力してください",
"invalid_format": "http:// または https:// で始まるURLを入力",
"missing_placeholder": "{{queryString}}を含めてください"
},
"test": "テスト"
}
}
}
}
}
}

View File

@ -314,6 +314,10 @@
"input.web_search.builtin.disabled_content": "Текущая модель не поддерживает веб-поиск",
"input.web_search.no_web_search": "Отключить веб-поиск",
"input.web_search.no_web_search.description": "Отключить веб-поиск",
"input.tools.collapse": "Свернуть",
"input.tools.expand": "Развернуть",
"input.tools.collapse_in": "Свернуть",
"input.tools.collapse_out": "Развернуть",
"input.thinking": "Мыслим",
"input.thinking.mode.default": "По умолчанию",
"input.thinking.mode.default.tip": "Модель автоматически определяет количество токенов для размышления",
@ -683,6 +687,8 @@
"refresh": "Обновить",
"close": "Закрыть встроенное приложение",
"minimize": "Свернуть встроенное приложение",
"goBack": "Назад",
"goForward": "Вперед",
"devtools": "Инструменты разработчика",
"openExternal": "Открыть в браузере",
"rightclick_copyurl": "ПКМ → Копировать URL",
@ -811,6 +817,7 @@
"regenerate.confirm": "Это заменит ваши существующие сгенерированные изображения. Хотите продолжить?",
"seed": "Ключ генерации",
"seed_tip": "Одинаковый ключ генерации и промпт могут производить похожие изображения",
"seed_desc_tip": "Одинаковые сиды и промпты могут генерировать похожие изображения, установка -1 будет создавать разные результаты каждый раз",
"title": "Изображения",
"magic_prompt_option": "Улучшение промпта",
"model": "Версия",
@ -819,6 +826,7 @@
"rendering_speed": "Скорость рендеринга",
"learn_more": "Узнать больше",
"prompt_placeholder_edit": "Введите ваше описание изображения, текстовая отрисовка использует двойные кавычки для обертки",
"paint_course":"Руководство / Учебник",
"proxy_required": "Сейчас необходимо открыть прокси для просмотра сгенерированных изображений, в будущем будет поддерживаться прямое соединение",
"image_file_required": "Пожалуйста, сначала загрузите изображение",
"image_file_retry": "Пожалуйста, сначала загрузите изображение",
@ -884,7 +892,8 @@
"seed_tip": "Контролирует случайный характер увеличения изображений для воспроизводимых результатов",
"magic_prompt_option_tip": "Улучшает увеличение изображений с помощью интеллектуального оптимизирования промптов"
},
"rendering_speed": "Скорость рендеринга"
"rendering_speed": "Скорость рендеринга",
"text_desc_required": "Пожалуйста, сначала введите описание изображения"
},
"prompts": {
"explanation": "Объясните мне этот концепт",
@ -938,7 +947,8 @@
"zhinao": "360AI",
"zhipu": "ZHIPU AI",
"voyageai": "Voyage AI",
"qiniu": "Qiniu"
"qiniu": "Qiniu",
"tokenflux": "TokenFlux"
},
"restore": {
"confirm": "Вы уверены, что хотите восстановить данные?",
@ -1299,6 +1309,8 @@
"stdio": "Стандартный ввод/вывод (stdio)",
"inMemory": "Память",
"config_description": "Настройка серверов протокола контекста модели",
"disable": "Отключить сервер MCP",
"disable.description": "Не включать функциональность сервера MCP",
"deleteError": "Не удалось удалить сервер",
"deleteSuccess": "Сервер успешно удален",
"dependenciesInstall": "Установить зависимости",
@ -1550,6 +1562,9 @@
"rate_limit": "Ограничение скорости",
"tooltip": "Для использования Github Copilot необходимо сначала войти в Github."
},
"dmxapi": {
"select_platform": "Выберите платформу"
},
"delete.content": "Вы уверены, что хотите удалить этот провайдер?",
"delete.title": "Удалить провайдер",
"docs_check": "Проверить",
@ -1626,6 +1641,7 @@
"topic.position.left": "Слева",
"topic.position.right": "Справа",
"topic.show.time": "Показывать время топика",
"topic.pin_to_top": "Закрепленные топики сверху",
"tray.onclose": "Свернуть в трей при закрытии",
"tray.show": "Показать значок в трее",
"tray.title": "Трей",
@ -1764,6 +1780,141 @@
"quit": "Выйти",
"show_window": "Показать окно",
"visualization": "Визуализация"
},
"selection": {
"name": "Помощник выбора",
"action": {
"builtin": {
"translate": "Перевести",
"explain": "Объяснить",
"summary": "Суммаризировать",
"search": "Поиск",
"refine": "Уточнить",
"copy": "Копировать"
},
"window": {
"pin": "Закрепить",
"pinned": "Закреплено",
"opacity": "Прозрачность окна",
"original_show": "Показать оригинал",
"original_hide": "Скрыть оригинал",
"original_copy": "Копировать оригинал",
"esc_close": "Esc - закрыть",
"esc_stop": "Esc - остановить",
"c_copy": "C - копировать"
}
},
"settings": {
"experimental": "Экспериментальные функции",
"enable": {
"title": "Включить",
"description": "Поддерживается только в Windows"
},
"toolbar": {
"title": "Панель инструментов",
"trigger_mode": {
"title": "Режим активации",
"description": "Показывать панель сразу при выделении или только при удержании Ctrl.",
"description_note": "В некоторых приложениях Ctrl может не работать. Если вы используете AHK или другие инструменты для переназначения Ctrl, это может привести к тому, что некоторые приложения не смогут выделить текст.",
"selected": "При выделении",
"ctrlkey": "По Ctrl"
},
"compact_mode": {
"title": "Компактный режим",
"description": "Отображать только иконки без текста"
}
},
"window": {
"title": "Окно действий",
"follow_toolbar": {
"title": "Следовать за панелью",
"description": "Окно будет следовать за панелью. Иначе - по центру."
},
"auto_close": {
"title": "Автозакрытие",
"description": "Закрывать окно при потере фокуса (если не закреплено)"
},
"auto_pin": {
"title": "Автозакрепление",
"description": "Закреплять окно по умолчанию"
},
"opacity": {
"title": "Прозрачность",
"description": "Установить прозрачность окна по умолчанию"
}
},
"actions": {
"title": "Действия",
"reset": {
"button": "Сбросить",
"tooltip": "Сбросить стандартные действия. Пользовательские останутся.",
"confirm": "Сбросить стандартные действия? Пользовательские останутся."
},
"add_tooltip": {
"enabled": "Добавить действие",
"disabled": "Достигнут лимит ({{max}})"
},
"delete_confirm": "Удалить это действие?",
"drag_hint": "Перетащите для сортировки. Включено: {{enabled}}/{{max}}"
},
"user_modal": {
"title": {
"add": "Добавить действие",
"edit": "Редактировать действие"
},
"name": {
"label": "Название",
"hint": "Введите название"
},
"icon": {
"label": "Иконка",
"placeholder": "Название иконки Lucide",
"error": "Некорректное название",
"tooltip": "Названия в lowercase, например arrow-right",
"view_all": "Все иконки",
"random": "Случайная"
},
"model": {
"label": "Модель",
"tooltip": "Использовать ассистента: будут применены его системные настройки",
"default": "По умолчанию",
"assistant": "Ассистент"
},
"assistant": {
"label": "Ассистент",
"default": "По умолчанию"
},
"prompt": {
"label": "Промпт",
"tooltip": "Дополняет ввод пользователя, не заменяя системный промпт ассистента",
"placeholder": "Используйте {{text}} для выделенного текста. Если пусто - текст будет добавлен",
"placeholder_text": "Плейсхолдер",
"copy_placeholder": "Копировать плейсхолдер"
}
},
"search_modal": {
"title": "Поисковая система",
"engine": {
"label": "Поисковик",
"custom": "Свой"
},
"custom": {
"name": {
"label": "Название",
"hint": "Название поисковика",
"max_length": "Не более 16 символов"
},
"url": {
"label": "URL поиска",
"hint": "Используйте {{queryString}} для представления поискового запроса",
"required": "Введите URL",
"invalid_format": "URL должен начинаться с http:// или https://",
"missing_placeholder": "Должен содержать {{queryString}}"
},
"test": "Тест"
}
}
}
}
}
}

View File

@ -100,7 +100,7 @@
"titleLabel": "标题",
"titlePlaceholder": "输入标题",
"contentLabel": "内容",
"contentPlaceholder": "请输入短语内容支持使用变量然后按Tab键可以快速定位到变量进行修改。比如\n帮我规划从${from}到${to}的路线,然后发送到${email}"
"contentPlaceholder": "请输入短语内容支持使用变量然后按Tab键可以快速定位到变量进行修改。比如\n帮我规划从${from}到${to}的路线,然后发送到${email}"
}
},
"auth": {
@ -113,7 +113,7 @@
"backup": {
"confirm": "确定要备份数据吗?",
"confirm.button": "选择备份位置",
"content": "备份全部数据,包括聊天记录、设置、知识库等所有数据。请注意,备份过程可能需要一些时间,感谢您的耐心等待",
"content": "备份全部数据,包括聊天记录、设置、知识库等所有数据。请注意,备份过程可能需要一些时间,感谢您的耐心等待",
"progress": {
"completed": "备份完成",
"compressing": "压缩文件...",
@ -144,7 +144,7 @@
"artifacts.preview.openExternal.error.content": "外部浏览器打开出错",
"assistant.search.placeholder": "搜索",
"deeply_thought": "已深度思考(用时 {{seconds}} 秒)",
"default.description": "你好,我是默认助手。你可以立刻开始跟我聊天",
"default.description": "你好,我是默认助手。你可以立刻开始跟我聊天",
"default.name": "默认助手",
"default.topic.name": "默认话题",
"history": {
@ -199,6 +199,10 @@
"input.web_search.builtin.disabled_content": "当前模型不支持网络搜索功能",
"input.web_search.no_web_search": "不使用网络",
"input.web_search.no_web_search.description": "不启用网络搜索功能",
"input.tools.collapse": "折叠",
"input.tools.expand": "展开",
"input.tools.collapse_in": "加入折叠",
"input.tools.collapse_out": "移出折叠",
"message.new.branch": "分支",
"message.new.branch.created": "新分支已创建",
"message.new.context": "清除上下文",
@ -238,7 +242,7 @@
"settings.code_cacheable": "代码块缓存",
"settings.code_cacheable.tip": "缓存代码块可以减少长代码块的渲染时间,但会增加内存占用",
"settings.code_cache_max_size": "缓存上限",
"settings.code_cache_max_size.tip": "允许缓存的字符数上限(千字符),按照高亮后的代码计算。高亮后的代码长度相比于纯文本会长很多",
"settings.code_cache_max_size.tip": "允许缓存的字符数上限(千字符),按照高亮后的代码计算。高亮后的代码长度相比于纯文本会长很多",
"settings.code_cache_ttl": "缓存期限",
"settings.code_cache_ttl.tip": "缓存过期时间(分钟)",
"settings.code_cache_threshold": "缓存阈值",
@ -681,6 +685,8 @@
"minapp": {
"popup": {
"refresh": "刷新",
"goBack": "后退",
"goForward": "前进",
"close": "关闭小程序",
"minimize": "最小化小程序",
"devtools": "开发者工具",
@ -811,6 +817,7 @@
"regenerate.confirm": "这将覆盖已生成的图片,是否继续?",
"seed": "随机种子",
"seed_tip": "相同的种子和提示词可以生成相似的图片",
"seed_desc_tip": "相同的种子和提示词可以生成相似的图片,设置 -1 每次生成都不一样",
"title": "图片",
"magic_prompt_option": "提示词增强",
"model": "版本",
@ -818,6 +825,7 @@
"style_type": "风格",
"rendering_speed": "渲染速度",
"learn_more": "了解更多",
"paint_course":"教程",
"prompt_placeholder_edit": "输入你的图片描述,文本绘制用 \"双引号\" 包裹",
"proxy_required": "目前需要打开代理才能查看生成图片,后续会支持国内直连",
"image_file_required": "请先上传图片",
@ -883,7 +891,8 @@
"number_images_tip": "生成的放大结果数量",
"seed_tip": "控制放大结果的随机性",
"magic_prompt_option_tip": "智能优化放大提示词"
}
},
"text_desc_required": "请先输入图片描述"
},
"prompts": {
"explanation": "帮我解释一下这个概念",
@ -937,12 +946,13 @@
"zhinao": "360智脑",
"zhipu": "智谱AI",
"voyageai": "Voyage AI",
"qiniu": "七牛云"
"qiniu": "七牛云",
"tokenflux": "TokenFlux"
},
"restore": {
"confirm": "确定要恢复数据吗?",
"confirm.button": "选择备份文件",
"content": "恢复操作将使用备份数据覆盖当前所有应用数据。请注意,恢复过程可能需要一些时间,感谢您的耐心等待",
"content": "恢复操作将使用备份数据覆盖当前所有应用数据。请注意,恢复过程可能需要一些时间,感谢您的耐心等待",
"progress": {
"completed": "恢复完成",
"copying_files": "复制文件... {{progress}}%",
@ -995,7 +1005,7 @@
"app_knowledge.remove_all_success": "文件删除成功",
"app_logs": "应用日志",
"backup.skip_file_data_title": "精简备份",
"backup.skip_file_data_help": "备份时跳过备份图片、知识库等数据文件,仅备份聊天记录和设置。减少空间占用, 加快备份速度",
"backup.skip_file_data_help": "备份时跳过备份图片、知识库等数据文件,仅备份聊天记录和设置。减少空间占用, 加快备份速度",
"clear_cache": {
"button": "清除缓存",
"confirm": "清除缓存将删除应用缓存的数据,包括小程序数据。此操作不可恢复,是否继续?",
@ -1037,7 +1047,7 @@
"url": "Joplin 剪裁服务监听 URL",
"url_placeholder": "http://127.0.0.1:41184/"
},
"markdown_export.force_dollar_math.help": "开启后导出Markdown时会将强制使用$$来标记LaTeX公式。注意该项也会影响所有通过Markdown导出的方式如Notion、语雀等",
"markdown_export.force_dollar_math.help": "开启后导出Markdown时会将强制使用$$来标记LaTeX公式。注意该项也会影响所有通过Markdown导出的方式如Notion、语雀等",
"markdown_export.force_dollar_math.title": "强制使用$$来标记LaTeX公式",
"markdown_export.help": "若填入,则每次导出时将自动保存到该路径;否则,将弹出保存对话框",
"markdown_export.path": "默认导出路径",
@ -1045,7 +1055,7 @@
"markdown_export.select": "选择",
"markdown_export.title": "Markdown 导出",
"message_title.use_topic_naming.title": "使用话题命名模型为导出的消息创建标题",
"message_title.use_topic_naming.help": "开启后使用话题命名模型为导出的消息创建标题。该项也会影响所有通过Markdown导出的方式",
"message_title.use_topic_naming.help": "开启后使用话题命名模型为导出的消息创建标题。该项也会影响所有通过Markdown导出的方式",
"minute_interval_one": "{{count}} 分钟",
"minute_interval_other": "{{count}} 分钟",
"notion.api_key": "Notion 密钥",
@ -1084,8 +1094,8 @@
"backup.manager.restore.success": "恢复成功,应用将在几秒后刷新",
"backup.manager.restore.error": "恢复失败",
"backup.manager.delete.confirm.title": "确认删除",
"backup.manager.delete.confirm.single": "确定要删除备份文件 \"{{fileName}}\" 吗?此操作不可恢复",
"backup.manager.delete.confirm.multiple": "确定要删除选中的 {{count}} 个备份文件吗?此操作不可恢复",
"backup.manager.delete.confirm.single": "确定要删除备份文件 \"{{fileName}}\" 吗?此操作不可恢复",
"backup.manager.delete.confirm.multiple": "确定要删除选中的 {{count}} 个备份文件吗?此操作不可恢复",
"backup.manager.delete.success.single": "删除成功",
"backup.manager.delete.success.multiple": "成功删除 {{count}} 个备份文件",
"backup.manager.delete.error": "删除失败",
@ -1215,20 +1225,20 @@
"custom": {
"title": "自定义",
"edit_title": "编辑自定义小程序",
"save_success": "自定义小程序保存成功",
"save_error": "自定义小程序保存失败",
"remove_success": "自定义小程序删除成功",
"remove_error": "自定义小程序删除失败",
"logo_upload_success": "Logo 上传成功",
"logo_upload_error": "Logo 上传失败",
"save_success": "自定义小程序保存成功",
"save_error": "自定义小程序保存失败",
"remove_success": "自定义小程序删除成功",
"remove_error": "自定义小程序删除失败",
"logo_upload_success": "Logo 上传成功",
"logo_upload_error": "Logo 上传失败",
"id": "ID",
"id_error": "ID 是必填项",
"id_error": "ID 是必填项",
"id_placeholder": "请输入 ID",
"name": "名称",
"name_error": "名称是必填项",
"name_error": "名称是必填项",
"name_placeholder": "请输入名称",
"url": "URL",
"url_error": "URL 是必填项",
"url_error": "URL 是必填项",
"url_placeholder": "请输入 URL",
"logo": "Logo",
"logo_url": "Logo URL",
@ -1238,7 +1248,7 @@
"logo_upload_label": "上传 Logo",
"logo_upload_button": "上传",
"save": "保存",
"edit_description": "在这里编辑自定义小应用的配置。每个应用需要包含 id、name、url 和 logo 字段",
"edit_description": "在这里编辑自定义小应用的配置。每个应用需要包含 id、name、url 和 logo 字段",
"placeholder": "请输入自定义小程序配置JSON格式",
"duplicate_ids": "发现重复的ID: {{ids}}",
"conflicting_ids": "与默认应用ID冲突: {{ids}}"
@ -1286,7 +1296,7 @@
"addServer": "添加服务器",
"addServer.create": "快速创建",
"addServer.importFrom": "从 JSON 导入",
"addServer.importFrom.tooltip": "请从 MCP Servers 的介绍页面复制配置JSON优先使用\n NPX或 UVX 配置),并粘贴到输入框中",
"addServer.importFrom.tooltip": "请从 MCP Servers 的介绍页面复制配置JSON优先使用\n NPX或 UVX 配置),并粘贴到输入框中",
"addServer.importFrom.placeholder": "粘贴 MCP 服务器 JSON 配置",
"addServer.importFrom.invalid": "无效输入,请检查 JSON 格式",
"addServer.importFrom.nameExists": "服务器已存在:{{name}}",
@ -1302,6 +1312,8 @@
"stdio": "标准输入/输出 (stdio)",
"inMemory": "内存",
"config_description": "配置模型上下文协议服务器",
"disable": "不使用 MCP 服务器",
"disable.description": "不启用 MCP 服务功能",
"deleteError": "删除服务器失败",
"deleteSuccess": "服务器删除成功",
"dependenciesInstall": "安装依赖项",
@ -1321,7 +1333,7 @@
"installError": "安装依赖项失败",
"installSuccess": "依赖项安装成功",
"jsonFormatError": "JSON格式化错误",
"jsonModeHint": "编辑MCP服务器配置的JSON表示。保存前请确保格式正确",
"jsonModeHint": "编辑MCP服务器配置的JSON表示。保存前请确保格式正确",
"jsonSaveError": "保存JSON配置失败",
"jsonSaveSuccess": "JSON配置已保存",
"missingDependencies": "缺失,请安装它以继续",
@ -1388,7 +1400,7 @@
"deleteServer": "删除服务器",
"deleteServerConfirm": "确定要删除此服务器吗?",
"registry": "包管理源",
"registryTooltip": "选择用于安装包的源,以解决默认源的网络问题",
"registryTooltip": "选择用于安装包的源,以解决默认源的网络问题",
"registryDefault": "默认",
"not_support": "模型不支持",
"user": "用户",
@ -1472,7 +1484,7 @@
"models.check.model_status_partial": "其中 {{count}} 个模型用某些密钥无法访问",
"models.check.model_status_passed": "{{count}} 个模型通过健康检测",
"models.check.model_status_summary": "{{provider}}: {{summary}}",
"models.check.no_api_keys": "未找到API密钥请先添加API密钥",
"models.check.no_api_keys": "未找到API密钥请先添加API密钥",
"models.check.passed": "通过",
"models.check.select_api_key": "选择要使用的API密钥",
"models.check.single": "单个",
@ -1520,7 +1532,7 @@
"api_key.tip": "多个密钥使用逗号分隔",
"api_version": "API 版本",
"basic_auth": "HTTP 认证",
"basic_auth.tip": "适用于通过服务器部署的实例(参见文档)。目前仅支持 Basic 方案RFC7617",
"basic_auth.tip": "适用于通过服务器部署的实例(参见文档)。目前仅支持 Basic 方案RFC7617",
"basic_auth.user_name": "用户名",
"basic_auth.user_name.tip": "留空以禁用",
"basic_auth.password": "密码",
@ -1562,6 +1574,9 @@
"rate_limit": "速率限制",
"tooltip": "使用 Github Copilot 需要先登录 Github"
},
"dmxapi": {
"select_platform": "选择平台"
},
"delete.content": "确定要删除此模型提供商吗?",
"delete.title": "删除提供商",
"docs_check": "查看",
@ -1635,6 +1650,7 @@
"topic.position.left": "左侧",
"topic.position.right": "右侧",
"topic.show.time": "显示话题时间",
"topic.pin_to_top": "固定话题置顶",
"tray.onclose": "关闭时最小化到托盘",
"tray.show": "显示托盘图标",
"tray.title": "托盘",
@ -1681,7 +1697,7 @@
"titleLabel": "标题",
"contentLabel": "内容",
"titlePlaceholder": "请输入短语标题",
"contentPlaceholder": "请输入短语内容支持使用变量然后按Tab键可以快速定位到变量进行修改。比如\n帮我规划从${from}到${to}的路线,然后发送到${email}",
"contentPlaceholder": "请输入短语内容支持使用变量然后按Tab键可以快速定位到变量进行修改。比如\n帮我规划从${from}到${to}的路线,然后发送到${email}",
"delete": "删除短语",
"deleteConfirm": "删除短语后将无法恢复,是否继续?",
"locationLabel": "添加位置",
@ -1763,6 +1779,141 @@
"quit": "退出",
"show_window": "显示窗口",
"visualization": "可视化"
},
"selection": {
"name": "划词助手",
"action": {
"builtin": {
"translate": "翻译",
"explain": "解释",
"summary": "总结",
"search": "搜索",
"refine": "优化",
"copy": "复制"
},
"window": {
"pin": "置顶",
"pinned": "已置顶",
"opacity": "窗口透明度",
"original_show": "显示原文",
"original_hide": "隐藏原文",
"original_copy": "复制原文",
"esc_close": "Esc 关闭",
"esc_stop": "Esc 停止",
"c_copy": "C 复制"
}
},
"settings": {
"experimental": "实验性功能",
"enable": {
"title": "启用",
"description": "当前仅支持 Windows 系统"
},
"toolbar": {
"title": "工具栏",
"trigger_mode": {
"title": "触发方式",
"description": "划词立即显示工具栏,或者划词后按住 Ctrl 键才显示工具栏。",
"description_note": "少数应用不支持通过 Ctrl 键划词。若使用了AHK等工具对 Ctrl 键进行了重映射,可能导致部分应用无法划词。",
"selected": "划词",
"ctrlkey": "Ctrl 键"
},
"compact_mode": {
"title": "紧凑模式",
"description": "紧凑模式下,只显示图标,不显示文字"
}
},
"window": {
"title": "功能窗口",
"follow_toolbar": {
"title": "跟随工具栏",
"description": "窗口位置将跟随工具栏显示,禁用后则始终居中显示"
},
"auto_close": {
"title": "自动关闭",
"description": "当窗口未置顶且失去焦点时,将自动关闭该窗口"
},
"auto_pin": {
"title": "自动置顶",
"description": "默认将窗口置于顶部"
},
"opacity": {
"title": "透明度",
"description": "设置窗口的默认透明度100%为完全不透明"
}
},
"actions": {
"title": "功能",
"reset": {
"button": "重置",
"tooltip": "重置为默认功能,自定义功能不会被删除",
"confirm": "确定要重置为默认功能吗?自定义功能不会被删除。"
},
"add_tooltip": {
"enabled": "添加自定义功能",
"disabled": "自定义功能已达上限 ({{max}}个)"
},
"delete_confirm": "确定要删除这个自定义功能吗?",
"drag_hint": "拖拽排序,移动到上方以启用功能 ({{enabled}}/{{max}})"
},
"user_modal": {
"title": {
"add": "添加自定义功能",
"edit": "编辑自定义功能"
},
"name": {
"label": "名称",
"hint": "请输入功能名称"
},
"icon": {
"label": "图标",
"placeholder": "输入 Lucide 图标名称",
"error": "无效的图标名称,请检查输入",
"tooltip": "Lucide图标名称为小写如 arrow-right",
"view_all": "查看所有图标",
"random": "随机图标"
},
"model": {
"label": "模型",
"tooltip": "使用助手:会同时使用助手的系统提示词和模型参数",
"default": "默认模型",
"assistant": "使用助手"
},
"assistant": {
"label": "选择助手",
"default": "默认"
},
"prompt": {
"label": "用户提示词(Prompt)",
"tooltip": "用户提示词,作为用户输入的补充,不会覆盖助手的系统提示词",
"placeholder": "使用占位符{{text}}代表选中的文本,不填写时,选中的文本将添加到本提示词的末尾",
"placeholder_text": "占位符",
"copy_placeholder": "复制占位符"
}
},
"search_modal": {
"title": "设置搜索引擎",
"engine": {
"label": "搜索引擎",
"custom": "自定义"
},
"custom": {
"name": {
"label": "自定义名称",
"hint": "请输入搜索引擎名称",
"max_length": "名称不能超过16个字符"
},
"url": {
"label": "自定义搜索 URL",
"hint": "用 {{queryString}} 代表搜索词",
"required": "请输入搜索 URL",
"invalid_format": "请输入以 http:// 或 https:// 开头的有效 URL",
"missing_placeholder": "URL 必须包含 {{queryString}} 占位符"
},
"test": "测试"
}
}
}
}
}
}

View File

@ -93,7 +93,7 @@
"titleLabel": "標題",
"titlePlaceholder": "輸入標題",
"contentLabel": "內容",
"contentPlaceholder": "請輸入短語內容支持使用變量然後按Tab鍵可以快速定位到變量進行修改。比如\n幫我規劃從${from}到${to}的行程,然後發送到${email}"
"contentPlaceholder": "請輸入短語內容支持使用變量然後按Tab鍵可以快速定位到變量進行修改。比如\n幫我規劃從${from}到${to}的行程,然後發送到${email}"
},
"settings.knowledge_base.recognition.tip": "智慧代理人將調用大語言模型的意圖識別能力,判斷是否需要調用知識庫進行回答,該功能將依賴模型的能力",
"settings.knowledge_base.recognition": "調用知識庫",
@ -113,7 +113,7 @@
"backup": {
"confirm": "確定要備份資料嗎?",
"confirm.button": "選擇備份位置",
"content": "備份全部資料,包括聊天記錄、設定、知識庫等全部資料。請注意,備份過程可能需要一些時間,感謝您的耐心等待",
"content": "備份全部資料,包括聊天記錄、設定、知識庫等全部資料。請注意,備份過程可能需要一些時間,感謝您的耐心等待",
"progress": {
"completed": "備份完成",
"compressing": "壓縮檔案...",
@ -144,7 +144,7 @@
"artifacts.preview.openExternal.error.content": "外部瀏覽器開啟出錯",
"assistant.search.placeholder": "搜尋",
"deeply_thought": "已深度思考(用時 {{seconds}} 秒)",
"default.description": "你好,我是預設助手。你可以立即開始與我聊天",
"default.description": "你好,我是預設助手。你可以立即開始與我聊天",
"default.name": "預設助手",
"default.topic.name": "預設話題",
"history": {
@ -224,18 +224,18 @@
"settings.code_cacheable": "程式碼區塊快取",
"settings.code_cacheable.tip": "快取程式碼區塊可以減少長程式碼區塊的渲染時間,但會增加記憶體使用量",
"settings.code_cache_max_size": "快取上限",
"settings.code_cache_max_size.tip": "允許快取的字元數上限(千字符),按照高亮後的程式碼計算。高亮後的程式碼長度相比純文字會長很多",
"settings.code_cache_max_size.tip": "允許快取的字元數上限(千字符),按照高亮後的程式碼計算。高亮後的程式碼長度相比純文字會長很多",
"settings.code_cache_ttl": "快取期限",
"settings.code_cache_ttl.tip": "快取的存活時間(分鐘)",
"settings.code_cache_threshold": "快取門檻",
"settings.code_cache_threshold.tip": "允許快取的最小程式碼長度(千字符),超過門檻的程式碼區塊才會被快取",
"settings.context_count": "上下文",
"settings.context_count.tip": "在上下文中保留的前幾則訊息",
"settings.context_count.tip": "在上下文中保留的前幾則訊息",
"settings.max": "最大",
"settings.max_tokens": "最大 Token 數",
"settings.max_tokens.confirm": "設置最大 Token 數",
"settings.max_tokens.confirm_content": "設置單次交互所用的最大 Token 數,會影響返回結果的長度。要根據模型上下文限制來設定,否則會發生錯誤",
"settings.max_tokens.tip": "模型可以生成的最大 Token 數。要根據模型上下文限制來設定,否則會發生錯誤",
"settings.max_tokens.confirm_content": "設置單次交互所用的最大 Token 數,會影響返回結果的長度。要根據模型上下文限制來設定,否則會發生錯誤",
"settings.max_tokens.tip": "模型可以生成的最大 Token 數。要根據模型上下文限制來設定,否則會發生錯誤",
"settings.reset": "重設",
"settings.set_as_default": "設為預設助手",
"settings.show_line_numbers": "程式碼顯示行號",
@ -314,6 +314,10 @@
"input.web_search.builtin.disabled_content": "當前模型不支持網路搜尋功能",
"input.web_search.no_web_search": "關閉網路搜尋",
"input.web_search.no_web_search.description": "關閉網路搜尋",
"input.tools.collapse": "折疊",
"input.tools.expand": "展開",
"input.tools.collapse_in": "加入折疊",
"input.tools.collapse_out": "移出折疊",
"input.thinking": "思考",
"input.thinking.mode.default": "預設",
"input.thinking.mode.default.tip": "模型會自動確定思考的 token 數",
@ -467,7 +471,7 @@
"type": "類型"
},
"gpustack": {
"keep_alive_time.description": "模型在記憶體中保持的時間(預設為 5 分鐘)",
"keep_alive_time.description": "模型在記憶體中保持的時間(預設為 5 分鐘)",
"keep_alive_time.placeholder": "分鐘",
"keep_alive_time.title": "保持活躍時間",
"title": "GPUStack"
@ -568,7 +572,7 @@
"spanish": "西班牙文"
},
"lmstudio": {
"keep_alive_time.description": "對話後模型在記憶體中保持的時間(預設為 5 分鐘)",
"keep_alive_time.description": "對話後模型在記憶體中保持的時間(預設為 5 分鐘)",
"keep_alive_time.placeholder": "分鐘",
"keep_alive_time.title": "保持活躍時間",
"title": "LM Studio"
@ -682,6 +686,8 @@
"minapp": {
"popup": {
"refresh": "重新整理",
"goBack": "上一頁",
"goForward": "下一頁",
"close": "關閉小工具",
"minimize": "最小化小工具",
"devtools": "開發者工具",
@ -788,7 +794,7 @@
"knowledge.error": "無法將 {{type}} 加入知識庫: {{error}}"
},
"ollama": {
"keep_alive_time.description": "對話後模型在記憶體中保持的時間(預設為 5 分鐘)",
"keep_alive_time.description": "對話後模型在記憶體中保持的時間(預設為 5 分鐘)",
"keep_alive_time.placeholder": "分鐘",
"keep_alive_time.title": "保持活躍時間",
"title": "Ollama"
@ -812,6 +818,7 @@
"regenerate.confirm": "這將覆蓋已生成的圖片,是否繼續?",
"seed": "隨機種子",
"seed_tip": "相同的種子和提示詞可以生成相似的圖片",
"seed_desc_tip": "相同的種子和提示詞可以生成相似的圖片,設置 -1 每次生成都不一樣",
"title": "繪圖",
"magic_prompt_option": "提示詞增強",
"model": "版本",
@ -819,6 +826,7 @@
"style_type": "風格",
"learn_more": "了解更多",
"prompt_placeholder_edit": "輸入你的圖片描述,文本繪製用 '雙引號' 包裹",
"paint_course":"教程",
"proxy_required": "目前需要打開代理才能查看生成圖片,後續會支持國內直連",
"image_file_required": "請先上傳圖片",
"image_file_retry": "請重新上傳圖片",
@ -884,7 +892,8 @@
"seed_tip": "控制放大結果的隨機性",
"magic_prompt_option_tip": "智能優化放大提示詞"
},
"rendering_speed": "渲染速度"
"rendering_speed": "渲染速度",
"text_desc_required": "請先輸入圖片描述"
},
"prompts": {
"explanation": "幫我解釋一下這個概念",
@ -938,12 +947,13 @@
"zhinao": "360 智腦",
"zhipu": "智譜 AI",
"voyageai": "Voyage AI",
"qiniu": "七牛雲"
"qiniu": "七牛雲",
"tokenflux": "TokenFlux"
},
"restore": {
"confirm": "確定要復原資料嗎?",
"confirm.button": "選擇備份檔案",
"content": "復原操作將使用備份資料覆蓋目前所有應用程式資料。請注意,復原過程可能需要一些時間,感謝您的耐心等待",
"content": "復原操作將使用備份資料覆蓋目前所有應用程式資料。請注意,復原過程可能需要一些時間,感謝您的耐心等待",
"progress": {
"completed": "復原完成",
"copying_files": "複製檔案... {{progress}}%",
@ -996,7 +1006,7 @@
"app_knowledge.remove_all_success": "檔案刪除成功",
"app_logs": "應用程式日誌",
"backup.skip_file_data_title": "精簡備份",
"backup.skip_file_data_help": "備份時跳過備份圖片、知識庫等數據文件,僅備份聊天記錄和設置。減少空間佔用, 加快備份速度",
"backup.skip_file_data_help": "備份時跳過備份圖片、知識庫等數據文件,僅備份聊天記錄和設置。減少空間佔用, 加快備份速度",
"clear_cache": {
"button": "清除快取",
"confirm": "清除快取將刪除應用快取資料,包括小工具資料。此操作不可恢復,是否繼續?",
@ -1038,9 +1048,9 @@
"url": "Joplin 剪輯服務 URL",
"url_placeholder": "http://127.0.0.1:41184/"
},
"markdown_export.force_dollar_math.help": "開啟後匯出Markdown時會強制使用$$來標記LaTeX公式。注意該項也會影響所有透過Markdown匯出的方式如Notion、語雀等",
"markdown_export.force_dollar_math.help": "開啟後匯出Markdown時會強制使用$$來標記LaTeX公式。注意該項也會影響所有透過Markdown匯出的方式如Notion、語雀等",
"markdown_export.force_dollar_math.title": "LaTeX公式強制使用$$",
"markdown_export.help": "若填入,每次匯出時將自動儲存至該路徑;否則,將彈出儲存對話框",
"markdown_export.help": "若填入,每次匯出時將自動儲存至該路徑;否則,將彈出儲存對話框",
"markdown_export.path": "預設匯出路徑",
"markdown_export.path_placeholder": "匯出路徑",
"markdown_export.select": "選擇",
@ -1083,8 +1093,8 @@
"backup.manager.restore.success": "恢復成功,應用將在幾秒後刷新",
"backup.manager.restore.error": "恢復失敗",
"backup.manager.delete.confirm.title": "確認刪除",
"backup.manager.delete.confirm.single": "確定要刪除備份文件 \"{{fileName}}\" 嗎?此操作不可恢復",
"backup.manager.delete.confirm.multiple": "確定要刪除選中的 {{count}} 個備份文件嗎?此操作不可恢復",
"backup.manager.delete.confirm.single": "確定要刪除備份文件 \"{{fileName}}\" 嗎?此操作不可恢復",
"backup.manager.delete.confirm.multiple": "確定要刪除選中的 {{count}} 個備份文件嗎?此操作不可恢復",
"backup.manager.delete.success.single": "刪除成功",
"backup.manager.delete.success.multiple": "成功刪除 {{count}} 個備份文件",
"backup.manager.delete.error": "刪除失敗",
@ -1187,7 +1197,7 @@
"new_folder.button": "新建文件夾"
},
"message_title.use_topic_naming.title": "使用話題命名模型為導出的消息創建標題",
"message_title.use_topic_naming.help": "此設定會影響所有通過Markdown導出的方式如Notion、語雀等"
"message_title.use_topic_naming.help": "此設定會影響所有通過Markdown導出的方式如Notion、語雀等"
},
"display.assistant.title": "助手設定",
"display.custom.css": "自訂 CSS",
@ -1218,20 +1228,20 @@
"conflicting_ids": "與預設應用ID衝突: {{ids}}",
"title": "自定義",
"edit_title": "編輯自定義小程序",
"save_success": "自定義小程序保存成功",
"save_error": "自定義小程序保存失敗",
"remove_success": "自定義小程序刪除成功",
"remove_error": "自定義小程序刪除失敗",
"logo_upload_success": "Logo 上傳成功",
"logo_upload_error": "Logo 上傳失敗",
"save_success": "自定義小程序保存成功",
"save_error": "自定義小程序保存失敗",
"remove_success": "自定義小程序刪除成功",
"remove_error": "自定義小程序刪除失敗",
"logo_upload_success": "Logo 上傳成功",
"logo_upload_error": "Logo 上傳失敗",
"id": "ID",
"id_error": "ID 是必填項",
"id_error": "ID 是必填項",
"id_placeholder": "請輸入 ID",
"name": "名稱",
"name_error": "名稱是必填項",
"name_error": "名稱是必填項",
"name_placeholder": "請輸入名稱",
"url": "URL",
"url_error": "URL 是必填項",
"url_error": "URL 是必填項",
"url_placeholder": "請輸入 URL",
"logo": "Logo",
"logo_url": "Logo URL",
@ -1286,7 +1296,7 @@
"addServer": "新增伺服器",
"addServer.create": "快速創建",
"addServer.importFrom": "從 JSON 導入",
"addServer.importFrom.tooltip": "請從 MCP Servers 的介紹頁面複製配置JSON優先使用\n NPX或 UVX 配置),並粘貼到輸入框中",
"addServer.importFrom.tooltip": "請從 MCP Servers 的介紹頁面複製配置JSON優先使用\n NPX或 UVX 配置),並粘貼到輸入框中",
"addServer.importFrom.placeholder": "貼上 MCP 伺服器 JSON 設定",
"addServer.importFrom.invalid": "無效的輸入,請檢查 JSON 格式",
"addServer.importFrom.nameExists": "伺服器已存在:{{name}}",
@ -1302,6 +1312,8 @@
"stdio": "標準輸入/輸出 (stdio)",
"inMemory": "記憶體",
"config_description": "設定模型上下文協議伺服器",
"disable": "不使用 MCP 伺服器",
"disable.description": "不啟用 MCP 服務功能",
"deleteError": "刪除伺服器失敗",
"deleteSuccess": "伺服器刪除成功",
"dependenciesInstall": "安裝相依套件",
@ -1321,7 +1333,7 @@
"installError": "安裝相依套件失敗",
"installSuccess": "相依套件安裝成功",
"jsonFormatError": "JSON格式錯誤",
"jsonModeHint": "編輯MCP伺服器配置的JSON表示。保存前請確保格式正確",
"jsonModeHint": "編輯MCP伺服器配置的JSON表示。保存前請確保格式正確",
"jsonSaveError": "保存JSON配置失敗",
"jsonSaveSuccess": "JSON配置已儲存",
"missingDependencies": "缺失,請安裝它以繼續",
@ -1388,7 +1400,7 @@
"deleteServer": "刪除伺服器",
"deleteServerConfirm": "確定要刪除此伺服器嗎?",
"registry": "套件管理源",
"registryTooltip": "選擇用於安裝套件的源,以解決預設源的網路問題",
"registryTooltip": "選擇用於安裝套件的源,以解決預設源的網路問題",
"registryDefault": "預設",
"not_support": "不支援此模型",
"user": "用戶",
@ -1472,7 +1484,7 @@
"models.check.model_status_partial": "其中 {{count}} 個模型用某些密鑰無法訪問",
"models.check.model_status_passed": "{{count}} 個模型通過健康檢查",
"models.check.model_status_summary": "{{provider}}: {{summary}}",
"models.check.no_api_keys": "未找到API密鑰請先添加API密鑰",
"models.check.no_api_keys": "未找到API密鑰請先添加API密鑰",
"models.check.passed": "通過",
"models.check.select_api_key": "選擇要使用的API密鑰",
"models.check.single": "單個",
@ -1514,7 +1526,7 @@
"api_key.tip": "多個金鑰使用逗號分隔",
"api_version": "API 版本",
"basic_auth": "HTTP 認證",
"basic_auth.tip": "適用於透過伺服器部署的實例(請參閱文檔)。目前僅支援 Basic 方案RFC7617",
"basic_auth.tip": "適用於透過伺服器部署的實例(請參閱文檔)。目前僅支援 Basic 方案RFC7617",
"basic_auth.user_name": "用戶",
"basic_auth.user_name.tip": "留空以停用",
"basic_auth.password": "密碼",
@ -1553,6 +1565,9 @@
"rate_limit": "速率限制",
"tooltip": "使用 Github Copilot 需要先登入 Github"
},
"dmxapi": {
"select_platform": "選擇平臺"
},
"delete.content": "確定要刪除此提供者嗎?",
"delete.title": "刪除提供者",
"docs_check": "檢查",
@ -1572,7 +1587,7 @@
"markdown_editor_default_value": "預覽區域"
},
"openai": {
"alert": "OpenAI Provider 不再支援舊的呼叫方法。如果使用第三方 API請建立新的服務供應商"
"alert": "OpenAI Provider 不再支援舊的呼叫方法。如果使用第三方 API請建立新的服務供應商"
}
},
"proxy": {
@ -1629,6 +1644,7 @@
"topic.position.left": "左側",
"topic.position.right": "右側",
"topic.show.time": "顯示話題時間",
"topic.pin_to_top": "固定話題置頂",
"tray.onclose": "關閉時最小化到系统匣",
"tray.show": "顯示系统匣圖示",
"tray.title": "系统匣",
@ -1666,7 +1682,7 @@
"apikey": "API 金鑰",
"free": "免費",
"content_limit": "內容長度限制",
"content_limit_tooltip": "限制搜尋結果的內容長度,超過限制的內容將被截斷"
"content_limit_tooltip": "限制搜尋結果的內容長度,超過限制的內容將被截斷"
},
"general.auto_check_update.title": "啟用自動更新",
"quickPhrase": {
@ -1676,7 +1692,7 @@
"titleLabel": "標題",
"contentLabel": "內容",
"titlePlaceholder": "請輸入短語標題",
"contentPlaceholder": "請輸入短語內容支持使用變量然後按Tab鍵可以快速定位到變量進行修改。比如\n幫我規劃從${from}到${to}的行程,然後發送到${email}",
"contentPlaceholder": "請輸入短語內容支持使用變量然後按Tab鍵可以快速定位到變量進行修改。比如\n幫我規劃從${from}到${to}的行程,然後發送到${email}",
"delete": "刪除短語",
"deleteConfirm": "刪除後無法復原,是否繼續?",
"locationLabel": "添加位置",
@ -1764,6 +1780,141 @@
"quit": "結束",
"show_window": "顯示視窗",
"visualization": "視覺化"
},
"selection": {
"name": "劃詞助手",
"action": {
"builtin": {
"translate": "翻譯",
"explain": "解釋",
"summary": "總結",
"search": "搜尋",
"refine": "優化",
"copy": "複製"
},
"window": {
"pin": "置頂",
"pinned": "已置頂",
"opacity": "視窗透明度",
"original_show": "顯示原文",
"original_hide": "隱藏原文",
"original_copy": "複製原文",
"esc_close": "Esc 關閉",
"esc_stop": "Esc 停止",
"c_copy": "C 複製"
}
},
"settings": {
"experimental": "實驗性功能",
"enable": {
"title": "啟用",
"description": "目前僅支援 Windows 系統"
},
"toolbar": {
"title": "工具列",
"trigger_mode": {
"title": "觸發方式",
"description": "劃詞立即顯示工具列,或者劃詞後按住 Ctrl 鍵才顯示工具列。",
"description_note": "在某些應用中可能無法透過 Ctrl 鍵劃詞。若使用了AHK等工具對Ctrl鍵進行了重新對應可能導致部分應用程式無法劃詞。",
"selected": "劃詞",
"ctrlkey": "Ctrl 鍵"
},
"compact_mode": {
"title": "緊湊模式",
"description": "緊湊模式下,只顯示圖示,不顯示文字"
}
},
"window": {
"title": "功能視窗",
"follow_toolbar": {
"title": "跟隨工具列",
"description": "視窗位置將跟隨工具列顯示,停用後則始終置中顯示"
},
"auto_close": {
"title": "自動關閉",
"description": "當視窗未置頂且失去焦點時,將自動關閉該視窗"
},
"auto_pin": {
"title": "自動置頂",
"description": "預設將視窗置於頂部"
},
"opacity": {
"title": "透明度",
"description": "設置視窗的默認透明度100%為完全不透明"
}
},
"actions": {
"title": "功能",
"reset": {
"button": "重設",
"tooltip": "重設為預設功能,自訂功能不會被刪除",
"confirm": "確定要重設為預設功能嗎?自訂功能不會被刪除。"
},
"add_tooltip": {
"enabled": "新增自訂功能",
"disabled": "自訂功能已達上限 ({{max}}個)"
},
"delete_confirm": "確定要刪除這個自訂功能嗎?",
"drag_hint": "拖曳排序,移動到上方以啟用功能 ({{enabled}}/{{max}})"
},
"user_modal": {
"title": {
"add": "新增自訂功能",
"edit": "編輯自訂功能"
},
"name": {
"label": "名稱",
"hint": "請輸入功能名稱"
},
"icon": {
"label": "圖示",
"placeholder": "輸入 Lucide 圖示名稱",
"error": "無效的圖示名稱,請檢查輸入",
"tooltip": "Lucide圖示名稱為小寫如 arrow-right",
"view_all": "檢視所有圖示",
"random": "隨機圖示"
},
"model": {
"label": "模型",
"tooltip": "使用助手:會同時使用助手的系統提示詞和模型參數",
"default": "預設模型",
"assistant": "使用助手"
},
"assistant": {
"label": "選擇助手",
"default": "預設"
},
"prompt": {
"label": "使用者提示詞(Prompt)",
"tooltip": "使用者提示詞,作為使用者輸入的補充,不會覆蓋助手的系統提示詞",
"placeholder": "使用佔位符{{text}}代表選取的文字,不填寫時,選取的文字將加到本提示詞的末尾",
"placeholder_text": "佔位符",
"copy_placeholder": "複製佔位符"
}
},
"search_modal": {
"title": "設定搜尋引擎",
"engine": {
"label": "搜尋引擎",
"custom": "自訂"
},
"custom": {
"name": {
"label": "自訂名稱",
"hint": "請輸入搜尋引擎名稱",
"max_length": "名稱不能超過16個字元"
},
"url": {
"label": "自訂搜尋 URL",
"hint": "使用 {{queryString}} 代表搜尋詞",
"required": "請輸入搜尋 URL",
"invalid_format": "請輸入以 http:// 或 https:// 開頭的有效 URL",
"missing_placeholder": "URL 必須包含 {{queryString}} 佔位符"
},
"test": "測試"
}
}
}
}
}
}

View File

@ -1,13 +1,11 @@
import { PlusOutlined, UploadOutlined } from '@ant-design/icons'
import MinAppIcon from '@renderer/components/Icons/MinAppIcon'
import { loadCustomMiniApp, ORIGIN_DEFAULT_MIN_APPS, updateDefaultMinApps } from '@renderer/config/minapps'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { useMinapps } from '@renderer/hooks/useMinapps'
import { MinAppType } from '@renderer/types'
import type { MenuProps } from 'antd'
import { Button, Dropdown, Form, Input, message, Modal, Radio, Upload } from 'antd'
import type { UploadFile } from 'antd/es/upload/interface'
import { FC, useState } from 'react'
import { Dropdown, message } from 'antd'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -24,215 +22,72 @@ const App: FC<Props> = ({ app, onClick, size = 60, isLast }) => {
const { minapps, pinned, disabled, updateMinapps, updateDisabledMinapps, updatePinnedMinapps } = useMinapps()
const isPinned = pinned.some((p) => p.id === app.id)
const isVisible = minapps.some((m) => m.id === app.id)
const [isModalVisible, setIsModalVisible] = useState(false)
const [form] = Form.useForm()
const [logoType, setLogoType] = useState<'url' | 'file'>('url')
const [fileList, setFileList] = useState<UploadFile[]>([])
const handleClick = () => {
if (isLast) {
setIsModalVisible(true)
return
}
openMinappKeepAlive(app)
onClick?.()
}
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
const menuItems: MenuProps['items'] = [
{
key: 'togglePin',
label: isPinned ? t('minapp.sidebar.remove.title') : t('minapp.sidebar.add.title'),
onClick: () => {
const newPinned = isPinned ? pinned.filter((item) => item.id !== app.id) : [...(pinned || []), app]
updatePinnedMinapps(newPinned)
}
if (ORIGIN_DEFAULT_MIN_APPS.some((app: MinAppType) => app.id === values.id)) {
message.error(t('settings.miniapps.custom.conflicting_ids', { ids: values.id }))
return
},
{
key: 'hide',
label: t('minapp.sidebar.hide.title'),
onClick: () => {
const newMinapps = minapps.filter((item) => item.id !== app.id)
updateMinapps(newMinapps)
const newDisabled = [...(disabled || []), app]
updateDisabledMinapps(newDisabled)
const newPinned = pinned.filter((item) => item.id !== app.id)
updatePinnedMinapps(newPinned)
}
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 handleLogoTypeChange = (e: any) => {
setLogoType(e.target.value)
form.setFieldValue('logo', '')
setFileList([])
}
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'))
}
}
}
const menuItems: MenuProps['items'] = isLast
? []
: [
{
key: 'togglePin',
label: isPinned ? t('minapp.sidebar.remove.title') : t('minapp.sidebar.add.title'),
onClick: () => {
const newPinned = isPinned ? pinned.filter((item) => item.id !== app.id) : [...(pinned || []), app]
updatePinnedMinapps(newPinned)
}
},
{
key: 'hide',
label: t('minapp.sidebar.hide.title'),
onClick: () => {
const newMinapps = minapps.filter((item) => item.id !== app.id)
updateMinapps(newMinapps)
const newDisabled = [...(disabled || []), app]
updateDisabledMinapps(newDisabled)
const newPinned = pinned.filter((item) => item.id !== app.id)
updatePinnedMinapps(newPinned)
}
},
...(app.type === 'Custom'
? [
{
key: 'removeCustom',
label: t('minapp.sidebar.remove_custom.title'),
danger: true,
onClick: async () => {
try {
const content = await window.api.file.read('custom-minapps.json')
const customApps = JSON.parse(content)
const updatedApps = customApps.filter((customApp: MinAppType) => customApp.id !== app.id)
await window.api.file.writeWithId('custom-minapps.json', JSON.stringify(updatedApps, null, 2))
message.success(t('settings.miniapps.custom.remove_success'))
const reloadedApps = [...ORIGIN_DEFAULT_MIN_APPS, ...(await loadCustomMiniApp())]
updateDefaultMinApps(reloadedApps)
updateMinapps(minapps.filter((item) => item.id !== app.id))
updatePinnedMinapps(pinned.filter((item) => item.id !== app.id))
updateDisabledMinapps(disabled.filter((item) => item.id !== app.id))
} catch (error) {
message.error(t('settings.miniapps.custom.remove_error'))
console.error('Failed to remove custom mini app:', error)
}
}
},
...(app.type === 'Custom'
? [
{
key: 'removeCustom',
label: t('minapp.sidebar.remove_custom.title'),
danger: true,
onClick: async () => {
try {
const content = await window.api.file.read('custom-minapps.json')
const customApps = JSON.parse(content)
const updatedApps = customApps.filter((customApp: MinAppType) => customApp.id !== app.id)
await window.api.file.writeWithId('custom-minapps.json', JSON.stringify(updatedApps, null, 2))
message.success(t('settings.miniapps.custom.remove_success'))
const reloadedApps = [...ORIGIN_DEFAULT_MIN_APPS, ...(await loadCustomMiniApp())]
updateDefaultMinApps(reloadedApps)
updateMinapps(minapps.filter((item) => item.id !== app.id))
updatePinnedMinapps(pinned.filter((item) => item.id !== app.id))
updateDisabledMinapps(disabled.filter((item) => item.id !== app.id))
} catch (error) {
message.error(t('settings.miniapps.custom.remove_error'))
console.error('Failed to remove custom mini app:', error)
}
]
: [])
]
}
}
]
: [])
]
if (!isVisible && !isLast) {
if (!isVisible) {
return null
}
return (
<>
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
<Container onClick={handleClick}>
{isLast ? (
<AddButton size={size}>
<PlusOutlined />
</AddButton>
) : (
<MinAppIcon size={size} app={app} />
)}
<AppTitle>{isLast ? t('settings.miniapps.custom.title') : app.name}</AppTitle>
</Container>
</Dropdown>
<Modal
title={t('settings.miniapps.custom.edit_title')}
open={isModalVisible}
onCancel={() => {
setIsModalVisible(false)
setFileList([])
}}
footer={null}
transitionName="animation-move-down"
centered>
<Form form={form} onFinish={handleAddCustomApp} layout="vertical">
<Form.Item
name="id"
label={t('settings.miniapps.custom.id')}
rules={[{ required: true, message: t('settings.miniapps.custom.id_error') }]}>
<Input placeholder={t('settings.miniapps.custom.id_placeholder')} />
</Form.Item>
<Form.Item
name="name"
label={t('settings.miniapps.custom.name')}
rules={[{ required: true, message: t('settings.miniapps.custom.name_error') }]}>
<Input placeholder={t('settings.miniapps.custom.name_placeholder')} />
</Form.Item>
<Form.Item
name="url"
label={t('settings.miniapps.custom.url')}
rules={[{ required: true, message: t('settings.miniapps.custom.url_error') }]}>
<Input placeholder={t('settings.miniapps.custom.url_placeholder')} />
</Form.Item>
<Form.Item label={t('settings.miniapps.custom.logo')}>
<Radio.Group value={logoType} onChange={handleLogoTypeChange}>
<Radio value="url">{t('settings.miniapps.custom.logo_url')}</Radio>
<Radio value="file">{t('settings.miniapps.custom.logo_file')}</Radio>
</Radio.Group>
</Form.Item>
{logoType === 'url' ? (
<Form.Item name="logo" label={t('settings.miniapps.custom.logo_url_label')}>
<Input placeholder={t('settings.miniapps.custom.logo_url_placeholder')} />
</Form.Item>
) : (
<Form.Item label={t('settings.miniapps.custom.logo_upload_label')}>
<Upload
accept="image/*"
maxCount={1}
fileList={fileList}
onChange={handleFileChange}
beforeUpload={() => false}>
<Button icon={<UploadOutlined />}>{t('settings.miniapps.custom.logo_upload_button')}</Button>
</Upload>
</Form.Item>
)}
<Form.Item>
<Button type="primary" htmlType="submit">
{t('settings.miniapps.custom.save')}
</Button>
</Form.Item>
</Form>
</Modal>
</>
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
<Container onClick={handleClick}>
<MinAppIcon size={size} app={app} />
<AppTitle>{isLast ? t('settings.miniapps.custom.title') : app.name}</AppTitle>
</Container>
</Dropdown>
)
}
@ -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

View File

@ -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 = () => {
</NavbarCenter>
</Navbar>
<ContentContainer id="content-container">
{isEmpty(filteredApps) ? (
<Center>
<App isLast app={filteredApps[0]} />
</Center>
) : (
<AppsContainer style={{ height: containerHeight }}>
{filteredApps.map((app) => (
<App key={app.id} app={app} />
))}
<App isLast app={filteredApps[0]} />
</AppsContainer>
)}
<AppsContainer style={{ height: containerHeight }}>
{filteredApps.map((app) => (
<App key={app.id} app={app} />
))}
<NewAppButton />
</AppsContainer>
</ContentContainer>
</Container>
)

View File

@ -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<Props> = ({ size = 60 }) => {
const { t } = useTranslation()
const [isModalVisible, setIsModalVisible] = useState(false)
const [fileList, setFileList] = useState<UploadFile[]>([])
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 (
<>
<Container onClick={() => setIsModalVisible(true)}>
<AddButton size={size}>
<PlusOutlined />
</AddButton>
<AppTitle>{t('settings.miniapps.custom.title')}</AppTitle>
</Container>
<Modal
title={t('settings.miniapps.custom.edit_title')}
open={isModalVisible}
onCancel={() => {
setIsModalVisible(false)
setFileList([])
}}
footer={null}
transitionName="animation-move-down"
centered>
<Form form={form} onFinish={handleAddCustomApp} layout="vertical">
<Form.Item
name="id"
label={t('settings.miniapps.custom.id')}
rules={[{ required: true, message: t('settings.miniapps.custom.id_error') }]}>
<Input placeholder={t('settings.miniapps.custom.id_placeholder')} />
</Form.Item>
<Form.Item
name="name"
label={t('settings.miniapps.custom.name')}
rules={[{ required: true, message: t('settings.miniapps.custom.name_error') }]}>
<Input placeholder={t('settings.miniapps.custom.name_placeholder')} />
</Form.Item>
<Form.Item
name="url"
label={t('settings.miniapps.custom.url')}
rules={[{ required: true, message: t('settings.miniapps.custom.url_error') }]}>
<Input placeholder={t('settings.miniapps.custom.url_placeholder')} />
</Form.Item>
<Form.Item label={t('settings.miniapps.custom.logo')}>
<Radio.Group value={logoType} onChange={handleLogoTypeChange}>
<Radio value="url">{t('settings.miniapps.custom.logo_url')}</Radio>
<Radio value="file">{t('settings.miniapps.custom.logo_file')}</Radio>
</Radio.Group>
</Form.Item>
{logoType === 'url' ? (
<Form.Item name="logo" label={t('settings.miniapps.custom.logo_url_label')}>
<Input placeholder={t('settings.miniapps.custom.logo_url_placeholder')} />
</Form.Item>
) : (
<Form.Item label={t('settings.miniapps.custom.logo_upload_label')}>
<Upload
accept="image/*"
maxCount={1}
fileList={fileList}
onChange={handleFileChange}
beforeUpload={() => false}>
<Button icon={<UploadOutlined />}>{t('settings.miniapps.custom.logo_upload_button')}</Button>
</Upload>
</Form.Item>
)}
<Form.Item>
<Button type="primary" htmlType="submit">
{t('settings.miniapps.custom.save')}
</Button>
</Form.Item>
</Form>
</Modal>
</>
)
}
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

View File

@ -36,9 +36,9 @@ const Chat: FC<Props> = (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', () => {

View File

@ -15,10 +15,6 @@ interface Props {
const GenerateImageButton: FC<Props> = ({ model, ToolbarButton, assistant, onEnableGenerateImage }) => {
const { t } = useTranslation()
if (!isGenerateImageModel(model)) {
return null
}
return (
<Tooltip
placement="top"

View File

@ -1,5 +1,5 @@
import { HolderOutlined } from '@ant-design/icons'
import { QuickPanelListItem, QuickPanelView, useQuickPanel } from '@renderer/components/QuickPanel'
import { QuickPanelView, useQuickPanel } from '@renderer/components/QuickPanel'
import TranslateButton from '@renderer/components/TranslateButton'
import Logger from '@renderer/config/logger'
import {
@ -39,42 +39,18 @@ import { Button, Tooltip } from 'antd'
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
import dayjs from 'dayjs'
import { debounce, isEmpty } from 'lodash'
import {
AtSign,
CirclePause,
FileSearch,
FileText,
Globe,
Languages,
LucideSquareTerminal,
Maximize,
MessageSquareDiff,
Minimize,
PaintbrushVertical,
Paperclip,
Upload,
Zap
} from 'lucide-react'
// import { CompletionUsage } from 'openai/resources'
import { CirclePause, FileSearch, FileText, Upload } from 'lucide-react'
import React, { CSSProperties, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import NarrowLayout from '../Messages/NarrowLayout'
import AttachmentButton, { AttachmentButtonRef } from './AttachmentButton'
import AttachmentPreview from './AttachmentPreview'
import GenerateImageButton from './GenerateImageButton'
import KnowledgeBaseButton, { KnowledgeBaseButtonRef } from './KnowledgeBaseButton'
import InputbarTools, { InputbarToolsRef } from './InputbarTools'
import KnowledgeBaseInput from './KnowledgeBaseInput'
import MCPToolsButton, { MCPToolsButtonRef } from './MCPToolsButton'
import MentionModelsButton, { MentionModelsButtonRef } from './MentionModelsButton'
import MentionModelsInput from './MentionModelsInput'
import NewContextButton from './NewContextButton'
import QuickPhrasesButton, { QuickPhrasesButtonRef } from './QuickPhrasesButton'
import SendMessageButton from './SendMessageButton'
import ThinkingButton, { ThinkingButtonRef } from './ThinkingButton'
import TokenCount from './TokenCount'
import WebSearchButton, { WebSearchButtonRef } from './WebSearchButton'
interface Props {
assistant: Assistant
@ -135,13 +111,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
const [tokenCount, setTokenCount] = useState(0)
const quickPhrasesButtonRef = useRef<QuickPhrasesButtonRef>(null)
const mentionModelsButtonRef = useRef<MentionModelsButtonRef>(null)
const knowledgeBaseButtonRef = useRef<KnowledgeBaseButtonRef>(null)
const mcpToolsButtonRef = useRef<MCPToolsButtonRef>(null)
const attachmentButtonRef = useRef<AttachmentButtonRef>(null)
const webSearchButtonRef = useRef<WebSearchButtonRef | null>(null)
const thinkingButtonRef = useRef<ThinkingButtonRef | null>(null)
const inputbarToolsRef = useRef<InputbarToolsRef>(null)
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedEstimate = useCallback(
@ -314,7 +284,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
description: '',
icon: <Upload />,
action: () => {
attachmentButtonRef.current?.openQuickPanel()
inputbarToolsRef.current?.openQuickPanel()
}
},
...knowledgeBases.map((base) => {
@ -333,92 +303,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
],
symbol: 'file'
})
}, [knowledgeBases, openKnowledgeFileList, quickPanel, t])
const quickPanelMenu = useMemo<QuickPanelListItem[]>(() => {
return [
{
label: t('settings.quickPhrase.title'),
description: '',
icon: <Zap />,
isMenu: true,
action: () => {
quickPhrasesButtonRef.current?.openQuickPanel()
}
},
{
label: t('agents.edit.model.select.title'),
description: '',
icon: <AtSign />,
isMenu: true,
action: () => {
mentionModelsButtonRef.current?.openQuickPanel()
}
},
{
label: t('chat.input.knowledge_base'),
description: '',
icon: <FileSearch />,
isMenu: true,
disabled: files.length > 0,
action: () => {
knowledgeBaseButtonRef.current?.openQuickPanel()
}
},
{
label: t('settings.mcp.title'),
description: t('settings.mcp.not_support'),
icon: <LucideSquareTerminal />,
isMenu: true,
action: () => {
mcpToolsButtonRef.current?.openQuickPanel()
}
},
{
label: `MCP ${t('settings.mcp.tabs.prompts')}`,
description: '',
icon: <LucideSquareTerminal />,
isMenu: true,
action: () => {
mcpToolsButtonRef.current?.openPromptList()
}
},
{
label: `MCP ${t('settings.mcp.tabs.resources')}`,
description: '',
icon: <LucideSquareTerminal />,
isMenu: true,
action: () => {
mcpToolsButtonRef.current?.openResourcesList()
}
},
{
label: t('chat.input.web_search'),
description: '',
icon: <Globe />,
isMenu: true,
action: () => {
webSearchButtonRef.current?.openQuickPanel()
}
},
{
label: isVisionModel(model) ? t('chat.input.upload') : t('chat.input.upload.document'),
description: '',
icon: <Paperclip />,
isMenu: true,
action: openSelectFileMenu
},
{
label: t('translate.title'),
description: t('translate.menu.description'),
icon: <Languages />,
action: () => {
if (!text) return
translate()
}
}
]
}, [files.length, model, openSelectFileMenu, t, text, translate])
}, [knowledgeBases, openKnowledgeFileList, quickPanel, t, inputbarToolsRef])
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
const isEnterPressed = event.keyCode == 13
@ -566,6 +451,16 @@ const Inputbar: FC<Props> = ({ 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<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
}
if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '@') {
mentionModelsButtonRef.current?.openQuickPanel()
inputbarToolsRef.current?.openMentionModelsPanel()
}
}
@ -936,75 +831,30 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
<HolderOutlined />
</DragHandle>
<Toolbar>
<InputbarTools
ref={inputbarToolsRef}
assistant={assistant}
model={model}
files={files}
setFiles={setFiles}
showThinkingButton={showThinkingButton}
showKnowledgeIcon={showKnowledgeIcon}
selectedKnowledgeBases={selectedKnowledgeBases}
handleKnowledgeBaseSelect={handleKnowledgeBaseSelect}
setText={setText}
resizeTextArea={resizeTextArea}
mentionModels={mentionModels}
onMentionModel={onMentionModel}
onEnableGenerateImage={onEnableGenerateImage}
isExpended={isExpended}
onToggleExpended={onToggleExpended}
addNewTopic={addNewTopic}
clearTopic={clearTopic}
onNewContext={onNewContext}
newTopicShortcut={newTopicShortcut}
cleanTopicShortcut={cleanTopicShortcut}
/>
<ToolbarMenu>
<Tooltip placement="top" title={t('chat.input.new_topic', { Command: newTopicShortcut })} arrow>
<ToolbarButton type="text" onClick={addNewTopic}>
<MessageSquareDiff size={19} />
</ToolbarButton>
</Tooltip>
<AttachmentButton
ref={attachmentButtonRef}
model={model}
files={files}
setFiles={setFiles}
ToolbarButton={ToolbarButton}
/>
{showThinkingButton && (
<ThinkingButton
ref={thinkingButtonRef}
model={model}
assistant={assistant}
ToolbarButton={ToolbarButton}
/>
)}
<WebSearchButton ref={webSearchButtonRef} assistant={assistant} ToolbarButton={ToolbarButton} />
{showKnowledgeIcon && (
<KnowledgeBaseButton
ref={knowledgeBaseButtonRef}
selectedBases={selectedKnowledgeBases}
onSelect={handleKnowledgeBaseSelect}
ToolbarButton={ToolbarButton}
disabled={files.length > 0}
/>
)}
<MCPToolsButton
assistant={assistant}
ref={mcpToolsButtonRef}
ToolbarButton={ToolbarButton}
setInputValue={setText}
resizeTextArea={resizeTextArea}
/>
<GenerateImageButton
model={model}
assistant={assistant}
onEnableGenerateImage={onEnableGenerateImage}
ToolbarButton={ToolbarButton}
/>
<MentionModelsButton
ref={mentionModelsButtonRef}
mentionModels={mentionModels}
onMentionModel={onMentionModel}
ToolbarButton={ToolbarButton}
/>
<QuickPhrasesButton
ref={quickPhrasesButtonRef}
setInputValue={setText}
resizeTextArea={resizeTextArea}
ToolbarButton={ToolbarButton}
assistantObj={assistant}
/>
<Tooltip placement="top" title={t('chat.input.clear', { Command: cleanTopicShortcut })} arrow>
<ToolbarButton type="text" onClick={clearTopic}>
<PaintbrushVertical size={18} />
</ToolbarButton>
</Tooltip>
<Tooltip placement="top" title={isExpended ? t('chat.input.collapse') : t('chat.input.expand')} arrow>
<ToolbarButton type="text" onClick={onToggleExpended}>
{isExpended ? <Minimize size={18} /> : <Maximize size={18} />}
</ToolbarButton>
</Tooltip>
<NewContextButton onNewContext={onNewContext} ToolbarButton={ToolbarButton} />
<TokenCount
estimateTokenCount={estimateTokenCount}
inputTokenCount={inputTokenCount}
@ -1012,8 +862,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
ToolbarButton={ToolbarButton}
onClick={onNewContext}
/>
</ToolbarMenu>
<ToolbarMenu>
<TranslateButton text={text} onTranslated={onTranslated} isLoading={isTranslating} />
{loading && (
<Tooltip placement="top" title={t('chat.input.pause')} arrow>
@ -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`

View File

@ -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<SetStateAction<string>>
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<InputbarToolsRef | null> }) => {
const { t } = useTranslation()
const dispatch = useAppDispatch()
const quickPhrasesButtonRef = useRef<QuickPhrasesButtonRef>(null)
const mentionModelsButtonRef = useRef<MentionModelsButtonRef>(null)
const knowledgeBaseButtonRef = useRef<KnowledgeBaseButtonRef>(null)
const mcpToolsButtonRef = useRef<MCPToolsButtonRef>(null)
const attachmentButtonRef = useRef<AttachmentButtonRef>(null)
const webSearchButtonRef = useRef<WebSearchButtonRef | null>(null)
const thinkingButtonRef = useRef<ThinkingButtonRef | null>(null)
const toolOrder = useAppSelector((state) => state.inputTools.toolOrder)
const isCollapse = useAppSelector((state) => state.inputTools.isCollapsed)
const [targetTool, setTargetTool] = useState<ToolButtonConfig | null>(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: <Zap />,
isMenu: true,
action: () => {
quickPhrasesButtonRef.current?.openQuickPanel()
}
},
{
label: t('agents.edit.model.select.title'),
description: '',
icon: <AtSign />,
isMenu: true,
action: () => {
mentionModelsButtonRef.current?.openQuickPanel()
}
},
{
label: t('chat.input.knowledge_base'),
description: '',
icon: <FileSearch />,
isMenu: true,
disabled: files.length > 0,
action: () => {
knowledgeBaseButtonRef.current?.openQuickPanel()
}
},
{
label: t('settings.mcp.title'),
description: t('settings.mcp.not_support'),
icon: <LucideSquareTerminal />,
isMenu: true,
action: () => {
mcpToolsButtonRef.current?.openQuickPanel()
}
},
{
label: `MCP ${t('settings.mcp.tabs.prompts')}`,
description: '',
icon: <LucideSquareTerminal />,
isMenu: true,
action: () => {
mcpToolsButtonRef.current?.openPromptList()
}
},
{
label: `MCP ${t('settings.mcp.tabs.resources')}`,
description: '',
icon: <LucideSquareTerminal />,
isMenu: true,
action: () => {
mcpToolsButtonRef.current?.openResourcesList()
}
},
{
label: t('chat.input.web_search'),
description: '',
icon: <Globe />,
isMenu: true,
action: () => {
webSearchButtonRef.current?.openQuickPanel()
}
},
{
label: isVisionModel(model) ? t('chat.input.upload') : t('chat.input.upload.document'),
description: '',
icon: <Paperclip />,
isMenu: true,
action: openSelectFileMenu
},
{
label: t('translate.title'),
description: t('translate.menu.description'),
icon: <Languages />,
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<ToolButtonConfig[]>(() => {
return [
{
key: 'new_topic',
label: t('chat.input.new_topic', { Command: '' }),
component: (
<Tooltip placement="top" title={t('chat.input.new_topic', { Command: newTopicShortcut })} arrow>
<ToolbarButton type="text" onClick={addNewTopic}>
<MessageSquareDiff size={19} />
</ToolbarButton>
</Tooltip>
)
},
{
key: 'attachment',
label: t('chat.input.upload'),
component: (
<AttachmentButton
ref={attachmentButtonRef}
model={model}
files={files}
setFiles={setFiles}
ToolbarButton={ToolbarButton}
/>
)
},
{
key: 'thinking',
label: t('chat.input.thinking'),
component: (
<ThinkingButton ref={thinkingButtonRef} model={model} assistant={assistant} ToolbarButton={ToolbarButton} />
),
condition: showThinkingButton
},
{
key: 'web_search',
label: t('chat.input.web_search'),
component: <WebSearchButton ref={webSearchButtonRef} assistant={assistant} ToolbarButton={ToolbarButton} />
},
{
key: 'knowledge_base',
label: t('chat.input.knowledge_base'),
component: (
<KnowledgeBaseButton
ref={knowledgeBaseButtonRef}
selectedBases={selectedKnowledgeBases}
onSelect={handleKnowledgeBaseSelect}
ToolbarButton={ToolbarButton}
disabled={files.length > 0}
/>
),
condition: showKnowledgeIcon
},
{
key: 'mcp_tools',
label: t('settings.mcp.title'),
component: (
<MCPToolsButton
assistant={assistant}
ref={mcpToolsButtonRef}
ToolbarButton={ToolbarButton}
setInputValue={setText}
resizeTextArea={resizeTextArea}
/>
)
},
{
key: 'generate_image',
label: t('chat.input.generate_image'),
component: (
<GenerateImageButton
model={model}
assistant={assistant}
onEnableGenerateImage={onEnableGenerateImage}
ToolbarButton={ToolbarButton}
/>
),
condition: isGenerateImageModel(model)
},
{
key: 'mention_models',
label: t('agents.edit.model.select.title'),
component: (
<MentionModelsButton
ref={mentionModelsButtonRef}
mentionModels={mentionModels}
onMentionModel={onMentionModel}
ToolbarButton={ToolbarButton}
/>
)
},
{
key: 'quick_phrases',
label: t('settings.quickPhrase.title'),
component: (
<QuickPhrasesButton
ref={quickPhrasesButtonRef}
setInputValue={setText}
resizeTextArea={resizeTextArea}
ToolbarButton={ToolbarButton}
assistantObj={assistant}
/>
)
},
{
key: 'clear_topic',
label: t('chat.input.clear', { Command: '' }),
component: (
<Tooltip placement="top" title={t('chat.input.clear', { Command: cleanTopicShortcut })} arrow>
<ToolbarButton type="text" onClick={clearTopic}>
<PaintbrushVertical size={18} />
</ToolbarButton>
</Tooltip>
)
},
{
key: 'toggle_expand',
label: isExpended ? t('chat.input.collapse') : t('chat.input.expand'),
component: (
<Tooltip placement="top" title={isExpended ? t('chat.input.collapse') : t('chat.input.expand')} arrow>
<ToolbarButton type="text" onClick={onToggleExpended}>
{isExpended ? <Minimize size={18} /> : <Maximize size={18} />}
</ToolbarButton>
</Tooltip>
)
},
{
key: 'new_context',
label: t('chat.input.new.context', { Command: '' }),
component: <NewContextButton onNewContext={onNewContext} ToolbarButton={ToolbarButton} />
}
]
}, [
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: (
<div style={{ width: 20, height: 20, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{tool.visible ? <Check size={16} /> : undefined}
</div>
),
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: <div style={{ width: 20, height: 20 }}></div>,
onClick: () => {
toggleToolVisibility(targetTool.key, targetTool.visible)
}
})
}
return baseItems
}, [hiddenTools, t, targetTool, toggleToolVisibility, visibleTools])
return (
<Dropdown menu={{ items: getMenuItems }} trigger={['contextMenu']}>
<ToolsContainer
onContextMenu={(e) => {
const target = e.target as HTMLElement
const isToolButton = target.closest('[data-key]')
if (!isToolButton) {
setTargetTool(null)
}
}}>
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="inputbar-tools-visible" direction="horizontal">
{(provided) => (
<VisibleTools ref={provided.innerRef} {...provided.droppableProps}>
{visibleTools.map(
(tool, index) =>
(tool.condition ?? true) && (
<Draggable key={tool.key} draggableId={tool.key} index={index}>
{(provided, snapshot) => (
<DraggablePortal isDragging={snapshot.isDragging}>
<ToolWrapper
data-key={tool.key}
onContextMenu={() => setTargetTool(tool)}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={{
...provided.draggableProps.style
}}>
{tool.component}
</ToolWrapper>
</DraggablePortal>
)}
</Draggable>
)
)}
{provided.placeholder}
</VisibleTools>
)}
</Droppable>
{showDivider && <Divider type="vertical" style={{ margin: '0 4px' }} />}
<Droppable droppableId="inputbar-tools-hidden" direction="horizontal">
{(provided) => (
<HiddenTools ref={provided.innerRef} {...provided.droppableProps}>
{hiddenTools.map(
(tool, index) =>
(tool.condition ?? true) && (
<Draggable key={tool.key} draggableId={tool.key} index={index}>
{(provided, snapshot) => (
<DraggablePortal isDragging={snapshot.isDragging}>
<ToolWrapper
data-key={tool.key}
className={classNames({
'is-collapsed': isCollapse
})}
onContextMenu={() => setTargetTool(tool)}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={{
...provided.draggableProps.style,
transitionDelay: `${index * 0.02}s`
}}>
{tool.component}
</ToolWrapper>
</DraggablePortal>
)}
</Draggable>
)
)}
{provided.placeholder}
</HiddenTools>
)}
</Droppable>
</DragDropContext>
{showCollapseButton && (
<Tooltip
placement="top"
title={isCollapse ? t('chat.input.tools.expand') : t('chat.input.tools.collapse')}
arrow>
<ToolbarButton type="text" onClick={() => dispatch(setIsCollapsed(!isCollapse))}>
<CircleChevronRight
size={18}
style={{
transform: isCollapse ? 'scaleX(1)' : 'scaleX(-1)'
}}
/>
</ToolbarButton>
</Tooltip>
)}
</ToolsContainer>
</Dropdown>
)
}
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

View File

@ -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<Props> = ({ 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<Props> = ({ 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<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
action: () => navigate('/settings/mcp')
})
newList.unshift({
label: t('common.close'),
description: t('settings.mcp.disable.description'),
icon: <CircleX />,
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<Props> = ({ 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<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
openResourcesList
}))
if (activedMcpServers.length === 0) {
return null
}
return (
<Tooltip placement="top" title={t('settings.mcp.title')} arrow>
<ToolbarButton type="text" onClick={handleOpenQuickPanel}>
<SquareTerminal size={18} color={buttonEnabled ? 'var(--color-primary)' : 'var(--color-icon)'} />
<SquareTerminal
size={18}
color={assistant.mcpServers && assistant.mcpServers.length > 0 ? 'var(--color-primary)' : 'var(--color-icon)'}
/>
</ToolbarButton>
</Tooltip>
)

View File

@ -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<Props> = ({ onNewContext, ToolbarButton }) => {
useShortcut('toggle_new_context', onNewContext)
return (
<Container>
<Tooltip placement="top" title={t('chat.input.new.context', { Command: newContextShortcut })} arrow>
<ToolbarButton type="text" onClick={onNewContext}>
<Eraser size={18} />
</ToolbarButton>
</Tooltip>
</Container>
<Tooltip placement="top" title={t('chat.input.new.context', { Command: newContextShortcut })} arrow>
<ToolbarButton type="text" onClick={onNewContext}>
<Eraser size={18} />
</ToolbarButton>
</Tooltip>
)
}
const Container = styled.div`
@media (max-width: 800px) {
display: none;
}
`
export default NewContextButton

View File

@ -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;

View File

@ -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<Props> = ({ 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: <Globe />,
icon: <CircleX />,
isSelected: !assistant.enableWebSearch && !assistant.webSearchProviderId,
action: () => {
updateSelectedWebSearchProvider(undefined)

View File

@ -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<Props> = ({ children, className, id, onSave }) => {
)
return match ? (
<CodeToolbarProvider>
<CodeBlockView language={language} onSave={handleSave}>
{children}
</CodeBlockView>
</CodeToolbarProvider>
<CodeBlockView language={language} onSave={handleSave}>
{children}
</CodeBlockView>
) : (
<code className={className} style={{ textWrap: 'wrap' }}>
{children}

View File

@ -163,7 +163,9 @@ const MainTextBlock: React.FC<Props> = ({ block, citationBlockId, role, mentions
</Flex>
)}
{role === 'user' && !renderInputMessageAsMarkdown ? (
<p style={{ marginBottom: 5, whiteSpace: 'pre-wrap' }}>{block.content}</p>
<p className="markdown" style={{ marginBottom: 5 }}>
{block.content}
</p>
) : (
<Markdown block={{ ...block, content: ignoreToolUse }} />
)}

View File

@ -255,7 +255,7 @@ const MessageBlockEditor: FC<Props> = ({ message, onSave, onResend, onCancel })
<Save size={16} />
</ToolbarButton>
</Tooltip>
{message.role === 'assistant' && (
{message.role === 'user' && (
<Tooltip title={t('chat.resend')}>
<ToolbarButton type="text" onClick={() => handleClick(true)}>
<Send size={16} />

View File

@ -190,16 +190,6 @@ const MessageGroup = ({ messages, topic, hidePresetMessages, registerMessageElem
</MessageWrapper>
)
const wrappedMessage = (
<SelectableMessage
key={`selectable-${message.id}`}
messageId={message.id}
topic={topic}
isClearMessage={message.type === 'clear'}>
{messageContent}
</SelectableMessage>
)
if (isGridGroupMessage) {
return (
<Popover
@ -216,12 +206,20 @@ const MessageGroup = ({ messages, topic, hidePresetMessages, registerMessageElem
trigger={gridPopoverTrigger}
styles={{ root: { maxWidth: '60vw', minWidth: '550px', overflowY: 'auto', zIndex: 1000 } }}
getPopupContainer={(triggerNode) => triggerNode.parentNode as HTMLElement}>
{wrappedMessage}
<div style={{ cursor: 'pointer' }}>{messageContent}</div>
</Popover>
)
}
return wrappedMessage
return (
<SelectableMessage
key={`selectable-${message.id}`}
messageId={message.id}
topic={topic}
isClearMessage={message.type === 'clear'}>
{messageContent}
</SelectableMessage>
)
},
[
isGrid,

View File

@ -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> = (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 () => {

Some files were not shown because too many files have changed in this diff Show More