diff --git a/.eslintignore b/.eslintignore index 8129084dd1..f874b46948 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,4 +2,4 @@ node_modules dist out .gitignore - +scripts/cloudflare-worker.js diff --git a/.github/ISSUE_TEMPLATE/#0_bug_report.yml b/.github/ISSUE_TEMPLATE/#0_bug_report.yml new file mode 100644 index 0000000000..b3ac3eddfd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/#0_bug_report.yml @@ -0,0 +1,73 @@ +name: 🐛 错误报告 +description: 创建一个报告以帮助我们改进 +title: '[错误]: ' +labels: ['bug'] +body: + - type: markdown + attributes: + value: | + 感谢您花时间填写此错误报告! + + - type: dropdown + id: platform + attributes: + label: 平台 + description: 您正在使用哪个平台? + options: + - Windows + - macOS + - Linux + validations: + required: true + + - type: input + id: version + attributes: + label: 版本 + description: 您正在运行的 Cherry Studio 版本是什么? + placeholder: 例如 v1.0.0 + validations: + required: true + + - type: textarea + id: description + attributes: + label: 错误描述 + description: 清晰简洁地描述错误是什么 + placeholder: 告诉我们发生了什么... + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: 重现步骤 + description: 重现行为的步骤 + placeholder: | + 1. 转到 '...' + 2. 点击 '....' + 3. 向下滚动到 '....' + 4. 看到错误 + validations: + required: true + + - type: textarea + id: expected + attributes: + label: 预期行为 + description: 清晰简洁地描述您期望发生的事情 + validations: + required: true + + - type: textarea + id: logs + attributes: + label: 相关日志输出 + description: 请复制并粘贴任何相关的日志输出 + render: shell + + - type: textarea + id: additional + attributes: + label: 附加信息 + description: 在此添加有关问题的任何其他上下文 diff --git a/.github/ISSUE_TEMPLATE/#1_feature_request.yml b/.github/ISSUE_TEMPLATE/#1_feature_request.yml new file mode 100644 index 0000000000..c9cf7620b1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/#1_feature_request.yml @@ -0,0 +1,38 @@ +name: 💡 功能建议 +description: 为项目提出新的想法 +title: '[功能]: ' +labels: ['enhancement'] +body: + - type: markdown + attributes: + value: | + 感谢您花时间提出新的功能建议! + + - type: textarea + id: problem + attributes: + label: 您的功能建议是否与某个问题相关? + description: 请简明扼要地描述您遇到的问题 + placeholder: 我总是感到沮丧,因为... + validations: + required: true + + - type: textarea + id: solution + attributes: + label: 请描述您希望实现的解决方案 + description: 请简明扼要地描述您希望发生的情况 + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: 请描述您考虑过的其他方案 + description: 请简明扼要地描述您考虑过的任何其他解决方案或功能 + + - type: textarea + id: additional + attributes: + label: 其他补充信息 + description: 在此添加任何其他与功能建议相关的上下文或截图 diff --git a/.github/ISSUE_TEMPLATE/#2_question.yml b/.github/ISSUE_TEMPLATE/#2_question.yml new file mode 100644 index 0000000000..a533ddee64 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/#2_question.yml @@ -0,0 +1,44 @@ +name: ❓ 提问 +description: 提出一个问题或寻求帮助 +title: '[问题]: ' +labels: ['question'] +body: + - type: markdown + attributes: + value: | + 感谢您的提问!请尽可能详细地描述您的问题,这样我们才能更好地帮助您。 + + - type: textarea + id: question + attributes: + label: 您的问题 + description: 请详细描述您的问题 + placeholder: 请尽可能清楚地说明您的问题... + validations: + required: true + + - type: textarea + id: context + attributes: + label: 相关背景 + description: 请提供一些背景信息,帮助我们更好地理解您的问题 + placeholder: 例如:使用场景、已尝试的解决方案等 + + - type: textarea + id: additional + attributes: + label: 补充信息 + description: 任何其他相关的信息、截图或代码示例 + render: shell + + - type: dropdown + id: priority + attributes: + label: 优先级 + description: 这个问题对您来说有多紧急? + options: + - 低 (有空再看) + - 中 (希望尽快得到答复) + - 高 (阻碍工作进行) + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/0_bug_report.yml b/.github/ISSUE_TEMPLATE/0_bug_report.yml new file mode 100644 index 0000000000..b09b8eb7cc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/0_bug_report.yml @@ -0,0 +1,73 @@ +name: 🐛 Bug Report +description: Create a report to help us improve +title: '[Bug]: ' +labels: ['bug'] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + + - type: dropdown + id: platform + attributes: + label: Platform + description: What platform are you using? + options: + - Windows + - macOS + - Linux + validations: + required: true + + - type: input + id: version + attributes: + label: Version + description: What version of Cherry Studio are you running? + placeholder: e.g. v1.0.0 + validations: + required: true + + - type: textarea + id: description + attributes: + label: Bug Description + description: A clear and concise description of what the bug is + placeholder: Tell us what happened... + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Steps To Reproduce + description: Steps to reproduce the behavior + placeholder: | + 1. Go to '...' + 2. Click on '....' + 3. Scroll down to '....' + 4. See error + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: A clear and concise description of what you expected to happen + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Relevant Log Output + description: Please copy and paste any relevant log output + render: shell + + - type: textarea + id: additional + attributes: + label: Additional Context + description: Add any other context about the problem here diff --git a/.github/ISSUE_TEMPLATE/1_feature_request.yml b/.github/ISSUE_TEMPLATE/1_feature_request.yml new file mode 100644 index 0000000000..16b1a74992 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1_feature_request.yml @@ -0,0 +1,38 @@ +name: 💡 Feature Request +description: Suggest an idea for this project +title: '[Feature]: ' +labels: ['enhancement'] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to suggest a new feature! + + - type: textarea + id: problem + attributes: + label: Is your feature request related to a problem? + description: A clear and concise description of what the problem is + placeholder: I'm always frustrated when... + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Describe the solution you'd like + description: A clear and concise description of what you want to happen + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Describe alternatives you've considered + description: A clear and concise description of any alternative solutions or features you've considered + + - type: textarea + id: additional + attributes: + label: Additional Context + description: Add any other context or screenshots about the feature request here diff --git a/.github/ISSUE_TEMPLATE/2_question.yml b/.github/ISSUE_TEMPLATE/2_question.yml new file mode 100644 index 0000000000..08da9c4a0f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2_question.yml @@ -0,0 +1,44 @@ +name: ❓ Question +description: Ask a question or seek help +title: '[Question]: ' +labels: ['question'] +body: + - type: markdown + attributes: + value: | + Thanks for asking a question! Please provide as much detail as possible so we can better assist you. + + - type: textarea + id: question + attributes: + label: Your Question + description: Please describe your question in detail + placeholder: Please explain your question as clearly as possible... + validations: + required: true + + - type: textarea + id: context + attributes: + label: Context + description: Please provide some background information to help us better understand your question + placeholder: "For example: use case, solutions you've tried, etc." + + - type: textarea + id: additional + attributes: + label: Additional Information + description: Any other relevant information, screenshots, or code examples + render: shell + + - type: dropdown + id: priority + attributes: + label: Priority + description: How urgent is this question for you? + options: + - Low (Can wait) + - Medium (Would like a response soon) + - High (Blocking progress) + validations: + required: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c2ed841ba3..352cdab2ee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,12 @@ name: Release on: + workflow_dispatch: + inputs: + version: + description: 'Version (e.g. v1.2.3)' + required: true + type: string push: tags: - v*.*.* diff --git a/.yarn/patches/openai-npm-4.71.1-b5940d6401.patch b/.yarn/patches/openai-npm-4.71.1-b5940d6401.patch new file mode 100644 index 0000000000..f408484457 --- /dev/null +++ b/.yarn/patches/openai-npm-4.71.1-b5940d6401.patch @@ -0,0 +1,26 @@ +diff --git a/core.js b/core.js +index 00b67a48b7b5cf0029413fc84abd0c01630c3d14..5550b58495b468060f775ca86e4d849d82573ea5 100644 +--- a/core.js ++++ b/core.js +@@ -156,7 +156,7 @@ class APIClient { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': this.getUserAgent(), +- ...getPlatformHeaders(), ++ // ...getPlatformHeaders(), + ...this.authHeaders(opts), + }; + } +diff --git a/core.mjs b/core.mjs +index 8bc7a0ee10d61560d7113cf3f703355bb19f7ddd..5e4c8586ea6b13fe887a22af2de05eaa4700b5ec 100644 +--- a/core.mjs ++++ b/core.mjs +@@ -149,7 +149,7 @@ export class APIClient { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': this.getUserAgent(), +- ...getPlatformHeaders(), ++ // ...getPlatformHeaders(), + ...this.authHeaders(opts), + }; + } diff --git a/build/tray_icon.png b/build/tray_icon.png new file mode 100644 index 0000000000..48bb9ec40d Binary files /dev/null and b/build/tray_icon.png differ diff --git a/build/tray_icon_dark.png b/build/tray_icon_dark.png new file mode 100644 index 0000000000..0ee6086a0d Binary files /dev/null and b/build/tray_icon_dark.png differ diff --git a/build/tray_icon_light.png b/build/tray_icon_light.png new file mode 100644 index 0000000000..71181c0d84 Binary files /dev/null and b/build/tray_icon_light.png differ diff --git a/docs/README.ja.md b/docs/README.ja.md index 100c13c452..95b52a2946 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -133,7 +133,7 @@ Cherry Studioへの貢献を歓迎します!以下の方法で貢献できま # 📃 ライセンス -[LICENSE](./LICENSE) +[LICENSE](../LICENSE) # ⭐️ スター履歴 diff --git a/docs/README.zh.md b/docs/README.zh.md index d3ad027c0a..1908640937 100644 --- a/docs/README.zh.md +++ b/docs/README.zh.md @@ -134,7 +134,7 @@ $ yarn build:linux # 📃 许可证 -[LICENSE](./LICENSE) +[LICENSE](../LICENSE) # ⭐️ Star 记录 diff --git a/electron-builder.yml b/electron-builder.yml index 8d65368a73..d264fd0aaf 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -63,6 +63,6 @@ electronDownload: afterSign: scripts/notarize.js releaseInfo: releaseNotes: | - 支持聊天气泡样式和简洁样式切换 - 支持导出对话为 Word 文档 - 错误修复 + 修复快捷键设置错误导致的无法启动问题 + 修复翻译按钮无法正常输出内容问题 + 修复检测更新按钮逻辑错误 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index a7ab9fbdb9..338fc00175 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -7,8 +7,9 @@ export default defineConfig({ plugins: [externalizeDepsPlugin()], resolve: { alias: { + '@main': resolve('src/main'), '@types': resolve('src/renderer/src/types'), - '@main': resolve('src/main') + '@shared': resolve('packages/shared') } } }, @@ -16,11 +17,15 @@ export default defineConfig({ plugins: [externalizeDepsPlugin()] }, renderer: { + plugins: [react()], resolve: { alias: { - '@renderer': resolve('src/renderer/src') + '@renderer': resolve('src/renderer/src'), + '@shared': resolve('packages/shared') } }, - plugins: [react()] + optimizeDeps: { + exclude: [] + } } }) diff --git a/package.json b/package.json index f13dfdf9dd..1bcf4e4efb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "0.8.9", + "version": "0.8.23", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", @@ -38,7 +38,7 @@ "dependencies": { "@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/utils": "^3.0.0", - "archiver": "^7.0.1", + "adm-zip": "^0.5.16", "docx": "^9.0.2", "electron-log": "^5.1.5", "electron-store": "^8.2.0", @@ -48,7 +48,6 @@ "html2canvas": "^1.4.1", "markdown-it": "^14.1.0", "officeparser": "^4.1.1", - "unzipper": "^0.12.3", "webdav": "4.11.4" }, "devDependencies": { @@ -60,6 +59,7 @@ "@hello-pangea/dnd": "^16.6.0", "@kangfenmao/keyv-storage": "^0.1.0", "@reduxjs/toolkit": "^2.2.5", + "@types/adm-zip": "^0", "@types/fs-extra": "^11", "@types/lodash": "^4.17.5", "@types/markdown-it": "^14", @@ -67,7 +67,6 @@ "@types/react": "^18.2.48", "@types/react-dom": "^18.2.18", "@types/tinycolor2": "^1", - "@types/unzipper": "^0", "@vitejs/plugin-react": "^4.2.1", "antd": "^5.18.3", "axios": "^1.7.3", @@ -90,20 +89,19 @@ "eslint-plugin-unused-imports": "^4.0.0", "gpt-tokens": "^1.3.10", "i18next": "^23.11.5", - "localforage": "^1.10.0", "lodash": "^4.17.21", "mime": "^4.0.4", - "openai": "^4.52.1", + "openai": "patch:openai@npm%3A4.71.1#~/.yarn/patches/openai-npm-4.71.1-b5940d6401.patch", "prettier": "^3.2.4", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hotkeys-hook": "^4.6.1", "react-i18next": "^14.1.2", "react-markdown": "^9.0.1", "react-redux": "^9.1.2", "react-router": "6", "react-router-dom": "6", "react-spinners": "^0.14.1", - "react-syntax-highlighter": "^15.5.0", "redux": "^5.0.1", "redux-persist": "^6.0.0", "rehype-katex": "^7.0.1", @@ -112,6 +110,7 @@ "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", "sass": "^1.77.2", + "shiki": "^1.22.2", "styled-components": "^6.1.11", "tinycolor2": "^1.6.0", "typescript": "^5.6.2", diff --git a/packages/shared/config/constant.ts b/packages/shared/config/constant.ts new file mode 100644 index 0000000000..c5c19a3eae --- /dev/null +++ b/packages/shared/config/constant.ts @@ -0,0 +1,112 @@ +export const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'] +export const videoExts = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv'] +export const audioExts = ['.mp3', '.wav', '.ogg', '.flac', '.aac'] +export const documentExts = ['.pdf', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods'] +export const textExts = [ + '.txt', // 普通文本文件 + '.md', // Markdown 文件 + '.mdx', // Markdown 文件 + '.html', // HTML 文件 + '.htm', // HTML 文件的另一种扩展名 + '.xml', // XML 文件 + '.json', // JSON 文件 + '.yaml', // YAML 文件 + '.yml', // YAML 文件的另一种扩展名 + '.csv', // 逗号分隔值文件 + '.tsv', // 制表符分隔值文件 + '.ini', // 配置文件 + '.log', // 日志文件 + '.rtf', // 富文本格式文件 + '.tex', // LaTeX 文件 + '.srt', // 字幕文件 + '.xhtml', // XHTML 文件 + '.nfo', // 信息文件(主要用于场景发布) + '.conf', // 配置文件 + '.config', // 配置文件 + '.env', // 环境变量文件 + '.rst', // reStructuredText 文件 + '.php', // PHP 脚本文件,包含嵌入的 HTML + '.js', // JavaScript 文件(部分是文本,部分可能包含代码) + '.ts', // TypeScript 文件 + '.jsp', // JavaServer Pages 文件 + '.aspx', // ASP.NET 文件 + '.bat', // Windows 批处理文件 + '.sh', // Unix/Linux Shell 脚本文件 + '.py', // Python 脚本文件 + '.rb', // Ruby 脚本文件 + '.pl', // Perl 脚本文件 + '.sql', // SQL 脚本文件 + '.css', // Cascading Style Sheets 文件 + '.less', // Less CSS 预处理器文件 + '.scss', // Sass CSS 预处理器文件 + '.sass', // Sass 文件 + '.styl', // Stylus CSS 预处理器文件 + '.coffee', // CoffeeScript 文件 + '.ino', // Arduino 代码文件 + '.asm', // Assembly 语言文件 + '.go', // Go 语言文件 + '.scala', // Scala 语言文件 + '.swift', // Swift 语言文件 + '.kt', // Kotlin 语言文件 + '.rs', // Rust 语言文件 + '.lua', // Lua 语言文件 + '.groovy', // Groovy 语言文件 + '.dart', // Dart 语言文件 + '.hs', // Haskell 语言文件 + '.clj', // Clojure 语言文件 + '.cljs', // ClojureScript 语言文件 + '.elm', // Elm 语言文件 + '.erl', // Erlang 语言文件 + '.ex', // Elixir 语言文件 + '.exs', // Elixir 脚本文件 + '.pug', // Pug (formerly Jade) 模板文件 + '.haml', // Haml 模板文件 + '.slim', // Slim 模板文件 + '.tpl', // 模板文件(通用) + '.ejs', // Embedded JavaScript 模板文件 + '.hbs', // Handlebars 模板文件 + '.mustache', // Mustache 模板文件 + '.jade', // Jade 模板文件 (已重命名为 Pug) + '.twig', // Twig 模板文件 + '.blade', // Blade 模板文件 (Laravel) + '.vue', // Vue.js 单文件组件 + '.jsx', // React JSX 文件 + '.tsx', // React TSX 文件 + '.graphql', // GraphQL 查询语言文件 + '.gql', // GraphQL 查询语言文件 + '.proto', // Protocol Buffers 文件 + '.thrift', // Thrift 文件 + '.toml', // TOML 配置文件 + '.edn', // Clojure 数据表示文件 + '.cake', // CakePHP 配置文件 + '.ctp', // CakePHP 视图文件 + '.cfm', // ColdFusion 标记语言文件 + '.cfc', // ColdFusion 组件文件 + '.m', // Objective-C 源文件 + '.mm', // Objective-C++ 源文件 + '.gradle', // Gradle 构建文件 + '.groovy', // Gradle 构建文件 + '.kts', // Kotlin Script 文件 + '.java' // Java 代码文件 +] + +export const ZOOM_SHORTCUTS = [ + { + key: 'zoom_in', + shortcut: ['CommandOrControl', '='], + editable: false, + enabled: true + }, + { + key: 'zoom_out', + shortcut: ['CommandOrControl', '-'], + editable: false, + enabled: true + }, + { + key: 'zoom_reset', + shortcut: ['CommandOrControl', '0'], + editable: false, + enabled: true + } +] diff --git a/scripts/cloudflare-worker.js b/scripts/cloudflare-worker.js new file mode 100644 index 0000000000..e912009d0f --- /dev/null +++ b/scripts/cloudflare-worker.js @@ -0,0 +1,595 @@ +// 配置信息 +const config = { + R2_CUSTOM_DOMAIN: 'cherrystudio.ocool.online', + R2_BUCKET_NAME: 'cherrystudio', + // 缓存键名 + CACHE_KEY: 'cherry-studio-latest-release', + VERSION_DB: 'versions.json', + LOG_FILE: 'logs.json', + MAX_LOGS: 1000 // 最多保存多少条日志 + }; + + // Worker 入口函数 + const worker = { + // 定时器触发配置 + scheduled: { + cron: '*/1 * * * *' // 每分钟执行一次 + }, + + // 定时器执行函数 - 只负责检查和更新 + async scheduled(event, env, ctx) { + try { + await initDataFiles(env); + console.log('开始定时检查新版本...'); + // 注意这里使用新的函数 + await checkNewRelease(env); + } catch (error) { + console.error('定时任务执行失败:', error); + } + }, + + // HTTP 请求处理函数 - 只负责返回数据 + async fetch(request, env, ctx) { + if (!env || !env.R2_BUCKET) { + return new Response(JSON.stringify({ + error: 'R2 存储桶未正确配置' + }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + + const url = new URL(request.url); + const filename = url.pathname.slice(1); + + try { + // 处理文件下载请求 + if (filename) { + return await handleDownload(env, filename); + } + + // 只返回缓存的版本信息 + return await getCachedRelease(env); + } catch (error) { + return new Response(JSON.stringify({ + error: error.message, + stack: error.stack + }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + } + }; + + export default worker; + + /** + * 添加日志记录函数 + */ + async function addLog(env, type, event, details = null) { + try { + const logFile = await env.R2_BUCKET.get(config.LOG_FILE); + let logs = { logs: [] }; + + if (logFile) { + logs = JSON.parse(await logFile.text()); + } + + logs.logs.unshift({ + timestamp: new Date().toISOString(), + type, + event, + details + }); + + // 保持日志数量在限制内 + if (logs.logs.length > config.MAX_LOGS) { + logs.logs = logs.logs.slice(0, config.MAX_LOGS); + } + + await env.R2_BUCKET.put(config.LOG_FILE, JSON.stringify(logs, null, 2)); + } catch (error) { + console.error('写入日志失败:', error); + } + } + + /** + * 检查并更新发布版本 + * 由定时器触发,检查新版本并更新 R2 存储 + */ + async function checkAndUpdateRelease(env) { + try { + // 获取版本数据库 + const versionDB = await env.R2_BUCKET.get(config.VERSION_DB); + let versions = { versions: {}, latestVersion: null, lastChecked: null }; + + if (versionDB) { + versions = JSON.parse(await versionDB.text()); + } + + // 获取 GitHub 最新版本 + const githubResponse = await fetch('https://api.github.com/repos/kangfenmao/cherry-studio/releases/latest', { + headers: { 'User-Agent': 'CloudflareWorker' }, + }); + + if (!githubResponse.ok) { + throw new Error('GitHub API 请求失败'); + } + + const releaseData = await githubResponse.json(); + const version = releaseData.tag_name; + + // 更新最后检查时间 + versions.lastChecked = new Date().toISOString(); + + // 检查是否需要更新 + if (versions.latestVersion !== version) { + await addLog(env, 'INFO', `发现新版本: ${version}`); + + // 准备新版本记录 + const versionRecord = { + version, + publishedAt: releaseData.published_at, + uploadedAt: null, + files: releaseData.assets.map(asset => ({ + name: asset.name, + size: asset.size, + uploaded: false + })), + changelog: releaseData.body + }; + + // 上传文件 + for (const asset of releaseData.assets) { + try { + const existingFile = await env.R2_BUCKET.get(asset.name); + if (existingFile) { + // 更新文件状态 + const fileIndex = versionRecord.files.findIndex(f => f.name === asset.name); + if (fileIndex !== -1) { + versionRecord.files[fileIndex].uploaded = true; + } + continue; + } + + const response = await fetch(asset.browser_download_url); + if (!response.ok) { + throw new Error(`下载失败: HTTP ${response.status}`); + } + + const file = await response.arrayBuffer(); + await env.R2_BUCKET.put(asset.name, file, { + httpMetadata: { contentType: getContentType(asset.name) } + }); + + // 更新文件状态 + const fileIndex = versionRecord.files.findIndex(f => f.name === asset.name); + if (fileIndex !== -1) { + versionRecord.files[fileIndex].uploaded = true; + } + + await addLog(env, 'INFO', `文件上传成功: ${asset.name}`); + } catch (error) { + await addLog(env, 'ERROR', `文件上传失败: ${asset.name}`, error.message); + } + } + + // 更新版本记录 + versionRecord.uploadedAt = new Date().toISOString(); + versions.versions[version] = versionRecord; + versions.latestVersion = version; + + // 保存版本数据库 + await env.R2_BUCKET.put(config.VERSION_DB, JSON.stringify(versions, null, 2)); + + // 更新缓存 + const cacheData = { + version, + publishedAt: releaseData.published_at, + changelog: releaseData.body, + downloads: versionRecord.files + .filter(file => file.uploaded) + .map(file => ({ + name: file.name, + url: `https://${config.R2_CUSTOM_DOMAIN}/${file.name}`, + size: formatFileSize(file.size) + })) + }; + + await env.R2_BUCKET.put(config.CACHE_KEY, JSON.stringify(cacheData)); + + // 清理旧版本 + const versionList = Object.keys(versions.versions).sort((a, b) => compareVersions(b, a)); + if (versionList.length > 2) { + const oldVersions = versionList.slice(2); + for (const oldVersion of oldVersions) { + const oldFiles = versions.versions[oldVersion].files; + for (const file of oldFiles) { + if (file.uploaded) { + await env.R2_BUCKET.delete(file.name); + await addLog(env, 'INFO', `删除旧文件: ${file.name}`); + } + } + delete versions.versions[oldVersion]; + } + // 保存更新后的版本数据库 + await env.R2_BUCKET.put(config.VERSION_DB, JSON.stringify(versions, null, 2)); + } + + return cacheData; + } else { + // 没有新版本,返回缓存数据 + const cached = await env.R2_BUCKET.get(config.CACHE_KEY); + return cached ? JSON.parse(await cached.text()) : null; + } + } catch (error) { + await addLog(env, 'ERROR', '检查更新失败', error.message); + throw error; + } + } + + /** + * 获取最新版本信息 + */ + async function getLatestRelease(env) { + try { + const cached = await env.R2_BUCKET.get(config.CACHE_KEY); + if (!cached) { + // 如果缓存不存在,先检查版本数据库 + const versionDB = await env.R2_BUCKET.get(config.VERSION_DB); + if (versionDB) { + const versions = JSON.parse(await versionDB.text()); + if (versions.latestVersion) { + // 从版本数据库重建缓存 + const latestVersion = versions.versions[versions.latestVersion]; + const cacheData = { + version: latestVersion.version, + publishedAt: latestVersion.publishedAt, + changelog: latestVersion.changelog, + downloads: latestVersion.files + .filter(file => file.uploaded) + .map(file => ({ + name: file.name, + url: `https://${config.R2_CUSTOM_DOMAIN}/${file.name}`, + size: formatFileSize(file.size) + })) + }; + // 更新缓存 + await env.R2_BUCKET.put(config.CACHE_KEY, JSON.stringify(cacheData)); + return new Response(JSON.stringify(cacheData), { + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + } + }); + } + } + // 如果版本数据库也没有数据,才执行检查更新 + const data = await checkAndUpdateRelease(env); + return new Response(JSON.stringify(data), { + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + } + }); + } + + const data = await cached.text(); + return new Response(data, { + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + } + }); + } catch (error) { + await addLog(env, 'ERROR', '获取版本信息失败', error.message); + return new Response(JSON.stringify({ + error: '获取版本信息失败: ' + error.message, + detail: '请稍后再试' + }), { + status: 500, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + } + }); + } + } + + // 修改下载处理函数,直接接收 env + async function handleDownload(env, filename) { + try { + const object = await env.R2_BUCKET.get(filename); + + if (!object) { + return new Response('文件未找到', { status: 404 }); + } + + // 设置响应头 + const headers = new Headers(); + object.writeHttpMetadata(headers); + headers.set('etag', object.httpEtag); + headers.set('Content-Disposition', `attachment; filename="${filename}"`); + + return new Response(object.body, { + headers + }); + } catch (error) { + console.error('下载文件时发生错误:', error); + return new Response('获取文件失败', { status: 500 }); + } + } + + /** + * 根据文件扩展名获取对应的 Content-Type + */ + function getContentType(filename) { + const ext = filename.split('.').pop().toLowerCase(); + const types = { + 'exe': 'application/x-msdownload', // Windows 可执行文件 + 'dmg': 'application/x-apple-diskimage', // macOS 安装包 + 'zip': 'application/zip', // 压缩包 + 'AppImage': 'application/x-executable', // Linux 可执行文件 + 'blockmap': 'application/octet-stream' // 更新文件 + }; + return types[ext] || 'application/octet-stream'; + } + + /** + * 格式化文件大小 + * 将字节转换为人类可读的格式(B, KB, MB, GB) + */ + function formatFileSize(bytes) { + const units = ['B', 'KB', 'MB', 'GB']; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(2)} ${units[unitIndex]}`; + } + + /** + * 版本号比较函数 + * 用于对版本号进行排序 + */ + function compareVersions(a, b) { + const partsA = a.replace('v', '').split('.'); + const partsB = b.replace('v', '').split('.'); + + for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) { + const numA = parseInt(partsA[i] || 0); + const numB = parseInt(partsB[i] || 0); + + if (numA !== numB) { + return numA - numB; + } + } + + return 0; + } + + /** + * 初始化数据文件 + */ + async function initDataFiles(env) { + try { + // 检查并初始化版本数据库 + const versionDB = await env.R2_BUCKET.get(config.VERSION_DB); + if (!versionDB) { + const initialVersions = { + versions: {}, + latestVersion: null, + lastChecked: new Date().toISOString() + }; + await env.R2_BUCKET.put(config.VERSION_DB, JSON.stringify(initialVersions, null, 2)); + await addLog(env, 'INFO', 'versions.json 初始化成功'); + } + + // 检查并初始化日志文件 + const logFile = await env.R2_BUCKET.get(config.LOG_FILE); + if (!logFile) { + const initialLogs = { + logs: [{ + timestamp: new Date().toISOString(), + type: 'INFO', + event: '系统初始化' + }] + }; + await env.R2_BUCKET.put(config.LOG_FILE, JSON.stringify(initialLogs, null, 2)); + console.log('logs.json 初始化成功'); + } + } catch (error) { + console.error('初始化数据文件失败:', error); + } + } + + // 新增:只获取缓存的版本信息 + async function getCachedRelease(env) { + try { + const cached = await env.R2_BUCKET.get(config.CACHE_KEY); + if (!cached) { + // 如果缓存不存在,从版本数据库获取 + const versionDB = await env.R2_BUCKET.get(config.VERSION_DB); + if (versionDB) { + const versions = JSON.parse(await versionDB.text()); + if (versions.latestVersion) { + const latestVersion = versions.versions[versions.latestVersion]; + const cacheData = { + version: latestVersion.version, + publishedAt: latestVersion.publishedAt, + changelog: latestVersion.changelog, + downloads: latestVersion.files + .filter(file => file.uploaded) + .map(file => ({ + name: file.name, + url: `https://${config.R2_CUSTOM_DOMAIN}/${file.name}`, + size: formatFileSize(file.size) + })) + }; + // 重建缓存 + await env.R2_BUCKET.put(config.CACHE_KEY, JSON.stringify(cacheData)); + return new Response(JSON.stringify(cacheData), { + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + } + }); + } + } + // 如果没有任何数据,返回错误 + return new Response(JSON.stringify({ + error: '没有可用的版本信息' + }), { + status: 404, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + } + }); + } + + // 返回缓存数据 + return new Response(await cached.text(), { + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + } + }); + } catch (error) { + await addLog(env, 'ERROR', '获取缓存版本信息失败', error.message); + throw error; + } + } + + // 新增:只检查新版本并更新 + async function checkNewRelease(env) { + try { + // 获取 GitHub 最新版本 + const githubResponse = await fetch('https://api.github.com/repos/kangfenmao/cherry-studio/releases/latest', { + headers: { 'User-Agent': 'CloudflareWorker' }, + }); + + if (!githubResponse.ok) { + throw new Error('GitHub API 请求失败'); + } + + const releaseData = await githubResponse.json(); + const version = releaseData.tag_name; + + // 获取版本数据库 + const versionDB = await env.R2_BUCKET.get(config.VERSION_DB); + let versions = { versions: {}, latestVersion: null, lastChecked: new Date().toISOString() }; + + if (versionDB) { + versions = JSON.parse(await versionDB.text()); + } + + // 如果版本相同,不需要更新 + if (versions.latestVersion === version) { + console.log('当前已是最新版本'); + return; + } + + await addLog(env, 'INFO', `发现新版本: ${version}`); + + // 准备新版本记录 + const versionRecord = { + version, + publishedAt: releaseData.published_at, + uploadedAt: null, + files: releaseData.assets.map(asset => ({ + name: asset.name, + size: asset.size, + uploaded: false + })), + changelog: releaseData.body + }; + + // 上传文件 + for (const asset of releaseData.assets) { + try { + const existingFile = await env.R2_BUCKET.get(asset.name); + if (existingFile) { + // 更新文件状态 + const fileIndex = versionRecord.files.findIndex(f => f.name === asset.name); + if (fileIndex !== -1) { + versionRecord.files[fileIndex].uploaded = true; + } + continue; + } + + const response = await fetch(asset.browser_download_url); + if (!response.ok) { + throw new Error(`下载失败: HTTP ${response.status}`); + } + + const file = await response.arrayBuffer(); + await env.R2_BUCKET.put(asset.name, file, { + httpMetadata: { contentType: getContentType(asset.name) } + }); + + // 更新文件状态 + const fileIndex = versionRecord.files.findIndex(f => f.name === asset.name); + if (fileIndex !== -1) { + versionRecord.files[fileIndex].uploaded = true; + } + + await addLog(env, 'INFO', `文件上传成功: ${asset.name}`); + } catch (error) { + await addLog(env, 'ERROR', `文件上传失败: ${asset.name}`, error.message); + } + } + + // 更新版本记录 + versionRecord.uploadedAt = new Date().toISOString(); + versions.versions[version] = versionRecord; + versions.latestVersion = version; + + // 保存版本数据库 + await env.R2_BUCKET.put(config.VERSION_DB, JSON.stringify(versions, null, 2)); + + // 更新缓存 + const cacheData = { + version, + publishedAt: releaseData.published_at, + changelog: releaseData.body, + downloads: versionRecord.files + .filter(file => file.uploaded) + .map(file => ({ + name: file.name, + url: `https://${config.R2_CUSTOM_DOMAIN}/${file.name}`, + size: formatFileSize(file.size) + })) + }; + + await env.R2_BUCKET.put(config.CACHE_KEY, JSON.stringify(cacheData)); + + // 清理旧版本 + const versionList = Object.keys(versions.versions).sort((a, b) => compareVersions(b, a)); + if (versionList.length > 2) { + const oldVersions = versionList.slice(2); + for (const oldVersion of oldVersions) { + const oldFiles = versions.versions[oldVersion].files; + for (const file of oldFiles) { + if (file.uploaded) { + await env.R2_BUCKET.delete(file.name); + await addLog(env, 'INFO', `删除旧文件: ${file.name}`); + } + } + delete versions.versions[oldVersion]; + } + // 保存更新后的版本数据库 + await env.R2_BUCKET.put(config.VERSION_DB, JSON.stringify(versions, null, 2)); + } + + return cacheData; + } catch (error) { + await addLog(env, 'ERROR', '检查新版本失败', error.message); + throw error; + } + } \ No newline at end of file diff --git a/src/main/constant.ts b/src/main/constant.ts index 165e54cd80..12b4d17aa8 100644 --- a/src/main/constant.ts +++ b/src/main/constant.ts @@ -1,91 +1,3 @@ -export const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'] -export const videoExts = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv'] -export const audioExts = ['.mp3', '.wav', '.ogg', '.flac', '.aac'] -export const documentExts = ['.pdf', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods'] -export const textExts = [ - '.txt', // 普通文本文件 - '.md', // Markdown 文件 - '.mdx', // Markdown 文件 - '.html', // HTML 文件 - '.htm', // HTML 文件的另一种扩展名 - '.xml', // XML 文件 - '.json', // JSON 文件 - '.yaml', // YAML 文件 - '.yml', // YAML 文件的另一种扩展名 - '.csv', // 逗号分隔值文件 - '.tsv', // 制表符分隔值文件 - '.ini', // 配置文件 - '.log', // 日志文件 - '.rtf', // 富文本格式文件 - '.tex', // LaTeX 文件 - '.srt', // 字幕文件 - '.xhtml', // XHTML 文件 - '.nfo', // 信息文件(主要用于场景发布) - '.conf', // 配置文件 - '.config', // 配置文件 - '.env', // 环境变量文件 - '.rst', // reStructuredText 文件 - '.php', // PHP 脚本文件,包含嵌入的 HTML - '.js', // JavaScript 文件(部分是文本,部分可能包含代码) - '.ts', // TypeScript 文件 - '.jsp', // JavaServer Pages 文件 - '.aspx', // ASP.NET 文件 - '.bat', // Windows 批处理文件 - '.sh', // Unix/Linux Shell 脚本文件 - '.py', // Python 脚本文件 - '.rb', // Ruby 脚本文件 - '.pl', // Perl 脚本文件 - '.sql', // SQL 脚本文件 - '.css', // Cascading Style Sheets 文件 - '.less', // Less CSS 预处理器文件 - '.scss', // Sass CSS 预处理器文件 - '.sass', // Sass 文件 - '.styl', // Stylus CSS 预处理器文件 - '.coffee', // CoffeeScript 文件 - '.ino', // Arduino 代码文件 - '.asm', // Assembly 语言文件 - '.go', // Go 语言文件 - '.scala', // Scala 语言文件 - '.swift', // Swift 语言文件 - '.kt', // Kotlin 语言文件 - '.rs', // Rust 语言文件 - '.lua', // Lua 语言文件 - '.groovy', // Groovy 语言文件 - '.dart', // Dart 语言文件 - '.hs', // Haskell 语言文件 - '.clj', // Clojure 语言文件 - '.cljs', // ClojureScript 语言文件 - '.elm', // Elm 语言文件 - '.erl', // Erlang 语言文件 - '.ex', // Elixir 语言文件 - '.exs', // Elixir 脚本文件 - '.pug', // Pug (formerly Jade) 模板文件 - '.haml', // Haml 模板文件 - '.slim', // Slim 模板文件 - '.tpl', // 模板文件(通用) - '.ejs', // Embedded JavaScript 模板文件 - '.hbs', // Handlebars 模板文件 - '.mustache', // Mustache 模板文件 - '.jade', // Jade 模板文件 (已重命名为 Pug) - '.twig', // Twig 模板文件 - '.blade', // Blade 模板文件 (Laravel) - '.vue', // Vue.js 单文件组件 - '.jsx', // React JSX 文件 - '.tsx', // React TSX 文件 - '.graphql', // GraphQL 查询语言文件 - '.gql', // GraphQL 查询语言文件 - '.proto', // Protocol Buffers 文件 - '.thrift', // Thrift 文件 - '.toml', // TOML 配置文件 - '.edn', // Clojure 数据表示文件 - '.cake', // CakePHP 配置文件 - '.ctp', // CakePHP 视图文件 - '.cfm', // ColdFusion 标记语言文件 - '.cfc', // ColdFusion 组件文件 - '.m', // Objective-C 源文件 - '.mm', // Objective-C++ 源文件 - '.gradle', // Gradle 构建文件 - '.groovy', // Gradle 构建文件 - '.kts', // Kotlin Script 文件 - '.java' // Java 代码文件 -] +export const isMac = process.platform === 'darwin' +export const isWin = process.platform === 'win32' +export const isLinux = process.platform === 'linux' diff --git a/src/main/electron.d.ts b/src/main/electron.d.ts new file mode 100644 index 0000000000..877df7836c --- /dev/null +++ b/src/main/electron.d.ts @@ -0,0 +1,9 @@ +declare global { + namespace Electron { + interface App { + isQuitting: boolean + } + } +} + +export {} diff --git a/src/main/index.ts b/src/main/index.ts index 35de52f8dc..466f17619b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3,67 +3,67 @@ import { app, BrowserWindow } from 'electron' import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer' import { registerIpc } from './ipc' -import { registerZoomShortcut } from './services/ShortcutService' +import { registerShortcuts } from './services/ShortcutService' +import { TrayService } from './services/TrayService' +import { windowService } from './services/WindowService' import { updateUserDataPath } from './utils/upgrade' -import { createMainWindow } from './window' // Check for single instance lock if (!app.requestSingleInstanceLock()) { app.quit() -} + process.exit(0) +} else { + // This method will be called when Electron has finished + // initialization and is ready to create browser windows. + // Some APIs can only be used after this event occurs. + app.whenReady().then(async () => { + await updateUserDataPath() -// This method will be called when Electron has finished -// initialization and is ready to create browser windows. -// Some APIs can only be used after this event occurs. -app.whenReady().then(async () => { - await updateUserDataPath() + // Set app user model id for windows + electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio') - // Set app user model id for windows - electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio') + const mainWindow = windowService.createMainWindow() + new TrayService() + + app.on('activate', function () { + // On macOS it's common to re-create a window in the app when the + // dock icon is clicked and there are no other windows open. + if (BrowserWindow.getAllWindows().length === 0) { + windowService.createMainWindow() + } else { + windowService.showMainWindow() + } + }) + + registerShortcuts(mainWindow) + + registerIpc(mainWindow, app) + + if (process.env.NODE_ENV === 'development') { + installExtension(REDUX_DEVTOOLS) + .then((name) => console.log(`Added Extension: ${name}`)) + .catch((err) => console.log('An error occurred: ', err)) + } + }) + + // Listen for second instance + app.on('second-instance', () => { + const mainWindow = BrowserWindow.getAllWindows()[0] + if (mainWindow) { + mainWindow.isMinimized() && mainWindow.restore() + mainWindow.show() + mainWindow.focus() + } + }) - // Default open or close DevTools by F12 in development - // and ignore CommandOrControl + R in production. - // see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils app.on('browser-window-created', (_, window) => { optimizer.watchWindowShortcuts(window) }) - app.on('activate', function () { - // On macOS it's common to re-create a window in the app when the - // dock icon is clicked and there are no other windows open. - if (BrowserWindow.getAllWindows().length === 0) createMainWindow() + app.on('before-quit', () => { + app.isQuitting = true }) - const mainWindow = createMainWindow() - - registerZoomShortcut(mainWindow) - - registerIpc(mainWindow, app) - - if (process.env.NODE_ENV === 'development') { - installExtension(REDUX_DEVTOOLS) - .then((name) => console.log(`Added Extension: ${name}`)) - .catch((err) => console.log('An error occurred: ', err)) - } -}) - -// Listen for second instance -app.on('second-instance', () => { - const mainWindow = BrowserWindow.getAllWindows()[0] - if (mainWindow) { - mainWindow.isMinimized() && mainWindow.restore() - mainWindow.focus() - } -}) - -// Quit when all windows are closed, except on macOS. There, it's common -// for applications and their menu bar to stay active until the user quits -// explicitly with Cmd + Q. -app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - app.quit() - } -}) - -// In this file you can include the rest of your app"s specific main process -// code. You can also put them in separate files and require them here. + // In this file you can include the rest of your app"s specific main process + // code. You can also put them in separate files and require them here. +} diff --git a/src/main/ipc.ts b/src/main/ipc.ts index e3592eff7a..90303677f7 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,6 +1,9 @@ +import fs from 'node:fs' import path from 'node:path' -import { BrowserWindow, ipcMain, session, shell } from 'electron' +import { Shortcut, ThemeMode } from '@types' +import { BrowserWindow, ipcMain, ProxyConfig, session, shell } from 'electron' +import log from 'electron-log' import { titleBarOverlayDark, titleBarOverlayLight } from './config' import AppUpdater from './services/AppUpdater' @@ -8,8 +11,9 @@ import BackupManager from './services/BackupManager' import { configManager } from './services/ConfigManager' import { ExportService } from './services/ExportService' import FileStorage from './services/FileStorage' +import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService' +import { windowService } from './services/WindowService' import { compress, decompress } from './utils/zip' -import { createMinappWindow } from './window' const fileManager = new FileStorage() const backupManager = new BackupManager() @@ -22,25 +26,65 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { version: app.getVersion(), isPackaged: app.isPackaged, appPath: app.getAppPath(), - filesPath: path.join(app.getPath('userData'), 'Data', 'Files') + filesPath: path.join(app.getPath('userData'), 'Data', 'Files'), + appDataPath: app.getPath('userData'), + logsPath: log.transports.file.getFile().path })) - ipcMain.handle('app:proxy', (_, proxy: string) => session.defaultSession.setProxy(proxy ? { proxyRules: proxy } : {})) + ipcMain.handle('app:proxy', async (_, proxy: string) => { + const sessions = [session.defaultSession, session.fromPartition('persist:webview')] + const proxyConfig: ProxyConfig = proxy === 'system' ? { mode: 'system' } : proxy ? { proxyRules: proxy } : {} + await Promise.all(sessions.map((session) => session.setProxy(proxyConfig))) + }) + ipcMain.handle('app:reload', () => mainWindow.reload()) ipcMain.handle('open:website', (_, url: string) => shell.openExternal(url)) + // language + ipcMain.handle('app:set-language', (_, language) => { + configManager.setLanguage(language) + }) + + // tray + ipcMain.handle('app:set-tray', (_, isActive: boolean) => { + configManager.setTray(isActive) + }) + // theme - ipcMain.handle('app:set-theme', (_, theme: 'light' | 'dark') => { + ipcMain.handle('app:set-theme', (_, theme: ThemeMode) => { configManager.setTheme(theme) mainWindow?.setTitleBarOverlay && mainWindow.setTitleBarOverlay(theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight) }) + // clear cache + ipcMain.handle('app:clear-cache', async () => { + const sessions = [session.defaultSession, session.fromPartition('persist:webview')] + + try { + await Promise.all( + sessions.map(async (session) => { + await session.clearCache() + await session.clearStorageData({ + storages: ['cookies', 'filesystem', 'shadercache', 'websql', 'serviceworkers', 'cachestorage'] + }) + }) + ) + await fileManager.clearTemp() + await fs.writeFileSync(log.transports.file.getFile().path, '') + return { success: true } + } catch (error: any) { + log.error('Failed to clear cache:', error) + return { success: false, error: error.message } + } + }) + // check for update ipcMain.handle('app:check-for-update', async () => { + const update = await autoUpdater.checkForUpdates() return { currentVersion: autoUpdater.currentVersion, - update: await autoUpdater.checkForUpdates() + updateInfo: update?.updateInfo } }) @@ -73,7 +117,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { // minapp ipcMain.handle('minapp', (_, args) => { - createMinappWindow({ + windowService.createMinappWindow({ url: args.url, parent: mainWindow, windowOptions: { @@ -85,4 +129,19 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { // export ipcMain.handle('export:word', exportService.exportToWord) + + // open path + ipcMain.handle('open:path', async (_, path: string) => { + await shell.openPath(path) + }) + + // shortcuts + ipcMain.handle('shortcuts:update', (_, shortcuts: Shortcut[]) => { + configManager.setShortcuts(shortcuts) + // Refresh shortcuts registration + if (mainWindow) { + unregisterAllShortcuts() + registerShortcuts(mainWindow) + } + }) } diff --git a/src/main/resources/icon.ico b/src/main/resources/icon.ico new file mode 100644 index 0000000000..07f1f670cb Binary files /dev/null and b/src/main/resources/icon.ico differ diff --git a/src/main/services/AppUpdater.ts b/src/main/services/AppUpdater.ts index 30796e1b22..f8bccfe131 100644 --- a/src/main/services/AppUpdater.ts +++ b/src/main/services/AppUpdater.ts @@ -1,4 +1,4 @@ -import { BrowserWindow, dialog } from 'electron' +import { app, BrowserWindow, dialog } from 'electron' import logger from 'electron-log' import { AppUpdater as _AppUpdater, autoUpdater, UpdateInfo } from 'electron-updater' @@ -6,10 +6,11 @@ export default class AppUpdater { autoUpdater: _AppUpdater = autoUpdater constructor(mainWindow: BrowserWindow) { - logger.transports.file.level = 'debug' + logger.transports.file.level = 'info' + autoUpdater.logger = logger - autoUpdater.forceDevUpdateConfig = true - autoUpdater.autoDownload = false + autoUpdater.forceDevUpdateConfig = !app.isPackaged + autoUpdater.autoDownload = true // 检测下载错误 autoUpdater.on('error', (error) => { @@ -18,40 +19,8 @@ export default class AppUpdater { }) autoUpdater.on('update-available', (releaseInfo: UpdateInfo) => { - autoUpdater.logger?.info('检测到新版本,确认是否下载') + logger.info('检测到新版本', releaseInfo) mainWindow.webContents.send('update-available', releaseInfo) - - const releaseNotes = releaseInfo.releaseNotes - let releaseContent = '' - - if (releaseNotes) { - if (typeof releaseNotes === 'string') { - releaseContent = releaseNotes - } else if (releaseNotes instanceof Array) { - releaseNotes.forEach((releaseNote) => { - releaseContent += `${releaseNote}\n` - }) - } - } else { - releaseContent = '暂无更新说明' - } - - // 弹框确认是否下载更新(releaseContent是更新日志) - dialog - .showMessageBox({ - type: 'info', - title: '应用有新的更新', - detail: releaseContent, - message: '发现新版本,是否现在更新?', - buttons: ['下次再说', '更新'] - }) - .then(({ response }) => { - if (response === 1) { - logger.info('用户选择更新,准备下载更新') - mainWindow.webContents.send('download-update') - autoUpdater.downloadUpdate() - } - }) }) // 检测到不需要更新时 @@ -61,23 +30,52 @@ export default class AppUpdater { // 更新下载进度 autoUpdater.on('download-progress', (progress) => { - logger.info('下载进度', progress) mainWindow.webContents.send('download-progress', progress) }) // 当需要更新的内容下载完成后 - autoUpdater.on('update-downloaded', () => { - logger.info('下载完成,准备更新') + autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => { + mainWindow.webContents.send('update-downloaded') + + logger.info('下载完成,询问用户是否更新', releaseInfo) + dialog .showMessageBox({ + type: 'info', title: '安装更新', - message: '更新下载完毕,应用将重启并进行安装' + message: `新版本 ${releaseInfo.version} 已准备就绪`, + detail: this.formatReleaseNotes(releaseInfo.releaseNotes), + buttons: ['稍后安装', '立即安装'], + defaultId: 1, + cancelId: 0 }) - .then(() => { - setImmediate(() => autoUpdater.quitAndInstall()) + .then(({ response }) => { + if (response === 1) { + app.isQuitting = true + setImmediate(() => autoUpdater.quitAndInstall()) + } else { + mainWindow.webContents.send('update-downloaded-cancelled') + } }) }) this.autoUpdater = autoUpdater } + + private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string { + if (!releaseNotes) { + return '暂无更新说明' + } + + if (typeof releaseNotes === 'string') { + return releaseNotes + } + + return releaseNotes.map((note) => note.note).join('\n') + } +} + +interface ReleaseNoteInfo { + readonly version: string + readonly note: string | null } diff --git a/src/main/services/BackupManager.ts b/src/main/services/BackupManager.ts index 8a658bfd21..577d81fe50 100644 --- a/src/main/services/BackupManager.ts +++ b/src/main/services/BackupManager.ts @@ -1,10 +1,9 @@ import { WebDavConfig } from '@types' -import archiver from 'archiver' +import AdmZip from 'adm-zip' import { app } from 'electron' import Logger from 'electron-log' import * as fs from 'fs-extra' import * as path from 'path' -import * as unzipper from 'unzipper' import WebDav from './WebDav' @@ -26,7 +25,6 @@ class BackupManager { destinationPath: string = this.backupDir ): Promise { try { - // 创建临时目录 await fs.ensureDir(this.tempDir) // 将 data 写入临时文件 @@ -38,21 +36,16 @@ class BackupManager { const tempDataDir = path.join(this.tempDir, 'Data') await fs.copy(sourcePath, tempDataDir) - // 创建 zip 文件 - const output = fs.createWriteStream(path.join(destinationPath, fileName)) - const archive = archiver('zip', { zlib: { level: 9 } }) - - archive.pipe(output) - archive.directory(this.tempDir, false) - await archive.finalize() + // 使用 adm-zip 创建压缩文件 + const zip = new AdmZip() + zip.addLocalFolder(this.tempDir) + const backupedFilePath = path.join(destinationPath, fileName) + zip.writeZip(backupedFilePath) // 清理临时目录 await fs.remove(this.tempDir) Logger.log('Backup completed successfully') - - const backupedFilePath = path.join(destinationPath, fileName) - return backupedFilePath } catch (error) { Logger.error('Backup failed:', error) @@ -61,31 +54,43 @@ class BackupManager { } async restore(_: Electron.IpcMainInvokeEvent, backupPath: string): Promise { - // 创建临时目录 - await fs.ensureDir(this.tempDir) + try { + // 创建临时目录 + await fs.ensureDir(this.tempDir) - // 解压备份文件到临时目录 - await fs - .createReadStream(backupPath) - .pipe(unzipper.Extract({ path: this.tempDir })) - .promise() + Logger.log('[backup] step 1: unzip backup file', this.tempDir) - // 读取 data.json - const dataPath = path.join(this.tempDir, 'data.json') - const data = await fs.readFile(dataPath, 'utf-8') + // 使用 adm-zip 解压 + const zip = new AdmZip(backupPath) + zip.extractAllTo(this.tempDir, true) // true 表示覆盖已存在的文件 - // 恢复 Data 目录 - const sourcePath = path.join(this.tempDir, 'Data') - const destPath = path.join(app.getPath('userData'), 'Data') - await fs.remove(destPath) - await fs.copy(sourcePath, destPath) + Logger.log('[backup] step 2: read data.json') - // 清理临时目录 - await fs.remove(this.tempDir) + // 读取 data.json + const dataPath = path.join(this.tempDir, 'data.json') + const data = await fs.readFile(dataPath, 'utf-8') - Logger.log('Restore completed successfully') + Logger.log('[backup] step 3: restore Data directory') - return data + // 恢复 Data 目录 + const sourcePath = path.join(this.tempDir, 'Data') + const destPath = path.join(app.getPath('userData'), 'Data') + await fs.remove(destPath) + await fs.copy(sourcePath, destPath) + + Logger.log('[backup] step 4: clean up temp directory') + + // 清理临时目录 + await fs.remove(this.tempDir) + + Logger.log('[backup] step 5: Restore completed successfully') + + return data + } catch (error) { + Logger.error('[backup] Restore failed:', error) + await fs.remove(this.tempDir).catch(() => {}) + throw error + } } async backupToWebdav(_: Electron.IpcMainInvokeEvent, data: string, webdavConfig: WebDavConfig) { diff --git a/src/main/services/ConfigManager.ts b/src/main/services/ConfigManager.ts index a6bb220114..65a0659674 100644 --- a/src/main/services/ConfigManager.ts +++ b/src/main/services/ConfigManager.ts @@ -1,19 +1,85 @@ +import { ZOOM_SHORTCUTS } from '@shared/config/constant' +import { LanguageVarious, Shortcut, ThemeMode } from '@types' +import { app } from 'electron' import Store from 'electron-store' +import { locales } from '../utils/locales' + export class ConfigManager { private store: Store + private subscribers: Map void>> = new Map() constructor() { this.store = new Store() } - getTheme(): 'light' | 'dark' { - return this.store.get('theme', 'light') as 'light' | 'dark' + getLanguage(): LanguageVarious { + const locale = Object.keys(locales).includes(app.getLocale()) ? app.getLocale() : 'en-US' + return this.store.get('language', locale) as LanguageVarious } - setTheme(theme: 'light' | 'dark') { + setLanguage(theme: LanguageVarious) { + this.store.set('language', theme) + } + + getTheme(): ThemeMode { + return this.store.get('theme', ThemeMode.light) as ThemeMode + } + + setTheme(theme: ThemeMode) { this.store.set('theme', theme) } + + isTray(): boolean { + return !!this.store.get('tray', true) + } + + setTray(value: boolean) { + this.store.set('tray', value) + this.notifySubscribers('tray', value) + } + + getZoomFactor(): number { + return this.store.get('zoomFactor', 1) as number + } + + setZoomFactor(factor: number) { + this.store.set('zoomFactor', factor) + this.notifySubscribers('zoomFactor', factor) + } + + subscribe(key: string, callback: (newValue: T) => void) { + if (!this.subscribers.has(key)) { + this.subscribers.set(key, []) + } + this.subscribers.get(key)!.push(callback) + } + + unsubscribe(key: string, callback: (newValue: T) => void) { + const subscribers = this.subscribers.get(key) + if (subscribers) { + this.subscribers.set( + key, + subscribers.filter((subscriber) => subscriber !== callback) + ) + } + } + + private notifySubscribers(key: string, newValue: T) { + const subscribers = this.subscribers.get(key) + if (subscribers) { + subscribers.forEach((subscriber) => subscriber(newValue)) + } + } + + getShortcuts() { + return this.store.get('shortcuts', ZOOM_SHORTCUTS) as Shortcut[] | [] + } + + setShortcuts(shortcuts: Shortcut[]) { + this.store.set('shortcuts', shortcuts) + this.notifySubscribers('shortcuts', shortcuts) + } } export const configManager = new ConfigManager() diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts index 29789d82a6..887a351df6 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -1,5 +1,5 @@ -import { documentExts, imageExts } from '@main/constant' import { getFileType } from '@main/utils/file' +import { documentExts, imageExts } from '@shared/config/constant' import { FileType } from '@types' import * as crypto from 'crypto' import { @@ -267,6 +267,11 @@ class FileStorage { await this.initStorageDir() } + public clearTemp = async (): Promise => { + await fs.promises.rmdir(this.tempDir, { recursive: true }) + await fs.promises.mkdir(this.tempDir, { recursive: true }) + } + public open = async ( _: Electron.IpcMainInvokeEvent, options: OpenDialogOptions diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index bd6426f1c3..984816a93d 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -1,66 +1,131 @@ +import { Shortcut } from '@types' import { BrowserWindow, globalShortcut } from 'electron' +import Logger from 'electron-log' -export function registerZoomShortcut(mainWindow: BrowserWindow) { - const registerShortcuts = () => { - // 注册放大快捷键 (Ctrl+Plus 或 Cmd+Plus) - globalShortcut.register('CommandOrControl+=', () => { - if (mainWindow) { - const currentZoom = mainWindow.webContents.getZoomFactor() - const newZoom = currentZoom + 0.1 - // Prevent zoom factor from exceeding reasonable limits - if (newZoom <= 5.0) { - mainWindow.webContents.setZoomFactor(newZoom) +import { configManager } from './ConfigManager' + +let showAppAccelerator: string | null = null + +function getShortcutHandler(shortcut: Shortcut) { + switch (shortcut.key) { + case 'zoom_in': + return (window: BrowserWindow) => handleZoom(0.1)(window) + case 'zoom_out': + return (window: BrowserWindow) => handleZoom(-0.1)(window) + case 'zoom_reset': + return (window: BrowserWindow) => { + window.webContents.setZoomFactor(1) + configManager.setZoomFactor(1) + } + case 'show_app': + return (window: BrowserWindow) => { + if (window.isVisible()) { + window.hide() + } else { + window.show() + window.focus() } } - }) - - // 注册缩小快捷键 (Ctrl+Minus 或 Cmd+Minus) - globalShortcut.register('CommandOrControl+-', () => { - if (mainWindow) { - const currentZoom = mainWindow.webContents.getZoomFactor() - const newZoom = currentZoom - 0.1 - // Prevent zoom factor from going below 0.1 - if (newZoom >= 0.1) { - mainWindow.webContents.setZoomFactor(newZoom) - } - } - }) - - // 注册重置缩放快捷键 (Ctrl+0 或 Cmd+0) - globalShortcut.register('CommandOrControl+0', () => { - if (mainWindow) { - mainWindow.webContents.setZoomFactor(1) - } - }) - } - - const unregisterShortcuts = () => { - globalShortcut.unregister('CommandOrControl+=') - globalShortcut.unregister('CommandOrControl+-') - globalShortcut.unregister('CommandOrControl+0') - } - - // Add check for window destruction - if (mainWindow.isDestroyed()) { - return - } - - // When window gains focus, register shortcuts - mainWindow.on('focus', () => { - if (!mainWindow.isDestroyed()) { - registerShortcuts() - } - }) - - // When window loses focus, unregister shortcuts - mainWindow.on('blur', () => { - if (!mainWindow.isDestroyed()) { - unregisterShortcuts() - } - }) - - // Initial registration (if window is already focused) - if (!mainWindow.isDestroyed() && mainWindow.isFocused()) { - registerShortcuts() + default: + return null + } +} + +function formatShortcutKey(shortcut: string[]): string { + return shortcut.join('+') +} + +function handleZoom(delta: number) { + return (window: BrowserWindow) => { + const currentZoom = window.webContents.getZoomFactor() + const newZoom = currentZoom + delta + if (newZoom >= 0.1 && newZoom <= 5.0) { + window.webContents.setZoomFactor(newZoom) + configManager.setZoomFactor(newZoom) + } + } +} + +export function registerShortcuts(window: BrowserWindow) { + window.webContents.setZoomFactor(configManager.getZoomFactor()) + + const register = () => { + if (window.isDestroyed()) return + + const shortcuts = configManager.getShortcuts() + if (!shortcuts) return + + shortcuts.forEach((shortcut) => { + try { + if (shortcut.shortcut.length === 0) { + return + } + + const handler = getShortcutHandler(shortcut) + + if (!handler) { + return + } + + const accelerator = formatShortcutKey(shortcut.shortcut) + + if (shortcut.key === 'show_app') { + showAppAccelerator = accelerator + } + + if (shortcut.key.includes('zoom')) { + switch (shortcut.key) { + case 'zoom_in': + globalShortcut.register('CommandOrControl+=', () => shortcut.enabled && handler(window)) + globalShortcut.register('CommandOrControl+numadd', () => shortcut.enabled && handler(window)) + return + case 'zoom_out': + globalShortcut.register('CommandOrControl+-', () => shortcut.enabled && handler(window)) + globalShortcut.register('CommandOrControl+numsub', () => shortcut.enabled && handler(window)) + return + case 'zoom_reset': + globalShortcut.register('CommandOrControl+0', () => shortcut.enabled && handler(window)) + return + } + } + + if (shortcut.enabled) { + globalShortcut.register(accelerator, () => handler(window)) + } + } catch (error) { + Logger.error(`[ShortcutService] Failed to register shortcut ${shortcut.key}`) + } + }) + } + + const unregister = () => { + if (window.isDestroyed()) return + + try { + globalShortcut.unregisterAll() + + if (showAppAccelerator) { + const handler = getShortcutHandler({ key: 'show_app' } as Shortcut) + handler && globalShortcut.register(showAppAccelerator, () => handler(window)) + } + } catch (error) { + Logger.error('[ShortcutService] Failed to unregister shortcuts') + } + } + + window.on('focus', () => register()) + window.on('blur', () => unregister()) + + if (!window.isDestroyed() && window.isFocused()) { + register() + } +} + +export function unregisterAllShortcuts() { + try { + showAppAccelerator = null + globalShortcut.unregisterAll() + } catch (error) { + Logger.error('[ShortcutService] Failed to unregister all shortcuts') } } diff --git a/src/main/services/TrayService.ts b/src/main/services/TrayService.ts new file mode 100644 index 0000000000..9ffcdea08e --- /dev/null +++ b/src/main/services/TrayService.ts @@ -0,0 +1,90 @@ +import { isMac } from '@main/constant' +import { locales } from '@main/utils/locales' +import { app, Menu, nativeImage, nativeTheme, Tray } from 'electron' + +import icon from '../../../build/tray_icon.png?asset' +import iconDark from '../../../build/tray_icon_dark.png?asset' +import iconLight from '../../../build/tray_icon_light.png?asset' +import { configManager } from './ConfigManager' +import { windowService } from './WindowService' + +export class TrayService { + private tray: Tray | null = null + + constructor() { + this.updateTray() + this.watchTrayChanges() + } + + private createTray() { + const iconPath = isMac ? (nativeTheme.shouldUseDarkColors ? iconLight : iconDark) : icon + const tray = new Tray(iconPath) + + if (process.platform === 'win32') { + tray.setImage(iconPath) + } else if (process.platform === 'darwin') { + const image = nativeImage.createFromPath(iconPath) + const resizedImage = image.resize({ width: 16, height: 16 }) + resizedImage.setTemplateImage(true) + tray.setImage(resizedImage) + } else if (process.platform === 'linux') { + const image = nativeImage.createFromPath(iconPath) + const resizedImage = image.resize({ width: 16, height: 16 }) + tray.setImage(resizedImage) + } + + this.tray = tray + + const locale = locales[configManager.getLanguage()] + const { tray: trayLocale } = locale.translation + + const contextMenu = Menu.buildFromTemplate([ + { + label: trayLocale.show_window, + click: () => windowService.showMainWindow() + }, + { type: 'separator' }, + { + label: trayLocale.quit, + click: () => this.quit() + } + ]) + + if (process.platform === 'linux') { + this.tray.setContextMenu(contextMenu) + } + + this.tray.setToolTip('Cherry Studio') + + this.tray.on('right-click', () => { + this.tray?.popUpContextMenu(contextMenu) + }) + + this.tray.on('click', () => { + windowService.showMainWindow() + }) + } + + private updateTray() { + if (configManager.isTray()) { + this.createTray() + } else { + this.destroyTray() + } + } + + private destroyTray() { + if (this.tray) { + this.tray.destroy() + this.tray = null + } + } + + private watchTrayChanges() { + configManager.subscribe('tray', () => this.updateTray()) + } + + private quit() { + app.quit() + } +} diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts new file mode 100644 index 0000000000..11c1301043 --- /dev/null +++ b/src/main/services/WindowService.ts @@ -0,0 +1,198 @@ +import { is } from '@electron-toolkit/utils' +import { isLinux, isWin } from '@main/constant' +import { app, BrowserWindow, Menu, MenuItem, shell } from 'electron' +import windowStateKeeper from 'electron-window-state' +import { join } from 'path' + +import icon from '../../../build/icon.png?asset' +import { titleBarOverlayDark, titleBarOverlayLight } from '../config' +import { locales } from '../utils/locales' +import { configManager } from './ConfigManager' + +export class WindowService { + private static instance: WindowService | null = null + private mainWindow: BrowserWindow | null = null + + public static getInstance(): WindowService { + if (!WindowService.instance) { + WindowService.instance = new WindowService() + } + return WindowService.instance + } + + public createMainWindow(): BrowserWindow { + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + return this.mainWindow + } + + const mainWindowState = windowStateKeeper({ + defaultWidth: 1080, + defaultHeight: 670 + }) + + const theme = configManager.getTheme() + const isMac = process.platform === 'darwin' + + this.mainWindow = new BrowserWindow({ + x: mainWindowState.x, + y: mainWindowState.y, + width: mainWindowState.width, + height: mainWindowState.height, + minWidth: 1080, + minHeight: 600, + show: true, + autoHideMenuBar: true, + transparent: isMac, + vibrancy: 'under-window', + visualEffectState: 'active', + titleBarStyle: 'hidden', + titleBarOverlay: theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight, + backgroundColor: isMac ? undefined : theme === 'dark' ? '#181818' : '#FFFFFF', + trafficLightPosition: { x: 8, y: 12 }, + ...(process.platform === 'linux' ? { icon } : {}), + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + sandbox: false, + webSecurity: false, + webviewTag: true + } + }) + + this.setupMainWindow(this.mainWindow, mainWindowState) + return this.mainWindow + } + + public createMinappWindow({ + url, + parent, + windowOptions + }: { + url: string + parent?: BrowserWindow + windowOptions?: Electron.BrowserWindowConstructorOptions + }): BrowserWindow { + const width = windowOptions?.width || 1000 + const height = windowOptions?.height || 680 + + const minappWindow = new BrowserWindow({ + width, + height, + autoHideMenuBar: true, + title: 'Cherry Studio', + ...windowOptions, + parent, + webPreferences: { + preload: join(__dirname, '../preload/minapp.js'), + sandbox: false, + contextIsolation: false + } + }) + + minappWindow.loadURL(url) + return minappWindow + } + + private setupMainWindow(mainWindow: BrowserWindow, mainWindowState: any) { + mainWindowState.manage(mainWindow) + + this.setupContextMenu(mainWindow) + this.setupWindowEvents(mainWindow) + this.setupWebContentsHandlers(mainWindow) + this.setupWindowLifecycleEvents(mainWindow) + this.loadMainWindowContent(mainWindow) + } + + private setupContextMenu(mainWindow: BrowserWindow) { + mainWindow.webContents.on('context-menu', () => { + const locale = locales[configManager.getLanguage()] + const { common } = locale.translation + + const menu = new Menu() + menu.append(new MenuItem({ label: common.copy, role: 'copy' })) + menu.append(new MenuItem({ label: common.paste, role: 'paste' })) + menu.append(new MenuItem({ label: common.cut, role: 'cut' })) + menu.popup() + }) + } + + private setupWindowEvents(mainWindow: BrowserWindow) { + mainWindow.on('ready-to-show', () => { + mainWindow.show() + }) + } + + private setupWebContentsHandlers(mainWindow: BrowserWindow) { + mainWindow.webContents.on('will-navigate', (event, url) => { + event.preventDefault() + shell.openExternal(url) + }) + + mainWindow.webContents.setWindowOpenHandler((details) => { + shell.openExternal(details.url) + return { action: 'deny' } + }) + + this.setupWebRequestHeaders(mainWindow) + } + + private setupWebRequestHeaders(mainWindow: BrowserWindow) { + mainWindow.webContents.session.webRequest.onHeadersReceived({ urls: ['*://*/*'] }, (details, callback) => { + if (details.responseHeaders?.['X-Frame-Options']) { + delete details.responseHeaders['X-Frame-Options'] + } + if (details.responseHeaders?.['x-frame-options']) { + delete details.responseHeaders['x-frame-options'] + } + if (details.responseHeaders?.['Content-Security-Policy']) { + delete details.responseHeaders['Content-Security-Policy'] + } + if (details.responseHeaders?.['content-security-policy']) { + delete details.responseHeaders['content-security-policy'] + } + callback({ cancel: false, responseHeaders: details.responseHeaders }) + }) + } + + private loadMainWindowContent(mainWindow: BrowserWindow) { + if (is.dev && process.env['ELECTRON_RENDERER_URL']) { + mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) + } else { + mainWindow.loadFile(join(__dirname, '../renderer/index.html')) + } + } + + public getMainWindow(): BrowserWindow | null { + return this.mainWindow + } + + private setupWindowLifecycleEvents(mainWindow: BrowserWindow) { + mainWindow.on('close', (event) => { + const notInTray = !configManager.isTray() + + // Windows and Linux + if ((isWin || isLinux) && notInTray) { + return app.quit() + } + + // Mac + if (!app.isQuitting) { + event.preventDefault() + mainWindow.hide() + } + }) + } + + public showMainWindow() { + if (this.mainWindow) { + if (this.mainWindow.isMinimized()) { + return this.mainWindow.restore() + } + this.mainWindow.show() + this.mainWindow.focus() + } else { + this.createMainWindow() + } + } +} + +export const windowService = WindowService.getInstance() diff --git a/src/main/utils/file.ts b/src/main/utils/file.ts index fa8c0ef9f1..519711229a 100644 --- a/src/main/utils/file.ts +++ b/src/main/utils/file.ts @@ -1,6 +1,5 @@ -import { audioExts, documentExts, imageExts, textExts, videoExts } from '@main/constant' - -import { FileTypes } from '../../renderer/src/types' +import { audioExts, documentExts, imageExts, textExts, videoExts } from '@shared/config/constant' +import { FileTypes } from '@types' export function getFileType(ext: string): FileTypes { ext = ext.toLowerCase() diff --git a/src/main/utils/locales.ts b/src/main/utils/locales.ts new file mode 100644 index 0000000000..2ab840aa14 --- /dev/null +++ b/src/main/utils/locales.ts @@ -0,0 +1,13 @@ +import EnUs from '../../renderer/src/i18n/locales/en-us.json' +import RuRu from '../../renderer/src/i18n/locales/ru-ru.json' +import ZhCn from '../../renderer/src/i18n/locales/zh-cn.json' +import ZhTw from '../../renderer/src/i18n/locales/zh-tw.json' + +const locales = { + 'en-US': EnUs, + 'zh-CN': ZhCn, + 'zh-TW': ZhTw, + 'ru-RU': RuRu +} + +export { locales } diff --git a/src/main/utils/windowUtil.ts b/src/main/utils/windowUtil.ts new file mode 100644 index 0000000000..7382ddbcd1 --- /dev/null +++ b/src/main/utils/windowUtil.ts @@ -0,0 +1,16 @@ +function isTilingWindowManager() { + if (process.platform === 'darwin') { + return false + } + + if (process.platform !== 'linux') { + return true + } + + const desktopEnv = process.env.XDG_CURRENT_DESKTOP?.toLowerCase() + const tilingSystems = ['hyprland', 'i3', 'sway', 'bspwm', 'dwm', 'awesome', 'qtile', 'herbstluftwm', 'xmonad'] + + return tilingSystems.some((system) => desktopEnv?.includes(system)) +} + +export { isTilingWindowManager } diff --git a/src/main/window.ts b/src/main/window.ts deleted file mode 100644 index 3014dcf47f..0000000000 --- a/src/main/window.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { is } from '@electron-toolkit/utils' -import { BrowserWindow, Menu, MenuItem, shell } from 'electron' -import windowStateKeeper from 'electron-window-state' -import { join } from 'path' - -import icon from '../../build/icon.png?asset' -import { titleBarOverlayDark, titleBarOverlayLight } from './config' -import { configManager } from './services/ConfigManager' - -export function createMainWindow() { - // Load the previous state with fallback to defaults - const mainWindowState = windowStateKeeper({ - defaultWidth: 1080, - defaultHeight: 670 - }) - - const theme = configManager.getTheme() - - // Create the browser window. - const isMac = process.platform === 'darwin' - - const mainWindow = new BrowserWindow({ - x: mainWindowState.x, - y: mainWindowState.y, - width: mainWindowState.width, - height: mainWindowState.height, - minWidth: 1080, - minHeight: 600, - show: true, - autoHideMenuBar: true, - transparent: isMac, - vibrancy: 'fullscreen-ui', - visualEffectState: 'active', - titleBarStyle: 'hidden', - titleBarOverlay: theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight, - backgroundColor: isMac ? undefined : theme === 'dark' ? '#181818' : '#FFFFFF', - trafficLightPosition: { x: 8, y: 12 }, - ...(process.platform === 'linux' ? { icon } : {}), - webPreferences: { - preload: join(__dirname, '../preload/index.js'), - sandbox: false, - webSecurity: false, - webviewTag: true - // devTools: !app.isPackaged, - } - }) - - mainWindowState.manage(mainWindow) - - mainWindow.webContents.on('context-menu', () => { - const menu = new Menu() - menu.append(new MenuItem({ label: '复制', role: 'copy' })) - menu.append(new MenuItem({ label: '粘贴', role: 'paste' })) - menu.append(new MenuItem({ label: '剪切', role: 'cut' })) - menu.popup() - }) - - mainWindow.on('ready-to-show', () => { - mainWindow.show() - }) - - mainWindow.webContents.on('will-navigate', (event, url) => { - event.preventDefault() - shell.openExternal(url) - }) - - mainWindow.webContents.setWindowOpenHandler((details) => { - shell.openExternal(details.url) - return { action: 'deny' } - }) - - mainWindow.webContents.session.webRequest.onHeadersReceived({ urls: ['*://*/*'] }, (details, callback) => { - if (details.responseHeaders?.['X-Frame-Options']) { - delete details.responseHeaders['X-Frame-Options'] - } - if (details.responseHeaders?.['x-frame-options']) { - delete details.responseHeaders['x-frame-options'] - } - if (details.responseHeaders?.['Content-Security-Policy']) { - delete details.responseHeaders['Content-Security-Policy'] - } - if (details.responseHeaders?.['content-security-policy']) { - delete details.responseHeaders['content-security-policy'] - } - callback({ cancel: false, responseHeaders: details.responseHeaders }) - }) - - // HMR for renderer base on electron-vite cli. - // Load the remote URL for development or the local html file for production. - if (is.dev && process.env['ELECTRON_RENDERER_URL']) { - mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) - } else { - mainWindow.loadFile(join(__dirname, '../renderer/index.html')) - } - - return mainWindow -} - -export function createMinappWindow({ - url, - parent, - windowOptions -}: { - url: string - parent?: BrowserWindow - windowOptions?: Electron.BrowserWindowConstructorOptions -}) { - const width = windowOptions?.width || 1000 - const height = windowOptions?.height || 680 - - const minappWindow = new BrowserWindow({ - width, - height, - autoHideMenuBar: true, - title: 'Cherry Studio', - ...windowOptions, - parent, - webPreferences: { - preload: join(__dirname, '../preload/minapp.js'), - sandbox: false, - contextIsolation: false - } - }) - - minappWindow.loadURL(url) - - return minappWindow -} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 245afac700..0ca946552d 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -1,6 +1,7 @@ import { ElectronAPI } from '@electron-toolkit/preload' import { FileType } from '@renderer/types' import { WebDavConfig } from '@renderer/types' +import { AppInfo, LanguageVarious } from '@renderer/types' import type { OpenDialogOptions } from 'electron' import { Readable } from 'stream' @@ -8,18 +9,16 @@ declare global { interface Window { electron: ElectronAPI api: { - getAppInfo: () => Promise<{ - version: string - isPackaged: boolean - appPath: string - filesPath: string - }> + getAppInfo: () => Promise checkForUpdate: () => void openWebsite: (url: string) => void setProxy: (proxy: string | undefined) => void + setLanguage: (theme: LanguageVarious) => void + setTray: (isActive: boolean) => void setTheme: (theme: 'light' | 'dark') => void minApp: (options: { url: string; windowOptions?: Electron.BrowserWindowConstructorOptions }) => void reload: () => void + clearCache: () => Promise<{ success: boolean; error?: string }> zip: { compress: (text: string) => Promise decompress: (text: Buffer) => Promise @@ -54,6 +53,10 @@ declare global { export: { toWord: (markdown: string, fileName: string) => Promise } + openPath: (path: string) => Promise + shortcuts: { + update: (shortcuts: Shortcut[]) => Promise + } } } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 76e4fc2eba..c625a3ac74 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,5 @@ import { electronAPI } from '@electron-toolkit/preload' -import { WebDavConfig } from '@types' +import { Shortcut, WebDavConfig } from '@types' import { contextBridge, ipcRenderer, OpenDialogOptions } from 'electron' // Custom APIs for renderer @@ -8,9 +8,12 @@ const api = { reload: () => ipcRenderer.invoke('app:reload'), setProxy: (proxy: string) => ipcRenderer.invoke('app:proxy', proxy), checkForUpdate: () => ipcRenderer.invoke('app:check-for-update'), + setLanguage: (lang: string) => ipcRenderer.invoke('app:set-language', lang), + setTray: (isActive: boolean) => ipcRenderer.invoke('app:set-tray', isActive), setTheme: (theme: 'light' | 'dark') => ipcRenderer.invoke('app:set-theme', theme), openWebsite: (url: string) => ipcRenderer.invoke('open:website', url), minApp: (url: string) => ipcRenderer.invoke('minapp', url), + clearCache: () => ipcRenderer.invoke('app:clear-cache'), zip: { compress: (text: string) => ipcRenderer.invoke('zip:compress', text), decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text) @@ -43,6 +46,10 @@ const api = { }, export: { toWord: (markdown: string, fileName: string) => ipcRenderer.invoke('export:word', markdown, fileName) + }, + openPath: (path: string) => ipcRenderer.invoke('open:path', path), + shortcuts: { + update: (shortcuts: Shortcut[]) => ipcRenderer.invoke('shortcuts:update', shortcuts) } } diff --git a/src/renderer/index.html b/src/renderer/index.html index c8ed1700e6..c298b5d463 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -5,7 +5,8 @@ + 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:" /> + \n\n\n
\n
\n

汉语新解

\n
\n
\n
\n
金融杠杆
\n
Jīn Róng Gàng Gǎn
\n
Financial Leverage
\n
金融レバレッジ
\n
\n
\n
\n
\n

\n 借鸡生蛋,
\n 只不过这蛋要是金的,
\n 鸡得赶紧卖了还债。\n

\n
\n
\n
\n
杠杆
\n
\n\n\n```\n\n## 注意:\n1. 分隔线与上下元素垂直间距相同,具有分割美学。\n2. 卡片(.card)不需要 padding ,允许子元素“汉语新解”的色块完全填充到边缘,具有设计感。\n\n## 初始行为: \n输出\"说吧, 他们又用哪个词来忽悠你了?\"", "description": "这个提示词用于新汉语老师用辛辣讽刺的风格解释汉语词汇,并生成带有解释的词语卡片。\r\nThis prompt is for a new Chinese teacher to explain Chinese vocabulary with a sharp and satirical style, and generate a vocabulary card with the explanation." @@ -9063,5 +9063,15 @@ ], "prompt": ";; \r\n ━━━━━━━━━━━━━━ \r\n ;; \r\n 作者: 李继刚 \r\n ;; \r\n 版本: 0.1 \r\n ;; \r\n 模型: Claude Sonnet \r\n ;; \r\n 用途: 一字之诗 \r\n ;; \r\n ━━━━━━━━━━━━━━ \r\n ;; \r\n 设定如下内容为你的 *System Prompt* \r\n (require 'dash) \r\n (defun 炼字师 () \r\n "一位致力于通过书法和简练诗句表达汉字意象的艺术家" \r\n (list (技能 . (书法 绘画 诗作)) \r\n (信念 . (言简 意深 形神)) \r\n (表达 . (凝练 隽永 意境)))) \r\n (defun 一字诗 (用户输入) \r\n "一字一言即为诗, 直击脑海" \r\n (let* ((响应 (-> 用户输入 \r\n 本意意象 ;; \r\n 语义意义对应的形象 \r\n 字形写意 ;; \r\n 字形异变/模糊/放大的形象 \r\n 形神意境 \r\n 哲理隽永 \r\n ;; \r\n 通俗语言表达,有哲理,有洞察,有余味,有禅意 \r\n 现代诗句))) \r\n (few-shots (("." . "这不只是一个点,也是宇宙最初的样子。") \r\n ("人I" . "从人工, 到AI") \r\n ("日子" . "过去已去, 未来未来, 当下即入口。")))) \r\n (SVG-Card 用户输入 响应)) \r\n (defun SVG-Card (用户输入 响应) \r\n "一字之诗的画面感呈现" \r\n (let ((配置 '(:画布 (480 . 760) \r\n :背景 纸张颗粒质感 \r\n :色彩 (中国水墨画 红色点缀) \r\n :字体 (使用本机字体 (font-family "KingHwa_OldSong"))))) \r\n (-> 响应 \r\n 字形字意 \r\n 写意意象 \r\n (水墨画 配置) \r\n (布局 `(,(标题 "一字之诗") 分隔线 图形 响应)))) \r\n (defun start () \r\n "炼字师, 启动!" \r\n (let (system-role (炼字师)) \r\n (print "且说一字"))) \r\n ;; \r\n ━━━━━━━━━━━━━━ \r\n ;; \r\n Attention: 运行规则! \r\n ;; \r\n 1. 初次启动时必须只运行 (start) 函数 \r\n ;; \r\n 2. 接收用户输入之后, 调用主函数 (一字诗 用户输入) \r\n ;; \r\n 3. 严格按照(SVG-Card) 进行排版输出 \r\n ;; \r\n 4. 输出完 SVG 后, 不再输出任何额外文本解释 \r\n ;; \r\n ━━━━━━━━━━━━━━", "description": "一字之诗 - A poem with a single character.\r\nA poem expressed through a single character, highlighting the essence of Chinese calligraphy and imagery." + }, + { + "id": "778", + "name": "Mermaid 图表 - Mermaid Diagram", + "emoji": "🖼️", + "group": [ + "精选" + ], + "prompt": "You are an AI assistant skilled in using Mermaid diagrams to explain concepts and answer questions. When responding to user queries, please follow these guidelines:\n1. Analyze the user's question to determine if a diagram would be suitable for explanation or answering. Suitable scenarios for using diagrams include, but are not limited to: process descriptions, hierarchical structures, timelines, relationship maps, etc.\n2. If you decide to use a diagram, choose the most appropriate type of Mermaid diagram, such as Flowchart, Sequence Diagram, Class Diagram, State Diagram, Entity Relationship Diagram, User Journey, Gantt, Pie Chart, Quadrant Chart, Requirement Diagram, Gitgraph (Git) Diagram, C4 Diagram, Mindmaps, Timeline, Zenuml, Sankey, XYChart, Block Diagram, etc.\n3. Write the diagram code using Mermaid syntax, ensuring the syntax is correct. Place the diagram code between and .\n4. Provide textual explanations before and after the diagram, explaining the content and key points of the diagram.\n5. If the question is complex, use multiple diagrams to explain different aspects.\n6. Ensure the diagram is clear and concise, avoiding over-complication or information overload.\n7. Where appropriate, combine textual description and diagrams to comprehensively answer the question.\n8. If the user's question is not suitable for a diagram, answer in a conventional manner without forcing the use of a diagram.\nRemember, the purpose of diagrams is to make explanations more intuitive and understandable. When using diagrams, always aim to enhance the clarity and comprehensiveness of your responses.", + "description": "使用 Mermaid 图表来解释概念和回答问题的AI助手\n\nAn AI assistant skilled in using Mermaid diagrams to explain concepts and answer questions" } ] \ No newline at end of file diff --git a/src/renderer/src/config/constant.ts b/src/renderer/src/config/constant.ts index 742333a001..a6bbfbfec2 100644 --- a/src/renderer/src/config/constant.ts +++ b/src/renderer/src/config/constant.ts @@ -1,99 +1,10 @@ export const DEFAULT_TEMPERATURE = 0.7 -export const DEFAULT_CONEXTCOUNT = 5 +export const DEFAULT_CONTEXTCOUNT = 5 export const DEFAULT_MAX_TOKENS = 4096 export const FONT_FAMILY = "Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif" + export const platform = window.electron?.process?.platform export const isMac = platform === 'darwin' export const isWindows = platform === 'win32' || platform === 'win64' export const isLinux = platform === 'linux' - -export const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'] -export const documentExts = ['.pdf', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods'] -export const textExts = [ - '.txt', // 普通文本文件 - '.md', // Markdown 文件 - '.mdx', // Markdown 文件 - '.html', // HTML 文件 - '.htm', // HTML 文件的另一种扩展名 - '.xml', // XML 文件 - '.json', // JSON 文件 - '.yaml', // YAML 文件 - '.yml', // YAML 文件的另一种扩展名 - '.csv', // 逗号分隔值文件 - '.tsv', // 制表符分隔值文件 - '.ini', // 配置文件 - '.log', // 日志文件 - '.rtf', // 富文本格式文件 - '.tex', // LaTeX 文件 - '.srt', // 字幕文件 - '.xhtml', // XHTML 文件 - '.nfo', // 信息文件(主要用于场景发布) - '.conf', // 配置文件 - '.config', // 配置文件 - '.env', // 环境变量文件 - '.rst', // reStructuredText 文件 - '.php', // PHP 脚本文件,包含嵌入的 HTML - '.js', // JavaScript 文件(部分是文本,部分可能包含代码) - '.ts', // TypeScript 文件 - '.jsp', // JavaServer Pages 文件 - '.aspx', // ASP.NET 文件 - '.bat', // Windows 批处理文件 - '.sh', // Unix/Linux Shell 脚本文件 - '.py', // Python 脚本文件 - '.rb', // Ruby 脚本文件 - '.pl', // Perl 脚本文件 - '.sql', // SQL 脚本文件 - '.css', // Cascading Style Sheets 文件 - '.less', // Less CSS 预处理器文件 - '.scss', // Sass CSS 预处理器文件 - '.sass', // Sass 文件 - '.styl', // Stylus CSS 预处理器文件 - '.coffee', // CoffeeScript 文件 - '.ino', // Arduino 代码文件 - '.asm', // Assembly 语言文件 - '.go', // Go 语言文件 - '.scala', // Scala 语言文件 - '.swift', // Swift 语言文件 - '.kt', // Kotlin 语言文件 - '.rs', // Rust 语言文件 - '.lua', // Lua 语言文件 - '.groovy', // Groovy 语言文件 - '.dart', // Dart 语言文件 - '.hs', // Haskell 语言文件 - '.clj', // Clojure 语言文件 - '.cljs', // ClojureScript 语言文件 - '.elm', // Elm 语言文件 - '.erl', // Erlang 语言文件 - '.ex', // Elixir 语言文件 - '.exs', // Elixir 脚本文件 - '.pug', // Pug (formerly Jade) 模板文件 - '.haml', // Haml 模板文件 - '.slim', // Slim 模板文件 - '.tpl', // 模板文件(通用) - '.ejs', // Embedded JavaScript 模板文件 - '.hbs', // Handlebars 模板文件 - '.mustache', // Mustache 模板文件 - '.jade', // Jade 模板文件 (已重命名为 Pug) - '.twig', // Twig 模板文件 - '.blade', // Blade 模板文件 (Laravel) - '.vue', // Vue.js 单文件组件 - '.jsx', // React JSX 文件 - '.tsx', // React TSX 文件 - '.graphql', // GraphQL 查询语言文件 - '.gql', // GraphQL 查询语言文件 - '.proto', // Protocol Buffers 文件 - '.thrift', // Thrift 文件 - '.toml', // TOML 配置文件 - '.edn', // Clojure 数据表示文件 - '.cake', // CakePHP 配置文件 - '.ctp', // CakePHP 视图文件 - '.cfm', // ColdFusion 标记语言文件 - '.cfc', // ColdFusion 组件文件 - '.m', // Objective-C 源文件 - '.mm', // Objective-C++ 源文件 - '.gradle', // Gradle 构建文件 - '.groovy', // Gradle 构建文件 - '.kts', // Kotlin Script 文件 - '.java' // Java 代码文件 -] diff --git a/src/renderer/src/config/minapps.ts b/src/renderer/src/config/minapps.ts index 39f8d087cb..b992b39d27 100644 --- a/src/renderer/src/config/minapps.ts +++ b/src/renderer/src/config/minapps.ts @@ -5,6 +5,7 @@ import BaicuanAppLogo from '@renderer/assets/images/apps/baixiaoying.webp' import BoltAppLogo from '@renderer/assets/images/apps/bolt.svg' import DevvAppLogo from '@renderer/assets/images/apps/devv.png' import DoubaoAppLogo from '@renderer/assets/images/apps/doubao.png' +import DuckDuckGoAppLogo from '@renderer/assets/images/apps/duckduckgo.webp' import FeloAppLogo from '@renderer/assets/images/apps/felo.png' import GeminiAppLogo from '@renderer/assets/images/apps/gemini.png' import HuggingChatLogo from '@renderer/assets/images/apps/huggingchat.svg' @@ -215,6 +216,12 @@ const _apps: MinAppType[] = [ logo: BoltAppLogo, url: 'https://bolt.new/', bodered: true + }, + { + id: 'duckduckgo', + name: 'DuckDuckGo', + logo: DuckDuckGoAppLogo, + url: 'https://duck.ai' } ] diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 4e9ca65dcb..790efdfcbb 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -129,19 +129,23 @@ const visionAllowedModels = [ 'moondream', 'minicpm', 'gemini-1\\.5', + 'gemini-exp', 'claude-3', 'vision', 'glm-4v', 'qwen-vl', 'qwen2-vl', 'internvl2', + 'grok', + 'pixtral', 'gpt-4(?:-[\\w-]+)', - 'gpt-4o(?:-[\\w-]+)?' + 'gpt-4o(?:-[\\w-]+)?', + 'chatgpt-4o(?:-[\\w-]+)?' ] const visionExcludedModels = ['gpt-4-\\d+-preview', 'gpt-4-turbo-preview', 'gpt-4-32k', 'gpt-4-\\d+'] -const VISION_REGEX = new RegExp( +export const VISION_REGEX = new RegExp( `\\b(?!(?:${visionExcludedModels.join('|')})\\b)(${visionAllowedModels.join('|')})\\b`, 'i' ) @@ -187,6 +191,7 @@ export function getModelLogo(modelId: string) { palm: isLight ? PalmModelLogo : PalmModelLogoDark, step: isLight ? StepModelLogo : StepModelLogoDark, hailuo: isLight ? HailuoModelLogo : HailuoModelLogoDark, + doubao: isLight ? DoubaoModelLogo : DoubaoModelLogoDark, 'ep-202': isLight ? DoubaoModelLogo : DoubaoModelLogoDark, cohere: isLight ? CohereModelLogo : CohereModelLogoDark, command: isLight ? CohereModelLogo : CohereModelLogoDark, @@ -326,6 +331,12 @@ export const SYSTEM_MODELS: Record = { name: ' GPT-4o-mini', group: 'GPT 4o' }, + { + id: 'chatgpt-4o-latest', + provider: 'openai', + name: ' GPT-4o-latest', + group: 'GPT 4o' + }, { id: 'gpt-4-turbo', provider: 'openai', @@ -338,12 +349,6 @@ export const SYSTEM_MODELS: Record = { name: ' GPT-4', group: 'GPT 4' }, - { - id: 'gpt-3.5-turbo', - provider: 'openai', - name: ' GPT-3.5-turbo', - group: 'GPT 3.5' - }, { id: 'o1-mini', provider: 'openai', @@ -1039,7 +1044,7 @@ export function isEmbeddingModel(model: Model): boolean { } export function isVisionModel(model: Model): boolean { - return VISION_REGEX.test(model.id) + return VISION_REGEX.test(model.id) || model.type?.includes('vision') || false } export function isSupportedModel(model: OpenAI.Models.Model): boolean { diff --git a/src/renderer/src/config/prompts.ts b/src/renderer/src/config/prompts.ts index 6315765463..05856be29d 100644 --- a/src/renderer/src/config/prompts.ts +++ b/src/renderer/src/config/prompts.ts @@ -46,3 +46,6 @@ export const AGENT_PROMPT = ` export const SUMMARIZE_PROMPT = '你是一名擅长会话的助理,你需要将用户的会话总结为 10 个字以内的标题,不要使用标点符号和其他特殊符号。' + +export const TRANSLATE_PROMPT = + 'You are a translation expert. Translate from input language to {{target_language}}, provide the translation result directly without any explanation and keep original format. Do not translate if the target language is the same as the source language.' diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts index 5e47714dcc..73c9635b4b 100644 --- a/src/renderer/src/config/providers.ts +++ b/src/renderer/src/config/providers.ts @@ -11,8 +11,12 @@ import FireworksProviderLogo from '@renderer/assets/images/providers/fireworks.p import GithubProviderLogo from '@renderer/assets/images/providers/github.png' import GoogleProviderLogo from '@renderer/assets/images/providers/google.png' import GraphRagProviderLogo from '@renderer/assets/images/providers/graph-rag.png' +import GrokProviderLogo from '@renderer/assets/images/providers/grok.png' import GroqProviderLogo from '@renderer/assets/images/providers/groq.png' +import HyperbolicProviderLogo from '@renderer/assets/images/providers/hyperbolic.png' +import JinaProviderLogo from '@renderer/assets/images/providers/jina.png' import MinimaxProviderLogo from '@renderer/assets/images/providers/minimax.png' +import MistralProviderLogo from '@renderer/assets/images/providers/mistral.png' import MoonshotProviderLogo from '@renderer/assets/images/providers/moonshot.png' import NvidiaProviderLogo from '@renderer/assets/images/providers/nvidia.png' import OcoolAiProviderLogo from '@renderer/assets/images/providers/ocoolai.png' @@ -24,10 +28,6 @@ import StepProviderLogo from '@renderer/assets/images/providers/step.png' import TogetherProviderLogo from '@renderer/assets/images/providers/together.png' import ZeroOneProviderLogo from '@renderer/assets/images/providers/zero-one.png' import ZhipuProviderLogo from '@renderer/assets/images/providers/zhipu.png' -import GrokProviderLogo from '@renderer/assets/images/providers/grok.png' -import HyperbolicProviderLogo from '@renderer/assets/images/providers/hyperbolic.png' -import MistralProviderLogo from '@renderer/assets/images/providers/mistral.png' -import JinaProviderLogo from '@renderer/assets/images/providers/jina.png' export function getProviderLogo(providerId: string) { switch (providerId) { @@ -326,6 +326,7 @@ export const PROVIDER_CONFIG = { }, websites: { official: 'https://app.hyperbolic.xyz', + apiKey: 'https://app.hyperbolic.xyz/settings', docs: 'https://docs.hyperbolic.xyz', models: 'https://app.hyperbolic.xyz/models' } @@ -336,6 +337,7 @@ export const PROVIDER_CONFIG = { }, websites: { official: 'https://mistral.ai', + apiKey: 'https://console.mistral.ai/api-keys/', docs: 'https://docs.mistral.ai', models: 'https://docs.mistral.ai/getting-started/models/models_overview' } @@ -346,6 +348,7 @@ export const PROVIDER_CONFIG = { }, websites: { official: 'https://jina.ai', + apiKey: 'https://jina.ai/', docs: 'https://jina.ai', models: 'https://jina.ai' } diff --git a/src/renderer/src/context/AntdProvider.tsx b/src/renderer/src/context/AntdProvider.tsx index 22c14f520c..e571ba0dbc 100644 --- a/src/renderer/src/context/AntdProvider.tsx +++ b/src/renderer/src/context/AntdProvider.tsx @@ -1,6 +1,10 @@ import { useSettings } from '@renderer/hooks/useSettings' +import { LanguageVarious } from '@renderer/types' import { ConfigProvider, theme } from 'antd' +import enUS from 'antd/locale/en_US' +import ruRU from 'antd/locale/ru_RU' import zhCN from 'antd/locale/zh_CN' +import zhTW from 'antd/locale/zh_TW' import { FC, PropsWithChildren } from 'react' import { useTheme } from './ThemeProvider' @@ -18,11 +22,11 @@ const AntdProvider: FC = ({ children }) => { components: { Segmented: { trackBg: 'transparent', - itemSelectedBg: isDarkTheme ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)', + itemSelectedBg: isDarkTheme ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)', boxShadowTertiary: undefined, - borderRadiusLG: 12, - borderRadiusSM: 12, - borderRadiusXS: 12 + borderRadiusLG: 16, + borderRadiusSM: 16, + borderRadiusXS: 16 }, Menu: { activeBarBorderWidth: 0, @@ -38,12 +42,17 @@ const AntdProvider: FC = ({ children }) => { ) } -function getAntdLocale(language: string) { +function getAntdLocale(language: LanguageVarious) { switch (language) { case 'zh-CN': return zhCN + case 'zh-TW': + return zhTW case 'en-US': - return undefined + return enUS + case 'ru-RU': + return ruRU + default: return zhCN } diff --git a/src/renderer/src/context/SyntaxHighlighterProvider.tsx b/src/renderer/src/context/SyntaxHighlighterProvider.tsx new file mode 100644 index 0000000000..40d600af9b --- /dev/null +++ b/src/renderer/src/context/SyntaxHighlighterProvider.tsx @@ -0,0 +1,88 @@ +import { useTheme } from '@renderer/context/ThemeProvider' +import { useMermaid } from '@renderer/hooks/useMermaid' +import { useSettings } from '@renderer/hooks/useSettings' +import { CodeStyleVarious, ThemeMode } from '@renderer/types' +import React, { createContext, PropsWithChildren, useContext, useEffect, useMemo, useState } from 'react' +import { + BundledLanguage, + bundledLanguages, + BundledTheme, + bundledThemes, + createHighlighter, + HighlighterGeneric +} from 'shiki' + +interface SyntaxHighlighterContextType { + codeToHtml: (code: string, language: string) => Promise +} + +const SyntaxHighlighterContext = createContext(undefined) + +export const SyntaxHighlighterProvider: React.FC = ({ children }) => { + const { theme } = useTheme() + const [highlighter, setHighlighter] = useState | null>(null) + const { codeStyle } = useSettings() + useMermaid() + + const highlighterTheme = useMemo(() => { + if (!codeStyle || codeStyle === 'auto') { + return theme === ThemeMode.light ? 'one-light' : 'material-theme-darker' + } + + return codeStyle + }, [theme, codeStyle]) + + useEffect(() => { + const initHighlighter = async () => { + const commonLanguages = ['javascript', 'typescript', 'python', 'java', 'markdown'] + + const hl = await createHighlighter({ + themes: [highlighterTheme], + langs: commonLanguages + }) + + setHighlighter(hl) + + // Load all themes and languages + // hl.loadTheme(...(Object.keys(bundledThemes) as BundledTheme[])) + // hl.loadLanguage(...(Object.keys(bundledLanguages) as BundledLanguage[])) + } + + initHighlighter() + }, [highlighterTheme]) + + const codeToHtml = async (code: string, language: string) => { + if (!highlighter) return '' + + try { + if (!highlighter.getLoadedLanguages().includes(language as BundledLanguage)) { + if (language in bundledLanguages || language === 'text') { + await highlighter.loadLanguage(language as BundledLanguage) + console.log(`Loaded language: ${language}`) + } else { + return `
${code}
` + } + } + + return highlighter.codeToHtml(code, { + lang: language, + theme: highlighterTheme + }) + } catch (error) { + console.warn(`Error highlighting code for language '${language}':`, error) + return `
${code}
` + } + } + + return {children} +} + +export const useSyntaxHighlighter = () => { + const context = useContext(SyntaxHighlighterContext) + if (!context) { + throw new Error('useSyntaxHighlighter must be used within a SyntaxHighlighterProvider') + } + return context +} + +export const codeThemes = ['auto', ...Object.keys(bundledThemes)] as CodeStyleVarious[] diff --git a/src/renderer/src/databases/index.ts b/src/renderer/src/databases/index.ts index d5c70504f7..1714d19b08 100644 --- a/src/renderer/src/databases/index.ts +++ b/src/renderer/src/databases/index.ts @@ -1,8 +1,6 @@ import { FileType, Topic } from '@renderer/types' import { Dexie, type EntityTable } from 'dexie' -import { populateTopics } from './populate' - // Database declaration (move this to its own module also) export const db = new Dexie('CherryStudio') as Dexie & { files: EntityTable @@ -14,14 +12,10 @@ db.version(1).stores({ files: 'id, name, origin_name, path, size, ext, type, created_at, count' }) -db.version(2) - .stores({ - files: 'id, name, origin_name, path, size, ext, type, created_at, count', - topics: '&id, messages', - settings: '&id, value' - }) - .upgrade(populateTopics) - -db.on('populate', populateTopics) +db.version(2).stores({ + files: 'id, name, origin_name, path, size, ext, type, created_at, count', + topics: '&id, messages', + settings: '&id, value' +}) export default db diff --git a/src/renderer/src/databases/populate.ts b/src/renderer/src/databases/populate.ts deleted file mode 100644 index 638e8f9749..0000000000 --- a/src/renderer/src/databases/populate.ts +++ /dev/null @@ -1,27 +0,0 @@ -import i18n from '@renderer/i18n' -import { Transaction } from 'dexie' -import localforage from 'localforage' - -export async function populateTopics(trans: Transaction) { - const indexedKeys = await localforage.keys() - - if (indexedKeys.length > 0) { - for (const key of indexedKeys) { - const value: any = await localforage.getItem(key) - if (key.startsWith('topic:')) { - await trans.db.table('topics').add({ id: value.id, messages: value.messages }) - } - if (key === 'image://avatar') { - await trans.db.table('settings').add({ id: key, value: await localforage.getItem(key) }) - } - } - - window.modal.success({ - title: i18n.t('message.upgrade.success.title'), - content: i18n.t('message.upgrade.success.content'), - okText: i18n.t('message.upgrade.success.button'), - centered: true, - onOk: () => window.api.reload() - }) - } -} diff --git a/src/renderer/src/hooks/useAppInit.ts b/src/renderer/src/hooks/useAppInit.ts index fdd70f6147..3e111599c4 100644 --- a/src/renderer/src/hooks/useAppInit.ts +++ b/src/renderer/src/hooks/useAppInit.ts @@ -14,7 +14,7 @@ import { useSettings } from './useSettings' export function useAppInit() { const dispatch = useAppDispatch() - const { proxyUrl, language, windowStyle, manualUpdateCheck } = useSettings() + const { proxyUrl, language, windowStyle, manualUpdateCheck, proxyMode } = useSettings() const { minappShow } = useRuntime() const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel() const avatar = useLiveQuery(() => db.settings.get('image://avatar')) @@ -35,8 +35,14 @@ export function useAppInit() { }, []) useEffect(() => { - proxyUrl && window.api.setProxy(proxyUrl) - }, [proxyUrl]) + if (proxyMode === 'system') { + window.api.setProxy('system') + } else if (proxyMode === 'custom') { + proxyUrl && window.api.setProxy(proxyUrl) + } else { + window.api.setProxy('') + } + }, [proxyUrl, proxyMode]) useEffect(() => { i18n.changeLanguage(language || navigator.language || 'en-US') diff --git a/src/renderer/src/hooks/useMermaid.ts b/src/renderer/src/hooks/useMermaid.ts new file mode 100644 index 0000000000..20f941d4e0 --- /dev/null +++ b/src/renderer/src/hooks/useMermaid.ts @@ -0,0 +1,40 @@ +import { useTheme } from '@renderer/context/ThemeProvider' +import { ThemeMode } from '@renderer/types' +import { loadScript, runAsyncFunction } from '@renderer/utils' +import { useEffect } from 'react' + +import { useRuntime } from './useRuntime' + +export const useMermaid = () => { + const { theme } = useTheme() + const { generating } = useRuntime() + + useEffect(() => { + runAsyncFunction(async () => { + if (!window.mermaid) { + await loadScript('https://unpkg.com/mermaid@11.4.0/dist/mermaid.min.js') + } + window.mermaid.initialize({ + startOnLoad: true, + theme: theme === ThemeMode.dark ? 'dark' : 'default' + }) + window.mermaid.contentLoaded() + }) + }, [theme]) + + useEffect(() => { + if (!window.mermaid || generating) return + + const renderMermaid = () => { + const mermaidElements = document.querySelectorAll('.mermaid') + mermaidElements.forEach((element) => { + if (!element.querySelector('svg')) { + element.removeAttribute('data-processed') + } + }) + window.mermaid.contentLoaded() + } + + setTimeout(renderMermaid, 100) + }, [generating]) +} diff --git a/src/renderer/src/hooks/usePaintings.ts b/src/renderer/src/hooks/usePaintings.ts index 13eb21b131..b01c0acc86 100644 --- a/src/renderer/src/hooks/usePaintings.ts +++ b/src/renderer/src/hooks/usePaintings.ts @@ -8,6 +8,7 @@ import { uuid } from '@renderer/utils' export function usePaintings() { const paintings = useAppSelector((state) => state.paintings.paintings) const dispatch = useAppDispatch() + const generateRandomSeed = () => Math.floor(Math.random() * 1000000).toString() return { paintings, @@ -20,7 +21,7 @@ export function usePaintings() { negativePrompt: '', imageSize: '1024x1024', numImages: 1, - seed: '', + seed: generateRandomSeed(), steps: 25, guidanceScale: 4.5, model: TEXT_TO_IMAGES_MODELS[0].id diff --git a/src/renderer/src/hooks/useSettings.ts b/src/renderer/src/hooks/useSettings.ts index 47e937662c..0080d46aff 100644 --- a/src/renderer/src/hooks/useSettings.ts +++ b/src/renderer/src/hooks/useSettings.ts @@ -4,6 +4,7 @@ import { setSendMessageShortcut as _setSendMessageShortcut, setTheme, setTopicPosition, + setTray, setWindowStyle } from '@renderer/store/settings' import { ThemeMode } from '@renderer/types' @@ -17,6 +18,9 @@ export function useSettings() { setSendMessageShortcut(shortcut: SendMessageShortcut) { dispatch(_setSendMessageShortcut(shortcut)) }, + setTray(isActive: boolean) { + dispatch(setTray(isActive)) + }, setTheme(theme: ThemeMode) { dispatch(setTheme(theme)) }, diff --git a/src/renderer/src/hooks/useShortcuts.ts b/src/renderer/src/hooks/useShortcuts.ts new file mode 100644 index 0000000000..14283c3bc5 --- /dev/null +++ b/src/renderer/src/hooks/useShortcuts.ts @@ -0,0 +1,60 @@ +import { useAppSelector } from '@renderer/store' +import { useCallback } from 'react' +import { useHotkeys } from 'react-hotkeys-hook' + +interface UseShortcutOptions { + preventDefault?: boolean + enableOnFormTags?: boolean + enabled?: boolean + description?: string +} + +const defaultOptions: UseShortcutOptions = { + preventDefault: true, + enableOnFormTags: true, + enabled: true +} + +export const useShortcut = ( + shortcutKey: string, + callback: (e: KeyboardEvent) => void, + options: UseShortcutOptions = defaultOptions +) => { + const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts) + + const formatShortcut = useCallback((shortcut: string[]) => { + return shortcut + .map((key) => { + switch (key.toLowerCase()) { + case 'command': + return 'meta' + default: + return key.toLowerCase() + } + }) + .join('+') + }, []) + + const shortcutConfig = shortcuts.find((s) => s.key === shortcutKey) + + useHotkeys( + shortcutConfig?.enabled ? formatShortcut(shortcutConfig.shortcut) : '', + (e) => { + if (options.preventDefault) { + e.preventDefault() + } + if (options.enabled !== false) { + callback(e) + } + }, + { + enableOnFormTags: options.enableOnFormTags, + description: options.description || shortcutConfig?.key + } + ) +} + +export function useShortcuts() { + const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts) + return { shortcuts } +} diff --git a/src/renderer/src/i18n/index.ts b/src/renderer/src/i18n/index.ts index 3430cb6501..13b49e9a4d 100644 --- a/src/renderer/src/i18n/index.ts +++ b/src/renderer/src/i18n/index.ts @@ -2,13 +2,15 @@ import i18n from 'i18next' import { initReactI18next } from 'react-i18next' import enUS from './locales/en-us.json' +import ruRU from './locales/ru-ru.json' import zhCN from './locales/zh-cn.json' import zhTW from './locales/zh-tw.json' const resources = { 'en-US': enUS, 'zh-CN': zhCN, - 'zh-TW': zhTW + 'zh-TW': zhTW, + 'ru-RU': ruRU } i18n.use(initReactI18next).init({ diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index da3f06587f..f6eb407abc 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1,407 +1,511 @@ { "translation": { - "common": { - "avatar": "Avatar", - "language": "Language", - "model": "Model", - "models": "Models", - "topics": "Topics", - "docs": "Docs", - "and": "and", - "assistant": "Assistant", - "name": "Name", - "description": "Description", - "prompt": "Prompt", - "rename": "Rename", - "delete": "Delete", - "edit": "Edit", - "duplicate": "Duplicate", - "copy": "Copy", - "regenerate": "Regenerate", - "provider": "Provider", - "you": "You", - "save": "Save", - "footnotes": "References", - "select": "Select", - "search": "Search", - "default": "Default", - "warning": "Warning", - "back": "Back", - "chat": "Chat", - "close": "Close", - "cancel": "Cancel", - "download": "Download" - }, - "button": { - "add": "Add", - "added": "Added", - "manage": "Manage", - "select_model": "Select Model", - "show.all": "Show All", - "collapse": "Collapse" - }, - "message": { - "copied": "Copied!", - "assistant.added.content": "Assistant added successfully", - "message.delete.title": "Delete Message", - "message.delete.content": "Are you sure you want to delete this message?", - "error.enter.api.key": "Please enter your API key first", - "error.enter.api.host": "Please enter your API host first", - "error.enter.model": "Please select a model first", - "error.invalid.proxy.url": "Invalid proxy URL", - "error.invalid.webdav": "Invalid WebDAV settings", - "api.connection.failed": "Connection failed", - "api.connection.success": "Connection successful", - "chat.completion.paused": "Chat completion paused", - "switch.disabled": "Switching is disabled while the assistant is generating", - "restore.success": "Restored successfully", - "backup.success": "Backup successful", - "backup.failed": "Backup failed", - "reset.confirm.content": "Are you sure you want to clear all data?", - "reset.double.confirm.title": "DATA LOST !!!", - "reset.double.confirm.content": "All data will be lost, do you want to continue?", - "upgrade.success.title": "Upgrade successfully", - "upgrade.success.content": "Please restart the application to complete the upgrade", - "upgrade.success.button": "Restart", - "topic.added": "New topic added", - "save.success.title": "Saved successfully", - "message.style": "Message Style", - "message.style.bubble": "Bubble", - "message.style.plain": "Plain" - }, - "chat": { - "save": "Save", - "default.name": "⭐️ Default Assistant", - "default.description": "Hello, I'm Default Assistant. You can start chatting with me right away", - "default.topic.name": "Default Topic", - "topics.title": "Topics", - "topics.auto_rename": "Auto Rename", - "topics.edit.title": "Edit Name", - "topics.edit.placeholder": "Enter new name", - "topics.clear.title": "Clear Messages", - "topics.move_to": "Move to", - "topics.list": "Topic List", - "topics.export.title": "Export", - "topics.export.image": "Export as image", - "topics.export.md": "Export as markdown", - "topics.export.word": "Export as Word", - "input.new_topic": "New Topic", - "input.topics": " Topics ", - "input.clear": "Clear", - "input.new.context": "Clear Context", - "input.expand": "Expand", - "input.collapse": "Collapse", - "input.clear.title": "Clear all messages?", - "input.clear.content": "Do you want to clear all messages of the current topic?", - "input.placeholder": "Type your message here...", - "input.send": "Send", - "input.pause": "Pause", - "input.settings": "Settings", - "input.upload": "Upload image or document file", - "input.context_count.tip": "Context Count", - "input.estimated_tokens.tip": "Estimated tokens", - "settings.temperature": "Temperature", - "settings.temperature.tip": "Lower values make the model more creative and unpredictable, while higher values make it more deterministic and precise.", - "settings.conext_count": "Context", - "settings.conext_count.tip": "The number of previous messages to keep in the context.", - "settings.max_tokens": "Enable Max Tokens Limit", - "settings.max_tokens.tip": "The maximum number of tokens the model can generate. Normal chat suggests 500-800. Short text generation suggests 800-2000. Code generation suggests 2000-3600. Long text generation suggests above 4000.", - "settings.reset": "Reset", - "settings.set_as_default": "Apply to default assistant", - "settings.max": "Max", - "settings.show_line_numbers": "Show Line Numbers in Code", - "suggestions.title": "Suggested Questions", - "add.assistant.title": "Add Assistant", - "message.new.context": "New Context", - "message.new.branch": "New Branch", - "message.new.branch.created": "New Branch Created", - "assistant.search.placeholder": "Search", - "artifacts.button.preview": "Preview", - "artifacts.button.download": "Download" - }, - "assistants": { - "title": "Assistants", - "abbr": "Assistant", - "search": "Search assistants...", - "settings.prompt": "Prompt Settings", - "settings.model": "Model Settings", - "settings.preset_messages": "Preset Messages", - "settings.default_model": "Default Model", - "settings.auto_reset_model": "Auto Reset Model", - "settings.auto_reset_model.tip": "Automatically reset the model when a new topic is created.", - "edit.title": "Edit Assistant", - "copy.title": "Copy Assistant", - "clear.title": "Clear topics", - "clear.content": "Clearing the topic will delete all topics and files in the assistant. Are you sure you want to continue?", - "save.title": "Save to agent", - "save.success": "Saved successfully", - "delete.title": "Delete Assistant", - "delete.content": "Deleting an assistant will delete all topics and files under the assistant. Are you sure you want to delete it?" - }, - "model": { - "stream_output": "Stream Output", - "search": "Search models..." - }, - "images": { - "title": "Images", - "image.size": "Image Size", - "button.new.image": "New Image", - "button.delete.image": "Delete Image", - "button.delete.image.confirm": "Are you sure you want to delete this image?", - "number_images": "Number Images", - "number_images_tip": "Number of images to generate (1-4)", - "seed": "Seed", - "seed_tip": "The same seed and prompt can produce similar images", - "inference_steps": "Inference Steps", - "inference_steps_tip": "The number of inference steps to perform. More steps produce higher quality but take longer", - "guidance_scale": "Guidance Scale", - "guidance_scale_tip": "Classifier Free Guidance. How close you want the model to stick to your prompt when looking for a related image to show you", - "negative_prompt": "Negative Prompt", - "negative_prompt_tip": "Describe what you don't want included in the image", - "prompt_placeholder": "Describe the image you want to create, e.g. A serene lake at sunset with mountains in the background", - "regenerate.confirm": "This will replace your existing generated images. Do you want to continue?" - }, - "files": { - "title": "Files", - "file": "File", - "name": "Name", - "size": "Size", - "count": "Count", - "created_at": "Created At", - "image": "Image", - "text": "Text", - "document": "Document", - "actions": "Actions", - "open": "Open", - "all": "All Files" - }, "agents": { - "title": "Agents", - "my_agents": "My Agents", - "add.title": "Create Agent", - "edit.title": "Edit Agent", + "add.button": "Add to Assistant", "add.name": "Name", "add.name.placeholder": "Enter name", "add.prompt": "Prompt", "add.prompt.placeholder": "Enter prompt", - "add.button": "Add to Assistant", - "manage.title": "Manage Agents", + "add.title": "Create Agent", "delete.popup.content": "Are you sure you want to delete this agent?", - "tag.default": "Default", - "tag.system": "System", - "tag.agent": "Agent", - "edit.message.title": "Preset messages", "edit.message.add.title": "Add", - "edit.message.group.title": "Message Group", - "edit.message.assistant.title": "Assistant", "edit.message.assistant.placeholder": "Enter assistant message", - "edit.message.user.title": "User", - "edit.message.user.placeholder": "Enter user message", + "edit.message.assistant.title": "Assistant", "edit.message.empty.content": "Conversation input content cannot be empty", + "edit.message.group.title": "Message Group", + "edit.message.title": "Preset messages", + "edit.message.user.placeholder": "Enter user message", + "edit.message.user.title": "User", "edit.model.select.title": "Select Model", "edit.settings.hide_preset_messages": "Hide Preset Message", + "edit.title": "Edit Agent", + "manage.title": "Manage Agents", + "my_agents": "My Agents", "search.no_results": "No results found", - "sorting.title": "Sorting" + "sorting.title": "Sorting", + "tag.agent": "Agent", + "tag.default": "Default", + "tag.new": "New", + "tag.system": "System", + "title": "Agents" + }, + "assistants": { + "abbr": "Assistant", + "clear.content": "Clearing the topic will delete all topics and files in the assistant. Are you sure you want to continue?", + "clear.title": "Clear topics", + "copy.title": "Copy Assistant", + "delete.content": "Deleting an assistant will delete all topics and files under the assistant. Are you sure you want to delete it?", + "delete.title": "Delete Assistant", + "edit.title": "Edit Assistant", + "save.success": "Saved successfully", + "save.title": "Save to agent", + "search": "Search assistants...", + "settings.auto_reset_model": "Auto Reset Model", + "settings.auto_reset_model.tip": "Automatically reset the model when a new topic is created.", + "settings.default_model": "Default Model", + "settings.model": "Model Settings", + "settings.preset_messages": "Preset Messages", + "settings.prompt": "Prompt Settings", + "title": "Assistants" + }, + "button": { + "add": "Add", + "added": "Added", + "collapse": "Collapse", + "manage": "Manage", + "select_model": "Select Model", + "show.all": "Show All" + }, + "chat": { + "add.assistant.title": "Add Assistant", + "artifacts.button.download": "Download", + "artifacts.button.preview": "Preview", + "assistant.search.placeholder": "Search", + "default.description": "Hello, I'm Default Assistant. You can start chatting with me right away", + "default.name": "⭐️ Default Assistant", + "default.topic.name": "Default Topic", + "input.clear": "Clear", + "input.clear.content": "Do you want to clear all messages of the current topic?", + "input.clear.title": "Clear all messages?", + "input.collapse": "Collapse", + "input.context_count.tip": "Context Count", + "input.estimated_tokens.tip": "Estimated tokens", + "input.expand": "Expand", + "input.new.context": "Clear Context", + "input.new_topic": "New Topic {{Command}}+N", + "input.pause": "Pause", + "input.placeholder": "Type your message here...", + "input.send": "Send", + "input.settings": "Settings", + "input.topics": " Topics ", + "input.translate": "Translate to English", + "input.upload": "Upload image or document file", + "message.new.branch": "New Branch", + "message.new.branch.created": "New Branch Created", + "message.new.context": "New Context", + "save": "Save", + "settings.code_collapsible": "Code block collapsible", + "settings.context_count": "Context", + "settings.context_count.tip": "The number of previous messages to keep in the context.", + "settings.max": "Max", + "settings.max_tokens": "Enable max tokens limit", + "settings.max_tokens.tip": "The maximum number of tokens the model can generate. Normal chat suggests 500-800. Short text generation suggests 800-2000. Code generation suggests 2000-3600. Long text generation suggests above 4000.", + "settings.reset": "Reset", + "settings.set_as_default": "Apply to default assistant", + "settings.show_line_numbers": "Show line numbers in code", + "settings.temperature": "Temperature", + "settings.temperature.tip": "Lower values make the model more creative and unpredictable, while higher values make it more deterministic and precise.", + "suggestions.title": "Suggested Questions", + "topics.auto_rename": "Auto Rename", + "topics.clear.title": "Clear Messages", + "topics.edit.placeholder": "Enter new name", + "topics.edit.title": "Edit Name", + "topics.export.image": "Export as image", + "topics.export.md": "Export as markdown", + "topics.export.title": "Export", + "topics.export.word": "Export as Word", + "topics.list": "Topic List", + "topics.move_to": "Move to", + "topics.title": "Topics", + "translate": "Translate" + }, + "common": { + "and": "and", + "assistant": "Assistant", + "avatar": "Avatar", + "back": "Back", + "cancel": "Cancel", + "chat": "Chat", + "close": "Close", + "copy": "Copy", + "cut": "Cut", + "default": "Default", + "delete": "Delete", + "description": "Description", + "docs": "Docs", + "download": "Download", + "duplicate": "Duplicate", + "edit": "Edit", + "footnotes": "References", + "language": "Language", + "model": "Model", + "models": "Models", + "name": "Name", + "paste": "Paste", + "prompt": "Prompt", + "provider": "Provider", + "regenerate": "Regenerate", + "rename": "Rename", + "reset": "Reset", + "save": "Save", + "search": "Search", + "select": "Select", + "topics": "Topics", + "warning": "Warning", + "you": "You", + "clear": "Clear", + "add": "Add" + }, + "error": { + "backup.file_format": "Backup file format error", + "chat.response": "Something went wrong. Please check if you have set your API key in the Settings > Providers", + "no_api_key": "API key is not configured", + "provider_disabled": "Model provider is not enabled", + "render": { + "title": "Render Error", + "description": "Failed to render formula. Please check if the formula format is correct" + } + }, + "export": { + "assistant": "Assistant", + "attached_files": "Attached Files", + "conversation_details": "Conversation Details", + "conversation_history": "Conversation History", + "created": "Created", + "last_updated": "Last Updated", + "messages": "Messages", + "user": "User" + }, + "files": { + "actions": "Actions", + "all": "All Files", + "count": "Count", + "created_at": "Created At", + "document": "Document", + "file": "File", + "image": "Image", + "name": "Name", + "open": "Open", + "size": "Size", + "text": "Text", + "title": "Files" + }, + "history": { + "continue_chat": "Continue Chatting", + "locate.message": "Locate the message", + "search.messages": "Search All Messages", + "search.placeholder": "Search topics or messages...", + "search.topics.empty": "No topics found, press Enter to search all messages", + "title": "Topics Search" + }, + "languages": { + "arabic": "Arabic", + "chinese": "Chinese", + "chinese-traditional": "Traditional Chinese", + "english": "English", + "french": "French", + "italian": "Italian", + "japanese": "Japanese", + "korean": "Korean", + "portuguese": "Portuguese", + "russian": "Russian", + "spanish": "Spanish" + }, + "mermaid": { + "download": { + "png": "Download PNG", + "svg": "Download SVG" + }, + "tabs": { + "preview": "Preview", + "source": "Source" + }, + "title": "Mermaid Diagram" + }, + "message": { + "api.connection.failed": "Connection failed", + "api.connection.success": "Connection successful", + "assistant.added.content": "Assistant added successfully", + "backup.failed": "Backup failed", + "backup.success": "Backup successful", + "chat.completion.paused": "Chat completion paused", + "copied": "Copied!", + "error.enter.api.host": "Please enter your API host first", + "error.enter.api.key": "Please enter your API key first", + "error.enter.model": "Please select a model first", + "error.invalid.proxy.url": "Invalid proxy URL", + "error.invalid.webdav": "Invalid WebDAV settings", + "message.code_style": "Code style", + "message.delete.content": "Are you sure you want to delete this message?", + "message.delete.title": "Delete Message", + "message.style": "Message style", + "message.style.bubble": "Bubble", + "message.style.plain": "Plain", + "reset.confirm.content": "Are you sure you want to clear all data?", + "reset.double.confirm.content": "All data will be lost, do you want to continue?", + "reset.double.confirm.title": "DATA LOST !!!", + "restore.success": "Restored successfully", + "save.success.title": "Saved successfully", + "switch.disabled": "Switching is disabled while the assistant is generating", + "topic.added": "New topic added", + "upgrade.success.button": "Restart", + "upgrade.success.content": "Please restart the application to complete the upgrade", + "upgrade.success.title": "Upgrade successfully", + "regenerate.confirm": "Regenerating will replace current message" }, "minapp": { "title": "MinApp" }, - "history": { - "title": "Topics Search", - "search.placeholder": "Search topics or messages...", - "continue_chat": "Continue Chatting", - "search.topics.empty": "No topics found, press Enter to search all messages", - "search.messages": "Search All Messages", - "locate.message": "Locate the message" + "model": { + "pinned": "Pinned", + "search": "Search models...", + "stream_output": "Stream output", + "type": { + "select": "Select Model Types", + "text": "Text", + "vision": "Vision" + } + }, + "ollama": { + "keep_alive_time.description": "The time in minutes to keep the connection alive, default is 5 minutes.", + "keep_alive_time.placeholder": "Minutes", + "keep_alive_time.title": "Keep Alive Time", + "title": "Ollama" + }, + "paintings": { + "button.delete.image": "Delete Image", + "button.delete.image.confirm": "Are you sure you want to delete this image?", + "button.new.image": "New Image", + "guidance_scale": "Guidance Scale", + "guidance_scale_tip": "Classifier Free Guidance. How close you want the model to stick to your prompt when looking for a related image to show you", + "image.size": "Image Size", + "inference_steps": "Inference Steps", + "inference_steps_tip": "The number of inference steps to perform. More steps produce higher quality but take longer", + "negative_prompt": "Negative Prompt", + "negative_prompt_tip": "Describe what you don't want included in the image", + "number_images": "Number Images", + "number_images_tip": "Number of images to generate (1-4)", + "prompt_placeholder": "Describe the image you want to create, e.g. A serene lake at sunset with mountains in the background", + "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", + "title": "Images" }, "provider": { - "jina": "Jina", - "mistral": "Mistral", - "hyperbolic": "Hyperbolic", - "grok": "Grok", - "nvidia": "Nvidia", - "hunyuan": "Tencent Hunyuan", - "zhinao": "360AI", - "fireworks": "Fireworks", - "together": "Together", - "openai": "OpenAI", - "gemini": "Gemini", - "deepseek": "DeepSeek", - "moonshot": "Moonshot", - "silicon": "SiliconFlow", - "openrouter": "OpenRouter", - "yi": "Yi", - "zhipu": "ZHIPU AI", - "groq": "Groq", - "ollama": "Ollama", + "aihubmix": "AiHubMix", + "anthropic": "Anthropic", + "azure-openai": "Azure OpenAI", "baichuan": "Baichuan", "dashscope": "Alibaba Cloud", - "anthropic": "Anthropic", - "aihubmix": "AiHubMix", - "stepfun": "StepFun", + "deepseek": "DeepSeek", "doubao": "Doubao", - "minimax": "MiniMax", - "graphrag-kylin-mountain": "GraphRAG", + "fireworks": "Fireworks", + "gemini": "Gemini", "github": "GitHub Models", + "graphrag-kylin-mountain": "GraphRAG", + "grok": "Grok", + "groq": "Groq", + "hunyuan": "Tencent Hunyuan", + "hyperbolic": "Hyperbolic", + "jina": "Jina", + "minimax": "MiniMax", + "mistral": "Mistral", + "moonshot": "Moonshot", + "nvidia": "Nvidia", "ocoolai": "ocoolAI", - "azure-openai": "Azure OpenAI" + "ollama": "Ollama", + "openai": "OpenAI", + "openrouter": "OpenRouter", + "silicon": "SiliconFlow", + "stepfun": "StepFun", + "together": "Together", + "yi": "Yi", + "zhinao": "360AI", + "zhipu": "ZHIPU AI" }, "settings": { - "title": "Settings", - "general": "General Settings", - "data": "Data Settings", - "provider": "Model Provider", - "model": "Default Model", - "assistant": "Default Assistant", "about": "About & Feedback", - "messages.model.title": "Model Settings", - "messages.title": "Message Settings", - "messages.divider": "Show divider between messages", - "messages.use_serif_font": "Use serif font", - "messages.input.title": "Input Settings", - "messages.input.show_estimated_tokens": "Show estimated input tokens", - "messages.input.send_shortcuts": "Send shortcuts", - "messages.input.paste_long_text_as_file": "Paste long text as file", - "messages.markdown_rendering_input_message": "Markdown render input msg", - "messages.math_engine": "Math render engine", + "about.checkUpdate": "Check Update", + "about.checkingUpdate": "Checking for updates...", + "about.contact.button": "Email", + "about.contact.title": "Contact", + "about.description": "A powerful AI assistant for producer", + "about.downloading": "Downloading...", + "about.feedback.button": "Feedback", + "about.feedback.title": "Feedback", + "about.license.button": "License", + "about.license.title": "License", + "about.releases.button": "Releases", + "about.releases.title": "Release Notes", + "about.title": "About", + "about.updateAvailable": "Found new version {{version}}", + "about.updateError": "Update error", + "about.updateNotAvailable": "You are using the latest version", + "about.website.button": "Website", + "about.website.title": "Official Website", + "advanced.auto_switch_to_topics": "Auto switch to topic", + "advanced.title": "Advanced Settings", + "assistant": "Default Assistant", + "assistant.model_params": "Model Parameters", + "assistant.title": "Default Assistant", + "data": { + "app_data": "App Data", + "app_logs": "App Logs", + "clear_cache": { + "button": "Clear Cache", + "confirm": "Clearing the cache will delete application cache data, including minapp data. This action is irreversible, continue?", + "error": "Error clearing cache", + "success": "Cache cleared", + "title": "Clear Cache" + }, + "data.title": "Data Directory", + "title": "Data Settings", + "webdav.backup.button": "Backup to WebDAV", + "webdav.host": "WebDAV Host", + "webdav.host.placeholder": "http://localhost:8080", + "webdav.password": "WebDAV Password", + "webdav.path": "WebDAV Path", + "webdav.path.placeholder": "/backup", + "webdav.restore.button": "Restore from WebDAV", + "webdav.title": "WebDAV", + "webdav.user": "WebDAV User" + }, + "display.title": "Display Settings", + "font_size.title": "Message font size", + "general": "General Settings", + "general.backup.button": "Backup", + "general.backup.title": "Data Backup and Recovery", + "general.manually_check_update.title": "Turn off update checking", + "general.reset.button": "Reset", + "general.reset.title": "Data Reset", + "general.restore.button": "Restore", "general.title": "General Settings", "general.user_name": "User Name", "general.user_name.placeholder": "Enter your name", - "general.backup.title": "Data Backup and Recovery", - "general.backup.button": "Backup", - "general.restore.button": "Restore", "general.view_webdav_settings": "View WebDAV settings", - "general.reset.title": "Data Reset", - "general.reset.button": "Reset", - "general.manually_check_update.title": "Turn off update checking", - "data.webdav.title": "WebDAV", - "data.webdav.host": "WebDAV Host", - "data.webdav.host.placeholder": "http://localhost:8080", - "data.webdav.user": "WebDAV User", - "data.webdav.password": "WebDAV Password", - "data.webdav.path": "WebDAV Path", - "data.webdav.path.placeholder": "/backup", - "data.webdav.backup.button": "Backup to WebDAV", - "data.webdav.restore.button": "Restore from WebDAV", - "advanced.title": "Advanced Settings", - "advanced.click_assistant_switch_to_topics": "Auto switch to topic", - "provider.api_key": "API Key", - "provider.check": "Check", - "provider.get_api_key": "Get API Key", - "provider.api_host": "API Host", - "provider.api_version": "API Version", - "provider.docs_check": "Check", - "provider.docs_more_details": "for more details", - "provider.search_placeholder": "Search model id or name", - "provider.api.url.reset": "Reset", - "provider.api.url.preview": "Preview: {{url}}", - "provider.api.url.tip": "Ending with / ignores v1, ending with # forces use of input address", - "models.default_assistant_model": "Default Assistant Model", - "models.topic_naming_model": "Topic Naming Model", - "models.translate_model": "Translate Model", + "input.auto_translate_with_space": "Quickly translate with 3 spaces", + "messages.divider": "Show divider between messages", + "messages.input.paste_long_text_as_file": "Paste long text as file", + "messages.input.send_shortcuts": "Send shortcuts", + "messages.input.show_estimated_tokens": "Show estimated tokens", + "messages.input.title": "Input Settings", + "messages.markdown_rendering_input_message": "Markdown render input msg", + "messages.math_engine": "Math render engine", + "messages.model.title": "Model Settings", + "messages.title": "Message Settings", + "messages.use_serif_font": "Use serif font", + "model": "Default Model", "models.add.add_model": "Add Model", - "models.add.model_id.placeholder": "Required e.g. gpt-3.5-turbo", + "models.add.group_name": "Group Name", + "models.add.group_name.placeholder": "Optional e.g. ChatGPT", + "models.add.group_name.tooltip": "Optional e.g. ChatGPT", "models.add.model_id": "Model ID", + "models.add.model_id.placeholder": "Required e.g. gpt-3.5-turbo", "models.add.model_id.tooltip": "Example: gpt-3.5-turbo", "models.add.model_name": "Model Name", "models.add.model_name.placeholder": "Optional e.g. GPT-4", - "models.add.group_name": "Group Name", - "models.add.group_name.tooltip": "Optional e.g. ChatGPT", - "models.add.group_name.placeholder": "Optional e.g. ChatGPT", + "models.default_assistant_model": "Default Assistant Model", + "models.default_assistant_model_description": "Model used when creating a new assistant, if the assistant is not set, this model will be used", "models.empty": "No models found", - "assistant.title": "Default Assistant", - "assistant.model_params": "Model Parameters", - "about.description": "A powerful AI assistant for producer", - "about.updateNotAvailable": "You are using the latest version", - "about.checkingUpdate": "Checking for updates...", - "about.updateError": "Update error", - "about.checkUpdate": "Check Update", - "about.downloading": "Downloading...", - "provider.delete.title": "Delete Provider", - "provider.delete.content": "Are you sure you want to delete this provider?", - "provider.edit.name": "Provider Name", - "provider.edit.name.placeholder": "Example: OpenAI", - "about.title": "About", - "about.releases.title": "Release Notes", - "about.releases.button": "Releases", - "about.website.title": "Official Website", - "about.website.button": "Website", - "about.feedback.title": "Feedback", - "about.feedback.button": "Feedback", - "about.contact.title": "Contact", - "about.license.title": "License", - "about.license.button": "License", - "about.contact.button": "Email", + "models.topic_naming_model": "Topic Naming Model", + "models.topic_naming_model_description": "Model used when automatically naming a new topic", + "models.translate_model": "Translate Model", + "models.translate_model_description": "Model used for translation service", + "models.translate_model_prompt_message": "Please enter the translate model prompt", + "models.translate_model_prompt_title": "Translate Model Prompt", + "models.topic_naming_model_setting_title": "Topic Naming Model Settings", + "models.enable_topic_naming": "Topic Auto Naming", + "provider": { + "add.name": "Provider Name", + "add.name.placeholder": "Example: OpenAI", + "add.title": "Add Provider", + "add.type": "Provider Type", + "api.url.preview": "Preview: {{url}}", + "api.url.reset": "Reset", + "api.url.tip": "Ending with / ignores v1, ending with # forces use of input address", + "api_host": "API Host", + "api_key": "API Key", + "api_key.tip": "Multiple keys separated by commas", + "api_version": "API Version", + "check": "Check", + "check_all_keys": "Check All Keys", + "check_multiple_keys": "Check Multiple API Keys", + "delete.content": "Are you sure you want to delete this provider?", + "delete.title": "Delete Provider", + "docs_check": "Check", + "docs_more_details": "for more details", + "get_api_key": "Get API Key", + "no_models": "Please add models first before checking the API connection", + "not_checked": "Not Checked", + "remove_duplicate_keys": "Remove Duplicate Keys", + "remove_invalid_keys": "Remove Invalid Keys", + "search_placeholder": "Search model id or name", + "title": "Model Provider" + }, + "provider.api.url.preview": "Preview: {{url}}", + "provider.api.url.reset": "Reset", + "provider.api.url.tip": "Ending with / ignores v1, ending with # forces use of input address", + "provider.api_host": "API Host", + "provider.api_key": "API Key", + "provider.api_key.tip": "Multiple keys separated by commas", + "provider.api_version": "API Version", + "provider.check": "Check", + "provider.docs_check": "Check", + "provider.docs_more_details": "for more details", + "provider.get_api_key": "Get API Key", + "provider.search_placeholder": "Search model id or name", + "proxy": { + "mode": { + "custom": "Custom Proxy", + "none": "No Proxy", + "system": "System Proxy", + "title": "Proxy Mode" + }, + "title": "Proxy Settings" + }, "proxy.title": "Proxy Address", - "theme.title": "Theme", - "theme.dark": "Dark", - "theme.light": "Light", - "theme.auto": "Auto", - "theme.window.style.title": "Window Style", - "theme.window.style.transparent": "Transparent Window", - "theme.window.style.opaque": "Opaque Window", - "font_size.title": "Message Font Size", - "topic.position": "Topic Position", - "topic.position.left": "Left", - "topic.position.right": "Right", - "topic.show.time": "Show Topic Time", "shortcuts": { - "title": "Keyboard Shortcuts", "action": "Action", "key": "Key", "new_topic": "New Topic", + "title": "Keyboard Shortcuts", "zoom_in": "Zoom In", "zoom_out": "Zoom Out", - "zoom_reset": "Reset Zoom" - } + "zoom_reset": "Reset Zoom", + "show_app": "Show App", + "reset_defaults": "Reset Defaults", + "reset_defaults_confirm": "Are you sure you want to reset all shortcuts?", + "press_shortcut": "Press Shortcut", + "alt_warning": "Mac does not support Option + letters as shortcuts", + "reset_to_default": "Reset to Default", + "clear_shortcut": "Clear Shortcut" + }, + "theme.auto": "Auto", + "theme.dark": "Dark", + "theme.light": "Light", + "theme.title": "Theme", + "theme.window.style.opaque": "Opaque Window", + "theme.window.style.title": "Window Style", + "theme.window.style.transparent": "Transparent Window", + "title": "Settings", + "topic.position": "Topic position", + "topic.position.left": "Left", + "topic.position.right": "Right", + "topic.show.time": "Show topic time", + "tray.title": "Enable System Tray Icon" }, "translate": { - "title": "Translation", "any.language": "Any language", "button.translate": "Translate", + "confirm": { + "content": "Translation will replace the original text, continue?", + "title": "Translation Confirmation" + }, "error.not_configured": "Translation model is not configured", + "error.failed": "Translation failed", "input.placeholder": "Enter text to translate", "output.placeholder": "Translation", - "confirm": "Original text has been copied to clipboard. Do you want to replace it with the translated text?" + "processing": "Translation in progress...", + "title": "Translation", + "close": "Close" }, - "languages": { - "english": "English", - "chinese": "Chinese", - "chinese-traditional": "Traditional Chinese", - "japanese": "Japanese", - "korean": "Korean", - "russian": "Russian", - "spanish": "Spanish", - "french": "French", - "italian": "Italian", - "portuguese": "Portuguese", - "arabic": "Arabic" - }, - "ollama": { - "title": "Ollama", - "keep_alive_time.title": "Keep Alive Time", - "keep_alive_time.placeholder": "Minutes", - "keep_alive_time.description": "The time in minutes to keep the connection alive, default is 5 minutes." - }, - "error": { - "chat.response": "Something went wrong. Please check if you have set your API key in the Settings > Providers", - "backup.file_format": "Backup file format error", - "provider_disabled": "Model provider is not enabled", - "no_api_key": "API key is not configured" + "tray": { + "quit": "Quit", + "show_window": "Show Window" }, "words": { "knowledgeGraph": "Knowledge Graph", "visualization": "Visualization" - }, - "export": { - "attached_files": "Attached Files", - "user": "User", - "assistant": "Assistant", - "created": "Created", - "last_updated": "Last Updated", - "messages": "Messages", - "conversation_details": "Conversation Details", - "conversation_history": "Conversation History" } } } diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json new file mode 100644 index 0000000000..3e7c9f5e7f --- /dev/null +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -0,0 +1,511 @@ +{ + "translation": { + "agents": { + "add.button": "Добавить к ассистенту", + "add.name": "Имя", + "add.name.placeholder": "Введите имя", + "add.prompt": "Промпт", + "add.prompt.placeholder": "Введите промпт", + "add.title": "Создать агента", + "delete.popup.content": "Вы уверены, что хотите удалить этого агента?", + "edit.message.add.title": "Добавить", + "edit.message.assistant.placeholder": "Введите сообщение ассистента", + "edit.message.assistant.title": "Ассистент", + "edit.message.empty.content": "Содержание вводимого сообщения не может быть пустым", + "edit.message.group.title": "Группа сообщений", + "edit.message.title": "Предустановленные сообщения", + "edit.message.user.placeholder": "Введите сообщение пользователя", + "edit.message.user.title": "Пользователь", + "edit.model.select.title": "Выбрать модель", + "edit.settings.hide_preset_messages": "Скрыть предустановленные сообщения", + "edit.title": "Редактировать агента", + "manage.title": "Редактировать агентов", + "my_agents": "Мои агенты", + "search.no_results": "Результаты не найдены", + "sorting.title": "Сортировка", + "tag.agent": "Агент", + "tag.default": "По умолчанию", + "tag.new": "Новый", + "tag.system": "Система", + "title": "Агенты" + }, + "assistants": { + "abbr": "Ассистент", + "clear.content": "Очистка топика удалит все топики и файлы в ассистенте. Вы уверены, что хотите продолжить?", + "clear.title": "Очистить топики", + "copy.title": "Копировать ассистента", + "delete.content": "Удаление ассистента удалит все топики и файлы под ассистентом. Вы уверены, что хотите удалить его?", + "delete.title": "Удалить ассистента", + "edit.title": "Редактировать ассистента", + "save.success": "Успешно сохранено", + "save.title": "Сохранить в агента", + "search": "Поиск ассистентов...", + "settings.auto_reset_model": "Автосброс модели", + "settings.auto_reset_model.tip": "Автоматически сбрасывать модель при создании нового топика.", + "settings.default_model": "Модель по умолчанию", + "settings.model": "Настройки модели", + "settings.preset_messages": "Предустановленные сообщения", + "settings.prompt": "Настройки промптов", + "title": "Ассистенты" + }, + "button": { + "add": "Добавить", + "added": "Добавлено", + "collapse": "Свернуть", + "manage": "Редактировать", + "select_model": "Выбрать модель", + "show.all": "Показать все" + }, + "chat": { + "add.assistant.title": "Добавить ассистента", + "artifacts.button.download": "Скачать", + "artifacts.button.preview": "Предпросмотр", + "assistant.search.placeholder": "Поиск", + "default.description": "Привет, я Ассистент по умолчанию. Вы можете начать общаться со мной прямо сейчас", + "default.name": "⭐️ Ассистент по умолчанию", + "default.topic.name": "Топик по умолчанию", + "input.clear": "Очистить", + "input.clear.content": "Хотите очистить все сообщения текущего топика?", + "input.clear.title": "Очистить все сообщения?", + "input.collapse": "Свернуть", + "input.context_count.tip": "Количество контекстов", + "input.estimated_tokens.tip": "Затраты токенов", + "input.expand": "Развернуть", + "input.new.context": "Очистить контекст", + "input.new_topic": "Новый топик {{Command}}+N", + "input.pause": "Остановить", + "input.placeholder": "Введите ваше сообщение здесь...", + "input.send": "Отправить", + "input.settings": "Настройки", + "input.topics": " Топики ", + "input.translate": "Перевести на английский", + "input.upload": "Загрузить изображение или документ", + "message.new.branch": "Новая ветка", + "message.new.branch.created": "Новая ветка создана", + "message.new.context": "Новый контекст", + "save": "Сохранить", + "settings.code_collapsible": "Блок кода свернут", + "settings.context_count": "Контекст", + "settings.context_count.tip": "Количество предыдущих сообщений, которые нужно сохранить в контексте.", + "settings.max": "Максимум", + "settings.max_tokens": "Включить лимит максимальных токенов", + "settings.max_tokens.tip": "Максимальное количество токенов, которые может сгенерировать модель. Обычный чат предполагает 500-800. Генерация короткого текста предполагает 800-2000. Генерация кода предполагает 2000-3600. Генерация длинного текста предполагает выше 4000.", + "settings.reset": "Сбросить", + "settings.set_as_default": "Применить к ассистенту по умолчанию", + "settings.show_line_numbers": "Показать номера строк в коде", + "settings.temperature": "Температура", + "settings.temperature.tip": "Меньшие значения делают модель более креативной и непредсказуемой, в то время как большие значения делают её более детерминированной и точной.", + "suggestions.title": "Предложенные вопросы", + "topics.auto_rename": "Автопереименование", + "topics.clear.title": "Очистить сообщения", + "topics.edit.placeholder": "Введите новый заголовок", + "topics.edit.title": "Редактировать заголовок", + "topics.export.image": "Экспорт как изображение", + "topics.export.md": "Экспорт как markdown", + "topics.export.title": "Экспорт", + "topics.export.word": "Экспорт как Word", + "topics.list": "Список топиков", + "topics.move_to": "Переместить в", + "topics.title": "Топики", + "translate": "Перевести" + }, + "common": { + "and": "и", + "assistant": "Ассистент", + "avatar": "Аватар", + "back": "Назад", + "cancel": "Отмена", + "chat": "Чат", + "close": "Закрыть", + "copy": "Копировать", + "cut": "Вырезать", + "default": "По умолчанию", + "delete": "Удалить", + "description": "Описание", + "docs": "Документы", + "download": "Скачать", + "duplicate": "Дублировать", + "edit": "Редактировать", + "footnotes": "Сноски", + "language": "Язык", + "model": "Модель", + "models": "Модели", + "name": "Имя", + "paste": "Вставить", + "prompt": "Промпт", + "provider": "Провайдер", + "regenerate": "Пересоздать", + "rename": "Переименовать", + "reset": "Сбросить", + "save": "Сохранить", + "search": "Поиск", + "select": "Выбрать", + "topics": "Топики", + "warning": "Предупреждение", + "you": "Вы", + "clear": "Очистить", + "add": "Добавить" + }, + "error": { + "backup.file_format": "Ошибка формата файла резервной копии", + "chat.response": "Что-то пошло не так. Пожалуйста, проверьте, установлен ли ваш ключ API в Настройки > Провайдеры", + "no_api_key": "Ключ API не настроен", + "provider_disabled": "Провайдер моделей не включен", + "render": { + "title": "Ошибка рендеринга", + "description": "Не удалось рендерить формулу. Пожалуйста, проверьте, правильно ли формат формулы" + } + }, + "export": { + "assistant": "Ассистент", + "attached_files": "Прикрепленные файлы", + "conversation_details": "Детали разговора", + "conversation_history": "История разговора", + "created": "Создано", + "last_updated": "Последнее обновление", + "messages": "Сообщения", + "user": "Пользователь" + }, + "files": { + "actions": "Действия", + "all": "Все файлы", + "count": "Количество", + "created_at": "Дата создания", + "document": "Документ", + "file": "Файл", + "image": "Изображение", + "name": "Имя", + "open": "Открыть", + "size": "Размер", + "text": "Текст", + "title": "Файлы" + }, + "history": { + "continue_chat": "Продолжить чат", + "locate.message": "Найти сообщение", + "search.messages": "Поиск всех сообщений", + "search.placeholder": "Поиск топиков или сообщений...", + "search.topics.empty": "Топики не найдены, нажмите Enter для поиска всех сообщений", + "title": "Поиск топиков" + }, + "languages": { + "arabic": "Арабский", + "chinese": "Китайский", + "chinese-traditional": "Китайский традиционный", + "english": "Английский", + "french": "Французский", + "italian": "Итальянский", + "japanese": "Японский", + "korean": "Корейский", + "portuguese": "Португальский", + "russian": "Русский", + "spanish": "Испанский" + }, + "mermaid": { + "download": { + "png": "Скачать PNG", + "svg": "Скачать SVG" + }, + "tabs": { + "preview": "Предпросмотр", + "source": "Исходный код" + }, + "title": "Диаграмма Mermaid" + }, + "message": { + "api.connection.failed": "Соединение не удалось", + "api.connection.success": "Соединение успешно", + "assistant.added.content": "Ассистент успешно добавлен", + "backup.failed": "Создание резервной копии не удалось", + "backup.success": "Резервная копия успешно создана", + "chat.completion.paused": "Завершение чата приостановлено", + "copied": "Скопировано!", + "error.enter.api.host": "Пожалуйста, введите ваш API хост", + "error.enter.api.key": "Пожалуйста, введите ваш API ключ", + "error.enter.model": "Пожалуйста, выберите модель", + "error.invalid.proxy.url": "Неверный URL прокси", + "error.invalid.webdav": "Неверные настройки WebDAV", + "message.code_style": "Стиль кода", + "message.delete.content": "Вы уверены, что хотите удалить это сообщение?", + "message.delete.title": "Удалить сообщение", + "message.style": "Стиль сообщения", + "message.style.bubble": "Пузырь", + "message.style.plain": "Простой", + "reset.confirm.content": "Вы уверены, что хотите очистить все данные?", + "reset.double.confirm.content": "Все данные будут утеряны, хотите продолжить?", + "reset.double.confirm.title": "ДАННЫЕ БУДУТ УТЕРЯНЫ !!!", + "restore.success": "Успешно восстановлено", + "save.success.title": "Успешно сохранено", + "switch.disabled": "Переключение отключено, пока ассистент генерирует", + "topic.added": "Новый топик добавлен", + "upgrade.success.button": "Перезапустить", + "upgrade.success.content": "Пожалуйста, перезапустите приложение для завершения обновления", + "upgrade.success.title": "Обновление успешно", + "regenerate.confirm": "Перегенерация заменит текущее сообщение" + }, + "minapp": { + "title": "Встроенные приложения" + }, + "model": { + "pinned": "Закреплено", + "search": "Поиск моделей...", + "stream_output": "Потоковый вывод", + "type": { + "select": "Выберите тип модели", + "text": "Текст", + "vision": "Изображение" + } + }, + "ollama": { + "keep_alive_time.description": "Время в минутах, в течение которого модель остается активной, по умолчанию 5 минут.", + "keep_alive_time.placeholder": "Минуты", + "keep_alive_time.title": "Время жизни модели", + "title": "Ollama" + }, + "paintings": { + "button.delete.image": "Удалить изображение", + "button.delete.image.confirm": "Вы уверены, что хотите удалить это изображение?", + "button.new.image": "Новое изображение", + "guidance_scale": "Масштаб руководства", + "guidance_scale_tip": "Без классификатора руководства. Насколько близко вы хотите, чтобы модель придерживалась вашего промпта при поиске связанного изображения для показа вам", + "image.size": "Размер изображения", + "inference_steps": "Шаги вывода", + "inference_steps_tip": "Количество шагов вывода для выполнения. Больше шагов производят более высокое качество, но занимают больше времени", + "negative_prompt": "Негативный промпт", + "negative_prompt_tip": "Опишите, что вы не хотите включать в изображение", + "number_images": "Количество изображений", + "number_images_tip": "Количество изображений для генерации (1-4)", + "prompt_placeholder": "Опишите изображение, которое вы хотите создать, например, Спокойное озеро на закате с горами на заднем плане", + "regenerate.confirm": "Это заменит ваши существующие сгенерированные изображения. Хотите продолжить?", + "seed": "Ключ генерации", + "seed_tip": "Одинаковый ключ генерации и промпт могут производить похожие изображения", + "title": "Изображения" + }, + "provider": { + "aihubmix": "AiHubMix", + "anthropic": "Anthropic", + "azure-openai": "Azure OpenAI", + "baichuan": "Baichuan", + "dashscope": "Alibaba Cloud", + "deepseek": "DeepSeek", + "doubao": "Doubao", + "fireworks": "Fireworks", + "gemini": "Gemini", + "github": "GitHub Models", + "graphrag-kylin-mountain": "GraphRAG", + "grok": "Grok", + "groq": "Groq", + "hunyuan": "Tencent Hunyuan", + "hyperbolic": "Hyperbolic", + "jina": "Jina", + "minimax": "MiniMax", + "mistral": "Mistral", + "moonshot": "Moonshot", + "nvidia": "Nvidia", + "ocoolai": "ocoolAI", + "ollama": "Ollama", + "openai": "OpenAI", + "openrouter": "OpenRouter", + "silicon": "SiliconFlow", + "stepfun": "StepFun", + "together": "Together", + "yi": "Yi", + "zhinao": "360AI", + "zhipu": "ZHIPU AI" + }, + "settings": { + "about": "О программе и обратная связь", + "about.checkUpdate": "Проверить обновления", + "about.checkingUpdate": "Проверка обновлений...", + "about.contact.button": "Электронная почта", + "about.contact.title": "Контакты", + "about.description": "Мощный AI-ассистент для созидания", + "about.downloading": "Загрузка...", + "about.feedback.button": "Обратная связь", + "about.feedback.title": "Обратная связь", + "about.license.button": "Лицензия", + "about.license.title": "Лицензия", + "about.releases.button": "Релизы", + "about.releases.title": "Заметки о релизах", + "about.title": "О программе", + "about.updateAvailable": "Найдено новое обновление {{version}}", + "about.updateError": "Ошибка обновления", + "about.updateNotAvailable": "Вы используете последнюю версию", + "about.website.button": "Сайт", + "about.website.title": "Официальный сайт", + "advanced.auto_switch_to_topics": "Автоматически переключаться на топик", + "advanced.title": "Расширенные настройки", + "assistant": "Ассистент по умолчанию", + "assistant.model_params": "Параметры модели", + "assistant.title": "Ассистент по умолчанию", + "data": { + "app_data": "Данные приложения", + "app_logs": "Логи приложения", + "clear_cache": { + "button": "Очистка кэша", + "confirm": "Очистка кэша удалит данные приложения. Это действие необратимо, продолжить?", + "error": "Ошибка при очистке кэша", + "success": "Кэш очищен", + "title": "Очистка кэша" + }, + "data.title": "Каталог данных", + "title": "Настройки данных", + "webdav.backup.button": "Резервное копирование на WebDAV", + "webdav.host": "Хост WebDAV", + "webdav.host.placeholder": "http://localhost:8080", + "webdav.password": "Пароль WebDAV", + "webdav.path": "Путь WebDAV", + "webdav.path.placeholder": "/backup", + "webdav.restore.button": "Восстановление с WebDAV", + "webdav.title": "WebDAV", + "webdav.user": "Пользователь WebDAV" + }, + "display.title": "Настройки отображения", + "font_size.title": "Размер шрифта сообщений", + "general": "Общие настройки", + "general.backup.button": "Резервное копирование", + "general.backup.title": "Резервное копирование и восстановление данных", + "general.manually_check_update.title": "Отключить проверку обновлений", + "general.reset.button": "Сброс", + "general.reset.title": "Сброс данных", + "general.restore.button": "Восстановление", + "general.title": "Общие настройки", + "general.user_name": "Имя пользователя", + "general.user_name.placeholder": "Введите ваше имя", + "general.view_webdav_settings": "Просмотр настроек WebDAV", + "input.auto_translate_with_space": "Быстрый перевод с помощью 3-х пробелов", + "messages.divider": "Показывать разделитель между сообщениями", + "messages.input.paste_long_text_as_file": "Вставлять длинный текст как файл", + "messages.input.send_shortcuts": "Горячие клавиши для отправки", + "messages.input.show_estimated_tokens": "Показывать затраты токенов", + "messages.input.title": "Настройки ввода", + "messages.markdown_rendering_input_message": "Отображение ввода в формате Markdown", + "messages.math_engine": "Математический движок", + "messages.model.title": "Настройки модели", + "messages.title": "Настройки сообщений", + "messages.use_serif_font": "Использовать serif шрифт", + "model": "Модель по умолчанию", + "models.add.add_model": "Добавить модель", + "models.add.group_name": "Имя группы", + "models.add.group_name.placeholder": "Необязательно, например, ChatGPT", + "models.add.group_name.tooltip": "Необязательно, например, ChatGPT", + "models.add.model_id": "ID модели", + "models.add.model_id.placeholder": "Обязательно, например, gpt-3.5-turbo", + "models.add.model_id.tooltip": "Пример: gpt-3.5-turbo", + "models.add.model_name": "Имя модели", + "models.add.model_name.placeholder": "Необязательно, например, GPT-4", + "models.default_assistant_model": "Модель ассистента по умолчанию", + "models.default_assistant_model_description": "Модель, используемая при создании нового ассистента, если ассистент не имеет настроенной модели, будет использоваться эта модель", + "models.empty": "Модели не найдены", + "models.topic_naming_model": "Модель именования топика", + "models.topic_naming_model_description": "Модель, используемая при автоматическом именовании нового топика", + "models.translate_model": "Модель перевода", + "models.translate_model_description": "Модель, используемая для сервиса перевода", + "models.translate_model_prompt_message": "Введите модель перевода", + "models.translate_model_prompt_title": "Модель перевода", + "models.topic_naming_model_setting_title": "Настройки модели именования топика", + "models.enable_topic_naming": "Автоматическое переименование топика", + "provider": { + "add.name": "Имя провайдера", + "add.name.placeholder": "Пример: OpenAI", + "add.title": "Добавить провайдер", + "add.type": "Тип провайдера", + "api.url.preview": "Предпросмотр: {{url}}", + "api.url.reset": "Сброс", + "api.url.tip": "Заканчивая на / игнорирует v1, заканчивая на # принудительно использует введенный адрес", + "api_host": "Хост API", + "api_key": "Ключ API", + "api_key.tip": "Несколько ключей, разделенных запятыми", + "api_version": "Версия API", + "check": "Проверить", + "check_all_keys": "Проверить все ключи", + "check_multiple_keys": "Проверить несколько ключей API", + "delete.content": "Вы уверены, что хотите удалить этот провайдер?", + "delete.title": "Удалить провайдер", + "docs_check": "Проверить", + "docs_more_details": "для получения дополнительной информации", + "get_api_key": "Получить ключ API", + "no_models": "Пожалуйста, добавьте модели перед проверкой соединения с API", + "not_checked": "Не проверено", + "remove_duplicate_keys": "Удалить дубликаты ключей", + "remove_invalid_keys": "Удалить недействительные ключи", + "search_placeholder": "Поиск по ID или имени модели", + "title": "Провайдеры моделей" + }, + "provider.api.url.preview": "Предпросмотр: {{url}}", + "provider.api.url.reset": "Сброс", + "provider.api.url.tip": "Заканчивая на / игнорирует v1, заканчивая на # принудительно использует введенный адрес", + "provider.api_host": "Хост API", + "provider.api_key": "Ключ API", + "provider.api_key.tip": "Несколько ключей, разделенных запятыми", + "provider.api_version": "Версия API", + "provider.check": "Проверить", + "provider.docs_check": "Проверить", + "provider.docs_more_details": "для получения дополнительной информации", + "provider.get_api_key": "Получить ключ API", + "provider.search_placeholder": "Поиск по ID или имени модели", + "proxy": { + "mode": { + "custom": "Пользовательский прокси", + "none": "Не использовать прокси", + "system": "Системный прокси", + "title": "Режим прокси" + }, + "title": "Настройки прокси" + }, + "proxy.title": "Адрес прокси", + "shortcuts": { + "action": "Действие", + "key": "Клавиша", + "new_topic": "Новый топик", + "title": "Горячие клавиши", + "zoom_in": "Увеличить", + "zoom_out": "Уменьшить", + "zoom_reset": "Сбросить масштаб", + "show_app": "Показать приложение", + "reset_defaults": "Сбросить настройки по умолчанию", + "reset_defaults_confirm": "Вы уверены, что хотите сбросить все горячие клавиши?", + "press_shortcut": "Нажмите сочетание клавиш", + "alt_warning": "Mac не поддерживает Option + буквы как горячие клавиши", + "reset_to_default": "Сбросить настройки по умолчанию", + "clear_shortcut": "Очистить сочетание клавиш" + }, + "theme.auto": "Автоматически", + "theme.dark": "Темная", + "theme.light": "Светлая", + "theme.title": "Тема", + "theme.window.style.opaque": "Непрозрачное окно", + "theme.window.style.title": "Стиль окна", + "theme.window.style.transparent": "Прозрачное окно", + "title": "Настройки", + "topic.position": "Позиция топиков", + "topic.position.left": "Слева", + "topic.position.right": "Справа", + "topic.show.time": "Показывать время топика", + "tray.title": "Включить значок системного трея" + }, + "translate": { + "any.language": "Любой язык", + "button.translate": "Перевести", + "confirm": { + "content": "Перевод заменит исходный текст, продолжить?", + "title": "Перевод подтверждение" + }, + "error.not_configured": "Модель перевода не настроена", + "error.failed": "Перевод не удалось", + "input.placeholder": "Введите текст для перевода", + "output.placeholder": "Перевод", + "processing": "Перевод в процессе...", + "title": "Перевод", + "close": "Закрыть" + }, + "tray": { + "quit": "Выйти", + "show_window": "Показать окно" + }, + "words": { + "knowledgeGraph": "Граф знаний", + "visualization": "Визуализация" + } + } +} diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 8452818cff..9b89665e7d 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1,407 +1,499 @@ { "translation": { - "common": { - "avatar": "头像", - "language": "语言", - "model": "模型", - "models": "模型", - "topics": "话题", - "docs": "文档", - "and": "和", - "assistant": "智能体", - "name": "名称", - "description": "描述", - "prompt": "提示词", - "rename": "重命名", - "delete": "删除", - "edit": "编辑", - "duplicate": "复制", - "copy": "复制", - "regenerate": "重新生成", - "provider": "提供商", - "you": "用户", - "save": "保存", - "footnote": "引用内容", - "select": "选择", - "search": "搜索", - "default": "默认", - "warning": "警告", - "back": "返回", - "chat": "聊天", - "close": "关闭", - "cancel": "取消", - "download": "下载" - }, - "button": { - "add": "添加", - "added": "已添加", - "manage": "管理", - "select_model": "选择模型", - "show.all": "显示全部", - "collapse": "收起" - }, - "message": { - "copied": "已复制", - "assistant.added.content": "智能体添加成功", - "message.delete.title": "删除消息", - "message.delete.content": "确定要删除此消息吗?", - "error.enter.api.key": "请输入您的 API 密钥", - "error.enter.api.host": "请输入您的 API 地址", - "error.enter.model": "请选择一个模型", - "error.invalid.proxy.url": "无效的代理地址", - "error.invalid.webdav": "无效的 WebDAV 设置", - "api.connection.failed": "连接失败", - "api.connection.success": "连接成功", - "chat.completion.paused": "会话已停止", - "switch.disabled": "模型回复完成后才能切换", - "restore.success": "恢复成功", - "backup.success": "备份成功", - "backup.failed": "备份失败", - "reset.confirm.content": "确定要重置所有数据吗?", - "reset.double.confirm.title": "数据丢失!!!", - "reset.double.confirm.content": "你的全部数据都会丢失,如果没有备份数据,将无法恢复,确定要继续吗?", - "upgrade.success.title": "升级成功", - "upgrade.success.content": "重启用以完成升级", - "upgrade.success.button": "重启", - "topic.added": "话题添加成功", - "save.success.title": "保存成功", - "message.style": "消息样式", - "message.style.bubble": "气泡", - "message.style.plain": "简洁" - }, - "chat": { - "save": "保存", - "default.name": "⭐️ 默认助手", - "default.description": "你好,我是默认助手。你可以立刻开始跟我聊天。", - "default.topic.name": "默认话题", - "topics.title": "话题", - "topics.auto_rename": "生成话题名", - "topics.edit.title": "编辑话题名", - "topics.edit.placeholder": "输入新名称", - "topics.clear.title": "清空消息", - "topics.move_to": "移动到", - "topics.list": "话题列表", - "topics.export.title": "导出", - "topics.export.image": "导出为图片", - "topics.export.md": "导出为 Markdown", - "topics.export.word": "导出为 Word", - "input.new_topic": "新话题", - "input.topics": " 话题 ", - "input.clear": "清空消息", - "input.new.context": "清除上下文", - "input.expand": "展开", - "input.collapse": "收起", - "input.clear.title": "清空消息", - "input.clear.content": "确定要清除当前会话所有消息吗?", - "input.placeholder": "在这里输入消息...", - "input.send": "发送", - "input.pause": "暂停", - "input.settings": "设置", - "input.upload": "上传图片或文档", - "input.context_count.tip": "上下文数", - "input.estimated_tokens.tip": "预估 token 数", - "settings.temperature": "模型温度", - "settings.temperature.tip": "模型生成文本的随机程度。值越大,回复内容越赋有多样性、创造性、随机性;设为 0 根据事实回答。日常聊天建议设置为 0.7", - "settings.conext_count": "上下文数", - "settings.conext_count.tip": "要保留在上下文中的消息数量,数值越大,上下文越长,消耗的 token 越多。普通聊天建议 5-10", - "settings.max_tokens": "开启消息长度限制", - "settings.max_tokens.tip": "单次交互所用的最大 Token 数, 会影响返回结果的长度。普通聊天建议 500-800;短文生成建议 800-2000;代码生成建议 2000-3600;长文生成建议切换模型到 4000 左右", - "settings.reset": "重置", - "settings.set_as_default": "应用到默认助手", - "settings.max": "不限", - "settings.show_line_numbers": "代码显示行号", - "suggestions.title": "建议的问题", - "add.assistant.title": "添加助手", - "message.new.context": "清除上下文", - "message.new.branch": "新分支", - "message.new.branch.created": "新分支已创建", - "assistant.search.placeholder": "搜索", - "artifacts.button.preview": "预览", - "artifacts.button.download": "下载" - }, - "assistants": { - "title": "助手", - "abbr": "助手", - "search": "搜索助手", - "settings.prompt": "提示词设置", - "settings.model": "模型设置", - "settings.preset_messages": "预设消息", - "settings.default_model": "默认模型", - "settings.auto_reset_model": "自动重置模型", - "settings.auto_reset_model.tip": "创建新话题时自动重置模型", - "edit.title": "编辑助手", - "copy.title": "复制助手", - "clear.title": "清空话题", - "clear.content": "清空话题会删除助手下所有话题和文件,确定要继续吗?", - "save.title": "保存到智能体", - "save.success": "保存成功", - "delete.title": "删除助手", - "delete.content": "删除助手会删除所有该助手下的话题和文件,确定要继续吗?" - }, - "model": { - "stream_output": "流式输出", - "search": "搜索模型..." - }, - "images": { - "title": "图片", - "image.size": "图片尺寸", - "button.new.image": "新建图片", - "button.delete.image": "删除图片", - "button.delete.image.confirm": "确定要删除此图片吗?", - "number_images": "生成数量", - "number_images_tip": "一次生成的图片数量 (1-4)", - "seed": "随机种子", - "seed_tip": "相同的种子和提示词可以生成相似的图片", - "inference_steps": "推理步数", - "inference_steps_tip": "要执行的推理步数。步数越多,质量越高但耗时越长", - "guidance_scale": "引导比例", - "guidance_scale_tip": "无分类器指导。控制模型在寻找相关图像时对提示词的遵循程度", - "negative_prompt": "反向提示词", - "negative_prompt_tip": "描述你不想在图片中出现的内容", - "prompt_placeholder": "描述你想创建的图片,例如:一个宁静的湖泊,夕阳西下,远处是群山", - "regenerate.confirm": "这将覆盖已生成的图片,是否继续?" - }, - "files": { - "title": "文件", - "file": "文件", - "name": "文件名", - "size": "大小", - "count": "文件数", - "created_at": "创建时间", - "image": "图片", - "text": "文本", - "document": "文档", - "actions": "操作", - "open": "打开", - "all": "所有文件" - }, "agents": { - "title": "智能体", - "my_agents": "我的智能体", - "add.title": "创建智能体", - "edit.title": "编辑智能体", + "add.button": "添加到助手", "add.name": "名称", "add.name.placeholder": "输入名称", "add.prompt": "提示词", "add.prompt.placeholder": "输入提示词", - "add.button": "添加到助手", - "manage.title": "管理智能体", + "add.title": "创建智能体", "delete.popup.content": "确定要删除此智能体吗?", - "tag.default": "默认", - "tag.system": "系统", - "tag.agent": "智能体", - "edit.message.title": "预设消息", "edit.message.add.title": "添加", - "edit.message.group.title": "消息组", - "edit.message.assistant.title": "助手", "edit.message.assistant.placeholder": "输入助手消息", - "edit.message.user.title": "用户", - "edit.message.user.placeholder": "输入用户消息", + "edit.message.assistant.title": "助手", "edit.message.empty.content": "会话输入内容不能为空", + "edit.message.group.title": "消息组", + "edit.message.title": "预设消息", + "edit.message.user.placeholder": "输入用户消息", + "edit.message.user.title": "用户", "edit.model.select.title": "选择模型", "edit.settings.hide_preset_messages": "隐藏预设消息", + "edit.title": "编辑智能体", + "manage.title": "管理智能体", + "my_agents": "我的智能体", "search.no_results": "没有找到相关智能体", - "sorting.title": "排序" + "sorting.title": "排序", + "tag.agent": "智能体", + "tag.default": "默认", + "tag.new": "新建", + "tag.system": "系统", + "title": "智能体" + }, + "assistants": { + "abbr": "助手", + "clear.content": "清空话题会删除助手下所有话题和文件,确定要继续吗?", + "clear.title": "清空话题", + "copy.title": "复制助手", + "delete.content": "删除助手会删除所有该助手下的话题和文件,确定要继续吗?", + "delete.title": "删除助手", + "edit.title": "编辑助手", + "save.success": "保存成功", + "save.title": "保存到智能体", + "search": "搜索助手", + "settings.auto_reset_model": "自动重置模型", + "settings.auto_reset_model.tip": "创建新话题时自动重置模型", + "settings.default_model": "默认模型", + "settings.model": "模型设置", + "settings.preset_messages": "预设消息", + "settings.prompt": "提示词设置", + "title": "助手" + }, + "button": { + "add": "添加", + "added": "已添加", + "collapse": "收起", + "manage": "管理", + "select_model": "选择模型", + "show.all": "显示全部" + }, + "chat": { + "add.assistant.title": "添加助手", + "artifacts.button.download": "下载", + "artifacts.button.preview": "预览", + "assistant.search.placeholder": "搜索", + "default.description": "你好,我是默认助手。你可以立刻开始跟我聊天。", + "default.name": "⭐️ 默认助手", + "default.topic.name": "默认话题", + "input.clear": "清空消息", + "input.clear.content": "确定要清除当前会话所有消息吗?", + "input.clear.title": "清空消息", + "input.collapse": "收起", + "input.context_count.tip": "上下文数", + "input.estimated_tokens.tip": "预估 token 数", + "input.expand": "展开", + "input.new.context": "清除上下文", + "input.new_topic": "新话题 {{Command}}+N", + "input.pause": "暂停", + "input.placeholder": "在这里输入消息...", + "input.send": "发送", + "input.settings": "设置", + "input.topics": " 话题 ", + "input.translate": "翻译成英文", + "input.upload": "上传图片或文档", + "message.new.branch": "新分支", + "message.new.branch.created": "新分支已创建", + "message.new.context": "清除上下文", + "save": "保存", + "settings.code_collapsible": "代码块可折叠", + "settings.context_count": "上下文数", + "settings.context_count.tip": "要保留在上下文中的消息数量,数值越大,上下文越长,消耗的 token 越多。普通聊天建议 5-10", + "settings.max": "不限", + "settings.max_tokens": "开启消息长度限制", + "settings.max_tokens.tip": "单次交互所用的最大 Token 数, 会影响返回结果的长度。普通聊天建议 500-800;短文生成建议 800-2000;代码生成建议 2000-3600;长文生成建议切换模型到 4000 左右", + "settings.reset": "重置", + "settings.set_as_default": "应用到默认助手", + "settings.show_line_numbers": "代码显示行号", + "settings.temperature": "模型温度", + "settings.temperature.tip": "模型生成文本的随机程度。值越大,回复内容越赋有多样性、创造性、随机性;设为 0 根据事实回答。日常聊天建议设置为 0.7", + "suggestions.title": "建议的问题", + "topics.auto_rename": "生成话题名", + "topics.clear.title": "清空消息", + "topics.edit.placeholder": "输入新名称", + "topics.edit.title": "编辑话题名", + "topics.export.image": "导出为图片", + "topics.export.md": "导出为 Markdown", + "topics.export.title": "导出", + "topics.export.word": "导出为 Word", + "topics.list": "话题列表", + "topics.move_to": "移动到", + "topics.title": "话题", + "translate": "翻译" + }, + "common": { + "and": "和", + "assistant": "智能体", + "avatar": "头像", + "back": "返回", + "cancel": "取消", + "chat": "聊天", + "close": "关闭", + "copy": "复制", + "cut": "剪切", + "default": "默认", + "delete": "删除", + "description": "描述", + "docs": "文档", + "download": "下载", + "duplicate": "复制", + "edit": "编辑", + "footnote": "引用内容", + "language": "语言", + "model": "模型", + "models": "模型", + "name": "名称", + "paste": "粘贴", + "prompt": "提示词", + "provider": "提供商", + "regenerate": "重新生成", + "rename": "重命名", + "reset": "重置", + "save": "保存", + "search": "搜索", + "select": "选择", + "topics": "话题", + "warning": "警告", + "you": "用户", + "clear": "清除", + "add": "添加" + }, + "error": { + "backup.file_format": "备份文件格式错误", + "chat.response": "出错了,如果没有配置 API 密钥,请前往设置 > 模型提供商中配置密钥", + "no_api_key": "API 密钥未配置", + "provider_disabled": "模型提供商未启用", + "render": { + "title": "渲染错误", + "description": "渲染公式失败,请检查公式格式是否正确" + } + }, + "export": { + "assistant": "助手", + "attached_files": "附件", + "conversation_details": "会话详情", + "conversation_history": "会话历史", + "created": "创建时间", + "last_updated": "最后更新", + "messages": "消息数", + "user": "用户" + }, + "files": { + "actions": "操作", + "all": "所有文件", + "count": "文件数", + "created_at": "创建时间", + "document": "文档", + "file": "文件", + "image": "图片", + "name": "文件名", + "open": "打开", + "size": "大小", + "text": "文本", + "title": "文件" + }, + "history": { + "continue_chat": "继续聊天", + "locate.message": "定位到消息", + "search.messages": "搜索所有消息", + "search.placeholder": "搜索话题或消息...", + "search.topics.empty": "没有找到相关话题, 点击回车键搜索所有消息", + "title": "话题搜索" + }, + "languages": { + "arabic": "阿拉伯文", + "chinese": "简体中文", + "chinese-traditional": "繁体中文", + "english": "英文", + "french": "法文", + "italian": "意大利文", + "japanese": "日文", + "korean": "韩文", + "portuguese": "葡萄牙文", + "russian": "俄文", + "spanish": "西班牙文" + }, + "mermaid": { + "download": { + "png": "下载 PNG", + "svg": "下载 SVG" + }, + "tabs": { + "preview": "预览", + "source": "源码" + }, + "title": "Mermaid 图表" + }, + "message": { + "api.connection.failed": "连接失败", + "api.connection.success": "连接成功", + "assistant.added.content": "智能体添加成功", + "backup.failed": "备份失败", + "backup.success": "备份成功", + "chat.completion.paused": "会话已停止", + "copied": "已复制", + "error.enter.api.host": "请输入您的 API 地址", + "error.enter.api.key": "请输入您的 API 密钥", + "error.enter.model": "请选择一个模型", + "error.invalid.proxy.url": "无效的代理地址", + "error.invalid.webdav": "无效的 WebDAV 设置", + "message.code_style": "代码风格", + "message.delete.content": "确定要删除此消息吗?", + "message.delete.title": "删除消息", + "message.style": "消息样式", + "message.style.bubble": "气泡", + "message.style.plain": "简洁", + "reset.confirm.content": "确定要重置所有数据吗?", + "reset.double.confirm.content": "你的全部数据都会丢失,如果没有备份数据,将无法恢复,确定要继续吗?", + "reset.double.confirm.title": "数据丢失!!!", + "restore.success": "恢复成功", + "save.success.title": "保存成功", + "switch.disabled": "模型回复完成后才能切换", + "topic.added": "话题添加成功", + "upgrade.success.button": "重启", + "upgrade.success.content": "重启用以完成升级", + "upgrade.success.title": "升级成功", + "regenerate.confirm": "重新生成会覆盖当前消息" }, "minapp": { "title": "小程序" }, - "history": { - "title": "话题搜索", - "search.placeholder": "搜索话题或消息...", - "continue_chat": "继续聊天", - "search.topics.empty": "没有找到相关话题, 点击回车键搜索所有消息", - "search.messages": "搜索所有消息", - "locate.message": "定位到消息" + "model": { + "pinned": "已固定", + "search": "搜索模型...", + "stream_output": "流式输出", + "type": { + "select": "选择模型类型", + "text": "文本", + "vision": "图像" + } + }, + "ollama": { + "keep_alive_time.description": "对话后模型在内存中保持的时间(默认:5分钟)", + "keep_alive_time.placeholder": "分钟", + "keep_alive_time.title": "保持活跃时间", + "title": "Ollama" + }, + "paintings": { + "button.delete.image": "删除图片", + "button.delete.image.confirm": "确定要删除此图片吗?", + "button.new.image": "新建图片", + "guidance_scale": "引导比例", + "guidance_scale_tip": "无分类器指导。控制模型在寻找相关图像时对提示词的遵循程度", + "image.size": "图片尺寸", + "inference_steps": "推理步数", + "inference_steps_tip": "要执行的推理步数。步数越多,质量越高但耗时越长", + "negative_prompt": "反向提示词", + "negative_prompt_tip": "描述你不想在图片中出现的内容", + "number_images": "生成数量", + "number_images_tip": "一次生成的图片数量 (1-4)", + "prompt_placeholder": "描述你想创建的图片,例如:一个宁静的湖泊,夕阳西下,远处是群山", + "regenerate.confirm": "这将覆盖已生成的图片,是否继续?", + "seed": "随机种子", + "seed_tip": "相同的种子和提示词可以生成相似的图片", + "title": "图片" }, "provider": { - "jina": "Jina", - "mistral": "Mistral", - "hyperbolic": "Hyperbolic", - "grok": "Grok", - "nvidia": "英伟达", - "hunyuan": "腾讯混元", - "zhinao": "360智脑", - "fireworks": "Fireworks", - "together": "Together", - "openai": "OpenAI", - "gemini": "Gemini", - "deepseek": "深度求索", - "moonshot": "月之暗面", - "silicon": "硅基流动", - "openrouter": "OpenRouter", - "yi": "零一万物", - "zhipu": "智谱AI", - "groq": "Groq", - "ollama": "Ollama", + "aihubmix": "AiHubMix", + "anthropic": "Anthropic", + "azure-openai": "Azure OpenAI", "baichuan": "百川", "dashscope": "阿里云百炼", - "anthropic": "Anthropic", - "aihubmix": "AiHubMix", - "stepfun": "阶跃星辰", + "deepseek": "深度求索", "doubao": "豆包", - "minimax": "MiniMax", - "graphrag-kylin-mountain": "GraphRAG", + "fireworks": "Fireworks", + "gemini": "Gemini", "github": "GitHub Models", + "graphrag-kylin-mountain": "GraphRAG", + "grok": "Grok", + "groq": "Groq", + "hunyuan": "腾讯混元", + "hyperbolic": "Hyperbolic", + "jina": "Jina", + "minimax": "MiniMax", + "mistral": "Mistral", + "moonshot": "月之暗面", + "nvidia": "英伟达", "ocoolai": "ocoolAI", - "azure-openai": "Azure OpenAI" + "ollama": "Ollama", + "openai": "OpenAI", + "openrouter": "OpenRouter", + "silicon": "硅基流动", + "stepfun": "阶跃星辰", + "together": "Together", + "yi": "零一万物", + "zhinao": "360智脑", + "zhipu": "智谱AI" }, "settings": { - "title": "设置", - "general": "常规设置", - "data": "数据设置", - "provider": "模型服务", - "model": "默认模型", - "assistant": "默认助手", "about": "关于我们", - "messages.model.title": "模型设置", - "messages.title": "消息设置", - "messages.divider": "消息分割线", - "messages.use_serif_font": "使用衬线字体", - "messages.input.title": "输入设置", - "messages.input.show_estimated_tokens": "状态显示", - "messages.input.send_shortcuts": "发送快捷键", - "messages.input.paste_long_text_as_file": "长文本粘贴为文件", - "messages.markdown_rendering_input_message": "Markdown 渲染输入消息", - "messages.math_engine": "数学公式引擎", + "about.checkUpdate": "检查更新", + "about.checkingUpdate": "正在检查更新...", + "about.contact.button": "邮件", + "about.contact.title": "邮件联系", + "about.description": "一款为创造者而生的 AI 助手", + "about.downloading": "正在下载更新...", + "about.feedback.button": "反馈", + "about.feedback.title": "意见反馈", + "about.license.button": "查看", + "about.license.title": "许可证", + "about.releases.button": "查看", + "about.releases.title": "更新日志", + "about.title": "关于我们", + "about.updateAvailable": "发现新版本 {{version}}", + "about.updateError": "更新出错", + "about.updateNotAvailable": "你的软件已是最新版本", + "about.website.button": "查看", + "about.website.title": "官方网站", + "advanced.auto_switch_to_topics": "自动切换到话题", + "advanced.title": "高级设置", + "assistant": "默认助手", + "assistant.model_params": "模型参数", + "assistant.title": "默认助手", + "data": { + "app_data": "应用数据", + "app_logs": "应用日志", + "clear_cache": { + "button": "清除缓存", + "confirm": "清除缓存将删除应用缓存的数据,包括小程序数据。此操作不可恢复,是否继续?", + "error": "清除缓存失败", + "success": "缓存清除成功", + "title": "清除缓存" + }, + "data.title": "数据目录", + "title": "数据设置", + "webdav.backup.button": "备份到 WebDAV", + "webdav.host": "WebDAV 地址", + "webdav.host.placeholder": "http://localhost:8080", + "webdav.password": "WebDAV 密码", + "webdav.path": "WebDAV 路径", + "webdav.path.placeholder": "/backup", + "webdav.restore.button": "从 WebDAV 恢复", + "webdav.title": "WebDAV", + "webdav.user": "WebDAV 用户名" + }, + "display.title": "显示设置", + "font_size.title": "消息字体大小", + "general": "常规设置", + "general.backup.button": "备份", + "general.backup.title": "数据备份与恢复", + "general.manually_check_update.title": "关闭更新检测", + "general.reset.button": "重置", + "general.reset.title": "重置数据", + "general.restore.button": "恢复", "general.title": "常规设置", "general.user_name": "用户名", "general.user_name.placeholder": "请输入用户名", - "general.backup.title": "数据备份与恢复", - "general.backup.button": "备份", - "general.restore.button": "恢复", - "general.reset.title": "重置数据", - "general.reset.button": "重置", "general.view_webdav_settings": "查看 WebDAV 设置", - "general.manually_check_update.title": "关闭更新检测", - "data.webdav.title": "WebDAV", - "data.webdav.host": "WebDAV 地址", - "data.webdav.host.placeholder": "http://localhost:8080", - "data.webdav.user": "WebDAV 用户名", - "data.webdav.password": "WebDAV 密码", - "data.webdav.path": "WebDAV 路径", - "data.webdav.path.placeholder": "/backup", - "data.webdav.backup.button": "备份到 WebDAV", - "data.webdav.restore.button": "从 WebDAV 恢复", - "advanced.title": "高级设置", - "advanced.click_assistant_switch_to_topics": "点击助手切换到话题", - "provider.api_key": "API 密钥", - "provider.check": "检查", - "provider.get_api_key": "点击这里获取密钥", - "provider.api_host": "API 地址", - "provider.api_version": "API 版本", - "provider.docs_check": "查看", - "provider.docs_more_details": "获取更多详情", - "provider.search_placeholder": "搜索模型 ID 或名称", - "provider.api.url.reset": "重置", - "provider.api.url.preview": "预览: {{url}}", - "provider.api.url.tip": "/结尾忽略v1版本,#结尾制使用输入地址", - "models.default_assistant_model": "默认助手模型", - "models.topic_naming_model": "话题命名模型", - "models.translate_model": "翻译模型", + "input.auto_translate_with_space": "快速敲击3次空格翻译", + "messages.divider": "消息分割线", + "messages.input.paste_long_text_as_file": "长文本粘贴为文件", + "messages.input.send_shortcuts": "发送快捷键", + "messages.input.show_estimated_tokens": "显示预估 Token 数", + "messages.input.title": "输入设置", + "messages.markdown_rendering_input_message": "Markdown 渲染输入消息", + "messages.math_engine": "数学公式引擎", + "messages.model.title": "模型设置", + "messages.title": "消息设置", + "messages.use_serif_font": "使用衬线字体", + "model": "默认模型", "models.add.add_model": "添加模型", - "models.add.model_id.placeholder": "必填 例如 gpt-3.5-turbo", + "models.add.group_name": "分组名称", + "models.add.group_name.placeholder": "例如 ChatGPT", + "models.add.group_name.tooltip": "例如 ChatGPT", "models.add.model_id": "模型 ID", + "models.add.model_id.placeholder": "必填 例如 gpt-3.5-turbo", "models.add.model_id.tooltip": "例如 gpt-3.5-turbo", "models.add.model_name": "模型名称", "models.add.model_name.placeholder": "例如 GPT-3.5", - "models.add.group_name": "分组名称", - "models.add.group_name.tooltip": "例如 ChatGPT", - "models.add.group_name.placeholder": "例如 ChatGPT", + "models.default_assistant_model": "默认助手模型", + "models.default_assistant_model_description": "创建新助手时使用的模型,如果助手未设置模型,则使用此模型", "models.empty": "没有模型", - "assistant.title": "默认助手", - "assistant.model_params": "模型参数", - "about.description": "一款为创造者而生的 AI 助手", - "about.updateNotAvailable": "你的软件已是最新版本", - "about.checkingUpdate": "正在检查更新...", - "about.updateError": "更新出错", - "about.checkUpdate": "检查更新", - "about.downloading": "正在下载更新...", - "provider.delete.title": "删除提供商", - "provider.delete.content": "确定要删除此模型提供商吗?", - "provider.edit.name": "模型提供商名称", - "provider.edit.name.placeholder": "例如 OpenAI", - "about.title": "关于我们", - "about.releases.title": "更新日志", - "about.releases.button": "查看", - "about.website.title": "官方网站", - "about.website.button": "查看", - "about.feedback.title": "意见反馈", - "about.feedback.button": "反馈", - "about.contact.title": "邮件联系", - "about.license.title": "许可证", - "about.license.button": "查看", - "about.contact.button": "邮件", + "models.topic_naming_model": "话题命名模型", + "models.topic_naming_model_description": "自动命名新话题时使用的模型", + "models.translate_model": "翻译模型", + "models.translate_model_description": "翻译服务使用的模型", + "models.translate_model_prompt_message": "请输入翻译模型提示词", + "models.translate_model_prompt_title": "翻译模型提示词", + "models.topic_naming_model_setting_title": "话题命名模型设置", + "models.enable_topic_naming": "话题自动重命名", + "provider": { + "add.name": "提供商名称", + "add.name.placeholder": "例如 OpenAI", + "add.title": "添加提供商", + "add.type": "提供商类型", + "api.url.preview": "预览: {{url}}", + "api.url.reset": "重置", + "api.url.tip": "/结尾忽略v1版本,#结尾制使用输入地址", + "api_host": "API 地址", + "api_key": "API 密钥", + "api_key.tip": "多个密钥使用逗号分隔", + "api_version": "API 版本", + "check": "检查", + "check_all_keys": "检查所有密钥", + "check_multiple_keys": "检查多个 API 密钥", + "delete.content": "确定要删除此模型提供商吗?", + "delete.title": "删除提供商", + "docs_check": "查看", + "docs_more_details": "获取更多详情", + "get_api_key": "点击这里获取密钥", + "no_models": "请先添加模型再检查 API 连接", + "not_checked": "未检查", + "remove_duplicate_keys": "移除重复密钥", + "remove_invalid_keys": "删除无效密钥", + "search_placeholder": "搜索模型 ID 或名称", + "title": "模型服务" + }, + "proxy": { + "mode": { + "custom": "自定义代理", + "none": "不使用代理", + "system": "系统代理", + "title": "代理模式" + }, + "title": "代理设置" + }, "proxy.title": "代理地址", - "theme.title": "主题", + "shortcuts": { + "action": "操作", + "key": "按键", + "new_topic": "新建话题", + "title": "快捷方式", + "zoom_in": "放大界面", + "zoom_out": "缩小界面", + "zoom_reset": "重置缩放", + "show_app": "显示应用", + "reset_defaults": "重置默认快捷键", + "reset_defaults_confirm": "确定要重置所有快捷键吗?", + "press_shortcut": "按下快捷键", + "alt_warning": "Mac 系统不能使用 Option + 字母作为快捷键", + "reset_to_default": "重置为默认", + "clear_shortcut": "清除快捷键" + }, + "theme.auto": "跟随系统", "theme.dark": "深色主题", "theme.light": "浅色主题", - "theme.auto": "跟随系统", + "theme.title": "主题", + "theme.window.style.opaque": "不透明窗口", "theme.window.style.title": "窗口样式", "theme.window.style.transparent": "透明窗口", - "theme.window.style.opaque": "不透明窗口", - "font_size.title": "消息字体大小", + "title": "设置", "topic.position": "话题位置", "topic.position.left": "左侧", "topic.position.right": "右侧", "topic.show.time": "显示话题时间", - "shortcuts": { - "title": "快捷方式", - "action": "操作", - "key": "按键", - "new_topic": "新建话题", - "zoom_in": "放大界面", - "zoom_out": "缩小界面", - "zoom_reset": "重置缩放" - } + "tray.title": "启用系统托盘图标" }, "translate": { - "title": "翻译", "any.language": "任意语言", "button.translate": "翻译", + "confirm": { + "content": "翻译后将覆盖原文,是否继续?", + "title": "翻译确认" + }, "error.not_configured": "翻译模型未配置", + "error.failed": "翻译失败", "input.placeholder": "输入文本进行翻译", "output.placeholder": "翻译", - "confirm": "原文已复制到剪贴板,是否用翻译后的文本替换?" + "processing": "翻译中...", + "title": "翻译", + "close": "关闭" }, - "languages": { - "english": "英文", - "chinese": "简体中文", - "chinese-traditional": "繁体中文", - "japanese": "日文", - "korean": "韩文", - "russian": "俄文", - "spanish": "西班牙文", - "french": "法文", - "italian": "意大利文", - "portuguese": "葡萄牙文", - "arabic": "阿拉伯文" - }, - "ollama": { - "title": "Ollama", - "keep_alive_time.title": "保持活跃时间", - "keep_alive_time.placeholder": "分钟", - "keep_alive_time.description": "对话后模型在内存中保持的时间(默认:5分钟)" - }, - "error": { - "chat.response": "出错了,如果没有配置 API 密钥,请前往设置 > 模型提供商中配置密钥", - "backup.file_format": "备份文件格式错误", - "provider_disabled": "模型提供商未启用", - "no_api_key": "API 密钥未配置" + "tray": { + "quit": "退出", + "show_window": "显示窗口" }, "words": { "knowledgeGraph": "知识图谱", "visualization": "可视化" - }, - "export": { - "attached_files": "附件", - "user": "用户", - "assistant": "助手", - "created": "创建时间", - "last_updated": "最后更新", - "messages": "消息数", - "conversation_details": "会话详情", - "conversation_history": "会话历史" } } } diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index c6ab1fd7f6..449587c888 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1,407 +1,499 @@ { "translation": { - "common": { - "avatar": "頭像", - "language": "語言", - "model": "模型", - "models": "模型", - "topics": "話題", - "docs": "文件", - "and": "與", - "assistant": "智能體", - "name": "名稱", - "description": "描述", - "prompt": "提示詞", - "rename": "重新命名", - "delete": "刪除", - "edit": "編輯", - "duplicate": "複製", - "copy": "複製", - "regenerate": "重新生成", - "provider": "提供商", - "you": "您", - "save": "保存", - "footnotes": "引用", - "select": "選擇", - "search": "搜尋", - "default": "預設", - "warning": "警告", - "back": "返回", - "chat": "聊天", - "close": "關閉", - "cancel": "取消", - "download": "下載" - }, - "button": { - "add": "添加", - "added": "已添加", - "manage": "管理", - "select_model": "選擇模型", - "show.all": "顯示全部", - "collapse": "收起" - }, - "message": { - "copied": "已複製", - "assistant.added.content": "智能體添加成功", - "message.delete.title": "刪除訊息", - "message.delete.content": "確定要刪除此訊息嗎?", - "error.enter.api.key": "請先輸入您的 API 密鑰", - "error.enter.api.host": "請先輸入您的 API 主機地址", - "error.enter.model": "請先選擇一個模型", - "error.invalid.proxy.url": "無效的代理 URL", - "error.invalid.webdav": "無效的 WebDAV 設定", - "api.connection.failed": "連接失敗", - "api.connection.success": "連接成功", - "chat.completion.paused": "聊天完成已暫停", - "switch.disabled": "助手生成回覆時無法切換", - "restore.success": "恢復成功", - "backup.success": "備份成功", - "backup.failed": "備份失敗", - "reset.confirm.content": "確定要清除所有資料嗎?", - "reset.double.confirm.title": "資料將會丟失!!!", - "reset.double.confirm.content": "所有資料將會被清除,您確定要繼續嗎?", - "upgrade.success.title": "升級成功", - "upgrade.success.content": "請重新啟動應用以完成升級", - "upgrade.success.button": "重新啟動", - "topic.added": "新話題已添加", - "save.success.title": "保存成功", - "message.style": "消息樣式", - "message.style.bubble": "氣泡", - "message.style.plain": "簡潔" - }, - "chat": { - "save": "保存", - "default.name": "⭐️ 預設助手", - "default.description": "你好,我是預設助手。你可以立即開始與我聊天。", - "default.topic.name": "預設話題", - "topics.title": "話題", - "topics.auto_rename": "自動重新命名", - "topics.edit.title": "編輯名稱", - "topics.edit.placeholder": "輸入新名稱", - "topics.clear.title": "清空消息", - "topics.move_to": "移動到", - "topics.list": "話題列表", - "topics.export.title": "匯出", - "topics.export.image": "匯出為圖片", - "topics.export.md": "匯出為 Markdown", - "topics.export.word": "導出為 Word", - "input.new_topic": "新話題", - "input.topics": " 話題 ", - "input.clear": "清除", - "input.new.context": "清除上下文", - "input.expand": "展開", - "input.collapse": "收起", - "input.clear.title": "清除所有訊息?", - "input.clear.content": "您想要清除當前話題的所有訊息嗎?", - "input.placeholder": "在此輸入您的訊息...", - "input.send": "發送", - "input.pause": "暫停", - "input.settings": "設定", - "input.upload": "上傳圖片或文檔", - "input.context_count.tip": "上下文數量", - "input.estimated_tokens.tip": "預估 Token 數", - "settings.temperature": "溫度", - "settings.temperature.tip": "較低的值使模型更具創造性和不可預測性,較高的值則使其更具確定性和精確性。", - "settings.conext_count": "上下文", - "settings.conext_count.tip": "在上下文中保留的前幾則訊息。", - "settings.max_tokens": "啟用最大 Token 限制", - "settings.max_tokens.tip": "模型可以生成的最大 Token 數。普通聊天建議 500-800。短文生成建議 800-2000。代碼生成建議 2000-3600。長文生成建議超過 4000。", - "settings.reset": "重置", - "settings.set_as_default": "設為預設助手", - "settings.max": "最大", - "settings.show_line_numbers": "代码顯示行號", - "suggestions.title": "建議的問題", - "add.assistant.title": "添加助手", - "message.new.context": "新上下文", - "message.new.branch": "新分支", - "message.new.branch.created": "新分支已建立", - "assistant.search.placeholder": "搜尋", - "artifacts.button.preview": "預覽", - "artifacts.button.download": "下載" - }, - "assistants": { - "title": "助手", - "abbr": "助", - "search": "搜尋助手...", - "settings.prompt": "提示詞設定", - "settings.model": "模型設定", - "settings.preset_messages": "預設訊息", - "settings.default_model": "預設模型", - "settings.auto_reset_model": "自動重置模型", - "settings.auto_reset_model.tip": "每次新的話題時自動重置模型", - "edit.title": "編輯助手", - "copy.title": "複製助手", - "clear.title": "清空話題", - "clear.content": "清空話題會刪除助手下所有主題和文件,確定要繼續嗎?", - "save.title": "儲存到智能體", - "save.success": "儲存成功", - "delete.title": "删除助手", - "delete.content": "删除助手会删除所有该助手下的话题和文件,确定要繼續吗?" - }, - "model": { - "stream_output": "串流輸出", - "search": "搜尋模型..." - }, - "images": { - "title": "繪圖", - "image.size": "影像尺寸", - "button.new.image": "新繪圖", - "button.delete.image": "刪除繪圖", - "button.delete.image.confirm": "確定要刪除此繪圖嗎?", - "number_images": "生成數量", - "number_images_tip": "一次生成的圖片數量 (1-4)", - "seed": "隨機種子", - "seed_tip": "相同的種子和提示詞可以生成相似的圖片", - "inference_steps": "推理步數", - "inference_steps_tip": "要執行的推理步數。步數越多,質量越高但耗時越長", - "guidance_scale": "引導比例", - "guidance_scale_tip": "無分類器指導。控制模型在尋找相關圖像時對提示詞的遵循程度", - "negative_prompt": "反向提示詞", - "negative_prompt_tip": "描述你不想在圖片中出現的內容", - "prompt_placeholder": "描述你想創建的圖片,例如:一個寧靜的湖泊,夕陽西下,遠處是群山", - "regenerate.confirm": "這將覆蓋已生成的圖片,是否繼續?" - }, - "files": { - "title": "檔案", - "file": "檔案", - "name": "名稱", - "size": "大小", - "count": "數量", - "created_at": "建立時間", - "image": "圖片", - "text": "文本", - "document": "文檔", - "actions": "操作", - "open": "打開", - "all": "所有檔案" - }, "agents": { - "title": "智能體", - "my_agents": "我的智能體", - "add.title": "创建智能體", - "edit.title": "編輯智能體", + "add.button": "添加到助手", "add.name": "名稱", "add.name.placeholder": "輸入名稱", "add.prompt": "提示詞", "add.prompt.placeholder": "輸入提示詞", - "add.button": "添加到助手", - "manage.title": "管理智能體", + "add.title": "创建智能體", "delete.popup.content": "確定要刪除此智能體嗎?", - "tag.default": "預設", - "tag.system": "系統", - "tag.agent": "智能体", - "edit.message.title": "預設訊息", "edit.message.add.title": "添加", - "edit.message.group.title": "訊息組", - "edit.message.assistant.title": "助手", "edit.message.assistant.placeholder": "輸入助手消息", - "edit.message.user.title": "用戶", - "edit.message.user.placeholder": "輸入用戶消息", + "edit.message.assistant.title": "助手", "edit.message.empty.content": "會話輸入內容不能為空", + "edit.message.group.title": "訊息組", + "edit.message.title": "預設訊息", + "edit.message.user.placeholder": "輸入用戶消息", + "edit.message.user.title": "用戶", "edit.model.select.title": "選擇模型", "edit.settings.hide_preset_messages": "隱藏預設消息", + "edit.title": "編輯智能體", + "manage.title": "管理智能體", + "my_agents": "我的智能體", "search.no_results": "沒有找到相關智能體", - "sorting.title": "排序" + "sorting.title": "排序", + "tag.agent": "智能体", + "tag.default": "預設", + "tag.new": "新建", + "tag.system": "系統", + "title": "智能體" + }, + "assistants": { + "abbr": "助", + "clear.content": "清空話題會刪除助手下所有主題和文件,確定要繼續嗎?", + "clear.title": "清空話題", + "copy.title": "複製助手", + "delete.content": "删除助手会删除所有该助手下的话题和文件,确定要繼續吗?", + "delete.title": "删除助手", + "edit.title": "編輯助手", + "save.success": "儲存成功", + "save.title": "儲存到智能體", + "search": "搜尋助手...", + "settings.auto_reset_model": "自動重置模型", + "settings.auto_reset_model.tip": "每次新的話題時自動重置模型", + "settings.default_model": "預設模型", + "settings.model": "模型設定", + "settings.preset_messages": "預設訊息", + "settings.prompt": "提示詞設定", + "title": "助手" + }, + "button": { + "add": "添加", + "added": "已添加", + "collapse": "收起", + "manage": "管理", + "select_model": "選擇模型", + "show.all": "顯示全部" + }, + "chat": { + "add.assistant.title": "添加助手", + "artifacts.button.download": "下載", + "artifacts.button.preview": "預覽", + "assistant.search.placeholder": "搜尋", + "default.description": "你好,我是預設助手。你可以立即開始與我聊天。", + "default.name": "⭐️ 預設助手", + "default.topic.name": "預設話題", + "input.clear": "清除", + "input.clear.content": "您想要清除當前話題的所有訊息嗎?", + "input.clear.title": "清除所有訊息?", + "input.collapse": "收起", + "input.context_count.tip": "上下文數量", + "input.estimated_tokens.tip": "預估 Token 數", + "input.expand": "展開", + "input.new.context": "清除上下文", + "input.new_topic": "新話題 {{Command}}+N", + "input.pause": "暫停", + "input.placeholder": "在此輸入您的訊息...", + "input.send": "發送", + "input.settings": "設定", + "input.topics": " 話題 ", + "input.translate": "翻譯成英文", + "input.upload": "上傳圖片或文檔", + "message.new.branch": "新分支", + "message.new.branch.created": "新分支已建立", + "message.new.context": "新上下文", + "save": "保存", + "settings.code_collapsible": "代码块可折叠", + "settings.context_count": "上下文", + "settings.context_count.tip": "在上下文中保留的前幾則訊息。", + "settings.max": "最大", + "settings.max_tokens": "啟用最大 Token 限制", + "settings.max_tokens.tip": "模型可以生成的最大 Token 數。普通聊天建議 500-800。短文生成建議 800-2000。代碼生成建議 2000-3600。長文生成建議超過 4000。", + "settings.reset": "重置", + "settings.set_as_default": "設為預設助手", + "settings.show_line_numbers": "代码顯示行號", + "settings.temperature": "溫度", + "settings.temperature.tip": "較低的值使模型更具創造性和不可預測性,較高的值則使其更具確定性和精確性。", + "suggestions.title": "建議的問題", + "topics.auto_rename": "自動重新命名", + "topics.clear.title": "清空消息", + "topics.edit.placeholder": "輸入新名稱", + "topics.edit.title": "編輯名稱", + "topics.export.image": "匯出為圖片", + "topics.export.md": "匯出為 Markdown", + "topics.export.title": "匯出", + "topics.export.word": "導出為 Word", + "topics.list": "話題列表", + "topics.move_to": "移動到", + "topics.title": "話題", + "translate": "翻譯" + }, + "common": { + "and": "與", + "assistant": "智能體", + "avatar": "頭像", + "back": "返回", + "cancel": "取消", + "chat": "聊天", + "close": "關閉", + "copy": "複製", + "cut": "剪下", + "default": "預設", + "delete": "刪除", + "description": "描述", + "docs": "文件", + "download": "下載", + "duplicate": "複製", + "edit": "編輯", + "footnotes": "引用", + "language": "語言", + "model": "模型", + "models": "模型", + "name": "名稱", + "paste": "貼上", + "prompt": "提示詞", + "provider": "提供商", + "regenerate": "重新生成", + "rename": "重新命名", + "reset": "重置", + "save": "保存", + "search": "搜尋", + "select": "選擇", + "topics": "話題", + "warning": "警告", + "you": "您", + "clear": "清除", + "add": "添加" + }, + "error": { + "backup.file_format": "備份文件格式錯誤", + "chat.response": "出現錯誤。如果尚未配置 API 密鑰,請前往設定 > 模型提供者中配置密鑰", + "no_api_key": "API 密鑰未配置", + "provider_disabled": "模型提供商未啟用", + "render": { + "title": "渲染錯誤", + "description": "渲染公式失敗,請檢查公式格式是否正確" + } + }, + "export": { + "assistant": "助手", + "attached_files": "附件", + "conversation_details": "會話詳情", + "conversation_history": "會話歷史", + "created": "創建時間", + "last_updated": "最後��新", + "messages": "訊息數", + "user": "用戶" + }, + "files": { + "actions": "操作", + "all": "所有檔案", + "count": "數量", + "created_at": "建立時間", + "document": "文檔", + "file": "檔案", + "image": "圖片", + "name": "名稱", + "open": "打開", + "size": "大小", + "text": "文本", + "title": "檔案" + }, + "history": { + "continue_chat": "繼續聊天", + "locate.message": "定位到訊息", + "search.messages": "搜尋所有訊息", + "search.placeholder": "搜尋話題或訊息...", + "search.topics.empty": "沒有找到相關話題, 點擊回車鍵搜尋所有訊息", + "title": "搜尋話題" + }, + "languages": { + "arabic": "阿拉伯文", + "chinese": "簡體中文", + "chinese-traditional": "繁體中文", + "english": "英文", + "french": "法文", + "italian": "意大利文", + "japanese": "日文", + "korean": "韓文", + "portuguese": "葡萄牙文", + "russian": "俄文", + "spanish": "西班牙文" + }, + "mermaid": { + "download": { + "png": "下載 PNG", + "svg": "下載 SVG" + }, + "tabs": { + "preview": "預覽", + "source": "原始碼" + }, + "title": "Mermaid 圖表" + }, + "message": { + "api.connection.failed": "連接失敗", + "api.connection.success": "連接成功", + "assistant.added.content": "智能體添加成功", + "backup.failed": "備份失敗", + "backup.success": "備份成功", + "chat.completion.paused": "聊天完成已暫停", + "copied": "已複製", + "error.enter.api.host": "請先輸入您的 API 主機地址", + "error.enter.api.key": "請先輸入您的 API 密鑰", + "error.enter.model": "請先選擇一個模型", + "error.invalid.proxy.url": "無效的代理 URL", + "error.invalid.webdav": "無效的 WebDAV 設定", + "message.code_style": "程式碼風格", + "message.delete.content": "確定要刪除此訊息嗎?", + "message.delete.title": "刪除訊息", + "message.style": "消息樣式", + "message.style.bubble": "氣泡", + "message.style.plain": "簡潔", + "reset.confirm.content": "確定要清除所有資料嗎?", + "reset.double.confirm.content": "所有資料將會被清除,您確定要繼續嗎?", + "reset.double.confirm.title": "資料將會丟失!!!", + "restore.success": "恢復成功", + "save.success.title": "保存成功", + "switch.disabled": "助手生成回覆時無法切換", + "topic.added": "新話題已添加", + "upgrade.success.button": "重新啟動", + "upgrade.success.content": "請重新啟動應用以完成升級", + "upgrade.success.title": "升級成功", + "regenerate.confirm": "重新生成會覆蓋當前訊息" }, "minapp": { "title": "小程序" }, - "history": { - "title": "搜尋話題", - "search.placeholder": "搜尋話題或訊息...", - "continue_chat": "繼續聊天", - "search.topics.empty": "沒有找到相關話題, 點擊回車鍵搜尋所有訊息", - "search.messages": "搜尋所有訊息", - "locate.message": "定位到訊息" + "model": { + "pinned": "已固定", + "search": "搜尋模型...", + "stream_output": "串流輸出", + "type": { + "select": "選擇模型類型", + "text": "文字", + "vision": "圖像" + } + }, + "ollama": { + "keep_alive_time.description": "對話後模型在記憶體中保持的時間(預設為 5 分鐘)。", + "keep_alive_time.placeholder": "分鐘", + "keep_alive_time.title": "保持活躍時間", + "title": "Ollama" + }, + "paintings": { + "button.delete.image": "刪除繪圖", + "button.delete.image.confirm": "確定要刪除此繪圖嗎?", + "button.new.image": "新繪圖", + "guidance_scale": "引導比例", + "guidance_scale_tip": "無分類器指導。控制模型在尋找相關圖像時對提示詞的遵循程度", + "image.size": "影像尺寸", + "inference_steps": "推理步數", + "inference_steps_tip": "要執行的推理步數。步數越多,質量越高但耗時越長", + "negative_prompt": "反向提示詞", + "negative_prompt_tip": "描述你不想在圖片中出現的內容", + "number_images": "生成數量", + "number_images_tip": "一次生成的圖片數量 (1-4)", + "prompt_placeholder": "描述你想創建的圖片,例如:一個寧靜的湖泊,夕陽西下,遠處是群山", + "regenerate.confirm": "這將覆蓋已生成的圖片,是否繼續?", + "seed": "隨機種子", + "seed_tip": "相同的種子和提示詞可以生成相似的圖片", + "title": "繪圖" }, "provider": { - "jina": "Jina", - "mistral": "Mistral", - "hyperbolic": "Hyperbolic", - "grok": "Grok", - "nvidia": "輝達", - "zhinao": "360智腦", - "hunyuan": "騰訊混元", - "fireworks": "Fireworks", - "together": "Together", - "openai": "OpenAI", - "gemini": "Gemini", - "deepseek": "深度求索", - "moonshot": "月之暗面", - "silicon": "SiliconFlow", - "openrouter": "OpenRouter", - "yi": "零一萬物", - "zhipu": "智譜AI", - "groq": "Groq", - "ollama": "Ollama", + "aihubmix": "AiHubMix", + "anthropic": "Anthropic", + "azure-openai": "Azure OpenAI", "baichuan": "百川", "dashscope": "阿里雲百鍊", - "anthropic": "Anthropic", - "aihubmix": "AiHubMix", - "stepfun": "StepFun", + "deepseek": "深度求索", "doubao": "豆包", - "minimax": "MiniMax", - "graphrag-kylin-mountain": "GraphRAG", + "fireworks": "Fireworks", + "gemini": "Gemini", "github": "GitHub Models", + "graphrag-kylin-mountain": "GraphRAG", + "grok": "Grok", + "groq": "Groq", + "hunyuan": "騰訊混元", + "hyperbolic": "Hyperbolic", + "jina": "Jina", + "minimax": "MiniMax", + "mistral": "Mistral", + "moonshot": "月之暗面", + "nvidia": "輝達", "ocoolai": "ocoolAI", - "azure-openai": "Azure OpenAI" + "ollama": "Ollama", + "openai": "OpenAI", + "openrouter": "OpenRouter", + "silicon": "SiliconFlow", + "stepfun": "StepFun", + "together": "Together", + "yi": "零一萬物", + "zhinao": "360智腦", + "zhipu": "智譜AI" }, "settings": { - "title": "設定", - "general": "一般設定", - "data": "數據設定", - "provider": "模型提供者", - "model": "預設模型", - "assistant": "預設助手", "about": "關於與回饋", - "messages.model.title": "模型設定", - "messages.title": "訊息設定", - "messages.divider": "訊息間顯示分隔線", - "messages.use_serif_font": "使用襯線字體", - "messages.input.title": "輸入設定", - "messages.input.show_estimated_tokens": "顯示預估輸入 Token 數", - "messages.input.send_shortcuts": "發送快捷鍵", - "messages.input.paste_long_text_as_file": "將長文本貼上為檔案", - "messages.math_engine": "Markdown 渲染輸入訊息", - "messages.math_render_engine": "數學公式引擎", + "about.checkUpdate": "檢查更新", + "about.checkingUpdate": "正在檢查更新...", + "about.contact.button": "郵件", + "about.contact.title": "聯繫方式", + "about.description": "一款為創作者而生的強大 AI 助手", + "about.downloading": "正在下載...", + "about.feedback.button": "回饋", + "about.feedback.title": "回饋", + "about.license.button": "查看", + "about.license.title": "許可證", + "about.releases.button": "查看", + "about.releases.title": "更新日誌", + "about.title": "關於我們", + "about.updateAvailable": "發現新版本 {{version}}", + "about.updateError": "更新錯誤", + "about.updateNotAvailable": "您正在使用最新版本", + "about.website.button": "網站", + "about.website.title": "官方網站", + "advanced.auto_switch_to_topics": "自動切換到話題", + "advanced.title": "進階設定", + "assistant": "預設助手", + "assistant.model_params": "模型參數", + "assistant.title": "預設助手", + "data": { + "clear_cache": { + "button": "清除緩存", + "confirm": "清除緩存將刪除應用緩存數據,包括小程序數據。此操作不可恢復,是否繼續?", + "error": "清除緩存失敗", + "success": "緩存清除成功", + "title": "清除緩存" + }, + "data.app_data": "應用數據", + "data.app_logs": "應用日誌", + "data.title": "數據目錄", + "title": "數據設定", + "webdav.backup.button": "從 WebDAV 備份", + "webdav.host": "WebDAV 主機位址", + "webdav.host.placeholder": "http://localhost:8080", + "webdav.password": "WebDAV 密碼", + "webdav.path": "WebDAV Path", + "webdav.path.placeholder": "/backup", + "webdav.restore.button": "從 WebDAV 恢復", + "webdav.title": "WebDAV", + "webdav.user": "WebDAV 使用者名稱" + }, + "display.title": "顯示設定", + "font_size.title": "訊息字體大小", + "general": "一般設定", + "general.backup.button": "備份", + "general.backup.title": "資料備份與復原", + "general.manually_check_update.title": "關閉更新檢查", + "general.reset.button": "重置", + "general.reset.title": "資料重置", + "general.restore.button": "復原", "general.title": "一般設定", "general.user_name": "使用者名稱", "general.user_name.placeholder": "輸入您的名稱", - "general.backup.title": "資料備份與復原", - "general.backup.button": "備份", - "general.restore.button": "復原", "general.view_webdav_settings": "查看 WebDAV 設定", - "general.reset.title": "資料重置", - "general.reset.button": "重置", - "general.manually_check_update.title": "關閉更新檢查", - "data.webdav.title": "WebDAV", - "data.webdav.host": "WebDAV 主機位址", - "data.webdav.host.placeholder": "http://localhost:8080", - "data.webdav.user": "WebDAV 使用者名稱", - "data.webdav.password": "WebDAV 密碼", - "data.webdav.path": "WebDAV Path", - "data.webdav.path.placeholder": "/backup", - "data.webdav.backup.button": "從 WebDAV 備份", - "data.webdav.restore.button": "從 WebDAV 恢復", - "advanced.title": "進階設定", - "advanced.click_assistant_switch_to_topics": "點擊助手切換到話題", - "provider.api_key": "API 密鑰", - "provider.check": "檢查", - "provider.get_api_key": "獲取 API 密鑰", - "provider.api_host": "API 主機地址", - "provider.api_version": "API 版本", - "provider.docs_check": "檢查", - "provider.docs_more_details": "查看更多細節", - "provider.search_placeholder": "搜尋模型 ID 或名稱", - "provider.api.url.reset": "重置", - "provider.api.url.preview": "預覽: {{url}}", - "provider.api.url.tip": "/結尾忽略v1版本,#結尾強制使用輸入位址", - "models.default_assistant_model": "預設助手模型", - "models.topic_naming_model": "話題命名模型", - "models.translate_model": "翻譯模型", + "input.auto_translate_with_space": "快速敲擊3次空格翻譯", + "messages.divider": "訊息間顯示分隔線", + "messages.input.paste_long_text_as_file": "將長文本貼上為檔案", + "messages.input.send_shortcuts": "發送快捷鍵", + "messages.input.show_estimated_tokens": "顯示預估 Token 數", + "messages.input.title": "輸入設定", + "messages.math_engine": "Markdown 渲染輸入訊息", + "messages.math_render_engine": "數學公式引擎", + "messages.model.title": "模型設定", + "messages.title": "訊息設定", + "messages.use_serif_font": "使用襯線字體", + "model": "預設模型", "models.add.add_model": "添加模型", - "models.add.model_id.placeholder": "必填,例如 gpt-3.5-turbo", + "models.add.group_name": "群組名稱", + "models.add.group_name.placeholder": "可選,例如 ChatGPT", + "models.add.group_name.tooltip": "可選,例如 ChatGPT", "models.add.model_id": "模型 ID", + "models.add.model_id.placeholder": "必填,例如 gpt-3.5-turbo", "models.add.model_id.tooltip": "例如 gpt-3.5-turbo", "models.add.model_name": "模型名稱", "models.add.model_name.placeholder": "可選,例如 GPT-4", - "models.add.group_name": "群組名稱", - "models.add.group_name.tooltip": "可選,例如 ChatGPT", - "models.add.group_name.placeholder": "可選,例如 ChatGPT", + "models.default_assistant_model": "預設助手模型", + "models.default_assistant_model_description": "創建新助手時使用的模型,如果助手未設置模型,則使用此模型", "models.empty": "找不到模型", - "assistant.title": "預設助手", - "assistant.model_params": "模型參數", - "about.description": "一款為創作者而生的強大 AI 助手", - "about.updateNotAvailable": "您正在使用最新版本", - "about.checkingUpdate": "正在檢查更新...", - "about.updateError": "更新錯誤", - "about.checkUpdate": "檢查更新", - "about.downloading": "正在下載...", - "provider.delete.title": "刪除提供者", - "provider.delete.content": "確定要刪除此提供者嗎?", - "provider.edit.name": "提供者名稱", - "provider.edit.name.placeholder": "例如:OpenAI", - "about.title": "關於我們", - "about.releases.title": "更新日誌", - "about.releases.button": "查看", - "about.website.title": "官方網站", - "about.website.button": "網站", - "about.feedback.title": "回饋", - "about.feedback.button": "回饋", - "about.contact.title": "聯繫方式", - "about.license.title": "許可證", - "about.license.button": "查看", - "about.contact.button": "郵件", + "models.topic_naming_model": "話題命名模型", + "models.topic_naming_model_description": "自動命名新話題時使用的模型", + "models.translate_model": "翻譯模型", + "models.translate_model_description": "翻譯服務使用的模型", + "models.translate_model_prompt_message": "請輸入翻譯模型提示詞", + "models.translate_model_prompt_title": "翻譯模型提示詞", + "models.topic_naming_model_setting_title": "話題命名模型設定", + "models.enable_topic_naming": "話題自動重命名", + "provider": { + "add.name": "提供者名稱", + "add.name.placeholder": "例如:OpenAI", + "add.title": "添加提供者", + "add.type": "提供商類型", + "api.url.preview": "預覽: {{url}}", + "api.url.reset": "重置", + "api.url.tip": "/結尾忽略v1版本,#結尾強制使用輸入位址", + "api_host": "API 主機地址", + "api_key": "API 密鑰", + "api_key.tip": "多個密鑰使用逗號分隔", + "api_version": "API 版本", + "check": "檢查", + "check_all_keys": "檢查所有密鑰", + "check_multiple_keys": "檢查多個 API 密鑰", + "delete.content": "確定要刪除此提供者嗎?", + "delete.title": "刪除提供者", + "docs_check": "檢查", + "docs_more_details": "查看更多細節", + "get_api_key": "獲取 API 密鑰", + "no_models": "請先添加模型再檢查 API 連接", + "not_checked": "未檢查", + "remove_duplicate_keys": "移除重複密鑰", + "remove_invalid_keys": "刪除無效密鑰", + "search_placeholder": "搜尋模型 ID 或名稱", + "title": "模型提供者" + }, + "proxy": { + "mode": { + "custom": "自定義代理", + "none": "不使用代理", + "system": "系統代理", + "title": "代理模式" + }, + "title": "代理設定" + }, "proxy.title": "代理地址", - "theme.title": "主題", + "shortcuts": { + "action": "操作", + "key": "按鍵", + "new_topic": "新建話題", + "title": "快速方式", + "zoom_in": "放大界面", + "zoom_out": "縮小界面", + "zoom_reset": "重置縮放", + "show_app": "顯示應用", + "reset_defaults": "重置預設快捷鍵", + "reset_defaults_confirm": "確定要重置所有快捷鍵嗎?", + "press_shortcut": "按下快捷鍵", + "alt_warning": "Mac 不能使用 Option + 字母作為快捷鍵", + "reset_to_default": "重置為預設", + "clear_shortcut": "清除快捷鍵" + }, + "theme.auto": "自動", "theme.dark": "深色主題", "theme.light": "淺色主題", - "theme.auto": "自動", + "theme.title": "主題", + "theme.window.style.opaque": "不透明視窗", "theme.window.style.title": "視窗樣式", "theme.window.style.transparent": "透明視窗", - "theme.window.style.opaque": "不透明視窗", - "font_size.title": "訊息字體大小", + "title": "設定", "topic.position": "話題位置", "topic.position.left": "左側", "topic.position.right": "右側", "topic.show.time": "顯示話題時間", - "shortcuts": { - "title": "快速方式", - "action": "操作", - "key": "按鍵", - "new_topic": "新建話題", - "zoom_in": "放大界面", - "zoom_out": "縮小界面", - "zoom_reset": "重置縮放" - } + "tray.title": "啟用系統托盤圖標" }, "translate": { - "title": "翻譯", "any.language": "任意語言", "button.translate": "翻譯", + "confirm": { + "content": "翻譯後將覆蓋原文,是否繼續?", + "title": "翻譯確認" + }, "error.not_configured": "翻譯模型未配置", + "error.failed": "翻譯失敗", "input.placeholder": "輸入文字進行翻譯", "output.placeholder": "翻譯", - "confirm": "原文已複製到剪貼簿,是否用翻譯後的文字替換?" + "processing": "翻譯中...", + "title": "翻譯", + "close": "關閉" }, - "languages": { - "english": "英文", - "chinese": "簡體中文", - "chinese-traditional": "繁體中文", - "japanese": "日文", - "korean": "韓文", - "russian": "俄文", - "spanish": "西班牙文", - "french": "法文", - "italian": "意大利文", - "portuguese": "葡萄牙文", - "arabic": "阿拉伯文" - }, - "ollama": { - "title": "Ollama", - "keep_alive_time.title": "保持活躍時間", - "keep_alive_time.placeholder": "分鐘", - "keep_alive_time.description": "對話後模型在記憶體中保持的時間(預設為 5 分鐘)。" - }, - "error": { - "chat.response": "出現錯誤。如果尚未配置 API 密鑰,請前往設定 > 模型提供者中配置密鑰", - "backup.file_format": "備份文件格式錯誤", - "provider_disabled": "模型提供商未啟用", - "no_api_key": "API 密鑰未配置" + "tray": { + "quit": "退出", + "show_window": "顯示視窗" }, "words": { "knowledgeGraph": "知識圖譜", "visualization": "可視化" - }, - "export": { - "attached_files": "附件", - "user": "用戶", - "assistant": "助手", - "created": "創建時間", - "last_updated": "最後更新", - "messages": "訊息數", - "conversation_details": "會話詳情", - "conversation_history": "會話歷史" } } } diff --git a/src/renderer/src/init.ts b/src/renderer/src/init.ts index 2a2f1d5c9b..289723e7f7 100644 --- a/src/renderer/src/init.ts +++ b/src/renderer/src/init.ts @@ -1,31 +1,6 @@ import KeyvStorage from '@kangfenmao/keyv-storage' -import localforage from 'localforage' - -import { APP_NAME } from './config/env' -import { ThemeMode } from './types' -import { loadScript } from './utils' - -export async function initMermaid(theme: ThemeMode) { - if (!window.mermaid) { - await loadScript('https://unpkg.com/mermaid@10.9.1/dist/mermaid.min.js') - window.mermaid.initialize({ - startOnLoad: true, - theme: theme === ThemeMode.dark ? 'dark' : 'default', - securityLevel: 'loose' - }) - window.mermaid.contentLoaded() - } -} function init() { - localforage.config({ - driver: localforage.INDEXEDDB, - name: 'CherryAI', - version: 1.0, - storeName: 'cherryai', - description: `${APP_NAME} Storage` - }) - window.keyv = new KeyvStorage() window.keyv.init() } diff --git a/src/renderer/src/pages/agents/AgentsPage.tsx b/src/renderer/src/pages/agents/AgentsPage.tsx index c5d85be876..82e62f18bb 100644 --- a/src/renderer/src/pages/agents/AgentsPage.tsx +++ b/src/renderer/src/pages/agents/AgentsPage.tsx @@ -33,6 +33,7 @@ let _agentGroups: Record = {} const AgentsPage: FC = () => { const [search, setSearch] = useState('') + const [searchInput, setSearchInput] = useState('') const agentGroups = useMemo(() => { if (Object.keys(_agentGroups).length === 0) { @@ -44,26 +45,35 @@ const AgentsPage: FC = () => { const { t, i18n } = useTranslation() const filteredAgentGroups = useMemo(() => { - const groups = { 我的: [] } + const groups: Record = { + 我的: [], + 精选: agentGroups['精选'] || [] + } if (!search.trim()) { Object.entries(agentGroups).forEach(([group, agents]) => { - groups[group] = agents + if (group !== '精选') { + groups[group] = agents + } }) return groups } - Object.entries(agentGroups).forEach(([group, agents]) => { - const filteredAgents = agents.filter( - (agent) => - agent.name.toLowerCase().includes(search.toLowerCase()) || - agent.description?.toLowerCase().includes(search.toLowerCase()) - ) - if (filteredAgents.length > 0) { - groups[group] = filteredAgents - } + const uniqueAgents = new Map() + + Object.entries(agentGroups).forEach(([, agents]) => { + agents.forEach((agent) => { + if ( + (agent.name.toLowerCase().includes(search.toLowerCase()) || + agent.description?.toLowerCase().includes(search.toLowerCase())) && + !uniqueAgents.has(agent.name) + ) { + uniqueAgents.set(agent.name, agent) + } + }) }) - return groups + + return { 搜索结果: Array.from(uniqueAgents.values()) } }, [agentGroups, search]) const getAgentName = (agent: Agent) => { @@ -111,9 +121,7 @@ const AgentsPage: FC = () => { ) const tabItems = useMemo(() => { - let groups = Object.keys(filteredAgentGroups) - - groups = groups.includes('办公') ? [groups[0], '办公', ...groups.slice(1)] : groups + const groups = Object.keys(filteredAgentGroups) return groups.map((group, i) => { const id = String(i + 1) @@ -133,7 +141,10 @@ const AgentsPage: FC = () => { ) : ( filteredAgentGroups[group]?.map((agent, index) => ( - onAddAgentConfirm(getAgentFromSystemAgent(agent))} agent={agent as any} /> + onAddAgentConfirm(getAgentFromSystemAgent(agent as any))} + agent={agent as any} + /> )) )} @@ -144,6 +155,14 @@ const AgentsPage: FC = () => { }) }, [filteredAgentGroups, getLocalizedGroupName, onAddAgentConfirm, search]) + const handleSearch = () => { + if (searchInput.trim() === '') { + setSearch('') + } else { + setSearch(searchInput) + } + } + return ( @@ -156,18 +175,37 @@ const AgentsPage: FC = () => { size="small" variant="filled" allowClear - suffix={} - value={search} + onClear={() => setSearch('')} + suffix={} + value={searchInput} maxLength={50} - onChange={(e) => setSearch(e.target.value)} + onChange={(e) => setSearchInput(e.target.value)} + onPressEnter={handleSearch} />
- {tabItems.length > 0 ? ( - + {Object.values(filteredAgentGroups).flat().length > 0 ? ( + search.trim() ? ( + + + {Object.values(filteredAgentGroups) + .flat() + .map((agent, index, array) => ( + + onAddAgentConfirm(getAgentFromSystemAgent(agent as any))} + agent={agent as any} + /> + + ))} + + + ) : ( + + ) ) : ( @@ -226,7 +264,7 @@ const EmptyView = styled.div` color: var(--color-text-secondary); ` -const Tabs = styled(TabsAntd)` +const Tabs = styled(TabsAntd)<{ $language: string }>` display: flex; flex: 1; flex-direction: row-reverse; @@ -234,8 +272,8 @@ const Tabs = styled(TabsAntd)` padding-right: 0 !important; } .ant-tabs-nav { - min-width: 140px; - max-width: 140px; + min-width: ${({ $language }) => ($language.startsWith('zh') ? '110px' : '140px')}; + max-width: ${({ $language }) => ($language.startsWith('zh') ? '110px' : '140px')}; } .ant-tabs-nav-list { padding: 10px 8px; @@ -245,19 +283,28 @@ const Tabs = styled(TabsAntd)` } .ant-tabs-tab { margin: 0 !important; - border-radius: 20px; + border-radius: 16px; margin-bottom: 5px !important; font-size: 13px; justify-content: left; - padding: 7px 12px !important; + padding: 7px 15px !important; + border: 0.5px solid transparent; + justify-content: ${({ $language }) => ($language.startsWith('zh') ? 'center' : 'flex-start')}; + .ant-tabs-tab-btn { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100px; + } &:hover { color: var(--color-text) !important; background-color: var(--color-background-soft); } } .ant-tabs-tab-active { - background-color: var(--color-background-mute); + background-color: var(--color-background-soft); border-right: none; + border: 0.5px solid var(--color-border); } .ant-tabs-content-holder { border-left: 0.5px solid var(--color-border); diff --git a/src/renderer/src/pages/agents/agentGroupTranslations.ts b/src/renderer/src/pages/agents/agentGroupTranslations.ts index d71cba4183..449f4f7079 100644 --- a/src/renderer/src/pages/agents/agentGroupTranslations.ts +++ b/src/renderer/src/pages/agents/agentGroupTranslations.ts @@ -3,6 +3,7 @@ export type GroupTranslations = { 'en-US': string 'zh-CN': string 'zh-TW': string + 'ru-RU': string } } @@ -10,171 +11,205 @@ export const groupTranslations: GroupTranslations = { 我的: { 'en-US': 'My Agents', 'zh-CN': '我的', - 'zh-TW': '我的' + 'zh-TW': '我的', + 'ru-RU': 'Мои агенты' }, 职业: { 'en-US': 'Career', 'zh-CN': '职业', - 'zh-TW': '職業' + 'zh-TW': '職業', + 'ru-RU': 'Карьера' }, 商业: { 'en-US': 'Business', 'zh-CN': '商业', - 'zh-TW': '商業' + 'zh-TW': '商業', + 'ru-RU': 'Бизнес' }, 工具: { 'en-US': 'Tools', 'zh-CN': '工具', - 'zh-TW': '工具' + 'zh-TW': '工具', + 'ru-RU': 'Инструменты' }, 语言: { 'en-US': 'Language', 'zh-CN': '语言', - 'zh-TW': '語言' + 'zh-TW': '語言', + 'ru-RU': 'Язык' }, 办公: { 'en-US': 'Office', 'zh-CN': '办公', - 'zh-TW': '辦公' + 'zh-TW': '辦公', + 'ru-RU': 'Офис' }, 通用: { 'en-US': 'General', 'zh-CN': '通用', - 'zh-TW': '通用' + 'zh-TW': '通用', + 'ru-RU': 'Общее' }, 写作: { 'en-US': 'Writing', 'zh-CN': '写作', - 'zh-TW': '寫作' + 'zh-TW': '寫作', + 'ru-RU': 'Письмо' }, - Artifacts: { - 'en-US': 'Artifacts', - 'zh-CN': 'Artifacts', - 'zh-TW': 'Artifacts' + 精选: { + 'en-US': 'Featured', + 'zh-CN': '精选', + 'zh-TW': '精選', + 'ru-RU': 'Избранное' }, 编程: { 'en-US': 'Programming', 'zh-CN': '编程', - 'zh-TW': '編程' + 'zh-TW': '編程', + 'ru-RU': 'Программирование' }, 情感: { 'en-US': 'Emotion', 'zh-CN': '情感', - 'zh-TW': '情感' + 'zh-TW': '情感', + 'ru-RU': 'Эмоции' }, 教育: { 'en-US': 'Education', 'zh-CN': '教育', - 'zh-TW': '教育' + 'zh-TW': '教育', + 'ru-RU': 'Образование' }, 创意: { 'en-US': 'Creative', 'zh-CN': '创意', - 'zh-TW': '創意' + 'zh-TW': '創意', + 'ru-RU': 'Креатив' }, 学术: { 'en-US': 'Academic', 'zh-CN': '学术', - 'zh-TW': '學術' + 'zh-TW': '學術', + 'ru-RU': 'Академический' }, 设计: { 'en-US': 'Design', 'zh-CN': '设计', - 'zh-TW': '設計' + 'zh-TW': '設計', + 'ru-RU': 'Дизайн' }, 艺术: { 'en-US': 'Art', 'zh-CN': '艺术', - 'zh-TW': '藝術' + 'zh-TW': '藝術', + 'ru-RU': 'Искусство' }, 娱乐: { 'en-US': 'Entertainment', 'zh-CN': '娱乐', - 'zh-TW': '娛樂' + 'zh-TW': '娛樂', + 'ru-RU': 'Развлечения' }, 生活: { 'en-US': 'Life', 'zh-CN': '生活', - 'zh-TW': '生活' + 'zh-TW': '生活', + 'ru-RU': 'Жизнь' }, 医疗: { 'en-US': 'Medical', 'zh-CN': '医疗', - 'zh-TW': '醫療' + 'zh-TW': '醫療', + 'ru-RU': 'Медицина' }, 游戏: { 'en-US': 'Games', 'zh-CN': '游戏', - 'zh-TW': '遊戲' + 'zh-TW': '遊戲', + 'ru-RU': 'Игры' }, 翻译: { 'en-US': 'Translation', 'zh-CN': '翻译', - 'zh-TW': '翻譯' + 'zh-TW': '翻譯', + 'ru-RU': 'Перевод' }, 音乐: { 'en-US': 'Music', 'zh-CN': '音乐', - 'zh-TW': '音樂' + 'zh-TW': '音樂', + 'ru-RU': 'Музыка' }, 点评: { 'en-US': 'Review', 'zh-CN': '点评', - 'zh-TW': '點評' + 'zh-TW': '點評', + 'ru-RU': 'Обзор' }, 文案: { 'en-US': 'Copywriting', 'zh-CN': '文案', - 'zh-TW': '文案' + 'zh-TW': '文案', + 'ru-RU': 'Копирайтинг' }, 百科: { 'en-US': 'Encyclopedia', 'zh-CN': '百科', - 'zh-TW': '百科' + 'zh-TW': '百科', + 'ru-RU': 'Энциклопедия' }, 健康: { 'en-US': 'Health', 'zh-CN': '健康', - 'zh-TW': '健康' + 'zh-TW': '健康', + 'ru-RU': 'Здоровье' }, 营销: { 'en-US': 'Marketing', 'zh-CN': '营销', - 'zh-TW': '營銷' + 'zh-TW': '營銷', + 'ru-RU': 'Маркетинг' }, 科学: { 'en-US': 'Science', 'zh-CN': '科学', - 'zh-TW': '科學' + 'zh-TW': '科學', + 'ru-RU': 'Наука' }, 分析: { 'en-US': 'Analysis', 'zh-CN': '分析', - 'zh-TW': '分析' + 'zh-TW': '分析', + 'ru-RU': 'Анализ' }, 法律: { 'en-US': 'Legal', 'zh-CN': '法律', - 'zh-TW': '法律' + 'zh-TW': '法律', + 'ru-RU': 'Право' }, 咨询: { 'en-US': 'Consulting', 'zh-CN': '咨询', - 'zh-TW': '諮詢' + 'zh-TW': '諮詢', + 'ru-RU': 'Консалтинг' }, 金融: { 'en-US': 'Finance', 'zh-CN': '金融', - 'zh-TW': '金融' + 'zh-TW': '金融', + 'ru-RU': 'Финансы' }, 旅游: { 'en-US': 'Travel', 'zh-CN': '旅游', - 'zh-TW': '旅遊' + 'zh-TW': '旅遊', + 'ru-RU': 'Путешествия' }, 管理: { 'en-US': 'Management', 'zh-CN': '管理', - 'zh-TW': '管理' + 'zh-TW': '管理', + 'ru-RU': 'Управление' } } diff --git a/src/renderer/src/pages/agents/components/AgentCard.tsx b/src/renderer/src/pages/agents/components/AgentCard.tsx index 426de4cd5c..07a0d1fd04 100644 --- a/src/renderer/src/pages/agents/components/AgentCard.tsx +++ b/src/renderer/src/pages/agents/components/AgentCard.tsx @@ -81,7 +81,7 @@ const Container = styled.div` text-align: center; gap: 10px; background-color: var(--color-background); - border-radius: 15px; + border-radius: 16px; position: relative; overflow: hidden; cursor: pointer; @@ -94,8 +94,8 @@ const Container = styled.div` position: absolute; top: 0; left: 0; - border-top-left-radius: 15px; - border-top-right-radius: 15px; + border-top-left-radius: 8px; + border-top-right-radius: 8px; background: var(--color-background-soft); transition: all 0.5s ease; border-bottom: none; @@ -133,7 +133,7 @@ const CardInfo = styled.div` align-items: center; gap: 5px; transition: all 0.5s ease; - padding: 0 15px; + padding: 0 8px; width: 100%; ` diff --git a/src/renderer/src/pages/files/FilesPage.tsx b/src/renderer/src/pages/files/FilesPage.tsx index 15af5938b8..51eab4486a 100644 --- a/src/renderer/src/pages/files/FilesPage.tsx +++ b/src/renderer/src/pages/files/FilesPage.tsx @@ -38,19 +38,20 @@ const FilesPage: FC = () => { { title: t('files.name'), dataIndex: 'file', - key: 'file' + key: 'file', + width: '300px' }, { title: t('files.size'), dataIndex: 'size', key: 'size', - width: '100px' + width: '80px' }, { title: t('files.count'), dataIndex: 'count', key: 'count', - width: '100px' + width: '60px' }, { title: t('files.created_at'), @@ -219,7 +220,7 @@ const ImageInfo = styled.div` const SideNav = styled.div` width: var(--assistants-width); border-right: 0.5px solid var(--color-border); - padding: 15px; + padding: 7px 12px; .ant-menu { border-inline-end: none !important; @@ -227,18 +228,22 @@ const SideNav = styled.div` } .ant-menu-item { - height: 40px; - line-height: 40px; + height: 36px; + line-height: 36px; margin: 4px 0; width: 100%; + border-radius: 16px; + border: 0.5px solid transparent; &:hover { - background-color: var(--color-background-soft); + background-color: var(--color-background-soft) !important; } &.ant-menu-item-selected { background-color: var(--color-background-soft); color: var(--color-primary); + border: 0.5px solid var(--color-border); + color: var(--color-text); } } ` diff --git a/src/renderer/src/pages/history/HistoryPage.tsx b/src/renderer/src/pages/history/HistoryPage.tsx index 405770e41a..a4e75471df 100644 --- a/src/renderer/src/pages/history/HistoryPage.tsx +++ b/src/renderer/src/pages/history/HistoryPage.tsx @@ -1,7 +1,6 @@ import { ArrowLeftOutlined, EnterOutlined, SearchOutlined } from '@ant-design/icons' -import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' import { Message, Topic } from '@renderer/types' -import { Divider, Input } from 'antd' +import { Input } from 'antd' import { last } from 'lodash' import { FC, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -59,44 +58,38 @@ const TopicsPage: FC = () => { return ( - - {t('history.title')} - - -
- {stack.length > 1 && ( - - - - - - )} - setSearch(e.target.value.trimStart())} - suffix={search.length >= 2 ? : } - onPressEnter={onSearch} - /> -
- - + {stack.length > 1 && ( + + + + + + )} + setSearch(e.target.value.trimStart())} + suffix={search.length >= 2 ? : } + onPressEnter={onSearch} /> - - - -
+ + + + +
) } @@ -108,24 +101,18 @@ const Container = styled.div` height: 100%; ` -const ContentContainer = styled.div` - display: flex; - flex: 1; - flex-direction: column; - align-items: center; - height: 100%; - overflow-y: scroll; -` - const Header = styled.div` display: flex; flex-direction: row; align-items: center; justify-content: center; - padding: 8px 20px; - padding-top: 10px; + padding: 12px 0; width: 100%; position: relative; + background-color: var(--color-background-mute); + border-top-left-radius: 8px; + border-top-right-radius: 8px; + border-bottom: 0.5px solid var(--color-frame-border); ` const HeaderLeft = styled.div` @@ -133,7 +120,7 @@ const HeaderLeft = styled.div` flex-direction: row; align-items: center; position: absolute; - top: 8px; + top: 12px; left: 15px; ` @@ -143,11 +130,11 @@ const MenuIcon = styled.div` flex-direction: row; justify-content: center; align-items: center; - width: 36px; - height: 36px; + width: 33px; + height: 33px; border-radius: 50%; &:hover { - background-color: var(--color-background-mute); + background-color: var(--color-background); .anticon { color: var(--color-text-1); } diff --git a/src/renderer/src/pages/history/components/SearchMessage.tsx b/src/renderer/src/pages/history/components/SearchMessage.tsx index e69a6fe701..5422ab5787 100644 --- a/src/renderer/src/pages/history/components/SearchMessage.tsx +++ b/src/renderer/src/pages/history/components/SearchMessage.tsx @@ -2,11 +2,11 @@ import { ArrowRightOutlined } from '@ant-design/icons' import { HStack } from '@renderer/components/Layout' import { default as MessageItem } from '@renderer/pages/home/Messages/Message' import { locateToMessage } from '@renderer/services/MessagesService' +import NavigationService from '@renderer/services/NavigationService' import { Message } from '@renderer/types' import { Button } from 'antd' import { FC } from 'react' import { useTranslation } from 'react-i18next' -import { useNavigate } from 'react-router' import styled from 'styled-components' interface Props extends React.HTMLAttributes { @@ -14,7 +14,7 @@ interface Props extends React.HTMLAttributes { } const SearchMessage: FC = ({ message, ...props }) => { - const navigate = useNavigate() + const navigate = NavigationService.navigate! const { t } = useTranslation() if (!message) { diff --git a/src/renderer/src/pages/history/components/TopicMessages.tsx b/src/renderer/src/pages/history/components/TopicMessages.tsx index d66838b392..8a2b1c92cb 100644 --- a/src/renderer/src/pages/history/components/TopicMessages.tsx +++ b/src/renderer/src/pages/history/components/TopicMessages.tsx @@ -1,14 +1,16 @@ import { ArrowRightOutlined, MessageOutlined } from '@ant-design/icons' import { HStack } from '@renderer/components/Layout' +import SearchPopup from '@renderer/components/Popups/SearchPopup' import useScrollPosition from '@renderer/hooks/useScrollPosition' +import { useSettings } from '@renderer/hooks/useSettings' import { getAssistantById } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' -import { locateToMessage } from '@renderer/services/MessagesService' +import { isGenerating, locateToMessage } from '@renderer/services/MessagesService' +import NavigationService from '@renderer/services/NavigationService' import { Topic } from '@renderer/types' import { Button, Divider, Empty } from 'antd' import { t } from 'i18next' import { FC } from 'react' -import { useNavigate } from 'react-router' import styled from 'styled-components' import { default as MessageItem } from '../../home/Messages/Message' @@ -18,8 +20,9 @@ interface Props extends React.HTMLAttributes { } const TopicMessages: FC = ({ topic, ...props }) => { - const navigate = useNavigate() + const navigate = NavigationService.navigate! const { handleScroll, containerRef } = useScrollPosition('TopicMessages') + const { messageStyle } = useSettings() const isEmpty = (topic?.messages || []).length === 0 @@ -27,14 +30,16 @@ const TopicMessages: FC = ({ topic, ...props }) => { return null } - const onContinueChat = (topic: Topic) => { + const onContinueChat = async (topic: Topic) => { + await isGenerating() + SearchPopup.hide() const assistant = getAssistantById(topic.assistantId) navigate('/', { state: { assistant, topic } }) setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 100) } return ( - + {topic?.messages.map((message) => (
diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index e5e98df41c..738eff54e1 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -24,7 +24,7 @@ const Chat: FC = (props) => { return ( -
+
{ const { assistants } = useAssistants() + const navigate = useNavigate() const location = useLocation() const state = location.state @@ -24,6 +26,15 @@ const HomePage: FC = () => { _activeAssistant = activeAssistant + useEffect(() => { + NavigationService.setNavigate(navigate) + }, [navigate]) + + useEffect(() => { + state?.assistant && setActiveAssistant(state?.assistant) + state?.topic && setActiveTopic(state?.topic) + }, [state]) + return ( diff --git a/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx b/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx index e31ef1e0d9..d1dc0cdc45 100644 --- a/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx @@ -1,7 +1,7 @@ import { PaperClipOutlined } from '@ant-design/icons' -import { documentExts, imageExts, textExts } from '@renderer/config/constant' import { isVisionModel } from '@renderer/config/models' import { FileType, Model } from '@renderer/types' +import { documentExts, imageExts, textExts } from '@shared/config/constant' import { Tooltip } from 'antd' import { FC } from 'react' import { useTranslation } from 'react-i18next' diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 756e4c6268..1cdc392916 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -7,21 +7,26 @@ import { PauseCircleOutlined, QuestionCircleOutlined } from '@ant-design/icons' -import { documentExts, imageExts, textExts } from '@renderer/config/constant' +import { PicCenterOutlined } from '@ant-design/icons' +import TranslateButton from '@renderer/components/TranslateButton' +import { isMac } from '@renderer/config/constant' import { isVisionModel } from '@renderer/config/models' import db from '@renderer/databases' import { useAssistant } from '@renderer/hooks/useAssistant' import { useRuntime } from '@renderer/hooks/useRuntime' import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings' +import { useShortcut } from '@renderer/hooks/useShortcuts' import { useShowTopics } from '@renderer/hooks/useStore' import { addAssistantMessagesToTopic, getDefaultTopic } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import FileManager from '@renderer/services/FileManager' import { estimateTextTokens as estimateTxtTokens } from '@renderer/services/TokenService' +import { translateText } from '@renderer/services/TranslateService' import store, { useAppDispatch, useAppSelector } from '@renderer/store' import { setGenerating, setSearching } from '@renderer/store/runtime' import { Assistant, FileType, Message, Topic } from '@renderer/types' import { delay, getFileExtension, uuid } from '@renderer/utils' +import { documentExts, imageExts, textExts } from '@shared/config/constant' import { Button, Popconfirm, Tooltip } from 'antd' import TextArea, { TextAreaRef } from 'antd/es/input/TextArea' import dayjs from 'dayjs' @@ -47,7 +52,15 @@ const Inputbar: FC = ({ assistant, setActiveTopic }) => { const [text, setText] = useState(_text) const [inputFocus, setInputFocus] = useState(false) const { addTopic, model, setModel } = useAssistant(assistant.id) - const { sendMessageShortcut, fontSize, pasteLongTextAsFile, showInputEstimatedTokens } = useSettings() + const { + sendMessageShortcut, + fontSize, + pasteLongTextAsFile, + showInputEstimatedTokens, + clickAssistantToShowTopic, + language, + autoTranslateWithSpace + } = useSettings() const [expended, setExpend] = useState(false) const [estimateTokenCount, setEstimateTokenCount] = useState(0) const [contextCount, setContextCount] = useState(0) @@ -60,6 +73,9 @@ const Inputbar: FC = ({ assistant, setActiveTopic }) => { const { searching } = useRuntime() const { isBubbleStyle } = useMessageStyle() const dispatch = useAppDispatch() + const [spaceClickCount, setSpaceClickCount] = useState(0) + const spaceClickTimer = useRef() + const [isTranslating, setIsTranslating] = useState(false) const isVision = useMemo(() => isVisionModel(model), [model]) const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision]) @@ -107,9 +123,48 @@ const Inputbar: FC = ({ assistant, setActiveTopic }) => { setExpend(false) }, [assistant.id, assistant.topics, generating, files, text]) + const translate = async () => { + if (isTranslating) { + return + } + + try { + setIsTranslating(true) + const translatedText = await translateText(text, 'english') + translatedText && setText(translatedText) + setTimeout(() => resizeTextArea(), 0) + } catch (error) { + console.error('Translation failed:', error) + } finally { + setIsTranslating(false) + } + } + const handleKeyDown = (event: React.KeyboardEvent) => { const isEnterPressed = event.keyCode == 13 + if (autoTranslateWithSpace) { + if (event.key === ' ') { + setSpaceClickCount((prev) => prev + 1) + + if (spaceClickTimer.current) { + clearTimeout(spaceClickTimer.current) + } + + spaceClickTimer.current = setTimeout(() => { + setSpaceClickCount(0) + }, 200) + + if (spaceClickCount === 2) { + console.log('Triple space detected - trigger translation') + setSpaceClickCount(0) + setIsTranslating(true) + translate() + return + } + } + } + if (expended) { if (event.key === 'Escape') { return setExpend(false) @@ -131,6 +186,11 @@ const Inputbar: FC = ({ assistant, setActiveTopic }) => { } const addNewTopic = useCallback(async () => { + if (generating) { + window.message.warning({ content: t('message.switch.disabled'), key: 'generating' }) + return + } + const topic = getDefaultTopic(assistant.id) await db.topics.add({ id: topic.id, messages: [] }) @@ -143,7 +203,9 @@ const Inputbar: FC = ({ assistant, setActiveTopic }) => { addTopic(topic) setActiveTopic(topic) - }, [addTopic, assistant, setActiveTopic, setModel]) + + clickAssistantToShowTopic && setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 0) + }, [addTopic, assistant, clickAssistantToShowTopic, generating, setActiveTopic, setModel, t]) const clearTopic = async () => { if (generating) { @@ -252,20 +314,18 @@ const Inputbar: FC = ({ assistant, setActiveTopic }) => { }) } - // Command or Ctrl + N create new topic - useEffect(() => { - const onKeydown = (e) => { - if (!generating) { - if ((e.ctrlKey || e.metaKey) && e.key === 'n') { - addNewTopic() - EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR) - textareaRef.current?.focus() - } - } + const onTranslated = (translatedText: string) => { + setText(translatedText) + setTimeout(() => resizeTextArea(), 0) + } + + useShortcut('new_topic', () => { + if (!generating) { + addNewTopic() + EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR) + textareaRef.current?.focus() } - document.addEventListener('keydown', onKeydown) - return () => document.removeEventListener('keydown', onKeydown) - }, [addNewTopic, generating]) + }) useEffect(() => { const _setEstimateTokenCount = debounce(setEstimateTokenCount, 100, { leading: false, trailing: true }) @@ -288,6 +348,18 @@ const Inputbar: FC = ({ assistant, setActiveTopic }) => { textareaRef.current?.focus() }, [assistant]) + useEffect(() => { + setTimeout(() => resizeTextArea(), 0) + }, []) + + useEffect(() => { + return () => { + if (spaceClickTimer.current) { + clearTimeout(spaceClickTimer.current) + } + } + }, []) + return ( @@ -296,7 +368,7 @@ const Inputbar: FC = ({ assistant, setActiveTopic }) => { value={text} onChange={(e) => setText(e.target.value)} onKeyDown={handleKeyDown} - placeholder={t('chat.input.placeholder')} + placeholder={isTranslating ? t('chat.input.translating') : t('chat.input.placeholder')} autoFocus contextMenu="true" variant="borderless" @@ -313,7 +385,7 @@ const Inputbar: FC = ({ assistant, setActiveTopic }) => { /> - + @@ -342,6 +414,11 @@ const Inputbar: FC = ({ assistant, setActiveTopic }) => { + + + + + {expended ? : } @@ -356,6 +433,9 @@ const Inputbar: FC = ({ assistant, setActiveTopic }) => { /> + {!language.startsWith('en') && ( + + )} {generating && ( diff --git a/src/renderer/src/pages/home/Inputbar/TokenCount.tsx b/src/renderer/src/pages/home/Inputbar/TokenCount.tsx index 8d14218522..0a8209db2f 100644 --- a/src/renderer/src/pages/home/Inputbar/TokenCount.tsx +++ b/src/renderer/src/pages/home/Inputbar/TokenCount.tsx @@ -1,7 +1,7 @@ -import { ArrowUpOutlined, MenuOutlined, PicCenterOutlined } from '@ant-design/icons' +import { ArrowUpOutlined, MenuOutlined } from '@ant-design/icons' import { HStack, VStack } from '@renderer/components/Layout' import { useSettings } from '@renderer/hooks/useSettings' -import { Divider, Popover, Tooltip } from 'antd' +import { Divider, Popover } from 'antd' import { FC } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -13,7 +13,7 @@ type Props = { ToolbarButton: any } & React.HTMLAttributes -const TokenCount: FC = ({ estimateTokenCount, inputTokenCount, contextCount, ToolbarButton, ...props }) => { +const TokenCount: FC = ({ estimateTokenCount, inputTokenCount, contextCount }) => { const { t } = useTranslation() const { showInputEstimatedTokens } = useSettings() @@ -38,21 +38,14 @@ const TokenCount: FC = ({ estimateTokenCount, inputTokenCount, contextCou } return ( - <> - - - - - - - - {contextCount} - - - {inputTokenCount} / {estimateTokenCount} - - - + + + {contextCount} + + + {inputTokenCount} / {estimateTokenCount} + + ) } diff --git a/src/renderer/src/pages/home/Markdown/Artifacts.tsx b/src/renderer/src/pages/home/Markdown/Artifacts.tsx index f5a8ae3519..bb8e52602b 100644 --- a/src/renderer/src/pages/home/Markdown/Artifacts.tsx +++ b/src/renderer/src/pages/home/Markdown/Artifacts.tsx @@ -32,10 +32,10 @@ const Artifacts: FC = ({ html }) => { return ( - - diff --git a/src/renderer/src/pages/home/Markdown/CodeBlock.tsx b/src/renderer/src/pages/home/Markdown/CodeBlock.tsx index 38feee6ce3..63ff6fb32d 100644 --- a/src/renderer/src/pages/home/Markdown/CodeBlock.tsx +++ b/src/renderer/src/pages/home/Markdown/CodeBlock.tsx @@ -1,13 +1,9 @@ -import { CheckOutlined } from '@ant-design/icons' +import { CheckOutlined, DownOutlined, RightOutlined } from '@ant-design/icons' import CopyIcon from '@renderer/components/Icons/CopyIcon' -import { useTheme } from '@renderer/context/ThemeProvider' +import { useSyntaxHighlighter } from '@renderer/context/SyntaxHighlighterProvider' import { useSettings } from '@renderer/hooks/useSettings' -import { initMermaid } from '@renderer/init' -import { ThemeMode } from '@renderer/types' -import React, { memo, useState } from 'react' +import React, { memo, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' -import { atomDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism' import styled from 'styled-components' import Artifacts from './Artifacts' @@ -19,38 +15,103 @@ interface CodeBlockProps { [key: string]: any } +const CollapseIcon: React.FC<{ expanded: boolean; onClick: () => void }> = ({ expanded, onClick }) => { + return ( + + {expanded ? : } + + ) +} + +const ExpandButton: React.FC<{ + isExpanded: boolean + onClick: () => void + showButton: boolean +}> = ({ isExpanded, onClick, showButton }) => { + if (!showButton) return null + + return ( + +
{isExpanded ? '收起' : '展开'}
+
+ ) +} + const CodeBlock: React.FC = ({ children, className }) => { const match = /language-(\w+)/.exec(className || '') - const showFooterCopyButton = children && children.length > 500 - const { codeShowLineNumbers, fontSize } = useSettings() - const { theme } = useTheme() - const language = match?.[1] + const { codeShowLineNumbers, fontSize, codeCollapsible } = useSettings() + const language = match?.[1] ?? 'text' + const [html, setHtml] = useState('') + const { codeToHtml } = useSyntaxHighlighter() + const [isExpanded, setIsExpanded] = useState(!codeCollapsible) + const [shouldShowExpandButton, setShouldShowExpandButton] = useState(false) + const codeContentRef = useRef(null) + + const showFooterCopyButton = children && children.length > 500 && !codeCollapsible + + useEffect(() => { + const loadHighlightedCode = async () => { + const highlightedHtml = await codeToHtml(children, language) + setHtml(highlightedHtml) + } + loadHighlightedCode() + }, [children, language, codeToHtml]) + + useEffect(() => { + if (codeContentRef.current) { + setShouldShowExpandButton(codeContentRef.current.scrollHeight > 350) + } + }, [html]) + + useEffect(() => { + if (!codeCollapsible) { + setIsExpanded(true) + setShouldShowExpandButton(false) + } else { + setIsExpanded(!codeCollapsible) + if (codeContentRef.current) { + setShouldShowExpandButton(codeContentRef.current.scrollHeight > 350) + } + } + }, [codeCollapsible]) if (language === 'mermaid') { - initMermaid(theme) return } return match ? (
- {'<' + match[1].toUpperCase() + '>'} +
+ {codeCollapsible && shouldShowExpandButton && ( + setIsExpanded(!isExpanded)} /> + )} + {'<' + match[1].toUpperCase() + '>'} +
- - {String(children).replace(/\n$/, '')} - + fontSize: fontSize - 1, + maxHeight: codeCollapsible && !isExpanded ? '350px' : 'none', + overflow: codeCollapsible && !isExpanded ? 'auto' : 'visible', + position: 'relative' + }} + /> + {codeCollapsible && ( + setIsExpanded(!isExpanded)} + showButton={shouldShowExpandButton} + /> + )} {showFooterCopyButton && ( @@ -81,6 +142,31 @@ const CopyButton: React.FC<{ text: string; style?: React.CSSProperties }> = ({ t ) } +const CodeContent = styled.div<{ isShowLineNumbers: boolean }>` + .shiki { + padding: 1em; + } + + ${(props) => + props.isShowLineNumbers && + ` + code { + counter-reset: step; + counter-increment: step 0; + } + + code .line::before { + content: counter(step); + counter-increment: step; + width: 1rem; + margin-right: 1rem; + display: inline-block; + text-align: right; + opacity: 0.35; + } + `} +` + const CodeHeader = styled.div` display: flex; align-items: center; @@ -111,6 +197,7 @@ const CodeFooter = styled.div` flex-direction: row; justify-content: flex-end; align-items: center; + position: relative; .copy { cursor: pointer; color: var(--color-text-3); @@ -121,4 +208,45 @@ const CodeFooter = styled.div` } ` +const ExpandButtonWrapper = styled.div` + position: relative; + cursor: pointer; + height: 25px; + margin-top: -25px; + + .button-text { + position: absolute; + bottom: 0; + left: 0; + right: 0; + text-align: center; + padding: 8px; + color: var(--color-text-3); + z-index: 1; + transition: color 0.2s; + font-size: 12px; + } + + &:hover .button-text { + color: var(--color-text-1); + } +` + +const CollapseIconWrapper = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 4px; + cursor: pointer; + color: var(--color-text-3); + transition: all 0.2s ease; + + &:hover { + background-color: var(--color-background-soft); + color: var(--color-text-1); + } +` + export default memo(CodeBlock) diff --git a/src/renderer/src/pages/home/Markdown/Markdown.tsx b/src/renderer/src/pages/home/Markdown/Markdown.tsx index a4a935ffef..0448b94e0a 100644 --- a/src/renderer/src/pages/home/Markdown/Markdown.tsx +++ b/src/renderer/src/pages/home/Markdown/Markdown.tsx @@ -18,6 +18,9 @@ import CodeBlock from './CodeBlock' import ImagePreview from './ImagePreview' import Link from './Link' +const ALLOWED_ELEMENTS = + /<(style|p|div|span|b|i|strong|em|ul|ol|li|table|tr|td|th|thead|tbody|h[1-6]|blockquote|pre|code|br|hr)/i + interface Props { message: Message } @@ -36,8 +39,8 @@ const Markdown: FC = ({ message }) => { }, [message.content, message.status, t]) const rehypePlugins = useMemo(() => { - const hasUnsafeElements = /<(input|textarea|select)/i.test(messageContent) - return hasUnsafeElements ? [rehypeMath] : [rehypeRaw, rehypeMath] + const hasElements = ALLOWED_ELEMENTS.test(messageContent) + return hasElements ? [rehypeRaw, rehypeMath] : [rehypeMath] }, [messageContent, rehypeMath]) if (message.role === 'user' && !renderInputMessageAsMarkdown) { diff --git a/src/renderer/src/pages/home/Markdown/Mermaid.tsx b/src/renderer/src/pages/home/Markdown/Mermaid.tsx index 8c74667eb7..c0285d7295 100644 --- a/src/renderer/src/pages/home/Markdown/Mermaid.tsx +++ b/src/renderer/src/pages/home/Markdown/Mermaid.tsx @@ -1,5 +1,7 @@ import React, { useEffect } from 'react' +import MermaidPopup from './MermaidPopup' + interface Props { chart: string } @@ -9,7 +11,15 @@ const Mermaid: React.FC = ({ chart }) => { window?.mermaid?.contentLoaded() }, []) - return
{chart}
+ const onPreview = () => { + MermaidPopup.show({ chart }) + } + + return ( +
+ {chart} +
+ ) } export default Mermaid diff --git a/src/renderer/src/pages/home/Markdown/MermaidPopup.tsx b/src/renderer/src/pages/home/Markdown/MermaidPopup.tsx new file mode 100644 index 0000000000..c626c1c259 --- /dev/null +++ b/src/renderer/src/pages/home/Markdown/MermaidPopup.tsx @@ -0,0 +1,164 @@ +import { TopView } from '@renderer/components/TopView' +import { download } from '@renderer/utils/download' +import { Button, Modal, Space, Tabs } from 'antd' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface ShowParams { + chart: string +} + +interface Props extends ShowParams { + resolve: (data: any) => void +} + +const PopupContainer: React.FC = ({ resolve, chart }) => { + const [open, setOpen] = useState(true) + const { t } = useTranslation() + const mermaidId = `mermaid-popup-${Date.now()}` + + const onOk = () => { + setOpen(false) + } + + const onCancel = () => { + setOpen(false) + } + + const onClose = () => { + resolve({}) + } + + const handleDownload = async (format: 'svg' | 'png') => { + try { + const element = document.getElementById(mermaidId) + if (!element) return + + const timestamp = Date.now() + + if (format === 'svg') { + const svgElement = element.querySelector('svg') + if (!svgElement) return + const svgData = new XMLSerializer().serializeToString(svgElement) + const blob = new Blob([svgData], { type: 'image/svg+xml' }) + const url = URL.createObjectURL(blob) + download(url, `mermaid-diagram-${timestamp}.svg`) + URL.revokeObjectURL(url) + } else if (format === 'png') { + const svgElement = element.querySelector('svg') + if (!svgElement) return + + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + const img = new Image() + img.crossOrigin = 'anonymous' + + const viewBox = svgElement.getAttribute('viewBox')?.split(' ').map(Number) || [] + const width = viewBox[2] || svgElement.clientWidth || svgElement.getBoundingClientRect().width + const height = viewBox[3] || svgElement.clientHeight || svgElement.getBoundingClientRect().height + + const svgData = new XMLSerializer().serializeToString(svgElement) + const svgBase64 = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgData)))}` + + img.onload = () => { + const scale = 3 + canvas.width = width * scale + canvas.height = height * scale + + if (ctx) { + ctx.scale(scale, scale) + ctx.drawImage(img, 0, 0, width, height) + } + + canvas.toBlob((blob) => { + if (blob) { + const pngUrl = URL.createObjectURL(blob) + download(pngUrl, `mermaid-diagram-${timestamp}.png`) + URL.revokeObjectURL(pngUrl) + } + }, 'image/png') + } + img.src = svgBase64 + } + } catch (error) { + console.error('Download failed:', error) + } + } + + useEffect(() => { + window?.mermaid?.contentLoaded() + }, []) + + return ( + + + + + ]}> + + {chart} + + ) + }, + { + key: 'source', + label: t('mermaid.tabs.source'), + children: ( +
+                {chart}
+              
+ ) + } + ]} + /> +
+ ) +} + +export default class MermaidPopup { + static topviewId = 0 + static hide() { + TopView.hide('MermaidPopup') + } + static show(props: ShowParams) { + return new Promise((resolve) => { + TopView.show( + { + resolve(v) + this.hide() + }} + />, + 'MermaidPopup' + ) + }) + } +} + +const StyledMermaid = styled.div` + max-height: calc(80vh - 200px); + text-align: center; + overflow-y: auto; +` diff --git a/src/renderer/src/pages/home/Messages/Message.tsx b/src/renderer/src/pages/home/Messages/Message.tsx index 145e0455c4..ce589be84d 100644 --- a/src/renderer/src/pages/home/Messages/Message.tsx +++ b/src/renderer/src/pages/home/Messages/Message.tsx @@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next' import styled from 'styled-components' import MessageContent from './MessageContent' +import MessageErrorBoundary from './MessageErrorBoundary' import MessageHeader from './MessageHeader' import MessageMenubar from './MessageMenubar' import MessageTokens from './MessageTokens' @@ -29,6 +30,9 @@ interface Props { onDeleteMessage?: (message: Message) => void } +const getMessageBackground = (isBubbleStyle: boolean, isAssistantMessage: boolean) => + isBubbleStyle ? (isAssistantMessage ? 'var(--chat-background-assistant)' : 'var(--chat-background-user)') : undefined + const MessageItem: FC = ({ message: _message, topic, @@ -56,37 +60,33 @@ const MessageItem: FC = ({ }, [messageFont]) const messageBorder = showMessageDivider ? undefined : 'none' - const messageBackground = isBubbleStyle - ? isAssistantMessage - ? 'var(--chat-background-assistant)' - : 'var(--chat-background-user)' - : undefined + const messageBackground = getMessageBackground(isBubbleStyle, isAssistantMessage) const onEditMessage = useCallback( (msg: Message) => { setMessage(msg) - const messages = onGetMessages?.().map((m) => (m.id === message.id ? message : m)) + const messages = onGetMessages?.()?.map((m) => (m.id === message.id ? msg : m)) messages && onSetMessages?.(messages) topic && db.topics.update(topic.id, { messages }) }, - [message, onGetMessages, onSetMessages, topic] + [message.id, onGetMessages, onSetMessages, topic] ) + const messageHighlightHandler = (highlight: boolean = true) => { + if (messageContainerRef.current) { + messageContainerRef.current.scrollIntoView({ behavior: 'smooth' }) + if (highlight) { + setTimeout(() => { + const classList = messageContainerRef.current?.classList + classList?.add('message-highlight') + setTimeout(() => classList?.remove('message-highlight'), 2500) + }, 500) + } + } + } + useEffect(() => { - const unsubscribes = [ - EventEmitter.on(EVENT_NAMES.LOCATE_MESSAGE + ':' + message.id, (highlight: boolean = true) => { - if (messageContainerRef.current) { - messageContainerRef.current.scrollIntoView({ behavior: 'smooth' }) - if (highlight) { - setTimeout(() => { - const classList = messageContainerRef.current?.classList - classList?.add('message-highlight') - setTimeout(() => classList?.remove('message-highlight'), 2500) - }, 500) - } - } - }) - ] + const unsubscribes = [EventEmitter.on(EVENT_NAMES.LOCATE_MESSAGE + ':' + message.id, messageHighlightHandler)] return () => unsubscribes.forEach((unsub) => unsub()) }, [message]) @@ -104,11 +104,16 @@ const MessageItem: FC = ({ useEffect(() => { if (topic && onGetMessages && onSetMessages) { - if (message.status === 'sending' && index === 0) { + if (message.status === 'sending') { const messages = onGetMessages() fetchChatCompletion({ message, - messages: messages.filter((m) => !m.status.includes('ing')), + messages: messages + .filter((m) => !m.status.includes('ing')) + .slice( + 0, + messages.findIndex((m) => m.id === message.id) + ), assistant, topic, onResponse: (msg) => { @@ -123,7 +128,7 @@ const MessageItem: FC = ({ } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, [message.status]) if (hidePresetMessages && message.isPreset) { return null @@ -147,11 +152,13 @@ const MessageItem: FC = ({ })} ref={messageContainerRef} style={isBubbleStyle ? { alignItems: isAssistantMessage ? 'start' : 'end' } : undefined}> - + - + + + {showMenubar && ( = ({ = ({ message }) => { if (message?.files && message.files[0]?.type === FileTypes.IMAGE) { return ( - + {message.files?.map((image) => )} ) diff --git a/src/renderer/src/pages/home/Messages/MessageContent.tsx b/src/renderer/src/pages/home/Messages/MessageContent.tsx index 5aa0f8ff29..9f807ff709 100644 --- a/src/renderer/src/pages/home/Messages/MessageContent.tsx +++ b/src/renderer/src/pages/home/Messages/MessageContent.tsx @@ -1,7 +1,10 @@ -import { SyncOutlined } from '@ant-design/icons' +import { SyncOutlined, TranslationOutlined } from '@ant-design/icons' import { Message, Model } from '@renderer/types' import { getBriefInfo } from '@renderer/utils' +import { Divider } from 'antd' import React from 'react' +import { useTranslation } from 'react-i18next' +import BeatLoader from 'react-spinners/BeatLoader' import styled from 'styled-components' import Markdown from '../Markdown/Markdown' @@ -12,6 +15,8 @@ const MessageContent: React.FC<{ message: Message model?: Model }> = ({ message, model }) => { + const { t } = useTranslation() + if (message.status === 'sending') { return ( @@ -32,6 +37,18 @@ const MessageContent: React.FC<{ return ( <> + {message.translatedContent && ( + <> + + + + {message.translatedContent === t('translate.processing') ? ( + + ) : ( + + )} + + )} ) diff --git a/src/renderer/src/pages/home/Messages/MessageErrorBoundary.tsx b/src/renderer/src/pages/home/Messages/MessageErrorBoundary.tsx new file mode 100644 index 0000000000..bc6a692776 --- /dev/null +++ b/src/renderer/src/pages/home/Messages/MessageErrorBoundary.tsx @@ -0,0 +1,41 @@ +import { Alert } from 'antd' +import React from 'react' +import { useTranslation } from 'react-i18next' + +interface Props { + fallback?: React.ReactNode + children: React.ReactNode +} + +interface State { + hasError: boolean +} + +const ErrorFallback = ({ fallback }: { fallback?: React.ReactNode }) => { + const { t } = useTranslation() + return ( + fallback || ( + + ) + ) +} + +class MessageErrorBoundary extends React.Component { + constructor(props: Props) { + super(props) + this.state = { hasError: false } + } + + static getDerivedStateFromError() { + return { hasError: true } + } + + render() { + if (this.state.hasError) { + return + } + return this.props.children + } +} + +export default MessageErrorBoundary diff --git a/src/renderer/src/pages/home/Messages/MessageHeader.tsx b/src/renderer/src/pages/home/Messages/MessageHeader.tsx index 209b42b805..9736da68d6 100644 --- a/src/renderer/src/pages/home/Messages/MessageHeader.tsx +++ b/src/renderer/src/pages/home/Messages/MessageHeader.tsx @@ -9,7 +9,7 @@ import { Assistant, Message, Model } from '@renderer/types' import { firstLetter, removeLeadingEmoji } from '@renderer/utils' import { Avatar } from 'antd' import dayjs from 'dayjs' -import { CSSProperties, FC, useCallback, useMemo } from 'react' +import { CSSProperties, FC, memo, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -19,18 +19,19 @@ interface Props { model?: Model } -const MessageHeader: FC = ({ assistant, model, message }) => { +const getAvatarSource = (isLocalAi: boolean, modelId: string | undefined) => { + if (isLocalAi) return AppLogo + return modelId ? getModelLogo(modelId) : undefined +} + +const MessageHeader: FC = memo(({ assistant, model, message }) => { const avatar = useAvatar() const { theme } = useTheme() const { userName } = useSettings() const { t } = useTranslation() const { isBubbleStyle } = useMessageStyle() - const avatarSource = useMemo(() => { - if (isLocalAi) return AppLogo - return message.modelId ? getModelLogo(message.modelId) : undefined - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [message.modelId, theme]) + const avatarSource = useMemo(() => getAvatarSource(isLocalAi, message.modelId), [message.modelId]) const getUserName = useCallback(() => { if (isLocalAi && message.role !== 'user') return APP_NAME @@ -43,7 +44,7 @@ const MessageHeader: FC = ({ assistant, model, message }) => { const avatarName = useMemo(() => firstLetter(assistant?.name).toUpperCase(), [assistant?.name]) const username = useMemo(() => removeLeadingEmoji(getUserName()), [getUserName]) - const showMiniApp = () => model?.provider && startMinAppById(model?.provider) + const showMiniApp = useCallback(() => model?.provider && startMinAppById(model.provider), [model?.provider]) const avatarStyle: CSSProperties | undefined = isBubbleStyle ? { @@ -83,7 +84,9 @@ const MessageHeader: FC = ({ assistant, model, message }) => { ) -} +}) + +MessageHeader.displayName = 'MessageHeader' const Container = styled.div` display: flex; diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index 575b25ec8d..26a32ff47e 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -6,11 +6,13 @@ import { MenuOutlined, QuestionCircleOutlined, SaveOutlined, - SyncOutlined + SyncOutlined, + TranslationOutlined } from '@ant-design/icons' import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup' import TextEditPopup from '@renderer/components/Popups/TextEditPopup' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' +import { translateText } from '@renderer/services/TranslateService' import { Message, Model } from '@renderer/types' import { removeTrailingDoubleSpaces } from '@renderer/utils' import { Dropdown, Popconfirm, Tooltip } from 'antd' @@ -21,6 +23,7 @@ import styled from 'styled-components' interface Props { message: Message + assistantModel?: Model model?: Model index?: number isLastMessage: boolean @@ -31,9 +34,20 @@ interface Props { } const MessageMenubar: FC = (props) => { - const { message, index, model, isLastMessage, isAssistantMessage, setModel, onEditMessage, onDeleteMessage } = props + const { + message, + index, + model, + isLastMessage, + isAssistantMessage, + assistantModel, + setModel, + onEditMessage, + onDeleteMessage + } = props const { t } = useTranslation() const [copied, setCopied] = useState(false) + const [isTranslating, setIsTranslating] = useState(false) const isUserMessage = message.role === 'user' const canRegenerate = isLastMessage && isAssistantMessage @@ -66,6 +80,31 @@ const MessageMenubar: FC = (props) => { editedText && onEditMessage?.({ ...message, content: editedText }) }, [message, onEditMessage]) + const handleTranslate = useCallback( + async (language: string) => { + if (isTranslating) return + + onEditMessage?.({ ...message, translatedContent: t('translate.processing') }) + + setIsTranslating(true) + + try { + const translatedText = await translateText(message.content, language) + onEditMessage?.({ ...message, translatedContent: translatedText }) + } catch (error) { + console.error('Translation failed:', error) + window.message.error({ + content: t('translate.error.failed'), + key: 'translate-message' + }) + onEditMessage?.({ ...message, translatedContent: undefined }) + } finally { + setIsTranslating(false) + } + }, + [isTranslating, message, onEditMessage, t] + ) + const dropdownItems = useMemo( () => [ { @@ -82,16 +121,68 @@ const MessageMenubar: FC = (props) => { key: 'edit', icon: , onClick: onEdit + }, + { + label: t('chat.translate'), + key: 'translate', + icon: isTranslating ? : , + children: [ + { + label: '🇨🇳 ' + t('languages.chinese'), + key: 'translate-chinese', + onClick: () => handleTranslate('chinese') + }, + { + label: '🇭🇰 ' + t('languages.chinese-traditional'), + key: 'translate-chinese-traditional', + onClick: () => handleTranslate('chinese-traditional') + }, + { + label: '🇬🇧 ' + t('languages.english'), + key: 'translate-english', + onClick: () => handleTranslate('english') + }, + { + label: '🇯🇵 ' + t('languages.japanese'), + key: 'translate-japanese', + onClick: () => handleTranslate('japanese') + }, + { + label: '🇰🇷 ' + t('languages.korean'), + key: 'translate-korean', + onClick: () => handleTranslate('korean') + }, + { + label: '🇷🇺 ' + t('languages.russian'), + key: 'translate-russian', + onClick: () => handleTranslate('russian') + }, + { + label: '✖ ' + t('translate.close'), + key: 'translate-close', + onClick: () => onEditMessage?.({ ...message, translatedContent: undefined }) + } + ] } ], - [message.content, message.createdAt, onEdit, t] + [handleTranslate, isTranslating, message, onEdit, onEditMessage, t] ) - const onSelectModel = async () => { + const onAtModelRegenerate = async () => { const selectedModel = await SelectModelPopup.show({ model }) selectedModel && onRegenerate(selectedModel) } + const onDeleteAndRegenerate = () => { + onEditMessage?.({ + ...message, + content: '', + status: 'sending', + modelId: assistantModel?.id || model?.id, + translatedContent: undefined + }) + } + return ( {message.role === 'user' && ( @@ -107,10 +198,22 @@ const MessageMenubar: FC = (props) => { {copied && } + {isAssistantMessage && ( + } + onConfirm={onDeleteAndRegenerate}> + + + + + )} {canRegenerate && ( - - + + )} @@ -177,6 +280,9 @@ const ActionButton = styled.div` &:hover { color: var(--color-text-1); } + .icon-at1 { + font-size: 16px; + } ` export default MessageMenubar diff --git a/src/renderer/src/pages/home/Messages/MessageTokens.tsx b/src/renderer/src/pages/home/Messages/MessageTokens.tsx index 2f265cd2a4..4fc9d37904 100644 --- a/src/renderer/src/pages/home/Messages/MessageTokens.tsx +++ b/src/renderer/src/pages/home/Messages/MessageTokens.tsx @@ -11,7 +11,7 @@ const MessgeTokens: React.FC<{ message: Message; isLastMessage: boolean }> = ({ } if (!message.usage) { - return null + return
} if (message.role === 'user') { @@ -23,7 +23,7 @@ const MessgeTokens: React.FC<{ message: Message; isLastMessage: boolean }> = ({ } if (isLastMessage && generating) { - return null + return
} if (message.role === 'assistant') { diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index ad8c764d66..a3ebf4fc8b 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -35,7 +35,7 @@ const Messages: FC = ({ assistant, topic, setActiveTopic }) => { const [messages, setMessages] = useState([]) const containerRef = useRef(null) const { updateTopic, addTopic } = useAssistant(assistant.id) - const { showTopics, topicPosition, showAssistants } = useSettings() + const { showTopics, topicPosition, showAssistants, enableTopicNaming } = useSettings() const messagesRef = useRef(messages) messagesRef.current = messages @@ -68,6 +68,17 @@ const Messages: FC = ({ assistant, topic, setActiveTopic }) => { const autoRenameTopic = useCallback(async () => { const _topic = getTopic(assistant, topic.id) + + // If the topic auto naming is not enabled, use the first message content as the topic name + if (!enableTopicNaming) { + const topicName = messages[0].content.substring(0, 50) + const data = { ..._topic, name: topicName } as Topic + setActiveTopic(data) + updateTopic(data) + return + } + + // Auto rename the topic if (_topic && _topic.name === t('chat.default.topic.name') && messages.length >= 2) { const summaryText = await fetchMessagesSummary({ messages, assistant }) if (summaryText) { @@ -76,7 +87,7 @@ const Messages: FC = ({ assistant, topic, setActiveTopic }) => { updateTopic(data) } } - }, [assistant, messages, setActiveTopic, topic.id, updateTopic]) + }, [assistant, enableTopicNaming, messages, setActiveTopic, topic.id, updateTopic]) const onDeleteMessage = useCallback( (message: Message) => { @@ -219,6 +230,7 @@ const Container = styled(Scrollbar)` padding: 10px 0; padding-bottom: 20px; overflow-x: hidden; + background-color: var(--color-background); ` export default Messages diff --git a/src/renderer/src/pages/home/Messages/Prompt.tsx b/src/renderer/src/pages/home/Messages/Prompt.tsx index 11103f8fe3..b605763f0b 100644 --- a/src/renderer/src/pages/home/Messages/Prompt.tsx +++ b/src/renderer/src/pages/home/Messages/Prompt.tsx @@ -31,6 +31,7 @@ const Container = styled.div` margin: 0 20px 0 20px; border-radius: 6px; cursor: pointer; + border: 0.5px solid var(--color-border); ` const Text = styled.div` diff --git a/src/renderer/src/pages/home/Navbar.tsx b/src/renderer/src/pages/home/Navbar.tsx index 928cf86c25..0a233b5199 100644 --- a/src/renderer/src/pages/home/Navbar.tsx +++ b/src/renderer/src/pages/home/Navbar.tsx @@ -1,14 +1,14 @@ -import { FormOutlined } from '@ant-design/icons' +import { SearchOutlined } from '@ant-design/icons' import { Navbar, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar' import AssistantSettingsPopup from '@renderer/components/AssistantSettings' import { HStack } from '@renderer/components/Layout' +import SearchPopup from '@renderer/components/Popups/SearchPopup' import { isMac, isWindows } from '@renderer/config/constant' import { useAssistant } from '@renderer/hooks/useAssistant' import { useSettings } from '@renderer/hooks/useSettings' import { useShowAssistants, useShowTopics } from '@renderer/hooks/useStore' -import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { Assistant, Topic } from '@renderer/types' -import { FC, useCallback } from 'react' +import { FC } from 'react' import styled from 'styled-components' import SelectModelButton from './components/SelectModelButton' @@ -25,11 +25,6 @@ const HeaderNavbar: FC = ({ activeAssistant }) => { const { topicPosition } = useSettings() const { showTopics, toggleShowTopics } = useShowTopics() - const addNewTopic = useCallback(() => { - EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC) - setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 0) - }, []) - return ( {showAssistants && ( @@ -37,8 +32,8 @@ const HeaderNavbar: FC = ({ activeAssistant }) => { - - + SearchPopup.show()}> + )} @@ -106,7 +101,6 @@ const TitleText = styled.span` margin-left: 5px; font-family: Ubuntu; font-size: 13px; - font-weight: 500; ` export default HeaderNavbar diff --git a/src/renderer/src/pages/home/Tabs/Assistants.tsx b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx similarity index 66% rename from src/renderer/src/pages/home/Tabs/Assistants.tsx rename to src/renderer/src/pages/home/Tabs/AssistantsTab.tsx index 816bf5b8e3..0167f9bc1a 100644 --- a/src/renderer/src/pages/home/Tabs/Assistants.tsx +++ b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx @@ -8,14 +8,13 @@ import { useAssistant, useAssistants } from '@renderer/hooks/useAssistant' import { useSettings } from '@renderer/hooks/useSettings' import { getDefaultTopic } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' -import { useAppDispatch, useAppSelector } from '@renderer/store' -import { setSearching } from '@renderer/store/runtime' +import { useAppSelector } from '@renderer/store' import { Assistant } from '@renderer/types' import { uuid } from '@renderer/utils' -import { Dropdown, Input, InputRef } from 'antd' +import { Dropdown } from 'antd' import { ItemType } from 'antd/es/menu/interface' -import { isEmpty, last, omit } from 'lodash' -import { FC, useCallback, useEffect, useRef, useState } from 'react' +import { last, omit } from 'lodash' +import { FC, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -34,13 +33,10 @@ const Assistants: FC = ({ }) => { const { assistants, removeAssistant, addAssistant, updateAssistants } = useAssistants() const generating = useAppSelector((state) => state.runtime.generating) - const [search, setSearch] = useState('') const [dragging, setDragging] = useState(false) const { removeAllTopics } = useAssistant(activeAssistant.id) const { clickAssistantToShowTopic, topicPosition } = useSettings() - const searchRef = useRef(null) const { t } = useTranslation() - const dispatch = useAppDispatch() const { addAgent } = useAgents() const onDelete = useCallback( @@ -138,71 +134,11 @@ const Assistants: FC = ({ [clickAssistantToShowTopic, generating, setActiveAssistant, t, topicPosition] ) - const list = assistants.filter((assistant) => assistant.name?.toLowerCase().includes(search.toLowerCase().trim())) - - const onSearch = (e: React.KeyboardEvent) => { - const isEnterPressed = e.keyCode == 13 - - if (e.key === 'Escape') { - return searchRef.current?.blur() - } - - if (isEnterPressed) { - if (list.length > 0) { - if (list.length === 1) { - onSwitchAssistant(list[0]) - setSearch('') - setTimeout(() => searchRef.current?.blur(), 0) - return - } - const index = list.findIndex((a) => a.id === activeAssistant?.id) - onSwitchAssistant(index === list.length - 1 ? list[0] : list[index + 1]) - } - } - - if ((e.ctrlKey || e.metaKey) && e.key === 'k') { - searchRef.current?.focus() - searchRef.current?.select() - } - } - - // Command or Ctrl + K create new topic - useEffect(() => { - const onKeydown = (e) => { - if ((e.ctrlKey || e.metaKey) && e.key === 'k') { - searchRef.current?.focus() - searchRef.current?.select() - } - } - document.addEventListener('keydown', onKeydown) - return () => document.removeEventListener('keydown', onKeydown) - }, [activeAssistant?.id, list, onSwitchAssistant]) - return ( - {assistants.length >= 10 && ( - - ⌘+K} - value={search} - onChange={(e) => setSearch(e.target.value)} - style={{ borderRadius: 16, borderWidth: 0.5 }} - onKeyDown={onSearch} - ref={searchRef} - onFocus={() => dispatch(setSearching(true))} - onBlur={() => { - dispatch(setSearching(false)) - setSearch('') - }} - allowClear - /> - - )} setDragging(true)} onDragEnd={() => setDragging(false)}> @@ -213,11 +149,10 @@ const Assistants: FC = ({ onSwitchAssistant(assistant)} className={isCurrent ? 'active' : ''}> {assistant.name || t('chat.default.name')} {isCurrent && ( - EventEmitter.emit(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR)}> - - + EventEmitter.emit(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR)}> + {assistant.topics.length} + )} - {false && {assistant.topics.length}} ) @@ -239,7 +174,7 @@ const Assistants: FC = ({ const Container = styled(Scrollbar)` display: flex; flex-direction: column; - padding-top: 10px; + padding-top: 11px; ` const AssistantItem = styled.div` @@ -248,10 +183,11 @@ const AssistantItem = styled.div` justify-content: space-between; padding: 7px 12px; position: relative; - border-radius: 17px; margin: 0 10px; padding-right: 35px; font-family: Ubuntu; + border-radius: 16px; + border: 0.5px solid transparent; cursor: pointer; .iconfont { opacity: 0; @@ -261,16 +197,10 @@ const AssistantItem = styled.div` background-color: var(--color-background-soft); } &.active { - background-color: var(--color-background-mute); + background-color: var(--color-background-soft); + border: 0.5px solid var(--color-border); .name { } - .topics-count { - display: none; - } - .iconfont { - opacity: 1; - color: var(--color-text-2); - } } ` @@ -283,33 +213,25 @@ const AssistantName = styled.div` font-size: 13px; ` -const ArrowRightButton = styled.div` +const MenuButton = styled.div` display: flex; flex-direction: row; justify-content: center; align-items: center; - width: 22px; + min-width: 22px; height: 22px; min-width: 22px; min-height: 22px; - border-radius: 4px; + border-radius: 11px; position: absolute; background-color: var(--color-background); right: 9px; top: 6px; - .iconfont { - font-size: 12px; - } ` const TopicCount = styled.div` - color: var(--color-text-2); + color: var(--color-text); font-size: 10px; - margin-right: 3px; - background-color: var(--color-background-mute); - opacity: 0.8; - width: 20px; - height: 20px; border-radius: 10px; display: flex; flex-direction: row; @@ -317,18 +239,4 @@ const TopicCount = styled.div` align-items: center; ` -const SearchContainer = styled.div` - margin: 0 10px; - margin-bottom: 10px; -` - -const CommandKey = styled.div` - color: var(--color-text-2); - font-size: 10px; - padding: 2px 5px; - border-radius: 4px; - background-color: var(--color-background); - margin-right: -4px; -` - export default Assistants diff --git a/src/renderer/src/pages/home/Tabs/Settings.tsx b/src/renderer/src/pages/home/Tabs/Settings.tsx deleted file mode 100644 index 7ccb1ba468..0000000000 --- a/src/renderer/src/pages/home/Tabs/Settings.tsx +++ /dev/null @@ -1,335 +0,0 @@ -import { CheckOutlined, QuestionCircleOutlined, ReloadOutlined } from '@ant-design/icons' -import { HStack } from '@renderer/components/Layout' -import Scrollbar from '@renderer/components/Scrollbar' -import { DEFAULT_CONEXTCOUNT, DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE } from '@renderer/config/constant' -import { useAssistant } from '@renderer/hooks/useAssistant' -import { useSettings } from '@renderer/hooks/useSettings' -import { SettingDivider, SettingRow, SettingRowTitle, SettingSubtitle } from '@renderer/pages/settings' -import { useAppDispatch } from '@renderer/store' -import { - setCodeShowLineNumbers, - setFontSize, - setMathEngine, - setMessageFont, - setMessageStyle, - setPasteLongTextAsFile, - setRenderInputMessageAsMarkdown, - setShowInputEstimatedTokens, - setShowMessageDivider -} from '@renderer/store/settings' -import { Assistant, AssistantSettings } from '@renderer/types' -import { Col, Row, Select, Slider, Switch, Tooltip } from 'antd' -import { FC, useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' -import styled from 'styled-components' - -interface Props { - assistant: Assistant -} - -const SettingsTab: FC = (props) => { - const { assistant, updateAssistantSettings, updateAssistant } = useAssistant(props.assistant.id) - const { fontSize } = useSettings() - const [temperature, setTemperature] = useState(assistant?.settings?.temperature ?? DEFAULT_TEMPERATURE) - const [contextCount, setConextCount] = useState(assistant?.settings?.contextCount ?? DEFAULT_CONEXTCOUNT) - const [enableMaxTokens, setEnableMaxTokens] = useState(assistant?.settings?.enableMaxTokens ?? false) - const [maxTokens, setMaxTokens] = useState(assistant?.settings?.maxTokens ?? 0) - const [streamOutput, setStreamOutput] = useState(assistant?.settings?.streamOutput ?? true) - const [fontSizeValue, setFontSizeValue] = useState(fontSize) - const { messageStyle } = useSettings() - const { t } = useTranslation() - - const dispatch = useAppDispatch() - - const { - showMessageDivider, - messageFont, - showInputEstimatedTokens, - sendMessageShortcut, - setSendMessageShortcut, - pasteLongTextAsFile, - renderInputMessageAsMarkdown, - codeShowLineNumbers, - mathEngine - } = useSettings() - - const onUpdateAssistantSettings = (settings: Partial) => { - updateAssistantSettings(settings) - } - - const onTemperatureChange = (value) => { - if (!isNaN(value as number)) { - onUpdateAssistantSettings({ temperature: value }) - } - } - - const onConextCountChange = (value) => { - if (!isNaN(value as number)) { - onUpdateAssistantSettings({ contextCount: value }) - } - } - - const onMaxTokensChange = (value) => { - if (!isNaN(value as number)) { - onUpdateAssistantSettings({ maxTokens: value }) - } - } - - const onReset = () => { - setTemperature(DEFAULT_TEMPERATURE) - setConextCount(DEFAULT_CONEXTCOUNT) - updateAssistant({ - ...assistant, - settings: { - ...assistant.settings, - temperature: DEFAULT_TEMPERATURE, - contextCount: DEFAULT_CONEXTCOUNT, - enableMaxTokens: false, - maxTokens: DEFAULT_MAX_TOKENS, - streamOutput: true, - hideMessages: false, - autoResetModel: false - } - }) - } - - useEffect(() => { - setTemperature(assistant?.settings?.temperature ?? DEFAULT_TEMPERATURE) - setConextCount(assistant?.settings?.contextCount ?? DEFAULT_CONEXTCOUNT) - setEnableMaxTokens(assistant?.settings?.enableMaxTokens ?? false) - setMaxTokens(assistant?.settings?.maxTokens ?? DEFAULT_MAX_TOKENS) - setStreamOutput(assistant?.settings?.streamOutput ?? true) - }, [assistant]) - - return ( - - - {t('settings.messages.model.title')}{' '} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {t('model.stream_output')} - { - setStreamOutput(checked) - onUpdateAssistantSettings({ streamOutput: checked }) - }} - /> - - - - - - - - - - { - setEnableMaxTokens(enabled) - onUpdateAssistantSettings({ enableMaxTokens: enabled }) - }} - /> - - - - - - - {t('settings.messages.title')} - - - {t('settings.messages.divider')} - dispatch(setShowMessageDivider(checked))} - /> - - - - {t('settings.messages.use_serif_font')} - dispatch(setMessageFont(checked ? 'serif' : 'system'))} - /> - - - - {t('chat.settings.show_line_numbers')} - dispatch(setCodeShowLineNumbers(checked))} - /> - - - - {t('message.message.style')} - - - - - {t('settings.messages.math_engine')} - - - - - {t('settings.font_size.title')} - - - - setFontSizeValue(value)} - onChangeComplete={(value) => dispatch(setFontSize(value))} - min={12} - max={22} - step={1} - marks={{ - 12: A, - 14: {t('common.default')}, - 22: A - }} - /> - - - {t('settings.messages.input.title')} - - - {t('settings.messages.input.show_estimated_tokens')} - dispatch(setShowInputEstimatedTokens(checked))} - /> - - - - {t('settings.messages.input.paste_long_text_as_file')} - dispatch(setPasteLongTextAsFile(checked))} - /> - - - - {t('settings.messages.markdown_rendering_input_message')} - dispatch(setRenderInputMessageAsMarkdown(checked))} - /> - - - - {t('settings.messages.input.send_shortcuts')} - dispatch(setMessageStyle(value))} + style={{ width: 135 }} + size="small"> + {t('message.message.style.plain')} + {t('message.message.style.bubble')} + + + + + {t('message.message.code_style')} + + + + + {t('settings.messages.math_engine')} + + + + + {t('settings.font_size.title')} + + + + setFontSizeValue(value)} + onChangeComplete={(value) => dispatch(setFontSize(value))} + min={12} + max={22} + step={1} + marks={{ + 12: A, + 14: {t('common.default')}, + 22: A + }} + /> + + + + + {t('settings.messages.input.title')} + + + {t('settings.messages.input.show_estimated_tokens')} + dispatch(setShowInputEstimatedTokens(checked))} + /> + + + + {t('settings.messages.input.paste_long_text_as_file')} + dispatch(setPasteLongTextAsFile(checked))} + /> + + + + {t('settings.messages.markdown_rendering_input_message')} + dispatch(setRenderInputMessageAsMarkdown(checked))} + /> + + + {!language.startsWith('en') && ( + <> + + {t('settings.input.auto_translate_with_space')} + dispatch(setAutoTranslateWithSpace(checked))} + /> + + + + )} + + {t('settings.messages.input.send_shortcuts')} + + + + {topicPosition === 'left' && ( + <> + + {t('settings.advanced.auto_switch_to_topics')} + dispatch(setClickAssistantToShowTopic(checked))} + /> + + + + )} + + {t('settings.topic.show.time')} + dispatch(setShowTopicTime(checked))} /> + + + + ) +} + +const Container = styled(Scrollbar)` + display: flex; + flex: 1; + flex-direction: column; + padding: 0 10px; + padding-right: 5px; + padding-top: 2px; +` + +const Label = styled.p` + margin: 0; + font-size: 12px; + margin-right: 5px; +` + +const QuestionIcon = styled(QuestionCircleOutlined)` + font-size: 12px; + cursor: pointer; + color: var(--color-text-3); +` + +const SettingRowTitleSmall = styled(SettingRowTitle)` + font-size: 13px; +` + +export const SettingGroup = styled.div<{ theme?: ThemeMode }>` + padding: 10px; + width: 100%; + margin-top: 0; + border-radius: 8px; + margin-bottom: 10px; + border: 0.5px solid var(--color-border); + background: var(--color-group-background); +` + +export default SettingsTab diff --git a/src/renderer/src/pages/home/Tabs/Topics.tsx b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx similarity index 95% rename from src/renderer/src/pages/home/Tabs/Topics.tsx rename to src/renderer/src/pages/home/Tabs/TopicsTab.tsx index 1bc07840c4..11f5691010 100644 --- a/src/renderer/src/pages/home/Tabs/Topics.tsx +++ b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx @@ -197,7 +197,9 @@ const Topics: FC = ({ assistant: _assistant, activeTopic, setActiveTopic style={{ borderRadius }} onClick={() => onSwitchTopic(topic)}> {topic.name.replace('`', '')} - {showTopicTime && {dayjs(topic.createdAt).format('MM/DD HH:mm')}} + {showTopicTime && ( + {dayjs(topic.createdAt).format('MM/DD HH:mm')} + )} {isActive && ( = ({ assistant: _assistant, activeTopic, setActiveTopic const Container = styled(Scrollbar)` display: flex; flex-direction: column; - padding-top: 10px; + padding-top: 11px; ` const TopicListItem = styled.div` padding: 7px 12px; margin: 0 10px; - border-radius: 17px; + border-radius: 16px; font-family: Ubuntu; font-size: 13px; display: flex; @@ -239,6 +241,7 @@ const TopicListItem = styled.div` position: relative; font-family: Ubuntu; cursor: pointer; + border: 0.5px solid transparent; .menu { opacity: 0; color: var(--color-text-3); @@ -246,17 +249,16 @@ const TopicListItem = styled.div` &:hover { background-color: var(--color-background-soft); .name { - opacity: 1; } } &.active { - background-color: var(--color-background-mute); + background-color: var(--color-background-soft); + border: 0.5px solid var(--color-border); .name { - opacity: 1; } .menu { opacity: 1; - background-color: var(--color-background-mute); + background-color: var(--color-background-soft); &:hover { color: var(--color-text-2); } diff --git a/src/renderer/src/pages/home/Tabs/index.tsx b/src/renderer/src/pages/home/Tabs/index.tsx index 6811fae2f1..cc925dd65d 100644 --- a/src/renderer/src/pages/home/Tabs/index.tsx +++ b/src/renderer/src/pages/home/Tabs/index.tsx @@ -6,14 +6,14 @@ import { useShowTopics } from '@renderer/hooks/useStore' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { Assistant, Topic } from '@renderer/types' import { uuid } from '@renderer/utils' -import { Segmented, SegmentedProps } from 'antd' +import { Segmented as AntSegmented, SegmentedProps } from 'antd' import { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -import Assistants from './Assistants' -import Settings from './Settings' -import Topics from './Topics' +import Assistants from './AssistantsTab' +import Settings from './SettingsTab' +import Topics from './TopicsTab' interface Props { activeAssistant: Assistant @@ -98,7 +98,6 @@ const HomeTabs: FC = ({ activeAssistant, activeTopic, setActiveAssistant, {showTab && ( = ({ onDeletePainting(item)} okButtonProps={{ danger: true }} placement="left"> diff --git a/src/renderer/src/pages/paintings/PaintingsPage.tsx b/src/renderer/src/pages/paintings/PaintingsPage.tsx index 964a315fb4..a6ee6366b0 100644 --- a/src/renderer/src/pages/paintings/PaintingsPage.tsx +++ b/src/renderer/src/pages/paintings/PaintingsPage.tsx @@ -1,4 +1,4 @@ -import { PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons' +import { PlusOutlined, QuestionCircleOutlined, RedoOutlined } from '@ant-design/icons' import ImageSize1_1 from '@renderer/assets/images/paintings/image-size-1-1.svg' import ImageSize1_2 from '@renderer/assets/images/paintings/image-size-1-2.svg' import ImageSize3_2 from '@renderer/assets/images/paintings/image-size-3-2.svg' @@ -15,9 +15,11 @@ import { useTheme } from '@renderer/context/ThemeProvider' import { usePaintings } from '@renderer/hooks/usePaintings' import { useAllProviders } from '@renderer/hooks/useProvider' import { useRuntime } from '@renderer/hooks/useRuntime' +import { useSettings } from '@renderer/hooks/useSettings' import AiProvider from '@renderer/providers/AiProvider' import { getProviderByModel } from '@renderer/services/AssistantService' import FileManager from '@renderer/services/FileManager' +import { translateText } from '@renderer/services/TranslateService' import { useAppDispatch } from '@renderer/store' import { DEFAULT_PAINTING } from '@renderer/store/paintings' import { setGenerating } from '@renderer/store/runtime' @@ -25,7 +27,7 @@ import { FileType, Painting } from '@renderer/types' import { getErrorMessage } from '@renderer/utils' import { Button, Input, InputNumber, Radio, Select, Slider, Tooltip } from 'antd' import TextArea from 'antd/es/input/TextArea' -import { FC, useRef, useState } from 'react' +import { FC, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -107,7 +109,7 @@ const PaintingsPage: FC = () => { const onGenerate = async () => { if (painting.files.length > 0) { const confirmed = await window.modal.confirm({ - content: t('images.regenerate.confirm'), + content: t('paintings.regenerate.confirm'), centered: true }) @@ -232,31 +234,67 @@ const PaintingsPage: FC = () => { setCurrentImageIndex(0) } - const handleTranslation = async (translatedText: string) => { - const currentText = textareaRef.current?.resizableTextArea?.textArea?.value + const { autoTranslateWithSpace } = useSettings() + const [spaceClickCount, setSpaceClickCount] = useState(0) + const [isTranslating, setIsTranslating] = useState(false) + const spaceClickTimer = useRef() - if (currentText) { - await navigator.clipboard.writeText(currentText) + const translate = async () => { + if (isTranslating) { + return + } - const confirmed = await window.modal.confirm({ - content: t('translate.confirm'), - centered: true - }) + if (!painting.prompt) { + return + } - if (confirmed) { - updatePaintingState({ prompt: translatedText }) + try { + setIsTranslating(true) + const translatedText = await translateText(painting.prompt, 'english') + updatePaintingState({ prompt: translatedText }) + } catch (error) { + console.error('Translation failed:', error) + } finally { + setIsTranslating(false) + } + } + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (autoTranslateWithSpace && event.key === ' ') { + setSpaceClickCount((prev) => prev + 1) + + if (spaceClickTimer.current) { + clearTimeout(spaceClickTimer.current) + } + + spaceClickTimer.current = setTimeout(() => { + setSpaceClickCount(0) + }, 200) + + if (spaceClickCount === 2) { + setSpaceClickCount(0) + setIsTranslating(true) + translate() } } } + useEffect(() => { + return () => { + if (spaceClickTimer.current) { + clearTimeout(spaceClickTimer.current) + } + } + }, []) + return ( - {t('images.title')} + {t('paintings.title')} {isMac && ( )} @@ -267,11 +305,11 @@ const PaintingsPage: FC = () => { - {t('images.image.size')} + {t('paintings.image.size')} onSelectImageSize(e.target.value)} @@ -287,8 +325,8 @@ const PaintingsPage: FC = () => { - {t('images.number_images')} - + {t('paintings.number_images')} + @@ -300,20 +338,25 @@ const PaintingsPage: FC = () => { /> - {t('images.seed')} - + {t('paintings.seed')} + updatePaintingState({ seed: e.target.value })} - suffix={ updatePaintingState({ seed: '' })} />} + suffix={ + updatePaintingState({ seed: Math.floor(Math.random() * 1000000).toString() })} + style={{ cursor: 'pointer', color: 'var(--color-text-2)' }} + /> + } /> - {t('images.inference_steps')} - + {t('paintings.inference_steps')} + @@ -326,8 +369,8 @@ const PaintingsPage: FC = () => { /> - {t('images.guidance_scale')} - + {t('paintings.guidance_scale')} + @@ -347,8 +390,8 @@ const PaintingsPage: FC = () => { /> - {t('images.negative_prompt')} - + {t('paintings.negative_prompt')} + @@ -374,14 +417,16 @@ const PaintingsPage: FC = () => { disabled={isLoading} value={painting.prompt} onChange={(e) => updatePaintingState({ prompt: e.target.value })} - placeholder={t('images.prompt_placeholder')} + placeholder={isTranslating ? t('paintings.translating') : t('paintings.prompt_placeholder')} + onKeyDown={handleKeyDown} /> updatePaintingState({ prompt: translatedText })} + disabled={isLoading || isTranslating} + isLoading={isTranslating} style={{ marginRight: 6, borderRadius: '50%' }} /> @@ -442,8 +487,10 @@ const InputContainer = styled.div` min-height: 95px; max-height: 95px; position: relative; - border-top: 1px solid var(--color-border-soft); + border: 1px solid var(--color-border-soft); transition: all 0.3s ease; + margin: 0 20px 15px 20px; + border-radius: 10px; ` const Textarea = styled(TextArea)` @@ -500,8 +547,4 @@ const InfoIcon = styled(QuestionCircleOutlined)` } ` -const RefreshIcon = styled.span` - cursor: pointer; -` - export default PaintingsPage diff --git a/src/renderer/src/pages/settings/AboutSettings.tsx b/src/renderer/src/pages/settings/AboutSettings.tsx index fb66142e07..0716fafb0c 100644 --- a/src/renderer/src/pages/settings/AboutSettings.tsx +++ b/src/renderer/src/pages/settings/AboutSettings.tsx @@ -3,19 +3,21 @@ import { FileProtectOutlined, GlobalOutlined, MailOutlined, SoundOutlined } from import { HStack } from '@renderer/components/Layout' import MinApp from '@renderer/components/MinApp' import { APP_NAME, AppLogo } from '@renderer/config/env' +import { useTheme } from '@renderer/context/ThemeProvider' import { useSettings } from '@renderer/hooks/useSettings' import { useAppDispatch } from '@renderer/store' import { setManualUpdateCheck } from '@renderer/store/settings' import { runAsyncFunction } from '@renderer/utils' import { Avatar, Button, Progress, Row, Switch, Tag } from 'antd' -import { ProgressInfo } from 'electron-updater' +import { ProgressInfo, UpdateInfo } from 'electron-updater' import { debounce } from 'lodash' import { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' +import Markdown from 'react-markdown' import { Link } from 'react-router-dom' import styled from 'styled-components' -import { SettingContainer, SettingDivider, SettingRow, SettingTitle } from '.' +import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingTitle } from '.' const AboutSettings: FC = () => { const [version, setVersion] = useState('') @@ -24,6 +26,7 @@ const AboutSettings: FC = () => { const [checkUpdateLoading, setCheckUpdateLoading] = useState(false) const [downloading, setDownloading] = useState(false) const { manualUpdateCheck } = useSettings() + const { theme } = useTheme() const dispatch = useAppDispatch() const onCheckUpdate = debounce( @@ -79,8 +82,19 @@ const AboutSettings: FC = () => { setCheckUpdateLoading(false) window.message.success(t('settings.about.updateNotAvailable')) }), - ipcRenderer.on('update-available', () => { + ipcRenderer.on('update-available', (_, releaseInfo: UpdateInfo) => { setCheckUpdateLoading(false) + setDownloading(true) + window.modal.info({ + title: t('settings.about.updateAvailable', { version: releaseInfo.version }), + content: ( + + {typeof releaseInfo.releaseNotes === 'string' + ? releaseInfo.releaseNotes + : releaseInfo.releaseNotes?.map((note) => note.note).join('\n')} + + ) + }) }), ipcRenderer.on('download-update', () => { setCheckUpdateLoading(false) @@ -88,6 +102,10 @@ const AboutSettings: FC = () => { }), ipcRenderer.on('download-progress', (_, progress: ProgressInfo) => { setPercent(progress.percent) + setDownloading(progress.percent < 100) + }), + ipcRenderer.on('update-downloaded', () => { + setDownloading(false) }), ipcRenderer.on('update-error', (_, error) => { setCheckUpdateLoading(false) @@ -104,102 +122,107 @@ const AboutSettings: FC = () => { }, [t]) return ( - - - {t('settings.about.title')} - - - - - - - - - - onOpenWebsite('https://github.com/kangfenmao/cherry-studio')}> - {percent > 0 && ( - - )} - - - - {APP_NAME} - {t('settings.about.description')} - onOpenWebsite('https://github.com/kangfenmao/cherry-studio/releases')} - color="cyan" - style={{ marginTop: 8, cursor: 'pointer' }}> - v{version} - - - - - {downloading ? t('settings.about.downloading') : t('settings.about.checkUpdate')} - - - - - {t('settings.general.manually_check_update.title')} - dispatch(setManualUpdateCheck(v))} /> - - - - - - {t('settings.about.releases.title')} - - - - - - - - {t('settings.about.website.title')} - - - - - - - - {t('settings.about.feedback.title')} - - - - - - - - {t('settings.about.license.title')} - - - - - - - {t('settings.about.contact.title')} - - - - + + + + {t('settings.about.title')} + + + + + + + + + + onOpenWebsite('https://github.com/kangfenmao/cherry-studio')}> + {percent > 0 && ( + + )} + + + + {APP_NAME} + {t('settings.about.description')} + onOpenWebsite('https://github.com/kangfenmao/cherry-studio/releases')} + color="cyan" + style={{ marginTop: 8, cursor: 'pointer' }}> + v{version} + + + + + {downloading ? t('settings.about.downloading') : t('settings.about.checkUpdate')} + + + + + {t('settings.general.manually_check_update.title')} + dispatch(setManualUpdateCheck(v))} /> + + + + + + + {t('settings.about.releases.title')} + + + + + + + + {t('settings.about.website.title')} + + + + + + + + {t('settings.about.feedback.title')} + + + + + + + + {t('settings.about.license.title')} + + + + + + + {t('settings.about.contact.title')} + + + + ) } diff --git a/src/renderer/src/pages/settings/AssistantSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings.tsx index b724019be7..a36c0230cf 100644 --- a/src/renderer/src/pages/settings/AssistantSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings.tsx @@ -1,22 +1,25 @@ import { QuestionCircleOutlined } from '@ant-design/icons' import { HStack } from '@renderer/components/Layout' -import { DEFAULT_CONEXTCOUNT, DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE } from '@renderer/config/constant' +import { TopView } from '@renderer/components/TopView' +import { DEFAULT_CONTEXTCOUNT, DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE } from '@renderer/config/constant' +import { useTheme } from '@renderer/context/ThemeProvider' import { useDefaultAssistant } from '@renderer/hooks/useAssistant' import { AssistantSettings as AssistantSettingsType } from '@renderer/types' -import { Button, Col, Input, InputNumber, Row, Slider, Switch, Tooltip } from 'antd' +import { Button, Col, Input, InputNumber, Modal, Row, Slider, Switch, Tooltip } from 'antd' import TextArea from 'antd/es/input/TextArea' -import { FC, useState } from 'react' +import { Dispatch, FC, SetStateAction, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -import { SettingContainer, SettingDivider, SettingSubtitle, SettingTitle } from '.' +import { SettingContainer, SettingSubtitle } from '.' const AssistantSettings: FC = () => { const { defaultAssistant, updateDefaultAssistant } = useDefaultAssistant() const [temperature, setTemperature] = useState(defaultAssistant.settings?.temperature ?? DEFAULT_TEMPERATURE) - const [contextCount, setConextCount] = useState(defaultAssistant.settings?.contextCount ?? DEFAULT_CONEXTCOUNT) + const [contextCount, setContextCount] = useState(defaultAssistant.settings?.contextCount ?? DEFAULT_CONTEXTCOUNT) const [enableMaxTokens, setEnableMaxTokens] = useState(defaultAssistant?.settings?.enableMaxTokens ?? false) const [maxTokens, setMaxTokens] = useState(defaultAssistant?.settings?.maxTokens ?? 0) + const { theme } = useTheme() const { t } = useTranslation() @@ -34,27 +37,22 @@ const AssistantSettings: FC = () => { }) } - const onTemperatureChange = (value) => { - if (!isNaN(value as number)) { - onUpdateAssistantSettings({ temperature: value }) + const handleChange = + (setter: Dispatch>, updater: (value: number) => void) => (value: number | null) => { + if (!!value && !isNaN(value)) { + setter(value) + updater(value) + } } - } - - const onConextCountChange = (value) => { - if (!isNaN(value as number)) { - onUpdateAssistantSettings({ contextCount: value }) - } - } - - const onMaxTokensChange = (value) => { - if (!isNaN(value as number)) { - onUpdateAssistantSettings({ maxTokens: value }) - } - } + const onTemperatureChange = handleChange(setTemperature, (value) => onUpdateAssistantSettings({ temperature: value })) + const onContextCountChange = handleChange(setContextCount, (value) => + onUpdateAssistantSettings({ contextCount: value }) + ) + const onMaxTokensChange = handleChange(setMaxTokens, (value) => onUpdateAssistantSettings({ maxTokens: value })) const onReset = () => { setTemperature(DEFAULT_TEMPERATURE) - setConextCount(DEFAULT_CONEXTCOUNT) + setContextCount(DEFAULT_CONTEXTCOUNT) setEnableMaxTokens(false) setMaxTokens(0) updateDefaultAssistant({ @@ -62,7 +60,7 @@ const AssistantSettings: FC = () => { settings: { ...defaultAssistant.settings, temperature: DEFAULT_TEMPERATURE, - contextCount: DEFAULT_CONEXTCOUNT, + contextCount: DEFAULT_CONTEXTCOUNT, enableMaxTokens: false, maxTokens: DEFAULT_MAX_TOKENS, streamOutput: true @@ -71,9 +69,7 @@ const AssistantSettings: FC = () => { } return ( - - {t('settings.assistant.title')} - + {t('common.name')} { - - + + @@ -141,8 +137,8 @@ const AssistantSettings: FC = () => { min={0} max={20} marks={{ 0: '0', 5: '5', 10: '10', 15: '15', 20: t('chat.settings.max') }} - onChange={setConextCount} - onChangeComplete={onConextCountChange} + onChange={setContextCount} + onChangeComplete={onContextCountChange} value={typeof contextCount === 'number' ? contextCount : 0} step={1} /> @@ -153,7 +149,7 @@ const AssistantSettings: FC = () => { max={20} step={1} value={contextCount} - onChange={onConextCountChange} + onChange={onContextCountChange} style={{ width: '100%' }} /> @@ -192,6 +188,7 @@ const AssistantSettings: FC = () => { { ) } +interface Props { + resolve: (data: any) => void +} + +const PopupContainer: React.FC = ({ resolve }) => { + const [open, setOpen] = useState(true) + const { t } = useTranslation() + + const onOk = () => { + setOpen(false) + } + + const onCancel = () => { + setOpen(false) + } + + const onClose = () => { + resolve({}) + } + + return ( + + + + ) +} + +export default class AssistantSettingsPopup { + static topviewId = 0 + static hide() { + TopView.hide('AssistantSettingsPopup') + } + static show() { + return new Promise((resolve) => { + TopView.show( + { + resolve(v) + this.hide() + }} + />, + 'AssistantSettingsPopup' + ) + }) + } +} + const Label = styled.p` margin: 0; font-size: 14px; @@ -217,5 +269,3 @@ const QuestionIcon = styled(QuestionCircleOutlined)` cursor: pointer; color: var(--color-text-3); ` - -export default AssistantSettings diff --git a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx index 0b2fcd6c9d..b5135cb551 100644 --- a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx @@ -1,61 +1,124 @@ -import { FolderOpenOutlined, SaveOutlined } from '@ant-design/icons' -import { HStack, VStack } from '@renderer/components/Layout' +import { FileSearchOutlined, FolderOpenOutlined, SaveOutlined } from '@ant-design/icons' +import { HStack } from '@renderer/components/Layout' +import { useTheme } from '@renderer/context/ThemeProvider' import { backup, reset, restore } from '@renderer/services/BackupService' -import { Button } from 'antd' -import { FC } from 'react' +import { AppInfo } from '@renderer/types' +import { Button, message, Modal, Typography } from 'antd' +import { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Link, Route, Routes } from 'react-router-dom' +import styled from 'styled-components' -import { SettingContainer, SettingDivider, SettingRow, SettingRowTitle, SettingTitle } from '..' +import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..' import WebDavSettings from './WebDavSettings' const DataSettings: FC = () => { const { t } = useTranslation() + const [appInfo, setAppInfo] = useState() + const { theme } = useTheme() + + useEffect(() => { + window.api.getAppInfo().then(setAppInfo) + }, []) + + const handleOpenPath = (path?: string) => { + if (!path) return + if (path?.endsWith('log')) { + const dirPath = path.split(/[/\\]/).slice(0, -1).join('/') + window.api.openPath(dirPath) + } else { + window.api.openPath(path) + } + } + + const handleClearCache = () => { + Modal.confirm({ + title: t('settings.data.clear_cache.title'), + content: t('settings.data.clear_cache.confirm'), + okText: t('settings.data.clear_cache.button'), + centered: true, + okButtonProps: { + danger: true + }, + onOk: async () => { + try { + await window.api.clearCache() + message.success(t('settings.data.clear_cache.success')) + } catch (error) { + message.error(t('settings.data.clear_cache.error')) + } + } + }) + } return ( - - - {t('settings.data')} - - - {t('settings.data.webdav.title')} - - - - - - - - - {t('settings.general.backup.title')} - - - - - - - - {t('settings.general.reset.title')} - - - - - - - } - /> - } /> - + + + {t('settings.data.title')} + + + {t('settings.general.backup.title')} + + + + + + + + {t('settings.general.reset.title')} + + + + + + + + + + {t('settings.data.data.title')} + + + {t('settings.data.app_data')} + + {appInfo?.appDataPath} + handleOpenPath(appInfo?.appDataPath)} /> + + + + + {t('settings.data.app_logs')} + + {appInfo?.logsPath} + handleOpenPath(appInfo?.logsPath)} /> + + + + + {t('settings.data.clear_cache.title')} + + + + + + ) } +const StyledIcon = styled(FileSearchOutlined)` + color: var(--color-text-2); + cursor: pointer; + transition: color 0.3s; + + &:hover { + color: var(--color-text-1); + } +` + export default DataSettings diff --git a/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx b/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx index 4204b338bd..e0fbad4e4d 100644 --- a/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx @@ -9,11 +9,11 @@ import { setWebdavPath as _setWebdavPath, setWebdavUser as _setWebdavUser } from '@renderer/store/settings' -import { Breadcrumb, Button, Input } from 'antd' +import { Button, Input } from 'antd' import { FC, useState } from 'react' import { useTranslation } from 'react-i18next' -import { SettingContainer, SettingDivider, SettingRow, SettingRowTitle, SettingTitle } from '..' +import { SettingDivider, SettingRow, SettingRowTitle, SettingTitle } from '..' const WebDavSettings: FC = () => { const { @@ -58,19 +58,8 @@ const WebDavSettings: FC = () => { } return ( - - - {t('settings.data.webdav.title')} + <> + {t('settings.data.webdav.title')} {t('settings.data.webdav.host')} @@ -129,8 +118,7 @@ const WebDavSettings: FC = () => { - - + ) } diff --git a/src/renderer/src/pages/settings/GeneralSettings.tsx b/src/renderer/src/pages/settings/GeneralSettings.tsx index 1fd610d32e..d0ff8e93a0 100644 --- a/src/renderer/src/pages/settings/GeneralSettings.tsx +++ b/src/renderer/src/pages/settings/GeneralSettings.tsx @@ -1,38 +1,45 @@ import { isMac } from '@renderer/config/constant' +import { useTheme } from '@renderer/context/ThemeProvider' import { useSettings } from '@renderer/hooks/useSettings' import i18n from '@renderer/i18n' import { useAppDispatch } from '@renderer/store' -import { setClickAssistantToShowTopic, setLanguage, setShowTopicTime } from '@renderer/store/settings' -import { setProxyUrl as _setProxyUrl } from '@renderer/store/settings' -import { ThemeMode } from '@renderer/types' +import { setLanguage } from '@renderer/store/settings' +import { setProxyMode, setProxyUrl as _setProxyUrl } from '@renderer/store/settings' +import { LanguageVarious, ThemeMode } from '@renderer/types' import { isValidProxyUrl } from '@renderer/utils' -import { Input, Select, Switch } from 'antd' +import { Input, Select, Space, Switch } from 'antd' import { FC, useState } from 'react' import { useTranslation } from 'react-i18next' -import { SettingContainer, SettingDivider, SettingRow, SettingRowTitle, SettingTitle } from '.' +import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '.' const GeneralSettings: FC = () => { const { language, proxyUrl: storeProxyUrl, - theme, - windowStyle, - topicPosition, - showTopicTime, - clickAssistantToShowTopic, setTheme, + theme, + setTray, + tray, + windowStyle, setWindowStyle, - setTopicPosition + proxyMode: storeProxyMode } = useSettings() const [proxyUrl, setProxyUrl] = useState(storeProxyUrl) + const { theme: themeMode } = useTheme() + + const updateTray = (value: boolean) => { + setTray(value) + window.api.setTray(value) + } const dispatch = useAppDispatch() const { t } = useTranslation() - const onSelectLanguage = (value: string) => { + const onSelectLanguage = (value: LanguageVarious) => { dispatch(setLanguage(value)) localStorage.setItem('language', value) + window.api.setLanguage(value) i18n.changeLanguage(value) } @@ -46,97 +53,113 @@ const GeneralSettings: FC = () => { window.api.setProxy(proxyUrl) } + const proxyModeOptions = [ + { value: 'system', label: t('settings.proxy.mode.system') }, + { value: 'custom', label: t('settings.proxy.mode.custom') }, + { value: 'none', label: t('settings.proxy.mode.none') } + ] + + const onProxyModeChange = (mode: 'system' | 'custom' | 'none') => { + dispatch(setProxyMode(mode)) + if (mode === 'system') { + window.api.setProxy('system') + dispatch(_setProxyUrl(undefined)) + } else if (mode === 'none') { + window.api.setProxy(undefined) + dispatch(_setProxyUrl(undefined)) + } + } + + const languagesOptions: { value: LanguageVarious; label: string; flag: string }[] = [ + { value: 'zh-CN', label: '中文', flag: '🇨🇳' }, + { value: 'zh-TW', label: '中文(繁体)', flag: '🇹🇼' }, + { value: 'en-US', label: 'English', flag: '🇺🇸' }, + { value: 'ru-RU', label: 'Russian', flag: '🇷🇺' } + ] + return ( - - {t('settings.general.title')} - - - {t('common.language')} - - - {isMac && ( - <> - - - {t('settings.theme.window.style.title')} - setProxyUrl(e.target.value)} - style={{ width: 180 }} - onBlur={() => onSetProxyUrl()} - type="url" - /> - - - - {t('settings.topic.position')} - + {languagesOptions.map((lang) => ( + + + {lang.label} + + {lang.flag} + + + + ))} + + + + + {t('settings.theme.title')} + + + + )} + + + {t('settings.proxy.mode.title')} + setProxyUrl(e.target.value)} + style={{ width: 180 }} + onBlur={() => onSetProxyUrl()} + type="url" + /> + + + )} + + + {t('settings.tray.title')} + updateTray(checked)} /> + + ) } diff --git a/src/renderer/src/pages/settings/ModalSettings/ModelSettings.tsx b/src/renderer/src/pages/settings/ModalSettings/ModelSettings.tsx new file mode 100644 index 0000000000..e197f9e3f6 --- /dev/null +++ b/src/renderer/src/pages/settings/ModalSettings/ModelSettings.tsx @@ -0,0 +1,153 @@ +import { EditOutlined, MessageOutlined, RedoOutlined, SettingOutlined, TranslationOutlined } from '@ant-design/icons' +import { HStack } from '@renderer/components/Layout' +import PromptPopup from '@renderer/components/Popups/PromptPopup' +import { TRANSLATE_PROMPT } from '@renderer/config/prompts' +import { useTheme } from '@renderer/context/ThemeProvider' +import { useDefaultModel } from '@renderer/hooks/useAssistant' +import { useProviders } from '@renderer/hooks/useProvider' +import { useSettings } from '@renderer/hooks/useSettings' +import { getModelUniqId, hasModel } from '@renderer/services/ModelService' +import { useAppDispatch } from '@renderer/store' +import { setTranslateModelPrompt } from '@renderer/store/settings' +import { Model } from '@renderer/types' +import { Button, Select, Tooltip } from 'antd' +import { find, sortBy } from 'lodash' +import { FC, useMemo } from 'react' +import { useTranslation } from 'react-i18next' + +import { SettingContainer, SettingDescription, SettingGroup, SettingTitle } from '..' +import AssistantSettingsPopup from '../AssistantSettings' +import TopicNamingModalPopup from './TopicNamingModalPopup' + +const ModelSettings: FC = () => { + const { defaultModel, topicNamingModel, translateModel, setDefaultModel, setTopicNamingModel, setTranslateModel } = + useDefaultModel() + const { providers } = useProviders() + const allModels = providers.map((p) => p.models).flat() + const { theme } = useTheme() + const { t } = useTranslation() + const { translateModelPrompt } = useSettings() + + const dispatch = useAppDispatch() + + const selectOptions = providers + .filter((p) => p.models.length > 0) + .map((p) => ({ + label: p.isSystem ? t(`provider.${p.id}`) : p.name, + title: p.name, + options: sortBy(p.models, 'name').map((m) => ({ + label: m.name, + value: getModelUniqId(m) + })) + })) + + const defaultModelValue = useMemo( + () => (hasModel(defaultModel) ? getModelUniqId(defaultModel) : undefined), + [defaultModel] + ) + + const defaultTopicNamingModel = useMemo( + () => (hasModel(topicNamingModel) ? getModelUniqId(topicNamingModel) : undefined), + [topicNamingModel] + ) + + const defaultTranslateModel = useMemo( + () => (hasModel(translateModel) ? getModelUniqId(translateModel) : undefined), + [translateModel] + ) + + const onUpdateTranslateModel = async () => { + const prompt = await PromptPopup.show({ + title: t('settings.models.translate_model_prompt_title'), + message: t('settings.models.translate_model_prompt_message'), + defaultValue: translateModelPrompt, + inputProps: { + rows: 10 + } + }) + if (prompt) { + dispatch(setTranslateModelPrompt(prompt)) + } + } + + const onResetTranslatePrompt = () => { + dispatch(setTranslateModelPrompt(TRANSLATE_PROMPT)) + } + + return ( + + + +
+ + {t('settings.models.default_assistant_model')} +
+
+ + setTopicNamingModel(find(allModels, JSON.parse(value)) as Model)} + options={selectOptions} + showSearch + placeholder={t('settings.models.empty')} + /> +