Compare commits

...

16 Commits

Author SHA1 Message Date
kangfenmao
8b6682c325 feat: enterprise version
feat: enterprise version

feat: enhance login functionality and UI improvements

- Added last login method tracking in the auth state to remember user preferences.
- Updated the login page to support switching between SSO and password login modes, improving user experience.
- Enhanced UI elements, including background image and button styles, for a more modern look.
- Introduced a new chat item in the launchpad with appropriate styling.
- Added translations for the new chat feature across multiple languages.

chore: update @cherrystudio/api-sdk to version 0.0.43 and refactor related code

- Updated the dependency version of @cherrystudio/api-sdk from 0.0.41 to 0.0.43 in package.json and yarn.lock.
- Refactored code in format.ts and index.ts to replace references from Agent to Assistant for improved clarity and consistency in the sync process.
- Adjusted function names and parameters accordingly to reflect the changes in the data model.

chore: upgrade api-sdk

refactor: mcp server edit

chore: upgrade api-sdk

chore: upgrade api-sdk

chore: remove outdated GitHub workflows and Dependabot configuration

- Deleted the Dependabot configuration file to streamline dependency management.
- Removed the dispatch-docs-update workflow, which was no longer needed for release documentation updates.

refactor: remove AGENT.md and enhance custom parameters formatting

- Deleted the AGENT.md file as it was no longer needed.
- Added a new function to format custom parameters in the sync service, improving the handling of assistant settings.
- Updated the formatAssistant function to include formatted custom parameters.

fix: sync mcp servers

feat: enhance user profile settings and internationalization

- Integrated server URL display in the user profile settings.
- Updated logout confirmation messages to use enterprise-specific translations.
- Refactored login page to utilize enterprise translations for error messages and placeholders, improving localization support across multiple languages.

chore(version): 1.5.61

refactor: update MCPToolsButton color logic and adjust knowledge recognition default

- Simplified the color logic for the MCPToolsButton based on the presence of MCP servers.
- Changed the default value of knowledge recognition from 'on' to 'off' in ApiService.
- Updated minApps state management to conditionally include logos based on server status.
- Enhanced sync settings to include new preprocess provider configurations and proxy settings.

chore: update enterprise protocol and version

- Changed protocol name from 'cherrystudio' to 'cherrystudio-enterprise' in electron-builder configuration and related files.
- Updated application version to 1.5.111 in package.json.

feat: add support for webview proxy configuration

- Introduced a new IPC channel `App_ProxyForWebview` to handle proxy settings specifically for webview sessions.
- Updated `ProxyManager` to manage webview proxy settings, ensuring that the proxy is applied only when necessary.
- Enhanced the preload API to include a method for setting the webview proxy.
- Updated the sync settings functionality to synchronize webview proxy settings from user preferences.

feat: enhance protocol handling for Cherry Studio

- Added support for the new `CHERRY_STUDIO_ENTERPRISE_PROTOCOL` in the application.
- Updated URL handling to recognize both `CHERRY_STUDIO_PROTOCOL` and `CHERRY_STUDIO_ENTERPRISE_PROTOCOL`.
- Modified the default protocol client registration to include the new enterprise protocol.
2025-09-22 14:04:21 +08:00
kangfenmao
fa9f59146e chore(version): 1.5.11 2025-09-12 16:20:22 +08:00
kangfenmao
c1d8bf38ef refactor: update styles and improve navbar handling
- Removed unnecessary margin-bottom style from bubble markdown.
- Adjusted margin in Prompt component for better layout.
- Enhanced useAppInit hook to include navbar position logic for background styling.
- Added alignment to ErrorBlock alert for improved visual consistency.
2025-09-12 16:20:07 +08:00
George·Dong
7217a7216e fix/miniapp-tab-cache (#10024)
* feat(minapps): add Tabs-mode webview pool and integrate page shell

* fix(minapp): position tabs pool below toolbar and preserve layout

* style(minapp): fix format issues

* style(minapps): optimize var name

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* feat(minapps): stabilize tab webview lifecycle and mount logic

* refactor(minapps): improve webview detection and state handling

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
# Conflicts:
#	src/renderer/src/components/Tab/TabContainer.tsx
2025-09-12 15:54:16 +08:00
George·Dong
d35998bd74 fix(codetool): incorrect codetool workdir on macOS (#10056)
fix(codetool): run command and cd in same shell on macOS
2025-09-12 15:53:31 +08:00
359156687
dbd090377d fix: workaround for electron build issue on rpm package (#9986)
* fix: add workaround for electron build issue on rpm package

Signed-off-by: 33671 <error_z@yeah.net>

* fix format

Signed-off-by: 33671 <error_z@yeah.net>

---------

Signed-off-by: 33671 <error_z@yeah.net>
2025-09-12 15:51:56 +08:00
Pleasure1234
2ebcb43d50 fix: improve note sorting behavior for drag and drop operations (#9971)
* fix: improve note sorting behavior for drag and drop operations

- Skip automatic sorting when performing same-level drag reordering
- Preserve treePath during same-level moves to maintain manual ordering
- Return special indicator for manual reorder operations to prevent conflicts

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: type safety issue

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-12 15:47:28 +08:00
Konv Suu
826b71deba fix(miniapp): title container background style align with sidebar (#9915) 2025-09-12 15:46:06 +08:00
George·Dong
7c0a800d9d refactor(miniapp): 适配顶部状态栏 (#9695)
* feat(minapp): add top-navbar fixed toolbar and layout adjustments

* refactor(minapps): optimize toolbar

* fix(minapps): hide redundant components

* feat(minapp): improve webview load handling and popup visibility

* feat(minapps): improve WebView load handling and clean up launchpad

* feat(minapp): 实现活跃小程序数量限制与关闭缓存清理

* fix(minapp): 修复WebView高度不正确的问题

* fix(minapp): show popup only for left navbar mode

* feat(minapps): add full-screen loading mask for webview

* fix: lint error

* feat(minapp): fix drawer sizing and layout when side navbar present

* refactor(minapp): 移除固定工具栏组件,优化弹窗容器布局

* feat(minapps): memoize app lookup to avoid unnecessary recompute

* chore(minapps): optimize comments

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix(renderer): remove stray blank line in MinAppFullPageView

* refactor(minapps): remove top navbar opened minapps component

* refactor(tab): remove unused TopNavbarOpenedMinappTabs import

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-12 15:42:50 +08:00
beyondkmp
6f9906fe49 fix: update User-Agent handling in WebviewService to conditionally set based on URL (#9931)
# Conflicts:
#	yarn.lock
2025-09-12 15:22:59 +08:00
kangfenmao
ba7eec64b0 feat: add client ID generation and update user agent headers in AppUpdater
- Introduced a new method in ConfigManager to generate and retrieve a unique client ID.
- Updated AppUpdater to include the client ID in the request headers alongside the user agent.
2025-09-12 09:59:22 +08:00
Phantom
83d2403339 refactor(OGCard): replace static image with dynamic generated graph (#10115)
* refactor(OGCard): replace static image with dynamic generated graph

- Replace CherryLogo import with GeneratedGraph component for dynamic preview
- Extract image height to constant for consistency
- Use useCallback for GeneratedGraph to optimize performance

* chore: remove unused banner.png asset

* style(OGCard): change image height from pixels to rem units

Use rem units for better responsiveness and consistency with the design system
2025-09-12 09:56:14 +08:00
kangfenmao
bc17dcb911 chore(version): 1.5.10 2025-09-11 16:28:24 +08:00
Konv Suu
44e93671fa fix: 对齐模型设置中 avatar 的样式 (#9829)
* fix: 对齐模型设置中 avatar 的样式

* update

* update

* fix: 修复上传弹出两次文件夹的问题

* update
2025-09-11 15:21:27 +08:00
Phantom
a5bfd8f3db fix: handle multiple content source when pasting to translate input (#9919)
* fix(translate): 处理粘贴事件时增加处理中状态检查

* fix(translate): 修复粘贴文本时未阻止默认行为的问题

添加event.preventDefault()以防止粘贴文本时触发默认行为
同时优化粘贴逻辑,优先处理文本内容
2025-09-11 15:20:46 +08:00
LiuVaayne
07c3c33acc refactor(mcp): enhance MCPService logging and error handling (#9878) 2025-09-11 15:19:31 +08:00
142 changed files with 4563 additions and 2938 deletions

View File

@@ -1,94 +0,0 @@
name: 🐛 错误报告 (中文)
description: 创建一个报告以帮助我们改进
title: '[错误]: '
labels: ['BUG']
body:
- type: markdown
attributes:
value: |
感谢您花时间填写此错误报告!
在提交此问题之前,请确保您已经了解了[常见问题](https://docs.cherry-ai.com/question-contact/questions)和[知识科普](https://docs.cherry-ai.com/question-contact/knowledge)
- type: checkboxes
id: checklist
attributes:
label: 提交前检查
description: |
在提交 Issue 前请确保您已经完成了以下所有步骤
options:
- label: 我理解 Issue 是用于反馈和解决问题的,而非吐槽评论区,将尽可能提供更多信息帮助问题解决。
required: true
- label: 我的问题不是 [常见问题](https://github.com/CherryHQ/cherry-studio/issues/3881) 中的内容。
required: true
- label: 我已经查看了 **置顶 Issue** 并搜索了现有的 [开放Issue](https://github.com/CherryHQ/cherry-studio/issues)和[已关闭Issue](https://github.com/CherryHQ/cherry-studio/issues?q=is%3Aissue%20state%3Aclosed%20),没有找到类似的问题。
required: true
- label: 我填写了简短且清晰明确的标题,以便开发者在翻阅 Issue 列表时能快速确定大致问题。而不是“一个建议”、“卡住了”等。
required: true
- label: 我确认我正在使用最新版本的 Cherry Studio。
required: true
- 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: 任何能让我们对你所遇到的问题有更多了解的东西

View File

@@ -1,76 +0,0 @@
name: 💡 功能建议 (中文)
description: 为项目提出新的想法
title: '[功能]: '
labels: ['feature']
body:
- type: markdown
attributes:
value: |
感谢您花时间提出新的功能建议!
在提交此问题之前,请确保您已经了解了[项目规划](https://docs.cherry-ai.com/cherrystudio/planning)和[功能介绍](https://docs.cherry-ai.com/cherrystudio/preview)
- type: checkboxes
id: checklist
attributes:
label: 提交前检查
description: |
在提交 Issue 前请确保您已经完成了以下所有步骤
options:
- label: 我理解 Issue 是用于反馈和解决问题的,而非吐槽评论区,将尽可能提供更多信息帮助问题解决。
required: true
- label: 我已经查看了置顶 Issue 并搜索了现有的 [开放Issue](https://github.com/CherryHQ/cherry-studio/issues)和[已关闭Issue](https://github.com/CherryHQ/cherry-studio/issues?q=is%3Aissue%20state%3Aclosed%20),没有找到类似的建议。
required: true
- label: 我填写了简短且清晰明确的标题,以便开发者在翻阅 Issue 列表时能快速确定大致问题。而不是“一个建议”、“卡住了”等。
required: true
- label: 最新的 Cherry Studio 版本没有实现我所提出的功能。
required: true
- 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: problem
attributes:
label: 您的功能建议是否与某个问题/issue相关?
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: 在此添加任何其他与功能建议相关的上下文或截图

View File

@@ -1,77 +0,0 @@
name: ❓ 提问 & 讨论 (中文)
description: 寻求帮助、讨论问题、提出疑问等...
title: '[讨论]: '
labels: ['discussion', 'help wanted']
body:
- type: markdown
attributes:
value: |
感谢您的提问!请尽可能详细地描述您的问题,这样我们才能更好地帮助您。
- type: checkboxes
id: checklist
attributes:
label: Issue 检查清单
description: |
在提交 Issue 前请确保您已经完成了以下所有步骤
options:
- label: 我理解 Issue 是用于反馈和解决问题的,而非吐槽评论区,将尽可能提供更多信息帮助问题解决。
required: true
- label: 我确认自己需要的是提出问题并且讨论问题,而不是 Bug 反馈或需求建议。
required: true
- 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: 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

View File

@@ -1,76 +0,0 @@
name: 🤔 其他问题 (中文)
description: 提交不属于错误报告或功能需求的问题
title: '[其他]: '
body:
- type: markdown
attributes:
value: |
感谢您花时间提出问题!
在提交此问题之前,请确保您已经了解了[常见问题](https://docs.cherry-ai.com/question-contact/questions)和[知识科普](https://docs.cherry-ai.com/question-contact/knowledge)
- type: checkboxes
id: checklist
attributes:
label: 提交前检查
description: |
在提交 Issue 前请确保您已经完成了以下所有步骤
options:
- label: 我理解 Issue 是用于反馈和解决问题的,而非吐槽评论区,将尽可能提供更多信息帮助问题解决。
required: true
- label: 我已经查看了置顶 Issue 并搜索了现有的 [开放Issue](https://github.com/CherryHQ/cherry-studio/issues)和[已关闭Issue](https://github.com/CherryHQ/cherry-studio/issues?q=is%3Aissue%20state%3Aclosed%20),没有找到类似的问题。
required: true
- label: 我填写了简短且清晰明确的标题,以便开发者在翻阅 Issue 列表时能快速确定大致问题。而不是"一个问题"、"求助"等。
required: true
- label: 我的问题不属于错误报告或功能需求类别。
required: true
- 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: question
attributes:
label: 问题描述
description: 请详细描述您的问题或疑问
placeholder: 我想了解有关...的更多信息
validations:
required: true
- type: textarea
id: context
attributes:
label: 相关背景
description: 请提供与您的问题相关的任何背景信息或上下文
placeholder: 我尝试实现...时遇到了疑问
validations:
required: true
- type: textarea
id: attempts
attributes:
label: 您已尝试的方法
description: 请描述您为解决问题已经尝试过的方法(如果有)
- type: textarea
id: additional
attributes:
label: 附加信息
description: 任何能让我们对您的问题有更多了解的信息,包括截图或相关链接

View File

@@ -1,94 +0,0 @@
name: 🐛 Bug Report (English)
description: Create a report to help us improve
title: '[Bug]: '
labels: ['BUG']
body:
- type: markdown
attributes:
value: |
Thank you for taking the time to fill out this bug report!
Before submitting this issue, please make sure that you have understood the [FAQ](https://docs.cherry-ai.com/question-contact/questions) and [Knowledge Science](https://docs.cherry-ai.com/question-contact/knowledge)
- type: checkboxes
id: checklist
attributes:
label: Issue Checklist
description: |
Before submitting an issue, please make sure you have completed the following steps
options:
- label: I understand that issues are for feedback and problem solving, not for complaining in the comment section, and will provide as much information as possible to help solve the problem.
required: true
- label: My issue is not listed in the [FAQ](https://github.com/CherryHQ/cherry-studio/issues/3881).
required: true
- label: I've looked at **pinned issues** and searched for existing [Open Issues](https://github.com/CherryHQ/cherry-studio/issues), [Closed Issues](https://github.com/CherryHQ/cherry-studio/issues?q=is%3Aissue%20state%3Aclosed), and [Discussions](https://github.com/CherryHQ/cherry-studio/discussions), no similar issue or discussion was found.
required: true
- label: I've filled in short, clear headings so that developers can quickly identify a rough idea of what to expect when flipping through the list of issues. And not "a suggestion", "stuck", etc.
required: true
- label: I've confirmed that I am using the latest version of Cherry Studio.
required: true
- 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: Please be as detailed as possible when describing the problem. Please provide screenshots or screen recordings whenever possible to help us better understand the issue.
placeholder: Tell us what happened... (Remember to attach screenshots/recordings if applicable)
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps To Reproduce
description: Provide detailed steps to reproduce the issue so that our developers can reproduce the issue accurately. Please include screenshots or screen recordings for each step when possible.
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
Remember to attach screenshots/recordings for each step when possible!
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: Anything that gives us a better understanding of the problem you're experiencing

View File

@@ -1,76 +0,0 @@
name: 💡 Feature Request (English)
description: Suggest an idea for this project
title: '[Feature]: '
labels: ['feature']
body:
- type: markdown
attributes:
value: |
Thank you for taking the time to submit a feature request!
Before submitting this issue, please make sure you have reviewed the [Project Roadmap](https://docs.cherry-ai.com/cherrystudio/planning) and the [Feature Overview](https://docs.cherry-ai.com/cherrystudio/preview).
- type: checkboxes
id: checklist
attributes:
label: Issue Checklist
description: |
Before submitting an issue, please make sure you have completed the following steps
options:
- label: I understand that issues are for reporting problems and requesting features, not for off-topic comments, and I will provide as much detail as possible to help resolve the issue.
required: true
- label: I have checked the pinned issues and searched through the existing [open issues](https://github.com/CherryHQ/cherry-studio/issues), [closed issues](https://github.com/CherryHQ/cherry-studio/issues?q=is%3Aissue%20state%3Aclosed), and [discussions](https://github.com/CherryHQ/cherry-studio/discussions) and did not find a similar suggestion.
required: true
- label: I have provided a short and descriptive title so that developers can quickly understand the issue when browsing the issue list, rather than vague titles like "A suggestion" or "Stuck."
required: true
- label: The latest version of Cherry Studio does not include the feature I am suggesting.
required: true
- 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: problem
attributes:
label: Is your feature request related to an existing issue?
description: Please briefly describe the problem you are experiencing. If possible, include screenshots or recordings to help illustrate the current situation or pain points.
placeholder: I often feel frustrated because... (Remember to attach screenshots/recordings if applicable)
validations:
required: true
- type: textarea
id: solution
attributes:
label: Desired Solution
description: Please briefly describe what you would like to happen. You can include mockups, screenshots, or screen recordings to better illustrate your proposed solution.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternative Solutions
description: Please briefly describe any alternative solutions or features you have considered. Feel free to include screenshots or mockups of alternative approaches.
- type: textarea
id: additional
attributes:
label: Additional Information
description: Add any other context, screenshots, mockups or recordings that can help us better understand your feature request.

View File

@@ -1,79 +0,0 @@
name: ❓ Questions & Discussion
description: Seeking help, discussing issues, asking questions, etc...
title: '[Discussion]: '
labels: ['discussion', 'help wanted']
body:
- type: markdown
attributes:
value: |
Thank you for your question! Please describe your issue in as much detail as possible so that we can better assist you.
- type: checkboxes
id: checklist
attributes:
label: Issue Checklist
description: |
Before submitting an issue, please make sure you have completed the following steps
options:
- label: I understand that issues are meant for feedback and problem-solving, not for venting, and I will provide as much detail as possible to help resolve the issue.
required: true
- label: I have checked the pinned issues and searched through the existing [open issues](https://github.com/CherryHQ/cherry-studio/issues), [closed issues](https://github.com/CherryHQ/cherry-studio/issues?q=is%3Aissue%20state%3Aclosed), and [discussions](https://github.com/CherryHQ/cherry-studio/discussions) and did not find a similar suggestion.
required: true
- label: I confirm that I am here to ask questions and discuss issues, not to report bugs or request features.
required: true
- 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: question
attributes:
label: Your Question
description: Please describe your issue in detail. Include screenshots or screen recordings whenever possible to help us better understand your question.
placeholder: Please explain your issue as clearly as possible...(Remember to attach screenshots/recordings if applicable)
validations:
required: true
- type: textarea
id: context
attributes:
label: Context
description: Please provide some background information to help us better understand your question. Screenshots or recordings of your current setup or situation can be very helpful.
placeholder: "For example: use case, solutions you've tried, etc. Don't forget to include relevant screenshots/recordings!"
- type: textarea
id: additional
attributes:
label: Additional Information
description: Any other relevant information, screenshots, recordings, or code examples that can help us better assist you
render: shell
- type: dropdown
id: priority
attributes:
label: Priority
description: How urgent is this issue for you?
options:
- Low (Review when available)
- Medium (Would like a response soon)
- High (Blocking progress)
validations:
required: true

View File

@@ -1,76 +0,0 @@
name: 🤔 Other Questions (English)
description: Submit questions that don't fit into bug reports or feature requests
title: '[Other]: '
body:
- type: markdown
attributes:
value: |
Thank you for taking the time to ask a question!
Before submitting this issue, please make sure you've reviewed the [FAQ](https://docs.cherry-ai.com/question-contact/questions) and [Knowledge Base](https://docs.cherry-ai.com/question-contact/knowledge)
- type: checkboxes
id: checklist
attributes:
label: Pre-submission Checklist
description: |
Please ensure you've completed all the steps below before submitting your issue
options:
- label: I understand that Issues are for feedback and problem-solving, not for complaints, and I will provide as much information as possible to help resolve the issue.
required: true
- label: I have checked the pinned Issues and searched through existing [open Issues](https://github.com/CherryHQ/cherry-studio/issues) and [closed Issues](https://github.com/CherryHQ/cherry-studio/issues?q=is%3Aissue%20state%3Aclosed%20) and didn't find similar questions.
required: true
- label: I have written a short and clear title that helps developers quickly understand the nature of my question, rather than vague titles like "A question" or "Help needed".
required: true
- label: My question doesn't fall under bug reports or feature requests categories.
required: true
- type: dropdown
id: platform
attributes:
label: Platform
description: Which 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: question
attributes:
label: Question Description
description: Please describe your question or inquiry in detail
placeholder: I would like to know more about...
validations:
required: true
- type: textarea
id: context
attributes:
label: Relevant Context
description: Please provide any background information or context related to your question
placeholder: I encountered this question while trying to implement...
validations:
required: true
- type: textarea
id: attempts
attributes:
label: Attempted Solutions
description: Please describe any methods you've already tried to resolve your question (if applicable)
- type: textarea
id: additional
attributes:
label: Additional Information
description: Any other information that could help us better understand your question, including screenshots or relevant links

View File

@@ -1,17 +0,0 @@
version: 2
updates:
- package-ecosystem: 'github-actions'
directory: '/'
schedule:
interval: 'monthly'
open-pull-requests-limit: 3
commit-message:
prefix: 'ci'
include: 'scope'
groups:
github-actions:
patterns:
- '*'
update-types:
- 'minor'
- 'patch'

View File

@@ -1,252 +0,0 @@
default-mode:
add:
remove: [pull_request_target, issues]
labels:
# <!-- [Ss]kip `LABEL` --> 跳过一个 label
# <!-- [Rr]emove `LABEL` --> 去掉一个 label
# skips and removes
- name: skip all
content:
regexes: '[Ss]kip (?:[Aa]ll |)[Ll]abels?'
- name: remove all
content:
regexes: '[Rr]emove (?:[Aa]ll |)[Ll]abels?'
- name: skip kind/bug
content:
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)kind/bug(?:`|)'
- name: remove kind/bug
content:
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)kind/bug(?:`|)'
- name: skip kind/enhancement
content:
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)kind/enhancement(?:`|)'
- name: remove kind/enhancement
content:
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)kind/enhancement(?:`|)'
- name: skip kind/question
content:
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)kind/question(?:`|)'
- name: remove kind/question
content:
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)kind/question(?:`|)'
- name: skip area/Connectivity
content:
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)area/Connectivity(?:`|)'
- name: remove area/Connectivity
content:
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)area/Connectivity(?:`|)'
- name: skip area/UI/UX
content:
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)area/UI/UX(?:`|)'
- name: remove area/UI/UX
content:
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)area/UI/UX(?:`|)'
- name: skip kind/documentation
content:
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)kind/documentation(?:`|)'
- name: remove kind/documentation
content:
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)kind/documentation(?:`|)'
- name: skip client:linux
content:
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)client:linux(?:`|)'
- name: remove client:linux
content:
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)client:linux(?:`|)'
- name: skip client:mac
content:
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)client:mac(?:`|)'
- name: remove client:mac
content:
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)client:mac(?:`|)'
- name: skip client:win
content:
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)client:win(?:`|)'
- name: remove client:win
content:
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)client:win(?:`|)'
- name: skip sig/Assistant
content:
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)sig/Assistant(?:`|)'
- name: remove sig/Assistant
content:
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)sig/Assistant(?:`|)'
- name: skip sig/Data
content:
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)sig/Data(?:`|)'
- name: remove sig/Data
content:
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)sig/Data(?:`|)'
- name: skip sig/MCP
content:
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)sig/MCP(?:`|)'
- name: remove sig/MCP
content:
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)sig/MCP(?:`|)'
- name: skip sig/RAG
content:
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)sig/RAG(?:`|)'
- name: remove sig/RAG
content:
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)sig/RAG(?:`|)'
- name: skip lgtm
content:
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)lgtm(?:`|)'
- name: remove lgtm
content:
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)lgtm(?:`|)'
- name: skip License
content:
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)License(?:`|)'
- name: remove License
content:
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)License(?:`|)'
# `Dev Team`
- name: Dev Team
mode:
add: [pull_request_target, issues]
author_association:
- COLLABORATOR
# Area labels
- name: area/Connectivity
content: area/Connectivity
regexes: '代理|[Pp]roxy'
skip-if:
- skip all
- skip area/Connectivity
remove-if:
- remove all
- remove area/Connectivity
- name: area/UI/UX
content: area/UI/UX
regexes: '界面|[Uu][Ii]|重叠|按钮|图标|组件|渲染|菜单|栏目|头像|主题|样式|[Cc][Ss][Ss]'
skip-if:
- skip all
- skip area/UI/UX
remove-if:
- remove all
- remove area/UI/UX
# Kind labels
- name: kind/documentation
content: kind/documentation
regexes: '文档|教程|[Dd]oc(s|umentation)|[Rr]eadme'
skip-if:
- skip all
- skip kind/documentation
remove-if:
- remove all
- remove kind/documentation
# Client labels
- name: client:linux
content: client:linux
regexes: '(?:[Ll]inux|[Uu]buntu|[Dd]ebian)'
skip-if:
- skip all
- skip client:linux
remove-if:
- remove all
- remove client:linux
- name: client:mac
content: client:mac
regexes: '(?:[Mm]ac|[Mm]acOS|[Oo]SX)'
skip-if:
- skip all
- skip client:mac
remove-if:
- remove all
- remove client:mac
- name: client:win
content: client:win
regexes: '(?:[Ww]in|[Ww]indows)'
skip-if:
- skip all
- skip client:win
remove-if:
- remove all
- remove client:win
# SIG labels
- name: sig/Assistant
content: sig/Assistant
regexes: '快捷助手|[Aa]ssistant'
skip-if:
- skip all
- skip sig/Assistant
remove-if:
- remove all
- remove sig/Assistant
- name: sig/Data
content: sig/Data
regexes: '[Ww]ebdav|坚果云|备份|同步|数据|Obsidian|Notion|Joplin|思源'
skip-if:
- skip all
- skip sig/Data
remove-if:
- remove all
- remove sig/Data
- name: sig/MCP
content: sig/MCP
regexes: '[Mm][Cc][Pp]'
skip-if:
- skip all
- skip sig/MCP
remove-if:
- remove all
- remove sig/MCP
- name: sig/RAG
content: sig/RAG
regexes: '知识库|[Rr][Aa][Gg]'
skip-if:
- skip all
- skip sig/RAG
remove-if:
- remove all
- remove sig/RAG
# Other labels
- name: lgtm
content: lgtm
regexes: '(?:[Ll][Gg][Tt][Mm]|[Ll]ooks [Gg]ood [Tt]o [Mm]e)'
skip-if:
- skip all
- skip lgtm
remove-if:
- remove all
- remove lgtm
- name: License
content: License
regexes: '(?:[Ll]icense|[Cc]opyright|[Mm][Ii][Tt]|[Aa]pache)'
skip-if:
- skip all
- skip License
remove-if:
- remove all
- remove License

View File

@@ -1,54 +0,0 @@
<!-- Template from https://github.com/kubevirt/kubevirt/blob/main/.github/PULL_REQUEST_TEMPLATE.md?-->
<!-- Thanks for sending a pull request! Here are some tips for you:
1. Consider creating this PR as draft: https://github.com/CherryHQ/cherry-studio/blob/main/CONTRIBUTING.md
-->
### What this PR does
Before this PR:
After this PR:
<!-- (optional, in `fixes #<issue number>(, fixes #<issue_number>, ...)` format, will close the issue(s) when PR gets merged)*: -->
Fixes #
### Why we need it and why it was done in this way
The following tradeoffs were made:
The following alternatives were considered:
Links to places where the discussion took place: <!-- optional: slack, other GH issue, mailinglist, ... -->
### Breaking changes
<!-- optional -->
If this PR introduces breaking changes, please describe the changes and the impact on users.
### Special notes for your reviewer
<!-- optional -->
### Checklist
This checklist is not enforcing, but it's a reminder of items that could be relevant to every PR.
Approvers are expected to review this list.
- [ ] PR: The PR description is expressive enough and will help future contributors
- [ ] Code: [Write code that humans can understand](https://en.wikiquote.org/wiki/Martin_Fowler#code-for-humans) and [Keep it simple](https://en.wikipedia.org/wiki/KISS_principle)
- [ ] Refactor: You have [left the code cleaner than you found it (Boy Scout Rule)](https://learning.oreilly.com/library/view/97-things-every/9780596809515/ch08.html)
- [ ] Upgrade: Impact of this change on upgrade flows was considered and addressed if required
- [ ] Documentation: A [user-guide update](https://docs.cherry-ai.com) was considered and is present (link) or not required. You want a user-guide update if it's a user facing feature.
### Release note
<!-- Write your release note:
1. Enter your extended release note in the below block. If the PR requires additional action from users switching to the new release, include the string "action required".
2. If no release note is required, just write "NONE".
-->
```release-note
```

View File

@@ -1,27 +0,0 @@
name: Dispatch Docs Update on Release
on:
release:
types: [released]
permissions:
contents: write
jobs:
dispatch-docs-update:
runs-on: ubuntu-latest
steps:
- name: Get Release Tag from Event
id: get-event-tag
shell: bash
run: |
# 从当前 Release 事件中获取 tag_name
echo "tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT
- name: Dispatch update-download-version workflow to cherry-studio-docs
uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.REPO_DISPATCH_TOKEN }}
repository: CherryHQ/cherry-studio-docs
event-type: update-download-version
client-payload: '{"version": "${{ steps.get-event-tag.outputs.tag }}"}'

View File

@@ -1,25 +0,0 @@
name: 'Issue Checker'
on:
issues:
types: [opened, edited]
pull_request_target:
types: [opened, edited]
issue_comment:
types: [created, edited]
permissions:
contents: read
issues: write
pull-requests: write
jobs:
triage:
runs-on: ubuntu-latest
steps:
- uses: MaaAssistantArknights/issue-checker@v1.14
with:
repo-token: '${{ secrets.GITHUB_TOKEN }}'
configuration-path: .github/issue-checker.yml
not-before: 2022-08-05T00:00:00Z
include-title: 1

View File

@@ -1,58 +0,0 @@
name: 'Stale Issue Management'
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
env:
daysBeforeStale: 30 # Number of days of inactivity before marking as stale
daysBeforeClose: 10 # Number of days to wait after marking as stale before closing
jobs:
stale:
if: github.repository_owner == 'CherryHQ'
runs-on: ubuntu-latest
permissions:
actions: write # Workaround for https://github.com/actions/stale/issues/1090
issues: write
# Completely disable stalling for PRs
pull-requests: none
contents: none
steps:
- name: Close needs-more-info issues
uses: actions/stale@v9
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
only-labels: 'needs-more-info'
days-before-stale: ${{ env.daysBeforeStale }}
days-before-close: 0 # Close immediately after stale
stale-issue-label: 'inactive'
close-issue-label: 'closed:no-response'
stale-issue-message: |
This issue has been labeled as needing more information and has been inactive for ${{ env.daysBeforeStale }} days.
It will be closed now due to lack of additional information.
该问题被标记为"需要更多信息"且已经 ${{ env.daysBeforeStale }} 天没有任何活动,将立即关闭。
operations-per-run: 50
exempt-issue-labels: 'pending, Dev Team'
days-before-pr-stale: -1
days-before-pr-close: -1
- name: Close inactive issues
uses: actions/stale@v9
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: ${{ env.daysBeforeStale }}
days-before-close: ${{ env.daysBeforeClose }}
stale-issue-label: 'inactive'
stale-issue-message: |
This issue has been inactive for a prolonged period and will be closed automatically in ${{ env.daysBeforeClose }} days.
该问题已长时间处于闲置状态,${{ env.daysBeforeClose }} 天后将自动关闭。
exempt-issue-labels: 'pending, Dev Team, kind/enhancement'
days-before-pr-stale: -1 # Completely disable stalling for PRs
days-before-pr-close: -1 # Completely disable closing for PRs
# Temporary to reduce the huge issues number
operations-per-run: 1000
debug-only: false

View File

@@ -1,294 +0,0 @@
name: Nightly Build
on:
workflow_dispatch:
schedule:
- cron: '0 17 * * *' # 1:00 BJ Time
permissions:
contents: write
actions: write # Required for deleting artifacts
jobs:
cleanup-artifacts:
runs-on: ubuntu-latest
steps:
- name: Delete old artifacts
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
# Calculate the date 14 days ago
cutoff_date=$(date -d "14 days ago" +%Y-%m-%d)
# List and delete artifacts older than cutoff date
gh api repos/$REPO/actions/artifacts --paginate | \
jq -r '.artifacts[] | select(.name | startswith("cherry-studio-nightly-")) | select(.created_at < "'$cutoff_date'") | .id' | \
while read artifact_id; do
echo "Deleting artifact $artifact_id"
gh api repos/$REPO/actions/artifacts/$artifact_id -X DELETE
done
check-repository:
runs-on: ubuntu-latest
outputs:
should_run: ${{ github.repository == 'CherryHQ/cherry-studio' }}
steps:
- name: Check if running in main repository
run: |
echo "Running in repository: ${{ github.repository }}"
echo "Should run: ${{ github.repository == 'CherryHQ/cherry-studio' }}"
nightly-build:
needs: check-repository
if: needs.check-repository.outputs.should_run == 'true'
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-latest, windows-latest, ubuntu-latest]
fail-fast: false
steps:
- name: Check out Git repository
uses: actions/checkout@v5
with:
ref: main
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: macos-latest dependencies fix
if: matrix.os == 'macos-latest'
run: |
brew install python-setuptools
- name: Install corepack
run: corepack enable && corepack prepare yarn@4.6.0 --activate
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
- name: Cache yarn dependencies
uses: actions/cache@v4
with:
path: |
${{ steps.yarn-cache-dir-path.outputs.dir }}
node_modules
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install Dependencies
run: yarn install
- name: Generate date tag
id: date
run: echo "date=$(date +'%Y%m%d')" >> $GITHUB_OUTPUT
shell: bash
- name: Build Linux
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get install -y rpm
yarn build:linux
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192
MAIN_VITE_CHERRYIN_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYIN_CLIENT_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
- name: Build Mac
if: matrix.os == 'macos-latest'
run: |
yarn build:mac
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ vars.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ vars.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192
MAIN_VITE_CHERRYIN_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYIN_CLIENT_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
- name: Build Windows
if: matrix.os == 'windows-latest'
run: |
yarn build:win
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192
MAIN_VITE_CHERRYIN_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYIN_CLIENT_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
- name: Rename artifacts with nightly format
shell: bash
run: |
mkdir -p renamed-artifacts
DATE=${{ steps.date.outputs.date }}
# Windows artifacts - based on actual file naming pattern
if [ "${{ matrix.os }}" == "windows-latest" ]; then
# Setup installer
find dist -name "*-x64-setup.exe" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-x64-setup.exe \;
find dist -name "*-arm64-setup.exe" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-arm64-setup.exe \;
# Portable exe
find dist -name "*-x64-portable.exe" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-x64-portable.exe \;
find dist -name "*-arm64-portable.exe" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-arm64-portable.exe \;
fi
# macOS artifacts
if [ "${{ matrix.os }}" == "macos-latest" ]; then
find dist -name "*-arm64.dmg" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-arm64.dmg \;
find dist -name "*-x64.dmg" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-x64.dmg \;
fi
# Linux artifacts
if [ "${{ matrix.os }}" == "ubuntu-latest" ]; then
find dist -name "*-x86_64.AppImage" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-x86_64.AppImage \;
find dist -name "*-arm64.AppImage" -exec cp {} renamed-artifacts/cherry-studio-nightly-${DATE}-arm64.AppImage \;
fi
# Copy update files
cp dist/latest*.yml renamed-artifacts/ || true
# Generate SHA256 checksums (Windows)
- name: Generate SHA256 checksums (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
cd renamed-artifacts
echo "# SHA256 checksums for Windows - $(Get-Date -Format 'yyyy-MM-dd')" > SHA256SUMS.txt
Get-ChildItem -File | Where-Object { $_.Name -ne 'SHA256SUMS.txt' } | ForEach-Object {
$file = $_.Name
$hash = (Get-FileHash -Algorithm SHA256 $file).Hash.ToLower()
Add-Content -Path SHA256SUMS.txt -Value "$hash $file"
}
cat SHA256SUMS.txt
# Generate SHA256 checksums (macOS/Linux)
- name: Generate SHA256 checksums (macOS/Linux)
if: runner.os != 'Windows'
shell: bash
run: |
cd renamed-artifacts
echo "# SHA256 checksums for ${{ runner.os }} - $(date +'%Y-%m-%d')" > SHA256SUMS.txt
if command -v shasum &>/dev/null; then
# macOS
shasum -a 256 * 2>/dev/null | grep -v SHA256SUMS.txt >> SHA256SUMS.txt || echo "No files to hash" >> SHA256SUMS.txt
else
# Linux
sha256sum * 2>/dev/null | grep -v SHA256SUMS.txt >> SHA256SUMS.txt || echo "No files to hash" >> SHA256SUMS.txt
fi
cat SHA256SUMS.txt
- name: List files to be uploaded
shell: bash
run: |
echo "准备上传的文件:"
if [ -x "$(command -v tree)" ]; then
tree renamed-artifacts
elif [ "$RUNNER_OS" == "Windows" ]; then
dir renamed-artifacts
else
ls -la renamed-artifacts
fi
echo "总计: $(find renamed-artifacts -type f | wc -l) 个文件"
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: cherry-studio-nightly-${{ steps.date.outputs.date }}-${{ matrix.os }}
path: renamed-artifacts/*
retention-days: 3 # 保留3天
compression-level: 8
Build-Summary:
needs: nightly-build
if: always()
runs-on: ubuntu-latest
steps:
- name: Get date tag
id: date
run: echo "date=$(date +'%Y%m%d')" >> $GITHUB_OUTPUT
shell: bash
- name: Download all artifacts
uses: actions/download-artifact@v5
with:
path: all-artifacts
merge-multiple: false
continue-on-error: true
- name: Create summary report
run: |
echo "## ⚠️ 警告:这是每日构建版本" >> $GITHUB_STEP_SUMMARY
echo "此版本为自动构建的不稳定版本,仅供测试使用。不建议在生产环境中使用。" >> $GITHUB_STEP_SUMMARY
echo "安装此版本前请务必备份数据,并做好数据迁移准备。" >> $GITHUB_STEP_SUMMARY
echo "构建日期:$(date +'%Y-%m-%d')" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "## 📦 安装包校验和" >> $GITHUB_STEP_SUMMARY
echo "请在下载后验证文件完整性。提供 SHA256 校验和。" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Check each platform's artifacts and show checksums if available
# Windows
WIN_ARTIFACT_DIR="all-artifacts/cherry-studio-nightly-${{ steps.date.outputs.date }}-windows-latest"
if [ -d "$WIN_ARTIFACT_DIR" ] && [ -f "$WIN_ARTIFACT_DIR/SHA256SUMS.txt" ]; then
echo "### Windows 安装包" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
cat "$WIN_ARTIFACT_DIR/SHA256SUMS.txt" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
else
echo "### Windows 安装包" >> $GITHUB_STEP_SUMMARY
echo "❌ Windows 构建未成功完成或未生成校验和。" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
fi
# macOS
MAC_ARTIFACT_DIR="all-artifacts/cherry-studio-nightly-${{ steps.date.outputs.date }}-macos-latest"
if [ -d "$MAC_ARTIFACT_DIR" ] && [ -f "$MAC_ARTIFACT_DIR/SHA256SUMS.txt" ]; then
echo "### macOS 安装包" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
cat "$MAC_ARTIFACT_DIR/SHA256SUMS.txt" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
else
echo "### macOS 安装包" >> $GITHUB_STEP_SUMMARY
echo "❌ macOS 构建未成功完成或未生成校验和。" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
fi
# Linux
LINUX_ARTIFACT_DIR="all-artifacts/cherry-studio-nightly-${{ steps.date.outputs.date }}-ubuntu-latest"
if [ -d "$LINUX_ARTIFACT_DIR" ] && [ -f "$LINUX_ARTIFACT_DIR/SHA256SUMS.txt" ]; then
echo "### Linux 安装包" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
cat "$LINUX_ARTIFACT_DIR/SHA256SUMS.txt" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
else
echo "### Linux 安装包" >> $GITHUB_STEP_SUMMARY
echo "❌ Linux 构建未成功完成或未生成校验和。" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
fi
echo "## ⚠️ Warning: This is a nightly build version" >> $GITHUB_STEP_SUMMARY
echo "This version is an unstable version built automatically and is only for testing. It is not recommended to use it in a production environment." >> $GITHUB_STEP_SUMMARY
echo "Please backup your data before installing this version and prepare for data migration." >> $GITHUB_STEP_SUMMARY
echo "Build date: $(date +'%Y-%m-%d')" >> $GITHUB_STEP_SUMMARY

View File

@@ -1,58 +0,0 @@
name: Pull Request CI
permissions:
contents: read
on:
workflow_dispatch:
pull_request:
branches:
- main
- develop
jobs:
build:
runs-on: ubuntu-latest
env:
PRCI: true
steps:
- name: Check out Git repository
uses: actions/checkout@v5
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install corepack
run: corepack enable && corepack prepare yarn@4.6.0 --activate
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
- name: Cache yarn dependencies
uses: actions/cache@v4
with:
path: |
${{ steps.yarn-cache-dir-path.outputs.dir }}
node_modules
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install Dependencies
run: yarn install
- name: Lint Check
run: yarn test:lint
- name: Type Check
run: yarn typecheck
- name: i18n Check
run: yarn check:i18n
- name: Test
run: yarn test

View File

@@ -2,14 +2,6 @@ name: Release
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag (e.g. v1.0.0)'
required: true
default: 'v1.0.0'
push:
tags:
- v*.*.*
permissions:
contents: write
@@ -29,22 +21,14 @@ jobs:
with:
fetch-depth: 0
- name: Get release tag
- name: Get release tag from package.json
id: get-tag
shell: bash
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
else
echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
fi
VERSION=$(node -p "require('./package.json').version")
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
echo "Using version from package.json: v$VERSION"
- name: Set package.json version
shell: bash
run: |
TAG="${{ steps.get-tag.outputs.tag }}"
VERSION="${TAG#v}"
npm version "$VERSION" --no-git-tag-version --allow-same-version
- name: Install Node.js
uses: actions/setup-node@v4

337
README.md
View File

@@ -1,316 +1,51 @@
<div align="right" >
<details>
<summary >🌐 Language</summary>
<div>
<div align="right">
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=en">English</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=zh-CN">简体中文</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=zh-TW">繁體中文</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=ja">日本語</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=ko">한국어</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=hi">हिन्दी</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=th">ไทย</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=fr">Français</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=de">Deutsch</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=es">Español</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=it">Italiano</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=ru">Русский</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=pt">Português</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=nl">Nederlands</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=pl">Polski</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=ar">العربية</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=fa">فارسی</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=tr">Türkçe</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=vi">Tiếng Việt</a></p>
<p><a href="https://openaitx.github.io/view.html?user=CherryHQ&project=cherry-studio&lang=id">Bahasa Indonesia</a></p>
</div>
</div>
</details>
</div>
## 🚀 Cherry Studio 企业版 (Enterprise Edition)
<h1 align="center">
<a href="https://github.com/CherryHQ/cherry-studio/releases">
<img src="https://github.com/CherryHQ/cherry-studio/blob/main/build/icon.png?raw=true" width="150" height="150" alt="banner" /><br>
</a>
</h1>
在社区版的基础上,我们隆重推出 **Cherry Studio 企业版**——一个专为现代团队和企业打造的、可私有化部署的 AI 生产力与管理平台。
<p align="center">English | <a href="./docs/README.zh.md">中文</a> | <a href="https://cherry-ai.com">Official Site</a> | <a href="https://docs.cherry-ai.com/cherry-studio-wen-dang/en-us">Documents</a> | <a href="./docs/dev.md">Development</a> | <a href="https://github.com/CherryHQ/cherry-studio/issues">Feedback</a><br></p>
企业版旨在解决团队协作中的核心痛点,通过集中化的方式管理 AI 资源、知识和数据,在保障企业数据 100% 安全可控的前提下,全面提升组织的工作效率、创新能力和合规性。
<div align="center">
[![][deepwiki-shield]][deepwiki-link]
[![][twitter-shield]][twitter-link]
[![][discord-shield]][discord-link]
[![][telegram-shield]][telegram-link]
### 核心优势
</div>
<div align="center">
[![][github-release-shield]][github-release-link]
[![][github-nightly-shield]][github-nightly-link]
[![][github-contributors-shield]][github-contributors-link]
[![][license-shield]][license-link]
[![][commercial-shield]][commercial-link]
[![][sponsor-shield]][sponsor-link]
- **统一模型管理**: 在企业内部集中接入和管理各类云端大模型(如 OpenAI, Anthropic, Google Gemini 等)以及本地私有化部署的模型,员工无需自行配置,开箱即用。
- **企业级知识库**: 构建、管理并授权共享团队知识库。确保核心知识有效沉淀,让团队成员基于统一、准确的信息进行 AI 交互,提升回答的一致性和专业性。
- **精细化权限控制**: 通过统一的管理后台,轻松管理员工账号,并基于角色(如管理员、普通成员)分配不同的模型、知识库和功能访问权限。
- **完全私有化部署**: 支持将完整的后端服务部署在企业内部服务器或您自己的私有云中,实现数据 100% 私有可控,满足最严格的数据安全与合规要求。
- **可靠的后端服务**: 提供稳定的 API 服务、企业级数据备份与恢复机制,保障业务连续性。
</div>
### ✨ 在线体验
<div align="center">
<a href="https://hellogithub.com/repository/1605492e1e2a4df3be07abfa4578dd37" target="_blank" style="text-decoration: none"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=1605492e1e2a4df3be07abfa4578dd37" alt="FeaturedHelloGitHub" width="220" height="55" /></a>
<a href="https://trendshift.io/repositories/14318" target="_blank" style="text-decoration: none"><img src="https://trendshift.io/api/badge/repositories/14318" alt="CherryHQ%2Fcherry-studio | Trendshift" width="220" height="55" /></a>
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry&#0045;studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry&#0032;Studio - AI&#0032;Chatbots&#0044;&#0032;AI&#0032;Desktop&#0032;Client | Product Hunt" width="220" height="55" /></a>
</div>
**[体验说明手册,点击查看](https://doc.weixin.qq.com/doc/w3_ASIAPQaBALgCNdQv1pcxUTJGhXLsX?scode=APkA7A7AeJABIVWchL1vASIAPQaBALg)**
# 🍒 Cherry Studio
Cherry Studio is a desktop client that supports multiple LLM providers, available on Windows, Mac and Linux.
👏 Join [Telegram Group](https://t.me/CherryStudioAI)[Discord](https://discord.gg/wez8HtpxqQ) | [QQ Group(575014769)](https://qm.qq.com/q/lo0D4qVZKi)
❤️ Like Cherry Studio? Give it a star 🌟 or [Sponsor](docs/sponsor.md) to support the development!
# 🌠 Screenshot
![](https://github.com/user-attachments/assets/36dddb2c-e0fb-4a5f-9411-91447bab6e18)
![](https://github.com/user-attachments/assets/f549e8a0-2385-40b4-b52b-2039e39f2930)
![](https://github.com/user-attachments/assets/58e0237c-4d36-40de-b428-53051d982026)
# 🌟 Key Features
1. **Diverse LLM Provider Support**:
- ☁️ Major LLM Cloud Services: OpenAI, Gemini, Anthropic, and more
- 🔗 AI Web Service Integration: Claude, Peplexity, Poe, and others
- 💻 Local Model Support with Ollama, LM Studio
2. **AI Assistants & Conversations**:
- 📚 300+ Pre-configured AI Assistants
- 🤖 Custom Assistant Creation
- 💬 Multi-model Simultaneous Conversations
3. **Document & Data Processing**:
- 📄 Supports Text, Images, Office, PDF, and more
- ☁️ WebDAV File Management and Backup
- 📊 Mermaid Chart Visualization
- 💻 Code Syntax Highlighting
4. **Practical Tools Integration**:
- 🔍 Global Search Functionality
- 📝 Topic Management System
- 🔤 AI-powered Translation
- 🎯 Drag-and-drop Sorting
- 🔌 Mini Program Support
- ⚙️ MCP(Model Context Protocol) Server
5. **Enhanced User Experience**:
- 🖥️ Cross-platform Support for Windows, Mac, and Linux
- 📦 Ready to Use - No Environment Setup Required
- 🎨 Light/Dark Themes and Transparent Window
- 📝 Complete Markdown Rendering
- 🤲 Easy Content Sharing
# 📝 Roadmap
We're actively working on the following features and improvements:
1. 🎯 **Core Features**
- Selection Assistant with smart content selection enhancement
- Deep Research with advanced research capabilities
- Memory System with global context awareness
- Document Preprocessing with improved document handling
- MCP Marketplace for Model Context Protocol ecosystem
2. 🗂 **Knowledge Management**
- Notes and Collections
- Dynamic Canvas visualization
- OCR capabilities
- TTS (Text-to-Speech) support
3. 📱 **Platform Support**
- HarmonyOS Edition (PC)
- Android App (Phase 1)
- iOS App (Phase 1)
- Multi-Window support
- Window Pinning functionality
4. 🔌 **Advanced Features**
- Plugin System
- ASR (Automatic Speech Recognition)
- Assistant and Topic Interaction Refactoring
Track our progress and contribute on our [project board](https://github.com/orgs/CherryHQ/projects/7).
Want to influence our roadmap? Join our [GitHub Discussions](https://github.com/CherryHQ/cherry-studio/discussions) to share your ideas and feedback!
# 🌈 Theme
- Theme Gallery: <https://cherrycss.com>
- Aero Theme: <https://github.com/hakadao/CherryStudio-Aero>
- PaperMaterial Theme: <https://github.com/rainoffallingstar/CherryStudio-PaperMaterial>
- Claude dynamic-style: <https://github.com/bjl101501/CherryStudio-Claudestyle-dynamic>
- Maple Neon Theme: <https://github.com/BoningtonChen/CherryStudio_themes>
Welcome PR for more themes
# 🤝 Contributing
We welcome contributions to Cherry Studio! Here are some ways you can contribute:
1. **Contribute Code**: Develop new features or optimize existing code.
2. **Fix Bugs**: Submit fixes for any bugs you find.
3. **Maintain Issues**: Help manage GitHub issues.
4. **Product Design**: Participate in design discussions.
5. **Write Documentation**: Improve user manuals and guides.
6. **Community Engagement**: Join discussions and help users.
7. **Promote Usage**: Spread the word about Cherry Studio.
Refer to the [Branching Strategy](docs/branching-strategy-en.md) for contribution guidelines
## Getting Started
1. **Fork the Repository**: Fork and clone it to your local machine.
2. **Create a Branch**: For your changes.
3. **Submit Changes**: Commit and push your changes.
4. **Open a Pull Request**: Describe your changes and reasons.
For more detailed guidelines, please refer to our [Contributing Guide](CONTRIBUTING.md).
Thank you for your support and contributions!
# 🔧 Developer Co-creation Program
We are launching the Cherry Studio Developer Co-creation Program to foster a healthy and positive-feedback loop within the open-source ecosystem. We believe that great software is built collaboratively, and every merged pull request breathes new life into the project.
We sincerely invite you to join our ranks of contributors and shape the future of Cherry Studio with us.
## Contributor Rewards Program
To give back to our core contributors and create a virtuous cycle, we have established the following long-term incentive plan.
**The inaugural tracking period for this program will be Q3 2025 (July, August, September). Rewards for this cycle will be distributed on October 1st.**
Within any tracking period (e.g., July 1st to September 30th for the first cycle), any developer who contributes more than **30 meaningful commits** to any of Cherry Studio's open-source projects on GitHub will be eligible for the following benefits:
- **Cursor Subscription Sponsorship**: Receive a **$70 USD** credit or reimbursement for your [Cursor](https://cursor.sh/) subscription, making AI your most efficient coding partner.
- **Unlimited Model Access**: Get **unlimited** API calls for the **DeepSeek** and **Qwen** models.
- **Cutting-Edge Tech Access**: Enjoy occasional perks, including API access to models like **Claude**, **Gemini**, and **OpenAI**, keeping you at the forefront of technology.
## Growing Together & Future Plans
A vibrant community is the driving force behind any sustainable open-source project. As Cherry Studio grows, so will our rewards program. We are committed to continuously aligning our benefits with the best-in-class tools and resources in the industry. This ensures our core contributors receive meaningful support, creating a positive cycle where developers, the community, and the project grow together.
**Moving forward, the project will also embrace an increasingly open stance to give back to the entire open-source community.**
## How to Get Started?
We look forward to your first Pull Request!
You can start by exploring our repositories, picking up a `good first issue`, or proposing your own enhancements. Every commit is a testament to the spirit of open source.
Thank you for your interest and contributions.
Let's build together.
# 🏢 Enterprise Edition
Building on the Community Edition, we are proud to introduce **Cherry Studio Enterprise Edition**—a privately-deployable AI productivity and management platform designed for modern teams and enterprises.
The Enterprise Edition addresses core challenges in team collaboration by centralizing the management of AI resources, knowledge, and data. It empowers organizations to enhance efficiency, foster innovation, and ensure compliance, all while maintaining 100% control over their data in a secure environment.
## Core Advantages
- **Unified Model Management**: Centrally integrate and manage various cloud-based LLMs (e.g., OpenAI, Anthropic, Google Gemini) and locally deployed private models. Employees can use them out-of-the-box without individual configuration.
- **Enterprise-Grade Knowledge Base**: Build, manage, and share team-wide knowledge bases. Ensures knowledge retention and consistency, enabling team members to interact with AI based on unified and accurate information.
- **Fine-Grained Access Control**: Easily manage employee accounts and assign role-based permissions for different models, knowledge bases, and features through a unified admin backend.
- **Fully Private Deployment**: Deploy the entire backend service on your on-premises servers or private cloud, ensuring your data remains 100% private and under your control to meet the strictest security and compliance standards.
- **Reliable Backend Services**: Provides stable API services and enterprise-grade data backup and recovery mechanisms to ensure business continuity.
## ✨ Online Demo
> 🚧 **Public Beta Notice**
> 🚧 **公测版说明**
>
> The Enterprise Edition is currently in its early public beta stage, and we are actively iterating and optimizing its features. We are aware that it may not be perfectly stable yet. If you encounter any issues or have valuable suggestions during your trial, we would be very grateful if you could contact us via email to provide feedback.
> 企业版目前处于早期公测阶段,我们正在积极迭代与优化功能。我们深知它可能还不够稳定,如果您在体验过程中遇到任何问题或有宝贵的建议,我们非常欢迎您通过邮件联系我们进行反馈。
**🔗 [Cherry Studio Enterprise](https://www.cherry-ai.com/enterprise)**
- **后台地址**: https://demo.admin.cherry-ai.com
- **体验账号**: admin
- **体验密码**: admin123
## Version Comparison
请下载配套的企业版客户端以获得完整体验:
| Feature | Community Edition | Enterprise Edition |
| :---------------- | :----------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------- |
| **Open Source** | ✅ Yes | ⭕️ Partially released to customers |
| **Cost** | Free for Personal Use / Commercial License | Buyout / Subscription Fee |
| **Admin Backend** | — | ● Centralized **Model** Access<br>**Employee** Management<br>● Shared **Knowledge Base**<br>**Access** Control<br>**Data** Backup |
| **Server** | — | ✅ Dedicated Private Deployment |
- **客户端下载**: https://pan.quark.cn/s/4b9d42625fd9
- **服务端地址**: https://demo.api.cherry-ai.com
- **账号**: demo
- **密码**: password
## Get the Enterprise Edition
![image](https://github.com/user-attachments/assets/c0931a77-ae8d-4442-827a-ab1b28563943)
We believe the Enterprise Edition will become your team's AI productivity engine. If you are interested in Cherry Studio Enterprise Edition and would like to learn more, request a quote, or schedule a demo, please feel free to contact us.
- **For Business Inquiries & Purchasing**:
### 版本对比
| 特性 | 社区版 (Community) | 企业版 (Enterprise) |
| :---------------- | :----------------- | :----------------------------------------------------------------------------------------------- |
| **开源** | ✅ 是 | ❌ 否 |
| **费用** | 个人免费/商用授权 | 买断/订阅服务费 |
| **管理后台/后端** | — | ● **模型**集中接入<br>● **员工**管理<br>● **共享**知识库<br>● **权限**控制<br>● **企业**数据备份<br>● **Dify** 工作流接入 |
| **服务端** | — | ✅ 专属私有部署 |
### 获取企业版
我们相信企业版将成为您团队的 AI 生产力引擎。如果您对 Cherry Studio 企业版感兴趣,希望了解更多详情、获取报价或申请产品演示,请通过以下方式联系我们。
- **商务合作与采购咨询**:
**📧 [bd@cherry-ai.com](mailto:bd@cherry-ai.com)**
# 🔗 Related Projects
- [one-api](https://github.com/songquanpeng/one-api): LLM API management and distribution system supporting mainstream models like OpenAI, Azure, and Anthropic. Features a unified API interface, suitable for key management and secondary distribution.
- [ublacklist](https://github.com/iorate/ublacklist): Blocks specific sites from appearing in Google search results
# 🚀 Contributors
<a href="https://github.com/CherryHQ/cherry-studio/graphs/contributors">
<img src="https://contrib.rocks/image?repo=CherryHQ/cherry-studio" />
</a>
<br /><br />
# 📊 GitHub Stats
![Stats](https://repobeats.axiom.co/api/embed/a693f2e5f773eed620f70031e974552156c7f397.svg 'Repobeats analytics image')
# ⭐️ Star History
<a href="https://www.star-history.com/#CherryHQ/cherry-studio&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=CherryHQ/cherry-studio&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=CherryHQ/cherry-studio&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=CherryHQ/cherry-studio&type=Date" />
</picture>
</a>
<!-- Links & Images -->
[deepwiki-shield]: https://img.shields.io/badge/Deepwiki-CherryHQ-0088CC?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNy45MyAzMiI+PHBhdGggZD0iTTE5LjMzIDE0LjEyYy42Ny0uMzkgMS41LS4zOSAyLjE4IDBsMS43NCAxYy4wNi4wMy4xMS4wNi4xOC4wN2guMDRjLjA2LjAzLjEyLjAzLjE4LjAzaC4wMmMuMDYgMCAuMTEgMCAuMTctLjAyaC4wM2MuMDYtLjAyLjEyLS4wNS4xNy0uMDhoLjAybDMuNDgtMi4wMWMuMjUtLjE0LjQtLjQxLjQtLjdWOC40YS44MS44MSAwIDAgMC0uNC0uN2wtMy40OC0yLjAxYS44My44MyAwIDAgMC0uODEgMEwxOS43NyA3LjdoLS4wMWwtLjE1LjEyLS4wMi4wMnMtLjA3LjA5LS4xLjE0VjhhLjQuNCAwIDAgMC0uMDguMTd2LjA0Yy0uMDMuMDYtLjAzLjEyLS4wMy4xOXYyLjAxYzAgLjc4LS40MSAxLjQ5LTEuMDkgMS44OC0uNjcuMzktMS41LjM5LTIuMTggMGwtMS43NC0xYS42LjYgMCAwIDAtLjIxLS4wOGMtLjA2LS4wMS0uMTItLjAyLS4xOC0uMDJoLS4wM2MtLjA2IDAtLjExLjAxLS4xNy4wMmgtLjAzYy0uMDYuMDItLjEyLjA0LS4xNy4wN2gtLjAybC0zLjQ3IDIuMDFjLS4yNS4xNC0uNC40MS0uNC43VjE4YzAgLjI5LjE1LjU1LjQuN2wzLjQ4IDIuMDFoLjAyYy4wNi4wNC4xMS4wNi4xNy4wOGguMDNjLjA1LjAyLjExLjAzLjE3LjAzaC4wMmMuMDYgMCAuMTIgMCAuMTgtLjAyaC4wNGMuMDYtLjAzLjEyLS4wNS4xOC0uMDhsMS43NC0xYy42Ny0uMzkgMS41LS4zOSAyLjE3IDBzMS4wOSAxLjExIDEuMDkgMS44OHYyLjAxYzAgLjA3IDAgLjEzLjAyLjE5di4wNGMuMDMuMDYuMDUuMTIuMDguMTd2LjAycy4wOC4wOS4xMi4xM2wuMDIuMDJzLjA5LjA4LjE1LjExYzAgMCAuMDEgMCAuMDEuMDFsMy40OCAyLjAxYy4yNS4xNC41Ni4xNC44MSAwbDMuNDgtMi4wMWMuMjUtLjE0LjQtLjQxLjQtLjd2LTQuMDFhLjgxLjgxIDAgMCAwLS40LS43bC0zLjQ4LTIuMDFoLS4wMmMtLjA1LS4wNC0uMTEtLjA2LS4xNy0uMDhoLS4wM2EuNS41IDAgMCAwLS4xNy0uMDNoLS4wM2MtLjA2IDAtLjEyIDAtLjE4LjAyLS4wNy4wMi0uMTUuMDUtLjIxLjA4bC0xLjc0IDFjLS42Ny4zOS0xLjUuMzktMi4xNyAwYTIuMTkgMi4xOSAwIDAgMS0xLjA5LTEuODhjMC0uNzguNDItMS40OSAxLjA5LTEuODhaIiBzdHlsZT0iZmlsbDojNWRiZjlkIi8+PHBhdGggZD0ibS40IDEzLjExIDMuNDcgMi4wMWMuMjUuMTQuNTYuMTQuOCAwbDMuNDctMi4wMWguMDFsLjE1LS4xMi4wMi0uMDJzLjA3LS4wOS4xLS4xNGwuMDItLjAyYy4wMy0uMDUuMDUtLjExLjA3LS4xN3YtLjA0Yy4wMy0uMDYuMDMtLjEyLjAzLS4xOVYxMC40YzAtLjc4LjQyLTEuNDkgMS4wOS0xLjg4czEuNS0uMzkgMi4xOCAwbDEuNzQgMWMuMDcuMDQuMTQuMDcuMjEuMDguMDYuMDEuMTIuMDIuMTguMDJoLjAzYy4wNiAwIC4xMS0uMDEuMTctLjAyaC4wM2MuMDYtLjAyLjEyLS4wNC4xNy0uMDdoLjAybDMuNDctMi4wMmMuMjUtLjE0LjQtLjQxLjQtLjd2LTRhLjgxLjgxIDAgMCAwLS40LS43bC0zLjQ2LTJhLjgzLjgzIDAgMCAwLS44MSAwbC0zLjQ4IDIuMDFoLS4wMWwtLjE1LjEyLS4wMi4wMi0uMS4xMy0uMDIuMDJjLS4wMy4wNS0uMDUuMTEtLjA3LjE3di4wNGMtLjAzLjA2LS4wMy4xMi0uMDMuMTl2Mi4wMWMwIC43OC0uNDIgMS40OS0xLjA5IDEuODhzLTEuNS4zOS0yLjE4IDBsLTEuNzQtMWEuNi42IDAgMCAwLS4yMS0uMDhjLS4wNi0uMDEtLjEyLS4wMi0uMTgtLjAyaC0uMDNjLS4wNiAwLS4xMS4wMS0uMTcuMDJoLS4wM2MtLjA2LjAyLS4xMi4wNS0uMTcuMDhoLS4wMkwuNCA3LjcxYy0uMjUuMTQtLjQuNDEtLjQuNjl2NC4wMWMwIC4yOS4xNS41Ni40LjciIHN0eWxlPSJmaWxsOiM0NDY4YzQiLz48cGF0aCBkPSJtMTcuODQgMjQuNDgtMy40OC0yLjAxaC0uMDJjLS4wNS0uMDQtLjExLS4wNi0uMTctLjA4aC0uMDNhLjUuNSAwIDAgMC0uMTctLjAzaC0uMDNjLS4wNiAwLS4xMiAwLS4xOC4wMmgtLjA0Yy0uMDYuMDMtLjEyLjA1LS4xOC4wOGwtMS43NCAxYy0uNjcuMzktMS41LjM5LTIuMTggMGEyLjE5IDIuMTkgMCAwIDEtMS4wOS0xLjg4di0yLjAxYzAtLjA2IDAtLjEzLS4wMi0uMTl2LS4wNGMtLjAzLS4wNi0uMDUtLjExLS4wOC0uMTdsLS4wMi0uMDJzLS4wNi0uMDktLjEtLjEzTDguMjkgMTlzLS4wOS0uMDgtLjE1LS4xMWgtLjAxbC0zLjQ3LTIuMDJhLjgzLjgzIDAgMCAwLS44MSAwTC4zNyAxOC44OGEuODcuODcgMCAwIDAtLjM3LjcxdjQuMDFjMCAuMjkuMTUuNTUuNC43bDMuNDcgMi4wMWguMDJjLjA1LjA0LjExLjA2LjE3LjA4aC4wM2MuMDUuMDIuMTEuMDMuMTYuMDNoLjAzYy4wNiAwIC4xMiAwIC4xOC0uMDJoLjA0Yy4wNi0uMDMuMTItLjA1LjE4LS4wOGwxLjc0LTFjLjY3LS4zOSAxLjUtLjM5IDIuMTcgMHMxLjA5IDEuMTEgMS4wOSAxLjg4djIuMDFjMCAuMDcgMCAuMTMuMDIuMTl2LjA0Yy4wMy4wNi4wNS4xMS4wOC4xN2wuMDIuMDJzLjA2LjA5LjEuMTRsLjAyLjAycy4wOS4wOC4xNS4xMWguMDFsMy40OCAyLjAyYy4yNS4xNC41Ni4xNC44MSAwbDMuNDgtMi4wMWMuMjUtLjE0LjQtLjQxLjQtLjdWMjUuMmEuODEuODEgMCAwIDAtLjQtLjdaIiBzdHlsZT0iZmlsbDojNDI5M2Q5Ii8+PC9zdmc+
[deepwiki-link]: https://deepwiki.com/CherryHQ/cherry-studio
[twitter-shield]: https://img.shields.io/badge/Twitter-CherryStudioApp-0088CC?logo=x
[twitter-link]: https://twitter.com/CherryStudioHQ
[discord-shield]: https://img.shields.io/badge/Discord-@CherryStudio-0088CC?logo=discord
[discord-link]: https://discord.gg/wez8HtpxqQ
[telegram-shield]: https://img.shields.io/badge/Telegram-@CherryStudioAI-0088CC?logo=telegram
[telegram-link]: https://t.me/CherryStudioAI
<!-- Links & Images -->
[github-release-shield]: https://img.shields.io/github/v/release/CherryHQ/cherry-studio?logo=github
[github-release-link]: https://github.com/CherryHQ/cherry-studio/releases
[github-nightly-shield]: https://img.shields.io/github/actions/workflow/status/CherryHQ/cherry-studio/nightly-build.yml?label=nightly%20build&logo=github
[github-nightly-link]: https://github.com/CherryHQ/cherry-studio/actions/workflows/nightly-build.yml
[github-contributors-shield]: https://img.shields.io/github/contributors/CherryHQ/cherry-studio?logo=github
[github-contributors-link]: https://github.com/CherryHQ/cherry-studio/graphs/contributors
<!-- Links & Images -->
[license-shield]: https://img.shields.io/badge/License-AGPLv3-important.svg?logo=gnu
[license-link]: https://www.gnu.org/licenses/agpl-3.0
[commercial-shield]: https://img.shields.io/badge/License-Contact-white.svg?logoColor=white&logo=telegram&color=blue
[commercial-link]: mailto:license@cherry-ai.com?subject=Commercial%20License%20Inquiry
[sponsor-shield]: https://img.shields.io/badge/Sponsor-FF6699.svg?logo=githubsponsors&logoColor=white
[sponsor-link]: https://github.com/CherryHQ/cherry-studio/blob/main/docs/sponsor.md

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -1,5 +1,5 @@
appId: com.kangfenmao.CherryStudio
productName: Cherry Studio
appId: com.cherry-ai.cherry-stuido-enterprise
productName: Cherry Studio 企业版
electronLanguages:
- zh-CN
- zh-TW
@@ -13,7 +13,7 @@ directories:
buildResources: build
protocols:
- name: Cherry Studio
- name: Cherry Studio 企业版
schemes:
- cherrystudio
files:
@@ -64,16 +64,16 @@ asarUnpack:
- '**/*.{metal,exp,lib}'
- 'node_modules/@img/sharp-libvips-*/**'
win:
executableName: Cherry Studio
artifactName: ${productName}-${version}-${arch}-setup.${ext}
executableName: Cherry Studio 企业版
artifactName: Cherry-Studio-Enterprise-${version}-${arch}-setup.${ext}
target:
- target: nsis
- target: portable
# - target: portable
signtoolOptions:
sign: scripts/win-sign.js
verifyUpdateCodeSignature: false
nsis:
artifactName: ${productName}-${version}-${arch}-setup.${ext}
artifactName: Cherry-Studio-Enterprise-${version}-${arch}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
@@ -81,13 +81,10 @@ nsis:
oneClick: false
include: build/nsis-installer.nsh
buildUniversalInstaller: false
portable:
artifactName: ${productName}-${version}-${arch}-portable.${ext}
buildUniversalInstaller: false
mac:
entitlementsInherit: build/entitlements.mac.plist
notarize: false
artifactName: ${productName}-${version}-${arch}.${ext}
artifactName: Cherry-Studio-Enterprise-${version}-${arch}.${ext}
minimumSystemVersion: '20.1.0' # 最低支持 macOS 11.0
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
@@ -98,7 +95,7 @@ mac:
- target: dmg
- target: zip
linux:
artifactName: ${productName}-${version}-${arch}.${ext}
artifactName: Cherry-Studio-Enterprise-${version}-${arch}.${ext}
target:
- target: AppImage
- target: deb
@@ -109,10 +106,14 @@ linux:
entry:
StartupWMClass: CherryStudio
mimeTypes:
- x-scheme-handler/cherrystudio
- x-scheme-handler/cherrystudio-enterprise
rpm:
# Workaround for electron build issue on rpm package:
# https://github.com/electron/forge/issues/3594
fpm: ['--rpm-rpmbuild-define=_build_id_links none']
publish:
provider: generic
url: https://releases.cherry-ai.com
url: https://releases.enterprise.cherry-ai.com
electronDownload:
mirror: https://npmmirror.com/mirrors/electron/
beforePack: scripts/before-pack.js
@@ -122,23 +123,19 @@ artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
✨ 重要更新:
- 新增笔记模块,支持富文本编辑和管理
- 内置 GLM-4.5-Flash 免费模型(由智谱开放平台提供)
- 内置 Qwen3-8B 免费模型(由硅基流动提供)
- 新增 Nano BananaGemini 2.5 Flash Image模型支持
- 新增系统 OCR 功能 (macOS & Windows)
- 新增图片 OCR 识别和翻译功能
- 模型切换支持通过标签筛选
- 翻译功能增强:历史搜索和收藏
- 新增标签页拖拽重新排序功能
- 增强笔记编辑器同步功能
- 链接预览支持解析 OG 数据
- 新增"重试失败消息"按钮
🔧 性能优化:
- 优化历史页面搜索性能
- 优化拖拽列表组件交互
- 升级 Electron 到 37.4.0
- 优化 MCP 服务日志和错误处理
- 改进构建配置和依赖管理
- 增强 Linux 系统 OCR 构建支持
🐛 修复问题:
- 修复知识库加密 PDF 文档处理
- 修复导航栏在左侧时笔记侧边栏按钮缺失
- 修复多个模型兼容性问题
- 修复 MCP 相关问题
- 其他稳定性改进
- 修复翻译功能相关问题
- 修复 MCP 服务相关问题
- 修复导航和标签页显示问题
- 修复 Obsidian 集成检测
- 其他界面和稳定性改进

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.5.9",
"name": "CherryStudioEnterprise",
"version": "1.5.111",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -67,7 +67,9 @@
"test:scripts": "vitest scripts",
"format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix && yarn typecheck && yarn check:i18n",
"prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky"
"prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky",
"sdk:upgrade": "yarn add @cherrystudio/api-sdk && git add package.json yarn.lock && git commit -m 'chore: upgrade api-sdk'",
"sdk:build": "cd ../cherry-studio-enterprise-api && yarn sdk:build && cd ../cherry-studio-enterprise && yarn add -D ../cherry-studio-enterprise-api-sdk"
},
"dependencies": {
"@libsql/client": "0.14.0",
@@ -94,6 +96,7 @@
"@aws-sdk/client-bedrock": "^3.840.0",
"@aws-sdk/client-bedrock-runtime": "^3.840.0",
"@aws-sdk/client-s3": "^3.840.0",
"@cherrystudio/api-sdk": "^0.0.45",
"@cherrystudio/embedjs": "^0.1.31",
"@cherrystudio/embedjs-libsql": "^0.1.31",
"@cherrystudio/embedjs-loader-csv": "^0.1.31",

View File

@@ -10,6 +10,7 @@ export enum IpcChannel {
App_Reload = 'app:reload',
App_Info = 'app:info',
App_Proxy = 'app:proxy',
App_ProxyForWebview = 'app:proxy-for-webview',
App_SetLaunchToTray = 'app:set-launch-to-tray',
App_SetTray = 'app:set-tray',
App_SetTrayOnClose = 'app:set-tray-on-close',
@@ -303,5 +304,8 @@ export enum IpcChannel {
OCR_ocr = 'ocr:ocr',
// Cherryin
Cherryin_GetSignature = 'cherryin:get-signature'
Cherryin_GetSignature = 'cherryin:get-signature',
// OAuth
OAuth_Casdoor = 'oauth:casdoor'
}

View File

@@ -1,208 +1,233 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Github Releases Timeline</title>
<link href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet" />
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/markdown-it@13.0.1/dist/markdown-it.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/typography@0.5.10/dist/typography.min.css"></script>
</head>
<body id="app">
<div :class="isDark ? 'dark-bg' : 'bg'" class="min-h-screen">
<div class="max-w-3xl mx-auto py-12 px-4">
<h1 class="text-3xl font-bold mb-8" :class="isDark ? 'text-white' : 'text-gray-900'">Release Timeline</h1>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cherry Studio Enterprise Release</title>
<link href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet" />
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/markdown-it@13.0.1/dist/markdown-it.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/typography@0.5.10/dist/typography.min.css"></script>
</head>
<!-- Loading状态 -->
<div v-if="loading" class="text-center py-8">
<div
class="inline-block animate-spin rounded-full h-8 w-8 border-4"
:class="isDark ? 'border-gray-700 border-t-blue-500' : 'border-gray-300 border-t-blue-500'"></div>
<body id="app">
<div :class="isDark ? 'dark-bg' : 'bg'" class="min-h-screen">
<div class="max-w-4xl mx-auto py-12 px-4">
<!-- Loading状态 -->
<div v-if="loading" class="text-center py-8">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-4"
:class="isDark ? 'border-gray-700 border-t-blue-500' : 'border-gray-300 border-t-blue-500'"></div>
<p class="mt-4" :class="isDark ? 'text-gray-400' : 'text-gray-600'">正在加载发布信息...</p>
</div>
<!-- Error 状态 -->
<div v-else-if="error" class="text-center py-8">
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded"
:class="isDark ? 'bg-red-900 border-red-700 text-red-200' : ''">
<strong class="font-bold">错误:</strong>
<span class="block sm:inline">{{ error }}</span>
</div>
</div>
<!-- Release 信息 -->
<div v-else-if="release" class="space-y-8">
<!-- 头部信息 -->
<div class="text-center">
<h1 class="text-4xl font-bold mb-4" :class="isDark ? 'text-white' : 'text-gray-900'">
Cherry Studio Enterprise
</h1>
<div class="inline-flex items-center px-4 py-2 rounded-full text-lg font-semibold mb-4"
:class="isDark ? 'bg-green-900 text-green-200' : 'bg-green-100 text-green-800'">
{{ release.tag_name }}
</div>
<p class="text-lg" :class="isDark ? 'text-gray-400' : 'text-gray-600'">
发布于 {{ formatDate(release.created_at) }}
</p>
</div>
<!-- Error 状态 -->
<div v-else-if="error" class="text-red-500 text-center py-8">{{ error }}</div>
<!-- 主要内容卡片 -->
<div class="rounded-lg shadow-lg p-8 transition-shadow"
:class="isDark ? 'bg-black hover:shadow-xl hover:shadow-black' : 'bg-white hover:shadow-xl'">
<!-- Release 列表 -->
<div v-else class="space-y-8">
<div
v-for="release in releases"
:key="release.id"
class="relative pl-8"
:class="isDark ? 'border-l-2 border-gray-700' : 'border-l-2 border-gray-200'">
<div class="absolute -left-2 top-0 w-4 h-4 rounded-full bg-green-500"></div>
<div
class="rounded-lg shadow-sm p-6 transition-shadow"
:class="isDark ? 'bg-black hover:shadow-md hover:shadow-black' : 'bg-white hover:shadow-md'">
<div class="flex items-start justify-between mb-4">
<div>
<h2 class="text-xl font-semibold" :class="isDark ? 'text-white' : 'text-gray-900'">
{{ release.name || release.tag_name }}
</h2>
<p class="text-sm mt-1" :class="isDark ? 'text-gray-400' : 'text-gray-500'">
{{ formatDate(release.published_at) }}
</p>
</div>
<span
class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium"
:class="isDark ? 'bg-green-900 text-green-200' : 'bg-green-100 text-green-800'">
{{ release.tag_name }}
</span>
<!-- 更新内容 -->
<div class="mb-8">
<h2 class="text-2xl font-bold mb-4" :class="isDark ? 'text-white' : 'text-gray-900'">
更新内容
</h2>
<div class="prose max-w-none" :class="isDark ? 'text-gray-300 dark-prose' : 'text-gray-600'"
v-html="renderMarkdown(release.body)"></div>
</div>
<!-- 版本信息 -->
<div class="pt-6 border-t" :class="isDark ? 'border-gray-700' : 'border-gray-200'">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div>
<span class="font-medium" :class="isDark ? 'text-gray-400' : 'text-gray-500'">版本:</span>
<span :class="isDark ? 'text-white' : 'text-gray-900'">{{ release.tag_name }}</span>
</div>
<div>
<span class="font-medium" :class="isDark ? 'text-gray-400' : 'text-gray-500'">提交:</span>
<span :class="isDark ? 'text-white' : 'text-gray-900'">{{ release.target_commitish.substring(0, 8)
}}</span>
</div>
<div>
<span class="font-medium" :class="isDark ? 'text-gray-400' : 'text-gray-500'">预发布:</span>
<span :class="isDark ? 'text-white' : 'text-gray-900'">{{ release.prerelease ? '是' : '否' }}</span>
</div>
<div
class="prose"
:class="isDark ? 'text-gray-300 dark-prose' : 'text-gray-600'"
v-html="renderMarkdown(release.body)"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
const md = window.markdownit({
breaks: true,
linkify: true
})
<script>
const md = window.markdownit({
breaks: true,
linkify: true
})
const { createApp } = Vue
const { createApp } = Vue
createApp({
data() {
return {
releases: [],
loading: true,
error: null,
isDark: false
}
},
methods: {
async fetchReleases() {
try {
this.loading = true
this.error = null
const response = await fetch('https://api.github.com/repos/kangfenmao/cherry-studio/releases')
if (!response.ok) {
throw new Error('Failed to fetch releases')
}
this.releases = await response.json()
} catch (err) {
this.error = 'Error loading releases: ' + err.message
} finally {
this.loading = false
}
},
formatDate(dateString) {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
},
renderMarkdown(content) {
if (!content) return ''
return md.render(content)
},
initTheme() {
// 从 URL 参数获取主题设置
const url = new URL(window.location.href)
const theme = url.searchParams.get('theme')
this.isDark = theme === 'dark'
}
},
mounted() {
this.initTheme()
this.fetchReleases()
createApp({
data() {
return {
release: null,
loading: true,
error: null,
isDark: false
}
}).mount('#app')
</script>
},
methods: {
async fetchRelease() {
try {
this.loading = true
this.error = null
const response = await fetch('https://releases.enterprise.cherry-ai.com/')
if (!response.ok) {
throw new Error('Failed to fetch release')
}
this.release = await response.json()
} catch (err) {
this.error = 'Error loading release: ' + err.message
} finally {
this.loading = false
}
},
formatDate(dateString) {
return new Date(dateString).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
},
renderMarkdown(content) {
if (!content) return ''
return md.render(content)
},
<style>
/* 基础的 Markdown 样式 */
.prose {
line-height: 1.6;
initTheme() {
// 从 URL 参数获取主题设置
const url = new URL(window.location.href)
const theme = url.searchParams.get('theme')
this.isDark = theme === 'dark'
}
},
mounted() {
this.initTheme()
this.fetchRelease()
}
}).mount('#app')
</script>
.prose h1 {
font-size: 1.5em;
margin: 1em 0;
}
<style>
/* 基础的 Markdown 样式 */
.prose {
line-height: 1.6;
}
.prose h2 {
font-size: 1.3em;
margin: 0.8em 0;
}
.prose h1 {
font-size: 1.5em;
margin: 1em 0;
}
.prose h3 {
font-size: 1.1em;
margin: 0.6em 0;
}
.prose h2 {
font-size: 1.3em;
margin: 0.8em 0;
}
.prose ul {
list-style-type: disc;
margin-left: 1.5em;
margin-bottom: 1em;
}
.prose h3 {
font-size: 1.1em;
margin: 0.6em 0;
}
.prose ol {
list-style-type: decimal;
margin-left: 1.5em;
margin-bottom: 1em;
}
.prose ul {
list-style-type: disc;
margin-left: 1.5em;
margin-bottom: 1em;
}
.prose code {
padding: 0.2em 0.4em;
border-radius: 0.2em;
font-size: 0.9em;
}
.prose ol {
list-style-type: decimal;
margin-left: 1.5em;
margin-bottom: 1em;
}
.dark .prose code {
background-color: #1f2937;
}
.prose code {
padding: 0.2em 0.4em;
border-radius: 0.2em;
font-size: 0.9em;
}
.prose code {
background-color: #f3f4f6;
}
.dark .prose code {
background-color: #1f2937;
}
.prose pre code {
display: block;
padding: 1em;
overflow-x: auto;
}
.prose code {
background-color: #f3f4f6;
}
.prose a {
color: #3b82f6;
text-decoration: underline;
}
.prose pre code {
display: block;
padding: 1em;
overflow-x: auto;
}
.dark .prose a {
color: #60a5fa;
}
.prose a {
color: #3b82f6;
text-decoration: underline;
}
.prose blockquote {
border-left: 4px solid #e5e7eb;
padding-left: 1em;
margin: 1em 0;
}
.dark .prose a {
color: #60a5fa;
}
.dark .prose blockquote {
border-left-color: #374151;
color: #9ca3af;
}
.prose blockquote {
border-left: 4px solid #e5e7eb;
padding-left: 1em;
margin: 1em 0;
}
.dark .prose {
color: #e5e7eb;
}
.dark .prose blockquote {
border-left-color: #374151;
color: #9ca3af;
}
.dark-bg {
background-color: #151515;
}
.dark .prose {
color: #e5e7eb;
}
.bg {
background-color: #f2f2f2;
}
</style>
</body>
</html>
.dark-bg {
background-color: #151515;
}
.bg {
background-color: #f2f2f2;
}
</style>
</body>
</html>

View File

@@ -15,7 +15,7 @@ exports.default = async function notarizing(context) {
await notarize({
appPath,
appBundleId: 'com.kangfenmao.CherryStudio',
appBundleId: 'com.cherry-ai.cherry-stuido-enterprise',
appleId: process.env.APPLE_ID,
appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD,
teamId: process.env.APPLE_TEAM_ID

View File

@@ -17,7 +17,7 @@ import { configManager } from './services/ConfigManager'
import mcpService from './services/MCPService'
import { nodeTraceService } from './services/NodeTraceService'
import {
CHERRY_STUDIO_PROTOCOL,
CHERRY_STUDIO_ENTERPRISE_PROTOCOL,
handleProtocolUrl,
registerProtocolClient,
setupAppImageDeepLink
@@ -106,7 +106,7 @@ if (!app.requestSingleInstanceLock()) {
app.whenReady().then(async () => {
// Set app user model id for windows
electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio')
electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.cherry-ai.cherry-stuido-enterprise')
// Mac: Hide dock icon before window creation when launch to tray is set
const isLaunchToTray = configManager.getLaunchToTray()
@@ -157,7 +157,7 @@ if (!app.requestSingleInstanceLock()) {
})
const handleOpenUrl = (args: string[]) => {
const url = args.find((arg) => arg.startsWith(CHERRY_STUDIO_PROTOCOL + '://'))
const url = args.find((arg) => arg.startsWith(CHERRY_STUDIO_ENTERPRISE_PROTOCOL + '://'))
if (url) handleProtocolUrl(url)
}

View File

@@ -122,6 +122,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
await proxyManager.configureProxy(proxyConfig)
})
ipcMain.handle(IpcChannel.App_ProxyForWebview, async (_, proxy: string) =>
proxyManager.setSessionsProxyForWebview(proxy)
)
ipcMain.handle(IpcChannel.App_Reload, () => mainWindow.reload())
ipcMain.handle(IpcChannel.Open_Website, (_, url: string) => shell.openExternal(url))

View File

@@ -40,14 +40,13 @@ export default abstract class BaseReranker {
*/
protected getRerankRequestBody(query: string, searchResults: ExtractChunkData[]) {
const provider = this.base.rerankApiClient?.provider
const documents = searchResults.map((doc) => doc.pageContent)
const topN = this.base.documentCount
if (provider === 'voyageai') {
return {
model: this.base.rerankApiClient?.model,
query,
documents,
documents: searchResults,
top_k: topN
}
} else if (provider === 'bailian') {
@@ -55,7 +54,7 @@ export default abstract class BaseReranker {
model: this.base.rerankApiClient?.model,
input: {
query,
documents
documents: searchResults
},
parameters: {
top_n: topN
@@ -64,14 +63,14 @@ export default abstract class BaseReranker {
} else if (provider?.includes('tei')) {
return {
query,
texts: documents,
texts: searchResults,
return_text: true
}
} else {
return {
model: this.base.rerankApiClient?.model,
query,
documents,
documents: searchResults,
top_n: topN
}
}

View File

@@ -1,15 +1,12 @@
import { loggerService } from '@logger'
import { isWin } from '@main/constant'
import { getIpCountry } from '@main/utils/ipService'
import { locales } from '@main/utils/locales'
import { generateUserAgent } from '@main/utils/systemInfo'
import { FeedUrl, UpgradeChannel } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { CancellationToken, UpdateInfo } from 'builder-util-runtime'
import { app, BrowserWindow, dialog, net } from 'electron'
import { app, BrowserWindow, dialog } from 'electron'
import { AppUpdater as _AppUpdater, autoUpdater, Logger, NsisUpdater, UpdateCheckResult } from 'electron-updater'
import path from 'path'
import semver from 'semver'
import icon from '../../../build/icon.png?asset'
import { configManager } from './ConfigManager'
@@ -30,7 +27,8 @@ export default class AppUpdater {
autoUpdater.autoInstallOnAppQuit = configManager.getAutoUpdate()
autoUpdater.requestHeaders = {
...autoUpdater.requestHeaders,
'User-Agent': generateUserAgent()
'User-Agent': generateUserAgent(),
'X-Client-Id': configManager.getClientId()
}
autoUpdater.on('error', (error) => {
@@ -67,132 +65,11 @@ export default class AppUpdater {
this.autoUpdater = autoUpdater
}
private async _getReleaseVersionFromGithub(channel: UpgradeChannel) {
const headers = {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'Accept-Language': 'en-US,en;q=0.9'
}
try {
logger.info(`get release version from github: ${channel}`)
const responses = await net.fetch('https://api.github.com/repos/CherryHQ/cherry-studio/releases?per_page=8', {
headers
})
const data = (await responses.json()) as GithubReleaseInfo[]
let mightHaveLatest = false
const release: GithubReleaseInfo | undefined = data.find((item: GithubReleaseInfo) => {
if (!item.draft && !item.prerelease) {
mightHaveLatest = true
}
return item.prerelease && item.tag_name.includes(`-${channel}.`)
})
if (!release) {
return null
}
// if the release version is the same as the current version, return null
if (release.tag_name === app.getVersion()) {
return null
}
if (mightHaveLatest) {
logger.info(`might have latest release, get latest release`)
const latestReleaseResponse = await net.fetch(
'https://api.github.com/repos/CherryHQ/cherry-studio/releases/latest',
{
headers
}
)
const latestRelease = (await latestReleaseResponse.json()) as GithubReleaseInfo
if (semver.gt(latestRelease.tag_name, release.tag_name)) {
logger.info(
`latest release version is ${latestRelease.tag_name}, prerelease version is ${release.tag_name}, return null`
)
return null
}
}
logger.info(`release url is ${release.tag_name}, set channel to ${channel}`)
return `https://github.com/CherryHQ/cherry-studio/releases/download/${release.tag_name}`
} catch (error) {
logger.error('Failed to get latest not draft version from github:', error as Error)
return null
}
}
public setAutoUpdate(isActive: boolean) {
autoUpdater.autoDownload = isActive
autoUpdater.autoInstallOnAppQuit = isActive
}
private _getChannelByVersion(version: string) {
if (version.includes(`-${UpgradeChannel.BETA}.`)) {
return UpgradeChannel.BETA
}
if (version.includes(`-${UpgradeChannel.RC}.`)) {
return UpgradeChannel.RC
}
return UpgradeChannel.LATEST
}
private _getTestChannel() {
const currentChannel = this._getChannelByVersion(app.getVersion())
const savedChannel = configManager.getTestChannel()
if (currentChannel === UpgradeChannel.LATEST) {
return savedChannel || UpgradeChannel.RC
}
if (savedChannel === currentChannel) {
return savedChannel
}
// if the upgrade channel is not equal to the current channel, use the latest channel
return UpgradeChannel.LATEST
}
private _setChannel(channel: UpgradeChannel, feedUrl: string) {
this.autoUpdater.channel = channel
this.autoUpdater.setFeedURL(feedUrl)
// disable downgrade after change the channel
this.autoUpdater.allowDowngrade = false
// github and gitcode don't support multiple range download
this.autoUpdater.disableDifferentialDownload = true
}
private async _setFeedUrl() {
const testPlan = configManager.getTestPlan()
if (testPlan) {
const channel = this._getTestChannel()
if (channel === UpgradeChannel.LATEST) {
this._setChannel(UpgradeChannel.LATEST, FeedUrl.GITHUB_LATEST)
return
}
const releaseUrl = await this._getReleaseVersionFromGithub(channel)
if (releaseUrl) {
logger.info(`release url is ${releaseUrl}, set channel to ${channel}`)
this._setChannel(channel, releaseUrl)
return
}
// if no prerelease url, use github latest to get release
this._setChannel(UpgradeChannel.LATEST, FeedUrl.GITHUB_LATEST)
return
}
this._setChannel(UpgradeChannel.LATEST, FeedUrl.PRODUCTION)
const ipCountry = await getIpCountry()
logger.info(`ipCountry is ${ipCountry}, set channel to ${UpgradeChannel.LATEST}`)
if (ipCountry.toLowerCase() !== 'cn') {
this._setChannel(UpgradeChannel.LATEST, FeedUrl.GITHUB_LATEST)
}
}
public cancelDownload() {
this.cancellationToken.cancel()
this.cancellationToken = new CancellationToken()
@@ -210,8 +87,6 @@ export default class AppUpdater {
}
try {
await this._setFeedUrl()
this.updateCheckResult = await this.autoUpdater.checkForUpdates()
logger.info(
`update check result: ${this.updateCheckResult?.isUpdateAvailable}, channel: ${this.autoUpdater.channel}, currentVersion: ${this.autoUpdater.currentVersion}`
@@ -282,11 +157,7 @@ export default class AppUpdater {
return releaseNotes.map((note) => note.note).join('\n')
}
}
interface GithubReleaseInfo {
draft: boolean
prerelease: boolean
tag_name: string
}
interface ReleaseNoteInfo {
readonly version: string
readonly note: string | null

View File

@@ -332,14 +332,15 @@ class CodeToolsService {
// macOS - Use osascript to launch terminal and execute command directly, without showing startup command
const envPrefix = buildEnvPrefix(false)
const command = envPrefix ? `${envPrefix} && ${baseCommand}` : baseCommand
// Combine directory change with the main command to ensure they execute in the same shell session
const fullCommand = `cd '${directory.replace(/'/g, "\\'")}' && clear && ${command}`
terminalCommand = 'osascript'
terminalArgs = [
'-e',
`tell application "Terminal"
set newTab to do script "cd '${directory.replace(/'/g, "\\'")}' && clear"
do script "${fullCommand.replace(/"/g, '\\"')}"
activate
do script "${command.replace(/"/g, '\\"')}" in newTab
end tell`
]
break

View File

@@ -2,6 +2,7 @@ import { defaultLanguage, UpgradeChannel, ZOOM_SHORTCUTS } from '@shared/config/
import { LanguageVarious, Shortcut, ThemeMode } from '@types'
import { app } from 'electron'
import Store from 'electron-store'
import { v4 as uuidv4 } from 'uuid'
import { locales } from '../utils/locales'
@@ -27,7 +28,8 @@ export enum ConfigKeys {
SelectionAssistantFilterList = 'selectionAssistantFilterList',
DisableHardwareAcceleration = 'disableHardwareAcceleration',
Proxy = 'proxy',
EnableDeveloperMode = 'enableDeveloperMode'
EnableDeveloperMode = 'enableDeveloperMode',
ClientId = 'clientId'
}
export class ConfigManager {
@@ -241,6 +243,17 @@ export class ConfigManager {
this.set(ConfigKeys.EnableDeveloperMode, value)
}
getClientId(): string {
let clientId = this.get<string>(ConfigKeys.ClientId)
if (!clientId) {
clientId = uuidv4()
this.set(ConfigKeys.ClientId, clientId)
}
return clientId
}
set(key: string, value: unknown, isNotify: boolean = false) {
this.store.set(key, value)
isNotify && this.notifySubscribers(key, value)

View File

@@ -56,6 +56,45 @@ type CallToolArgs = { server: MCPServer; name: string; args: any; callId?: strin
const logger = loggerService.withContext('MCPService')
// Redact potentially sensitive fields in objects (headers, tokens, api keys)
function redactSensitive(input: any): any {
const SENSITIVE_KEYS = ['authorization', 'Authorization', 'apiKey', 'api_key', 'apikey', 'token', 'access_token']
const MAX_STRING = 300
const redact = (val: any): any => {
if (val == null) return val
if (typeof val === 'string') {
return val.length > MAX_STRING ? `${val.slice(0, MAX_STRING)}…<${val.length - MAX_STRING} more>` : val
}
if (Array.isArray(val)) return val.map((v) => redact(v))
if (typeof val === 'object') {
const out: Record<string, any> = {}
for (const [k, v] of Object.entries(val)) {
if (SENSITIVE_KEYS.includes(k)) {
out[k] = '<redacted>'
} else {
out[k] = redact(v)
}
}
return out
}
return val
}
return redact(input)
}
// Create a context-aware logger for a server
function getServerLogger(server: MCPServer, extra?: Record<string, any>) {
const base = {
serverName: server?.name,
serverId: server?.id,
baseUrl: server?.baseUrl,
type: server?.type || (server?.command ? 'stdio' : server?.baseUrl ? 'http' : 'inmemory')
}
return loggerService.withContext('MCPService', { ...base, ...(extra || {}) })
}
/**
* Higher-order function to add caching capability to any async function
* @param fn The original function to be wrapped with caching
@@ -74,15 +113,17 @@ function withCache<T extends unknown[], R>(
const cacheKey = getCacheKey(...args)
if (CacheService.has(cacheKey)) {
logger.debug(`${logPrefix} loaded from cache`)
logger.debug(`${logPrefix} loaded from cache`, { cacheKey })
const cachedData = CacheService.get<R>(cacheKey)
if (cachedData) {
return cachedData
}
}
const start = Date.now()
const result = await fn(...args)
CacheService.set(cacheKey, result, ttl)
logger.debug(`${logPrefix} cached`, { cacheKey, ttlMs: ttl, durationMs: Date.now() - start })
return result
}
}
@@ -128,6 +169,7 @@ class McpService {
// If there's a pending initialization, wait for it
const pendingClient = this.pendingClients.get(serverKey)
if (pendingClient) {
getServerLogger(server).silly(`Waiting for pending client initialization`)
return pendingClient
}
@@ -136,8 +178,11 @@ class McpService {
if (existingClient) {
try {
// Check if the existing client is still connected
const pingResult = await existingClient.ping()
logger.debug(`Ping result for ${server.name}:`, pingResult)
const pingResult = await existingClient.ping({
// add short timeout to prevent hanging
timeout: 1000
})
getServerLogger(server).debug(`Ping result`, { ok: !!pingResult })
// If the ping fails, remove the client from the cache
// and create a new one
if (!pingResult) {
@@ -146,7 +191,7 @@ class McpService {
return existingClient
}
} catch (error: any) {
logger.error(`Error pinging server ${server.name}:`, error?.message)
getServerLogger(server).error(`Error pinging server`, error as Error)
this.clients.delete(serverKey)
}
}
@@ -172,15 +217,15 @@ class McpService {
> => {
// Create appropriate transport based on configuration
if (isBuiltinMCPServer(server) && server.name !== BuiltinMCPServerNames.mcpAutoInstall) {
logger.debug(`Using in-memory transport for server: ${server.name}`)
getServerLogger(server).debug(`Using in-memory transport`)
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
// start the in-memory server with the given name and environment variables
const inMemoryServer = createInMemoryMCPServer(server.name, args, server.env || {})
try {
await inMemoryServer.connect(serverTransport)
logger.debug(`In-memory server started: ${server.name}`)
getServerLogger(server).debug(`In-memory server started`)
} catch (error: Error | any) {
logger.error(`Error starting in-memory server: ${error}`)
getServerLogger(server).error(`Error starting in-memory server`, error as Error)
throw new Error(`Failed to start in-memory server: ${error.message}`)
}
// set the client transport to the client
@@ -193,7 +238,10 @@ class McpService {
},
authProvider
}
logger.debug(`StreamableHTTPClientTransport options:`, options)
// redact headers before logging
getServerLogger(server).debug(`StreamableHTTPClientTransport options`, {
options: redactSensitive(options)
})
return new StreamableHTTPClientTransport(new URL(server.baseUrl!), options)
} else if (server.type === 'sse') {
const options: SSEClientTransportOptions = {
@@ -209,7 +257,7 @@ class McpService {
headers['Authorization'] = `Bearer ${tokens.access_token}`
}
} catch (error) {
logger.error('Failed to fetch tokens:', error as Error)
getServerLogger(server).error('Failed to fetch tokens:', error as Error)
}
}
@@ -239,15 +287,18 @@ class McpService {
...server.env,
...resolvedConfig.env
}
logger.debug(`Using resolved DXT config - command: ${cmd}, args: ${args?.join(' ')}`)
getServerLogger(server).debug(`Using resolved DXT config`, {
command: cmd,
args
})
} else {
logger.warn(`Failed to resolve DXT config for ${server.name}, falling back to manifest values`)
getServerLogger(server).warn(`Failed to resolve DXT config, falling back to manifest values`)
}
}
if (server.command === 'npx') {
cmd = await getBinaryPath('bun')
logger.debug(`Using command: ${cmd}`)
getServerLogger(server).debug(`Using command`, { command: cmd })
// add -x to args if args exist
if (args && args.length > 0) {
@@ -282,7 +333,7 @@ class McpService {
}
}
logger.debug(`Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`)
getServerLogger(server).debug(`Starting server`, { command: cmd, args })
// Logger.info(`[MCP] Environment variables for server:`, server.env)
const loginShellEnv = await this.getLoginShellEnv()
@@ -304,12 +355,14 @@ class McpService {
// For DXT servers, set the working directory to the extracted path
if (server.dxtPath) {
transportOptions.cwd = server.dxtPath
logger.debug(`Setting working directory for DXT server: ${server.dxtPath}`)
getServerLogger(server).debug(`Setting working directory for DXT server`, {
cwd: server.dxtPath
})
}
const stdioTransport = new StdioClientTransport(transportOptions)
stdioTransport.stderr?.on('data', (data) =>
logger.debug(`Stdio stderr for server: ${server.name}` + data.toString())
getServerLogger(server).debug(`Stdio stderr`, { data: data.toString() })
)
return stdioTransport
} else {
@@ -318,7 +371,7 @@ class McpService {
}
const handleAuth = async (client: Client, transport: SSEClientTransport | StreamableHTTPClientTransport) => {
logger.debug(`Starting OAuth flow for server: ${server.name}`)
getServerLogger(server).debug(`Starting OAuth flow`)
// Create an event emitter for the OAuth callback
const events = new EventEmitter()
@@ -331,27 +384,27 @@ class McpService {
// Set a timeout to close the callback server
const timeoutId = setTimeout(() => {
logger.warn(`OAuth flow timed out for server: ${server.name}`)
getServerLogger(server).warn(`OAuth flow timed out`)
callbackServer.close()
}, 300000) // 5 minutes timeout
try {
// Wait for the authorization code
const authCode = await callbackServer.waitForAuthCode()
logger.debug(`Received auth code: ${authCode}`)
getServerLogger(server).debug(`Received auth code`)
// Complete the OAuth flow
await transport.finishAuth(authCode)
logger.debug(`OAuth flow completed for server: ${server.name}`)
getServerLogger(server).debug(`OAuth flow completed`)
const newTransport = await initTransport()
// Try to connect again
await client.connect(newTransport)
logger.debug(`Successfully authenticated with server: ${server.name}`)
getServerLogger(server).debug(`Successfully authenticated`)
} catch (oauthError) {
logger.error(`OAuth authentication failed for server ${server.name}:`, oauthError as Error)
getServerLogger(server).error(`OAuth authentication failed`, oauthError as Error)
throw new Error(
`OAuth authentication failed: ${oauthError instanceof Error ? oauthError.message : String(oauthError)}`
)
@@ -390,7 +443,7 @@ class McpService {
logger.debug(`Activated server: ${server.name}`)
return client
} catch (error: any) {
logger.error(`Error activating server ${server.name}:`, error?.message)
getServerLogger(server).error(`Error activating server`, error as Error)
throw new Error(`[MCP] Error activating server ${server.name}: ${error.message}`)
}
} finally {
@@ -450,9 +503,9 @@ class McpService {
logger.debug(`Message from server ${server.name}:`, notification.params)
})
logger.debug(`Set up notification handlers for server: ${server.name}`)
getServerLogger(server).debug(`Set up notification handlers`)
} catch (error) {
logger.error(`Failed to set up notification handlers for server ${server.name}:`, error as Error)
getServerLogger(server).error(`Failed to set up notification handlers`, error as Error)
}
}
@@ -470,7 +523,7 @@ class McpService {
CacheService.remove(`mcp:list_tool:${serverKey}`)
CacheService.remove(`mcp:list_prompts:${serverKey}`)
CacheService.remove(`mcp:list_resources:${serverKey}`)
logger.debug(`Cleared all caches for server: ${serverKey}`)
logger.debug(`Cleared all caches for server`, { serverKey })
}
async closeClient(serverKey: string) {
@@ -478,18 +531,18 @@ class McpService {
if (client) {
// Remove the client from the cache
await client.close()
logger.debug(`Closed server: ${serverKey}`)
logger.debug(`Closed server`, { serverKey })
this.clients.delete(serverKey)
// Clear all caches for this server
this.clearServerCache(serverKey)
} else {
logger.warn(`No client found for server: ${serverKey}`)
logger.warn(`No client found for server`, { serverKey })
}
}
async stopServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
const serverKey = this.getServerKey(server)
logger.debug(`Stopping server: ${server.name}`)
getServerLogger(server).debug(`Stopping server`)
await this.closeClient(serverKey)
}
@@ -505,16 +558,16 @@ class McpService {
try {
const cleaned = this.dxtService.cleanupDxtServer(server.name)
if (cleaned) {
logger.debug(`Cleaned up DXT server directory for: ${server.name}`)
getServerLogger(server).debug(`Cleaned up DXT server directory`)
}
} catch (error) {
logger.error(`Failed to cleanup DXT server: ${server.name}`, error as Error)
getServerLogger(server).error(`Failed to cleanup DXT server`, error as Error)
}
}
}
async restartServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
logger.debug(`Restarting server: ${server.name}`)
getServerLogger(server).debug(`Restarting server`)
const serverKey = this.getServerKey(server)
await this.closeClient(serverKey)
// Clear cache before restarting to ensure fresh data
@@ -527,7 +580,7 @@ class McpService {
try {
await this.closeClient(key)
} catch (error: any) {
logger.error(`Failed to close client: ${error?.message}`)
logger.error(`Failed to close client`, error as Error)
}
}
}
@@ -536,9 +589,9 @@ class McpService {
* Check connectivity for an MCP server
*/
public async checkMcpConnectivity(_: Electron.IpcMainInvokeEvent, server: MCPServer): Promise<boolean> {
logger.debug(`Checking connectivity for server: ${server.name}`)
getServerLogger(server).debug(`Checking connectivity`)
try {
logger.debug(`About to call initClient for server: ${server.name}`, { hasInitClient: !!this.initClient })
getServerLogger(server).debug(`About to call initClient`, { hasInitClient: !!this.initClient })
if (!this.initClient) {
throw new Error('initClient method is not available')
@@ -547,10 +600,10 @@ class McpService {
const client = await this.initClient(server)
// Attempt to list tools as a way to check connectivity
await client.listTools()
logger.debug(`Connectivity check successful for server: ${server.name}`)
getServerLogger(server).debug(`Connectivity check successful`)
return true
} catch (error) {
logger.error(`Connectivity check failed for server: ${server.name}`, error as Error)
getServerLogger(server).error(`Connectivity check failed`, error as Error)
// Close the client if connectivity check fails to ensure a clean state for the next attempt
const serverKey = this.getServerKey(server)
await this.closeClient(serverKey)
@@ -559,9 +612,8 @@ class McpService {
}
private async listToolsImpl(server: MCPServer): Promise<MCPTool[]> {
logger.debug(`Listing tools for server: ${server.name}`)
getServerLogger(server).debug(`Listing tools`)
const client = await this.initClient(server)
logger.debug(`Client for server: ${server.name}`, client)
try {
const { tools } = await client.listTools()
const serverTools: MCPTool[] = []
@@ -576,7 +628,7 @@ class McpService {
})
return serverTools
} catch (error: any) {
logger.error(`Failed to list tools for server: ${server.name}`, error?.message)
getServerLogger(server).error(`Failed to list tools`, error as Error)
return []
}
}
@@ -613,12 +665,16 @@ class McpService {
const callToolFunc = async ({ server, name, args }: CallToolArgs) => {
try {
logger.debug(`Calling: ${server.name} ${name} ${JSON.stringify(args)} callId: ${toolCallId}`, server)
getServerLogger(server, { tool: name, callId: toolCallId }).debug(`Calling tool`, {
args: redactSensitive(args)
})
if (typeof args === 'string') {
try {
args = JSON.parse(args)
} catch (e) {
logger.error('args parse error', args)
getServerLogger(server, { tool: name, callId: toolCallId }).error('args parse error', e as Error, {
args
})
}
if (args === '') {
args = {}
@@ -627,8 +683,9 @@ class McpService {
const client = await this.initClient(server)
const result = await client.callTool({ name, arguments: args }, undefined, {
onprogress: (process) => {
logger.debug(`Progress: ${process.progress / (process.total || 1)}`)
logger.debug(`Progress notification received for server: ${server.name}`, process)
getServerLogger(server, { tool: name, callId: toolCallId }).debug(`Progress`, {
ratio: process.progress / (process.total || 1)
})
const mainWindow = windowService.getMainWindow()
if (mainWindow) {
mainWindow.webContents.send('mcp-progress', process.progress / (process.total || 1))
@@ -643,7 +700,7 @@ class McpService {
})
return result as MCPCallToolResponse
} catch (error) {
logger.error(`Error calling tool ${name} on ${server.name}:`, error as Error)
getServerLogger(server, { tool: name, callId: toolCallId }).error(`Error calling tool`, error as Error)
throw error
} finally {
this.activeToolCalls.delete(toolCallId)
@@ -667,7 +724,7 @@ class McpService {
*/
private async listPromptsImpl(server: MCPServer): Promise<MCPPrompt[]> {
const client = await this.initClient(server)
logger.debug(`Listing prompts for server: ${server.name}`)
getServerLogger(server).debug(`Listing prompts`)
try {
const { prompts } = await client.listPrompts()
return prompts.map((prompt: any) => ({
@@ -679,7 +736,7 @@ class McpService {
} catch (error: any) {
// -32601 is the code for the method not found
if (error?.code !== -32601) {
logger.error(`Failed to list prompts for server: ${server.name}`, error?.message)
getServerLogger(server).error(`Failed to list prompts`, error as Error)
}
return []
}
@@ -748,7 +805,7 @@ class McpService {
} catch (error: any) {
// -32601 is the code for the method not found
if (error?.code !== -32601) {
logger.error(`Failed to list resources for server: ${server.name}`, error?.message)
getServerLogger(server).error(`Failed to list resources`, error as Error)
}
return []
}
@@ -774,7 +831,7 @@ class McpService {
* Get a specific resource from an MCP server (implementation)
*/
private async getResourceImpl(server: MCPServer, uri: string): Promise<GetResourceResponse> {
logger.debug(`Getting resource ${uri} from server: ${server.name}`)
getServerLogger(server, { uri }).debug(`Getting resource`)
const client = await this.initClient(server)
try {
const result = await client.readResource({ uri: uri })
@@ -792,7 +849,7 @@ class McpService {
contents: contents
}
} catch (error: Error | any) {
logger.error(`Failed to get resource ${uri} from server: ${server.name}`, error.message)
getServerLogger(server, { uri }).error(`Failed to get resource`, error as Error)
throw new Error(`Failed to get resource ${uri} from server: ${server.name}: ${error.message}`)
}
}
@@ -837,10 +894,10 @@ class McpService {
if (activeToolCall) {
activeToolCall.abort()
this.activeToolCalls.delete(callId)
logger.debug(`Aborted tool call: ${callId}`)
logger.debug(`Aborted tool call`, { callId })
return true
} else {
logger.warn(`No active tool call found for callId: ${callId}`)
logger.warn(`No active tool call found for callId`, { callId })
return false
}
}
@@ -850,22 +907,22 @@ class McpService {
*/
public async getServerVersion(_: Electron.IpcMainInvokeEvent, server: MCPServer): Promise<string | null> {
try {
logger.debug(`Getting server version for: ${server.name}`)
getServerLogger(server).debug(`Getting server version`)
const client = await this.initClient(server)
// Try to get server information which may include version
const serverInfo = client.getServerVersion()
logger.debug(`Server info for ${server.name}:`, serverInfo)
getServerLogger(server).debug(`Server info`, redactSensitive(serverInfo))
if (serverInfo && serverInfo.version) {
logger.debug(`Server version for ${server.name}: ${serverInfo.version}`)
getServerLogger(server).debug(`Server version`, { version: serverInfo.version })
return serverInfo.version
}
logger.warn(`No version information available for server: ${server.name}`)
getServerLogger(server).warn(`No version information available`)
return null
} catch (error: any) {
logger.error(`Failed to get server version for ${server.name}:`, error?.message)
getServerLogger(server).error(`Failed to get server version`, error as Error)
return null
}
}

View File

@@ -4,6 +4,7 @@ import path from 'node:path'
import { promisify } from 'node:util'
import { loggerService } from '@logger'
import { IpcChannel } from '@shared/IpcChannel'
import { app } from 'electron'
import { handleProvidersProtocolUrl } from './urlschema/handle-providers'
@@ -13,15 +14,17 @@ import { windowService } from './WindowService'
const logger = loggerService.withContext('ProtocolClient')
export const CHERRY_STUDIO_PROTOCOL = 'cherrystudio'
export const CHERRY_STUDIO_ENTERPRISE_PROTOCOL = 'cherrystudio-enterprise'
export function registerProtocolClient(app: Electron.App) {
if (process.defaultApp) {
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient(CHERRY_STUDIO_PROTOCOL, process.execPath, [process.argv[1]])
app.setAsDefaultProtocolClient(CHERRY_STUDIO_ENTERPRISE_PROTOCOL, process.execPath, [process.argv[1]])
return
}
}
app.setAsDefaultProtocolClient(CHERRY_STUDIO_PROTOCOL)
app.setAsDefaultProtocolClient(CHERRY_STUDIO_ENTERPRISE_PROTOCOL)
}
export function handleProtocolUrl(url: string) {
@@ -40,6 +43,9 @@ export function handleProtocolUrl(url: string) {
case 'providers':
handleProvidersProtocolUrl(urlObj)
return
case 'oauth':
handleOauthProtocolUrl(urlObj)
return
}
// You can send the data to your renderer process
@@ -53,6 +59,21 @@ export function handleProtocolUrl(url: string) {
}
}
function handleOauthProtocolUrl(urlObj: URL) {
const params = new URLSearchParams(urlObj.search)
const pathname = urlObj.pathname
const mainWindow = windowService.getMainWindow()
if (mainWindow && pathname === '/casdoor') {
const token = params.get('token')
const user = params.get('user')
mainWindow.webContents.send(IpcChannel.OAuth_Casdoor, {
token,
user: JSON.parse(user || '{}')
})
}
}
const execAsync = promisify(exec)
const DESKTOP_FILE_NAME = 'cherrystudio-url-handler.desktop'
@@ -87,11 +108,11 @@ export async function setupAppImageDeepLink(): Promise<void> {
// %U allows passing the URL to the application
// NoDisplay=true hides it from the regular application menu
const desktopFileContent = `[Desktop Entry]
Name=Cherry Studio
Name=Cherry Studio 企业版
Exec=${escapePathForExec(appPath)} %U
Terminal=false
Type=Application
MimeType=x-scheme-handler/${CHERRY_STUDIO_PROTOCOL};
MimeType=x-scheme-handler/${CHERRY_STUDIO_ENTERPRISE_PROTOCOL}
NoDisplay=true
`

View File

@@ -101,6 +101,9 @@ export class ProxyManager {
private originalHttpsGet: typeof https.get
private originalHttpsRequest: typeof https.request
// for webview
private wvproxy: string = ''
private originalAxiosAdapter
constructor() {
@@ -206,7 +209,6 @@ export class ProxyManager {
this.setEnvironment(config.proxyRules || '')
this.setGlobalFetchProxy(config)
this.setSessionsProxy(config)
this.setGlobalHttpProxy(config)
}
@@ -314,12 +316,24 @@ export class ProxyManager {
}
private async setSessionsProxy(config: ProxyConfig): Promise<void> {
const sessions = [session.defaultSession, session.fromPartition('persist:webview')]
await Promise.all(sessions.map((session) => session.setProxy(config)))
await session.defaultSession.setProxy(config)
if (!this.wvproxy) {
await session.fromPartition('persist:webview').setProxy(config)
}
// set proxy for electron
app.setProxy(config)
}
public setSessionsProxyForWebview(proxy: string): void {
const wvsession = session.fromPartition('persist:webview')
this.wvproxy = proxy
logger.info(`setSessionsProxyForWebview: ${proxy}`)
if (proxy.includes('://')) {
wvsession.setProxy({ mode: 'fixed_servers', proxyRules: proxy })
}
}
}
export const proxyManager = new ProxyManager()

View File

@@ -51,7 +51,7 @@ export class TrayService {
this.tray.setContextMenu(this.contextMenu)
}
this.tray.setToolTip('Cherry Studio')
this.tray.setToolTip('Cherry Studio 企业版')
this.tray.on('right-click', () => {
if (this.contextMenu) {

View File

@@ -13,7 +13,7 @@ export function initSessionUserAgent() {
wvSession.webRequest.onBeforeSendHeaders((details, cb) => {
const headers = {
...details.requestHeaders,
'User-Agent': newUA
'User-Agent': details.url.includes('google.com') ? originUA : newUA
}
cb({ requestHeaders: headers })
})

View File

@@ -47,6 +47,7 @@ const api = {
reload: () => ipcRenderer.invoke(IpcChannel.App_Reload),
setProxy: (proxy: string | undefined, bypassRules?: string) =>
ipcRenderer.invoke(IpcChannel.App_Proxy, proxy, bypassRules),
setProxyForWebview: (proxy: string) => ipcRenderer.invoke(IpcChannel.App_ProxyForWebview, proxy),
checkForUpdate: () => ipcRenderer.invoke(IpcChannel.App_CheckForUpdate),
showUpdateDialog: () => ipcRenderer.invoke(IpcChannel.App_ShowUpdateDialog),
setLanguage: (lang: string) => ipcRenderer.invoke(IpcChannel.App_SetLanguage, lang),

View File

@@ -8,6 +8,7 @@ import { PersistGate } from 'redux-persist/integration/react'
import TopViewContainer from './components/TopView'
import AntdProvider from './context/AntdProvider'
import AuthProvider from './context/AuthProvider'
import { CodeStyleProvider } from './context/CodeStyleProvider'
import { NotificationProvider } from './context/NotificationProvider'
import StyleSheetManager from './context/StyleSheetManager'
@@ -39,7 +40,9 @@ function App(): React.ReactElement {
<CodeStyleProvider>
<PersistGate loading={null} persistor={persistor}>
<TopViewContainer>
<Router />
<AuthProvider>
<Router />
</AuthProvider>
</TopViewContainer>
</PersistGate>
</CodeStyleProvider>

View File

@@ -14,6 +14,7 @@ import FilesPage from './pages/files/FilesPage'
import HomePage from './pages/home/HomePage'
import KnowledgePage from './pages/knowledge/KnowledgePage'
import LaunchpadPage from './pages/launchpad/LaunchpadPage'
import MinAppPage from './pages/minapps/MinAppPage'
import MinAppsPage from './pages/minapps/MinAppsPage'
import NotesPage from './pages/notes/NotesPage'
import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
@@ -34,6 +35,7 @@ const Router: FC = () => {
<Route path="/files" element={<FilesPage />} />
<Route path="/notes" element={<NotesPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/apps/:appId" element={<MinAppPage />} />
<Route path="/apps" element={<MinAppsPage />} />
<Route path="/code" element={<CodeToolsPage />} />
<Route path="/settings/*" element={<SettingsPage />} />

View File

@@ -483,7 +483,7 @@ export class AnthropicAPIClient extends BaseApiClient<
}
const commonParams: MessageCreateParamsBase = {
model: model.id,
model: model.id + '@' + model.provider,
messages:
isRecursiveCall && recursiveSdkMessages && recursiveSdkMessages.length > 0
? recursiveSdkMessages

View File

@@ -91,7 +91,8 @@ export class GeminiAPIClient extends BaseApiClient<
abortSignal: options?.signal,
httpOptions: {
...rest.config?.httpOptions,
timeout: options?.timeout
timeout: options?.timeout,
headers: this.defaultHeaders()
}
}
} satisfies SendMessageParameters
@@ -100,6 +101,8 @@ export class GeminiAPIClient extends BaseApiClient<
const chat = sdk.chats.create({
model: model,
// @ts-ignore provider is not typed
provider: 'gemini',
history: history
})
@@ -121,11 +124,12 @@ export class GeminiAPIClient extends BaseApiClient<
aspectRatio: imageSize,
abortSignal: signal,
httpOptions: {
timeout: defaultTimeout
timeout: defaultTimeout,
headers: this.defaultHeaders()
}
}
const response = await sdk.models.generateImages({
model: model,
model,
prompt,
config
})
@@ -181,7 +185,8 @@ export class GeminiAPIClient extends BaseApiClient<
baseUrl: this.getBaseURL(),
apiVersion: this.getApiVersion(),
headers: {
...this.provider.extra_headers
...this.provider.extra_headers,
...this.defaultHeaders()
}
}
})

View File

@@ -696,7 +696,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
}
const commonParams: OpenAISdkParams = {
model: model.id,
model: model.id + '@' + model.provider,
messages:
isRecursiveCall && recursiveSdkMessages && recursiveSdkMessages.length > 0
? recursiveSdkMessages

View File

@@ -89,7 +89,7 @@ export abstract class OpenAIBaseClient<
const sdk = await this.getSdkInstance()
const data = await sdk.embeddings.create({
model: model.id,
model: model.id + '@' + model.provider,
input: model?.provider === 'baidu-cloud' ? ['hi'] : 'hi',
encoding_format: this.provider.id === 'voyageai' ? undefined : 'float'
})

View File

@@ -452,7 +452,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
}
const commonParams: OpenAIResponseSdkParams = {
model: model.id,
model: model.id + '@' + model.provider,
input:
isRecursiveCall && recursiveSdkMessages && recursiveSdkMessages.length > 0
? recursiveSdkMessages

View File

@@ -80,7 +80,7 @@ export const ImageGenerationMiddleware: CompletionsMiddleware =
if (imageFiles.length > 0) {
response = await sdk.images.edit(
{
model: assistant.model.id,
model: assistant.model.id + '@' + assistant.model.provider,
image: imageFiles,
prompt: prompt || ''
},
@@ -89,7 +89,7 @@ export const ImageGenerationMiddleware: CompletionsMiddleware =
} else {
response = await sdk.images.generate(
{
model: assistant.model.id,
model: assistant.model.id + '@' + assistant.model.provider,
prompt: prompt || '',
response_format: assistant.model.id.includes('gpt-image-1') ? undefined : 'b64_json'
},

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 129 KiB

View File

@@ -165,9 +165,6 @@ ul {
}
.markdown {
display: flow-root;
*:last-child {
margin-bottom: 0;
}
}
}

View File

@@ -0,0 +1,52 @@
import { isMac } from '@renderer/config/constant'
import { UserAvatar as DefaultUserAvatar } from '@renderer/config/env'
import { isEmoji } from '@renderer/utils/naming'
import { Avatar } from 'antd'
import { FC } from 'react'
import styled from 'styled-components'
import UserPopup from '../Popups/UserPopup'
import EmojiAvatar from './EmojiAvatar'
interface Props {
avatar: string
size: number
}
const UserAvatar: FC<Props> = ({ avatar, size = 31 }) => {
const onEditUser = () => UserPopup.show()
if (isEmoji(avatar)) {
return (
<EmojiAvatar onClick={onEditUser} className="sidebar-avatar" size={size} fontSize={18}>
{avatar}
</EmojiAvatar>
)
}
return (
<AvatarImg
src={avatar || DefaultUserAvatar}
draggable={false}
size={size}
className="nodrag"
onClick={onEditUser}
/>
)
}
const AvatarImg = styled(Avatar)`
width: 31px;
height: 31px;
background-color: var(--color-background-soft);
margin-bottom: ${isMac ? '12px' : '12px'};
margin-top: ${isMac ? '0px' : '2px'};
border: none;
cursor: pointer;
[navbar-position='top'] & {
margin-bottom: 0px;
margin-top: 0px;
}
`
export default UserAvatar

View File

@@ -11,11 +11,9 @@ interface Props {
}
const MinAppIcon: FC<Props> = ({ app, size = 48, style, sidebar = false }) => {
const _app = DEFAULT_MIN_APPS.find((item) => item.id === app.id)
const systemApp = DEFAULT_MIN_APPS.find((item) => item.id === app.id)
if (!_app) {
return null
}
const _app = systemApp || app
return (
<Container

View File

@@ -13,6 +13,7 @@ import { Dropdown } from 'antd'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { useDispatch } from 'react-redux'
import { useNavigate } from 'react-router-dom'
import styled from 'styled-components'
interface Props {
@@ -30,6 +31,7 @@ const MinApp: FC<Props> = ({ app, onClick, size = 60, isLast }) => {
const { minapps, pinned, disabled, updateMinapps, updateDisabledMinapps, updatePinnedMinapps } = useMinapps()
const { openedKeepAliveMinapps, currentMinappId, minappShow } = useRuntime()
const dispatch = useDispatch()
const navigate = useNavigate()
const isPinned = pinned.some((p) => p.id === app.id)
const isVisible = minapps.some((m) => m.id === app.id)
const isActive = minappShow && currentMinappId === app.id
@@ -37,7 +39,13 @@ const MinApp: FC<Props> = ({ app, onClick, size = 60, isLast }) => {
const { isTopNavbar } = useNavbarPosition()
const handleClick = () => {
openMinappKeepAlive(app)
if (isTopNavbar) {
// 顶部导航栏:导航到小程序页面
navigate(`/apps/${app.id}`)
} else {
// 侧边导航栏:保持原有弹窗行为
openMinappKeepAlive(app)
}
onClick?.()
}

View File

@@ -0,0 +1,143 @@
import { loggerService } from '@logger'
import WebviewContainer from '@renderer/components/MinApp/WebviewContainer'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useNavbarPosition } from '@renderer/hooks/useSettings'
import { getWebviewLoaded, setWebviewLoaded } from '@renderer/utils/webviewStateManager'
import { WebviewTag } from 'electron'
import React, { useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'
import styled from 'styled-components'
/**
* Mini-app WebView pool for Tab 模式 (顶部导航).
*
* 与 Popup 模式相似,但独立存在:
* - 仅在 isTopNavbar=true 且访问 /apps 路由时显示
* - 保证已打开的 keep-alive 小程序对应的 <webview> 不被卸载,只通过 display 切换
* - LRU 淘汰通过 openedKeepAliveMinapps 变化自动移除 DOM
*
* 后续可演进:与 Popup 共享同一实例(方案 B
*/
const logger = loggerService.withContext('MinAppTabsPool')
const MinAppTabsPool: React.FC = () => {
const { openedKeepAliveMinapps, currentMinappId } = useRuntime()
const { isTopNavbar } = useNavbarPosition()
const location = useLocation()
// webview refs池内部自用用于控制显示/隐藏)
const webviewRefs = useRef<Map<string, WebviewTag | null>>(new Map())
// 使用集中工具进行更稳健的路由判断
const isAppDetail = (() => {
const pathname = location.pathname
if (pathname === '/apps') return false
if (!pathname.startsWith('/apps/')) return false
const parts = pathname.split('/').filter(Boolean) // ['apps', '<id>', ...]
return parts.length >= 2
})()
const shouldShow = isTopNavbar && isAppDetail
// 组合当前需要渲染的列表(保持顺序即可)
const apps = openedKeepAliveMinapps
/** 设置 ref 回调 */
const handleSetRef = (appid: string, el: WebviewTag | null) => {
if (el) {
webviewRefs.current.set(appid, el)
} else {
webviewRefs.current.delete(appid)
}
}
/** WebView 加载完成回调 */
const handleLoaded = (appid: string) => {
setWebviewLoaded(appid, true)
logger.debug(`TabPool webview loaded: ${appid}`)
}
/** 记录导航(暂未外曝 URL 状态,后续可接入全局 URL Map */
const handleNavigate = (appid: string, url: string) => {
logger.debug(`TabPool webview navigate: ${appid} -> ${url}`)
}
/** 切换显示状态:仅当前 active 的显示,其余隐藏 */
useEffect(() => {
webviewRefs.current.forEach((ref, id) => {
if (!ref) return
const active = id === currentMinappId && shouldShow
ref.style.display = active ? 'inline-flex' : 'none'
})
}, [currentMinappId, shouldShow, apps.length])
/** 当某个已在 Map 里但不再属于 openedKeepAlive 时移除引用React 自身会卸载元素) */
useEffect(() => {
const existing = Array.from(webviewRefs.current.keys())
existing.forEach((id) => {
if (!apps.find((a) => a.id === id)) {
webviewRefs.current.delete(id)
// loaded 状态也清理LRU 已在其它地方清除,双保险)
if (getWebviewLoaded(id)) {
setWebviewLoaded(id, false)
}
}
})
}, [apps])
// 不显示时直接 hidden避免闪烁仍然保留 DOM 做保活
const toolbarHeight = 35 // 与 MinimalToolbar 高度保持一致
return (
<PoolContainer
style={
shouldShow
? {
visibility: 'visible',
top: toolbarHeight,
height: `calc(100% - ${toolbarHeight}px)`
}
: { visibility: 'hidden' }
}
data-minapp-tabs-pool
aria-hidden={!shouldShow}>
{apps.map((app) => (
<WebviewWrapper key={app.id} $active={app.id === currentMinappId}>
<WebviewContainer
appid={app.id}
url={app.url}
onSetRefCallback={handleSetRef}
onLoadedCallback={handleLoaded}
onNavigateCallback={handleNavigate}
/>
</WebviewWrapper>
))}
</PoolContainer>
)
}
const PoolContainer = styled.div`
position: absolute;
left: 0;
right: 0;
bottom: 0;
/* top 在运行时通过 style 注入 (toolbarHeight) */
width: 100%;
overflow: hidden;
border-radius: 0 0 8px 8px;
z-index: 1;
pointer-events: none;
& webview {
pointer-events: auto;
}
`
const WebviewWrapper = styled.div<{ $active: boolean }>`
position: absolute;
inset: 0;
width: 100%;
height: 100%;
/* display 控制在内部 webview 元素上做,这里保持结构稳定 */
pointer-events: ${(props) => (props.$active ? 'auto' : 'none')};
`
export default MinAppTabsPool

View File

@@ -24,6 +24,7 @@ import { useAppDispatch } from '@renderer/store'
import { setMinappsOpenLinkExternal } from '@renderer/store/settings'
import { MinAppType } from '@renderer/types'
import { delay } from '@renderer/utils'
import { clearWebviewState, getWebviewLoaded, setWebviewLoaded } from '@renderer/utils/webviewStateManager'
import { Alert, Avatar, Button, Drawer, Tooltip } from 'antd'
import { WebviewTag } from 'electron'
import { useEffect, useMemo, useRef, useState } from 'react'
@@ -162,8 +163,7 @@ const MinappPopupContainer: React.FC = () => {
/** store the webview refs, one of the key to make them keepalive */
const webviewRefs = useRef<Map<string, WebviewTag | null>>(new Map())
/** indicate whether the webview has loaded */
const webviewLoadedRefs = useRef<Map<string, boolean>>(new Map())
/** Note: WebView loaded states now managed globally via webviewStateManager */
/** whether the minapps open link external is enabled */
const { minappsOpenLinkExternal } = useSettings()
@@ -185,7 +185,7 @@ const MinappPopupContainer: React.FC = () => {
setIsPopupShow(true)
if (webviewLoadedRefs.current.get(currentMinappId)) {
if (getWebviewLoaded(currentMinappId)) {
setIsReady(true)
/** the case that open the minapp from sidebar */
} else if (lastMinappId.current !== currentMinappId && lastMinappShow.current === minappShow) {
@@ -216,17 +216,21 @@ const MinappPopupContainer: React.FC = () => {
webviewRef.style.display = appid === currentMinappId ? 'inline-flex' : 'none'
})
//delete the extra webviewLoadedRefs
webviewLoadedRefs.current.forEach((_, appid) => {
if (!webviewRefs.current.has(appid)) {
webviewLoadedRefs.current.delete(appid)
} else if (appid === currentMinappId) {
const webviewId = webviewRefs.current.get(appid)?.getWebContentsId()
if (webviewId) {
window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal)
// Set external link behavior for current minapp
if (currentMinappId) {
const webviewElement = webviewRefs.current.get(currentMinappId)
if (webviewElement) {
try {
const webviewId = webviewElement.getWebContentsId()
if (webviewId) {
window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal)
}
} catch (error) {
// WebView not ready yet, will be set when it's loaded
logger.debug(`WebView ${currentMinappId} not ready for getWebContentsId()`)
}
}
})
}
}, [currentMinappId, minappsOpenLinkExternal])
/** only the keepalive minapp can be minimized */
@@ -255,15 +259,17 @@ const MinappPopupContainer: React.FC = () => {
/** get the current app info with extra info */
let currentAppInfo: AppInfo | null = null
if (currentMinappId) {
const currentApp = combinedApps.find((item) => item.id === currentMinappId) as MinAppType
currentAppInfo = { ...currentApp, ...appsExtraInfo[currentApp.id] }
const currentApp = combinedApps.find((item) => item.id === currentMinappId)
if (currentApp) {
currentAppInfo = { ...currentApp, ...appsExtraInfo[currentApp.id] }
}
}
/** will close the popup and delete the webview */
const handlePopupClose = async (appid: string) => {
setIsPopupShow(false)
await delay(0.3)
webviewLoadedRefs.current.delete(appid)
clearWebviewState(appid)
closeMinapp(appid)
}
@@ -292,10 +298,17 @@ const MinappPopupContainer: React.FC = () => {
/** the callback function to set the webviews loaded indicator */
const handleWebviewLoaded = (appid: string) => {
webviewLoadedRefs.current.set(appid, true)
const webviewId = webviewRefs.current.get(appid)?.getWebContentsId()
if (webviewId) {
window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal)
setWebviewLoaded(appid, true)
const webviewElement = webviewRefs.current.get(appid)
if (webviewElement) {
try {
const webviewId = webviewElement.getWebContentsId()
if (webviewId) {
window.api.webview.setOpenLinkExternal(webviewId, minappsOpenLinkExternal)
}
} catch (error) {
logger.debug(`WebView ${appid} not ready for getWebContentsId() in handleWebviewLoaded`)
}
}
if (appid == currentMinappId) {
setTimeoutTimer('handleWebviewLoaded', () => setIsReady(true), 200)
@@ -352,16 +365,28 @@ const MinappPopupContainer: React.FC = () => {
/** navigate back in webview history */
const handleGoBack = (appid: string) => {
const webview = webviewRefs.current.get(appid)
if (webview && webview.canGoBack()) {
webview.goBack()
if (webview) {
try {
if (webview.canGoBack()) {
webview.goBack()
}
} catch (error) {
logger.debug(`WebView ${appid} not ready for goBack()`)
}
}
}
/** navigate forward in webview history */
const handleGoForward = (appid: string) => {
const webview = webviewRefs.current.get(appid)
if (webview && webview.canGoForward()) {
webview.goForward()
if (webview) {
try {
if (webview.canGoForward()) {
webview.goForward()
}
} catch (error) {
logger.debug(`WebView ${appid} not ready for goForward()`)
}
}
}
@@ -409,7 +434,7 @@ const MinappPopupContainer: React.FC = () => {
</Tooltip>
)}
<Spacer />
<ButtonsGroup className={isWin || isLinux ? 'windows' : ''}>
<ButtonsGroup className={isWin || isLinux ? 'windows' : ''} isTopNavbar={isTopNavbar}>
<Tooltip title={t('minapp.popup.goBack')} mouseEnterDelay={0.8} placement="bottom">
<TitleButton onClick={() => handleGoBack(appInfo.id)}>
<ArrowLeftOutlined />
@@ -498,19 +523,25 @@ const MinappPopupContainer: React.FC = () => {
return (
<Drawer
title={<Title appInfo={currentAppInfo} url={currentUrl} />}
title={isTopNavbar ? null : <Title appInfo={currentAppInfo} url={currentUrl} />}
placement="bottom"
onClose={handlePopupMinimize}
open={isPopupShow}
mask={false}
rootClassName="minapp-drawer"
maskClassName="minapp-mask"
height={'100%'}
height={isTopNavbar ? 'calc(100% - var(--navbar-height))' : '100%'}
maskClosable={false}
closeIcon={null}
style={{
marginLeft: isLeftNavbar ? 'var(--sidebar-width)' : 0,
backgroundColor: window.root.style.background
styles={{
wrapper: {
position: 'fixed',
marginLeft: isLeftNavbar ? 'var(--sidebar-width)' : 0,
marginTop: isTopNavbar ? 'var(--navbar-height)' : 0
},
content: {
backgroundColor: window.root.style.background
}
}}>
{/* 在所有小程序中显示GoogleLoginTip */}
<GoogleLoginTip isReady={isReady} currentUrl={currentUrl} currentAppId={currentMinappId} />
@@ -566,7 +597,7 @@ const TitleTextTooltip = styled.span`
}
`
const ButtonsGroup = styled.div`
const ButtonsGroup = styled.div<{ isTopNavbar: boolean }>`
display: flex;
flex-direction: row;
align-items: center;

View File

@@ -1,11 +1,14 @@
import MinappPopupContainer from '@renderer/components/MinApp/MinappPopupContainer'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useNavbarPosition } from '@renderer/hooks/useSettings'
const TopViewMinappContainer = () => {
const { openedKeepAliveMinapps, openedOneOffMinapp } = useRuntime()
const { isLeftNavbar } = useNavbarPosition()
const isCreate = openedKeepAliveMinapps.length > 0 || openedOneOffMinapp !== null
return <>{isCreate && <MinappPopupContainer />}</>
// Only show popup container in sidebar mode (left navbar), not in tab mode (top navbar)
return <>{isCreate && isLeftNavbar && <MinappPopupContainer />}</>
}
export default TopViewMinappContainer

View File

@@ -1,7 +1,10 @@
import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
import { loggerService } from '@logger'
import { useSettings } from '@renderer/hooks/useSettings'
import { WebviewTag } from 'electron'
import { memo, useEffect, useRef } from 'react'
const logger = loggerService.withContext('WebviewContainer')
/**
* WebviewContainer is a component that renders a webview element.
* It is used in the MinAppPopupContainer component.
@@ -23,7 +26,6 @@ const WebviewContainer = memo(
}) => {
const webviewRef = useRef<WebviewTag | null>(null)
const { enableSpellCheck } = useSettings()
const { isLeftNavbar } = useNavbarPosition()
const setRef = (appid: string) => {
onSetRefCallback(appid, null)
@@ -41,8 +43,29 @@ const WebviewContainer = memo(
useEffect(() => {
if (!webviewRef.current) return
let loadCallbackFired = false
const handleLoaded = () => {
onLoadedCallback(appid)
logger.debug(`WebView did-finish-load for app: ${appid}`)
// Only fire callback once per load cycle
if (!loadCallbackFired) {
loadCallbackFired = true
// Small delay to ensure content is actually visible
setTimeout(() => {
logger.debug(`Calling onLoadedCallback for app: ${appid}`)
onLoadedCallback(appid)
}, 100)
}
}
// Additional callback for when page is ready to show
const handleReadyToShow = () => {
logger.debug(`WebView ready-to-show for app: ${appid}`)
if (!loadCallbackFired) {
loadCallbackFired = true
logger.debug(`Calling onLoadedCallback from ready-to-show for app: ${appid}`)
onLoadedCallback(appid)
}
}
const handleNavigate = (event: any) => {
@@ -56,16 +79,25 @@ const WebviewContainer = memo(
}
}
const handleStartLoading = () => {
// Reset callback flag when starting a new load
loadCallbackFired = false
}
webviewRef.current.addEventListener('did-start-loading', handleStartLoading)
webviewRef.current.addEventListener('dom-ready', handleDomReady)
webviewRef.current.addEventListener('did-finish-load', handleLoaded)
webviewRef.current.addEventListener('ready-to-show', handleReadyToShow)
webviewRef.current.addEventListener('did-navigate-in-page', handleNavigate)
// we set the url when the webview is ready
webviewRef.current.src = url
return () => {
webviewRef.current?.removeEventListener('did-start-loading', handleStartLoading)
webviewRef.current?.removeEventListener('dom-ready', handleDomReady)
webviewRef.current?.removeEventListener('did-finish-load', handleLoaded)
webviewRef.current?.removeEventListener('ready-to-show', handleReadyToShow)
webviewRef.current?.removeEventListener('did-navigate-in-page', handleNavigate)
}
// because the appid and url are enough, no need to add onLoadedCallback
@@ -73,8 +105,8 @@ const WebviewContainer = memo(
}, [appid, url])
const WebviewStyle: React.CSSProperties = {
width: isLeftNavbar ? 'calc(100vw - var(--sidebar-width))' : '100vw',
height: 'calc(100vh - var(--navbar-height))',
width: '100%',
height: '100%',
backgroundColor: 'var(--color-background)',
display: 'inline-flex'
}
@@ -83,6 +115,7 @@ const WebviewContainer = memo(
<webview
key={appid}
ref={setRef(appid)}
data-minapp-id={appid}
style={WebviewStyle}
allowpopups={'true' as any}
partition="persist:webview"

View File

@@ -1,8 +1,7 @@
import CherryLogo from '@renderer/assets/images/banner.png'
import Favicon from '@renderer/components/Icons/FallbackFavicon'
import { useMetaDataParser } from '@renderer/hooks/useMetaDataParser'
import { Skeleton, Typography } from 'antd'
import { useEffect, useMemo } from 'react'
import { useCallback, useEffect, useMemo } from 'react'
import styled from 'styled-components'
const { Title, Paragraph } = Typography
@@ -11,6 +10,8 @@ type Props = {
show: boolean
}
const IMAGE_HEIGHT = '9rem' // equals h-36
export const OGCard = ({ link, show }: Props) => {
const openGraph = ['og:title', 'og:description', 'og:image', 'og:imageAlt'] as const
const { metadata, isLoading, parseMetadata } = useMetaDataParser(link, openGraph)
@@ -32,6 +33,14 @@ export const OGCard = ({ link, show }: Props) => {
}
}, [parseMetadata, isLoading, show])
const GeneratedGraph = useCallback(() => {
return (
<div className="flex h-36 items-center justify-center bg-accent p-4">
<h2 className="text-2xl font-bold">{metadata['og:title'] || hostname}</h2>
</div>
)
}, [hostname, metadata])
if (isLoading) {
return <CardSkeleton />
}
@@ -45,7 +54,7 @@ export const OGCard = ({ link, show }: Props) => {
)}
{!hasImage && (
<PreviewImageContainer>
<PreviewImage src={CherryLogo} alt={'no image'} />
<GeneratedGraph />
</PreviewImageContainer>
)}
@@ -113,8 +122,8 @@ const PreviewContainer = styled.div<{ hasImage?: boolean }>`
const PreviewImageContainer = styled.div`
width: 100%;
height: 140px;
min-height: 140px;
height: ${IMAGE_HEIGHT};
min-height: ${IMAGE_HEIGHT};
overflow: hidden;
`
@@ -128,7 +137,7 @@ const PreviewContent = styled.div`
const PreviewImage = styled.img`
width: 100%;
height: 140px;
height: ${IMAGE_HEIGHT};
object-fit: cover;
`

View File

@@ -0,0 +1,144 @@
import { PoeLogo } from '@renderer/components/Icons'
import { getProviderLogo } from '@renderer/config/providers'
import { Provider } from '@renderer/types'
import { generateColorFromChar, getFirstCharacter, getForegroundColor } from '@renderer/utils'
import { Avatar } from 'antd'
import React from 'react'
import styled from 'styled-components'
interface ProviderAvatarPrimitiveProps {
providerId: string
providerName: string
logoSrc?: string
size?: number
className?: string
style?: React.CSSProperties
}
interface ProviderAvatarProps {
provider: Provider
customLogos?: Record<string, string>
size?: number
className?: string
style?: React.CSSProperties
}
const ProviderSvgLogo = styled.div`
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border: 0.5px solid var(--color-border);
border-radius: 100%;
& > svg {
width: 80%;
height: 80%;
}
`
const ProviderLogo = styled(Avatar)`
width: 100%;
height: 100%;
border: 0.5px solid var(--color-border);
`
export const ProviderAvatarPrimitive: React.FC<ProviderAvatarPrimitiveProps> = ({
providerId,
providerName,
logoSrc,
size,
className,
style
}) => {
if (providerId === 'poe') {
return (
<ProviderSvgLogo className={className} style={style}>
<PoeLogo fontSize={size} />
</ProviderSvgLogo>
)
}
if (logoSrc) {
return (
<ProviderLogo draggable="false" shape="circle" src={logoSrc} className={className} style={style} size={size} />
)
}
const backgroundColor = generateColorFromChar(providerName)
const color = providerName ? getForegroundColor(backgroundColor) : 'white'
return (
<ProviderLogo
size={size}
shape="circle"
className={className}
style={{
backgroundColor,
color,
...style
}}>
{getFirstCharacter(providerName)}
</ProviderLogo>
)
}
export const ProviderAvatar: React.FC<ProviderAvatarProps> = ({
provider,
customLogos = {},
className,
style,
size
}) => {
const systemLogoSrc = getProviderLogo(provider.id)
if (systemLogoSrc) {
return (
<ProviderAvatarPrimitive
size={size}
providerId={provider.id}
providerName={provider.name}
logoSrc={systemLogoSrc}
className={className}
style={style}
/>
)
}
const customLogo = customLogos[provider.id]
if (customLogo) {
if (customLogo === 'poe') {
return (
<ProviderAvatarPrimitive
size={size}
providerId="poe"
providerName={provider.name}
className={className}
style={style}
/>
)
}
return (
<ProviderAvatarPrimitive
providerId={provider.id}
providerName={provider.name}
logoSrc={customLogo}
size={size}
className={className}
style={style}
/>
)
}
return (
<ProviderAvatarPrimitive
providerId={provider.id}
providerName={provider.name}
size={size}
className={className}
style={style}
/>
)
}

View File

@@ -1,4 +1,5 @@
import { SearchOutlined } from '@ant-design/icons'
import { ProviderAvatarPrimitive } from '@renderer/components/ProviderAvatar'
import { PROVIDER_LOGO_MAP } from '@renderer/config/providers'
import { getProviderLabel } from '@renderer/i18n/label'
import { Input, Tooltip } from 'antd'
@@ -48,10 +49,10 @@ const ProviderLogoPicker: FC<Props> = ({ onProviderClick }) => {
/>
</SearchContainer>
<LogoGrid>
{filteredProviders.map(({ id, logo, name }) => (
{filteredProviders.map(({ id, name, logo }) => (
<Tooltip key={id} title={name} placement="top" mouseLeaveDelay={0}>
<LogoItem onClick={(e) => handleProviderClick(e, id)}>
<img src={logo} alt={name} draggable={false} />
<ProviderAvatarPrimitive providerId={id} size={52} providerName={name} logoSrc={logo} />
</LogoItem>
</Tooltip>
))}
@@ -86,11 +87,12 @@ const LogoGrid = styled.div`
const LogoItem = styled.div`
width: 52px;
height: 52px;
border-radius: 100%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 8px;
transition: all 0.2s ease;
background: var(--color-background-soft);
border: 0.5px solid var(--color-border);
@@ -102,8 +104,8 @@ const LogoItem = styled.div`
}
img {
width: 32px;
height: 32px;
width: 100%;
height: 100%;
object-fit: contain;
user-select: none;
-webkit-user-drag: none;

View File

@@ -1,11 +1,12 @@
import { PlusOutlined } from '@ant-design/icons'
import { TopNavbarOpenedMinappTabs } from '@renderer/components/app/PinnedMinapps'
import { Sortable, useDndReorder } from '@renderer/components/dnd'
import Scrollbar from '@renderer/components/Scrollbar'
import { isLinux, isMac, isWin } from '@renderer/config/constant'
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useFullscreen } from '@renderer/hooks/useFullscreen'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { useMinapps } from '@renderer/hooks/useMinapps'
import { getThemeModeLabel, getTitleLabel } from '@renderer/i18n/label'
import tabsService from '@renderer/services/TabsService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
@@ -37,11 +38,23 @@ import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate } from 'react-router-dom'
import styled from 'styled-components'
import MinAppIcon from '../Icons/MinAppIcon'
import MinAppTabsPool from '../MinApp/MinAppTabsPool'
interface TabsContainerProps {
children: React.ReactNode
}
const getTabIcon = (tabId: string): React.ReactNode | undefined => {
const getTabIcon = (tabId: string, minapps: any[]): React.ReactNode | undefined => {
// Check if it's a minapp tab (format: apps:appId)
if (tabId.startsWith('apps:')) {
const appId = tabId.replace('apps:', '')
const app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
if (app) {
return <MinAppIcon size={14} app={app} />
}
}
switch (tabId) {
case 'home':
return <Home size={14} />
@@ -70,7 +83,7 @@ const getTabIcon = (tabId: string): React.ReactNode | undefined => {
}
}
let lastSettingsPath = '/settings/provider'
let lastSettingsPath = '/settings/user'
const specialTabs = ['launchpad', 'settings']
const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
@@ -82,6 +95,7 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
const isFullscreen = useFullscreen()
const { settedTheme, toggleTheme } = useTheme()
const { hideMinappPopup } = useMinappPopup()
const { minapps } = useMinapps()
const { t } = useTranslation()
const scrollRef = useRef<HTMLDivElement>(null)
const [canScroll, setCanScroll] = useState(false)
@@ -89,9 +103,23 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
const getTabId = (path: string): string => {
if (path === '/') return 'home'
const segments = path.split('/')
// Handle minapp paths: /apps/appId -> apps:appId
if (segments[1] === 'apps' && segments[2]) {
return `apps:${segments[2]}`
}
return segments[1] // 获取第一个路径段作为 id
}
const getTabTitle = (tabId: string): string => {
// Check if it's a minapp tab
if (tabId.startsWith('apps:')) {
const appId = tabId.replace('apps:', '')
const app = [...DEFAULT_MIN_APPS, ...minapps].find((app) => app.id === appId)
return app ? app.name : 'MinApp'
}
return getTitleLabel(tabId)
}
const shouldCreateTab = (path: string) => {
if (path === '/') return false
if (path === '/settings') return false
@@ -127,6 +155,12 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
removeSpecialTabs()
}, [removeSpecialTabs])
useEffect(() => {
navigate('/launchpad')
dispatch(setActiveTab('launchpad'))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const closeTab = (tabId: string) => {
tabsService.closeTab(tabId)
}
@@ -196,8 +230,8 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
renderItem={(tab) => (
<Tab key={tab.id} active={tab.id === activeTabId} onClick={() => handleTabClick(tab)}>
<TabHeader>
{tab.id && <TabIcon>{getTabIcon(tab.id)}</TabIcon>}
<TabTitle>{getTitleLabel(tab.id)}</TabTitle>
{tab.id && <TabIcon>{getTabIcon(tab.id, minapps)}</TabIcon>}
<TabTitle>{getTabTitle(tab.id)}</TabTitle>
</TabHeader>
{tab.id !== 'home' && (
<CloseButton
@@ -224,7 +258,6 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
</AddTabButton>
</TabsArea>
<RightButtonsContainer>
<TopNavbarOpenedMinappTabs />
<Tooltip
title={t('settings.theme.title') + ': ' + getThemeModeLabel(settedTheme)}
mouseEnterDelay={0.8}
@@ -244,7 +277,11 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
</SettingsButton>
</RightButtonsContainer>
</TabsBar>
<TabContent>{children}</TabContent>
<TabContent>
{/* MiniApp WebView 池Tab 模式保活) */}
<MinAppTabsPool />
{children}
</TabContent>
</Container>
)
}
@@ -443,6 +480,7 @@ const TabContent = styled.div`
margin-top: 0;
border-radius: 8px;
overflow: hidden;
position: relative; /* 约束 MinAppTabsPool 绝对定位范围 */
`
export default TabsContainer

View File

@@ -0,0 +1,199 @@
import DefaultAvatar from '@renderer/assets/images/avatar.png'
import EmojiAvatar from '@renderer/components/Avatar/EmojiAvatar'
import { useTheme } from '@renderer/context/ThemeProvider'
import { logout, useAuth } from '@renderer/hooks/useAuth'
import useAvatar from '@renderer/hooks/useAvatar'
import { useSettings } from '@renderer/hooks/useSettings'
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '@renderer/pages/settings'
import ImageStorage from '@renderer/services/ImageStorage'
import NavigationService from '@renderer/services/NavigationService'
import { useAppDispatch } from '@renderer/store'
import { setAvatar } from '@renderer/store/runtime'
import { setUserName } from '@renderer/store/settings'
import { compressImage, isEmoji } from '@renderer/utils'
import { Avatar, Button, Dropdown, Input, Popover, Upload } from 'antd'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import EmojiPicker from '../EmojiPicker'
const UserProfileSettings: React.FC = () => {
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false)
const [dropdownOpen, setDropdownOpen] = useState(false)
const { t } = useTranslation()
const { userName } = useSettings()
const { theme } = useTheme()
const dispatch = useAppDispatch()
const avatar = useAvatar()
const { serverUrl } = useAuth()
const handleEmojiClick = async (emoji: string) => {
try {
await ImageStorage.set('avatar', emoji)
dispatch(setAvatar(emoji))
setEmojiPickerOpen(false)
} catch (error: any) {
window.message.error(error.message)
}
}
const handleReset = async () => {
try {
await ImageStorage.set('avatar', DefaultAvatar)
dispatch(setAvatar(DefaultAvatar))
setDropdownOpen(false)
} catch (error: any) {
window.message.error(error.message)
}
}
const onLogout = () => {
window.modal.confirm({
title: t('enterprise.auth.logout'),
content: t('enterprise.auth.logout_confirm'),
centered: true,
onOk() {
logout()
NavigationService.navigate?.('/')
}
})
}
const items = [
{
key: 'upload',
label: (
<div style={{ width: '100%', textAlign: 'center' }}>
<Upload
customRequest={() => {}}
accept="image/png, image/jpeg, image/gif"
itemRender={() => null}
maxCount={1}
onChange={async ({ file }) => {
try {
const _file = file.originFileObj as File
if (_file.type === 'image/gif') {
await ImageStorage.set('avatar', _file)
} else {
const compressedFile = await compressImage(_file)
await ImageStorage.set('avatar', compressedFile)
}
dispatch(setAvatar(await ImageStorage.get('avatar')))
setDropdownOpen(false)
} catch (error: any) {
window.message.error(error.message)
}
}}>
{t('settings.general.image_upload')}
</Upload>
</div>
)
},
{
key: 'emoji',
label: (
<div
style={{ width: '100%', textAlign: 'center' }}
onClick={(e) => {
e.stopPropagation()
setEmojiPickerOpen(true)
setDropdownOpen(false)
}}>
{t('settings.general.emoji_picker')}
</div>
)
},
{
key: 'reset',
label: (
<div
style={{ width: '100%', textAlign: 'center' }}
onClick={(e) => {
e.stopPropagation()
handleReset()
}}>
{t('settings.general.avatar.reset')}
</div>
)
}
]
return (
<SettingGroup theme={theme}>
<SettingTitle>{t('common.you')}</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('common.avatar')}</SettingRowTitle>
<Dropdown
menu={{ items }}
trigger={['click']}
open={dropdownOpen}
align={{ offset: [0, 4] }}
placement="bottomLeft"
onOpenChange={(visible) => {
setDropdownOpen(visible)
if (visible) {
setEmojiPickerOpen(false)
}
}}>
<Popover
content={<EmojiPicker onEmojiClick={handleEmojiClick} />}
trigger="click"
open={emojiPickerOpen}
onOpenChange={(visible) => {
setEmojiPickerOpen(visible)
if (visible) {
setDropdownOpen(false)
}
}}
placement="bottom">
{isEmoji(avatar) ? (
<EmojiAvatar size={40} fontSize={20}>
{avatar}
</EmojiAvatar>
) : (
<UserAvatar src={avatar} />
)}
</Popover>
</Dropdown>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.general.user_name.label')}</SettingRowTitle>
<Input
placeholder={t('settings.general.user_name.placeholder')}
value={userName}
variant="borderless"
onChange={(e) => dispatch(setUserName(e.target.value.trim()))}
style={{ width: 'auto', textAlign: 'right' }}
maxLength={30}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('enterprise.login.placeholder.server_url')}</SettingRowTitle>
<SettingRowTitle>{serverUrl}</SettingRowTitle>
</SettingRow>
<SettingDivider />
<SettingRow>
<span style={{ flex: 1 }} />
<Button type="text" onClick={onLogout} danger>
{t('enterprise.auth.logout')}
</Button>
</SettingRow>
</SettingGroup>
)
}
const UserAvatar = styled(Avatar)`
cursor: pointer;
width: 40px;
height: 40px;
transition: opacity 0.3s ease;
&:hover {
opacity: 0.8;
}
`
export default UserProfileSettings

View File

@@ -0,0 +1 @@
export { default } from './UserProfileSettings'

View File

@@ -6,107 +6,13 @@ import { useNavbarPosition, useSettings } from '@renderer/hooks/useSettings'
import { MinAppType } from '@renderer/types'
import type { MenuProps } from 'antd'
import { Dropdown, Tooltip } from 'antd'
import { FC, useEffect, useState } from 'react'
import { FC, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { DraggableList } from '../DraggableList'
import MinAppIcon from '../Icons/MinAppIcon'
/** Tabs of opened minapps in top navbar */
export const TopNavbarOpenedMinappTabs: FC = () => {
const { minappShow, openedKeepAliveMinapps, currentMinappId } = useRuntime()
const { openMinappKeepAlive, hideMinappPopup, closeMinapp, closeAllMinapps } = useMinappPopup()
const { showOpenedMinappsInSidebar } = useSettings()
const { theme } = useTheme()
const { t } = useTranslation()
const [keepAliveMinapps, setKeepAliveMinapps] = useState(openedKeepAliveMinapps)
useEffect(() => {
const timer = setTimeout(() => setKeepAliveMinapps(openedKeepAliveMinapps), 300)
return () => clearTimeout(timer)
}, [openedKeepAliveMinapps])
// animation for minapp switch indicator
useEffect(() => {
const iconDefaultWidth = 30 // 22px icon + 8px gap
const iconDefaultOffset = 10 // initial offset
const container = document.querySelector('.TopNavContainer') as HTMLElement
const activeIcon = document.querySelector('.TopNavContainer .opened-active') as HTMLElement
let indicatorLeft = 0,
indicatorBottom = 0
if (minappShow && activeIcon && container) {
indicatorLeft = activeIcon.offsetLeft + activeIcon.offsetWidth / 2 - 4 // 4 is half of the indicator's width (8px)
indicatorBottom = 0
} else {
indicatorLeft =
((keepAliveMinapps.length > 0 ? keepAliveMinapps.length : 1) / 2) * iconDefaultWidth + iconDefaultOffset - 4
indicatorBottom = -50
}
container?.style.setProperty('--indicator-left', `${indicatorLeft}px`)
container?.style.setProperty('--indicator-bottom', `${indicatorBottom}px`)
}, [currentMinappId, keepAliveMinapps, minappShow])
const handleOnClick = (app: MinAppType) => {
if (minappShow && currentMinappId === app.id) {
hideMinappPopup()
} else {
openMinappKeepAlive(app)
}
}
// 检查是否需要显示已打开小程序组件
const isShowOpened = showOpenedMinappsInSidebar && keepAliveMinapps.length > 0
// 如果不需要显示,返回空容器
if (!isShowOpened) return null
return (
<TopNavContainer
className="TopNavContainer"
style={{ backgroundColor: keepAliveMinapps.length > 0 ? 'var(--color-list-item)' : 'transparent' }}>
<TopNavMenus>
{keepAliveMinapps.map((app) => {
const menuItems: MenuProps['items'] = [
{
key: 'closeApp',
label: t('minapp.sidebar.close.title'),
onClick: () => {
closeMinapp(app.id)
}
},
{
key: 'closeAllApp',
label: t('minapp.sidebar.closeall.title'),
onClick: () => {
closeAllMinapps()
}
}
]
const isActive = minappShow && currentMinappId === app.id
return (
<Tooltip key={app.id} title={app.name} mouseEnterDelay={0.8} placement="bottom">
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']} overlayStyle={{ zIndex: 10000 }}>
<TopNavItemContainer
onClick={() => handleOnClick(app)}
theme={theme}
className={`${isActive ? 'opened-active' : ''}`}>
<TopNavIcon theme={theme}>
<MinAppIcon size={22} app={app} style={{ border: 'none', padding: 0 }} />
</TopNavIcon>
</TopNavItemContainer>
</Dropdown>
</Tooltip>
)
})}
</TopNavMenus>
</TopNavContainer>
)
}
/** Tabs of opened minapps in sidebar */
export const SidebarOpenedMinappTabs: FC = () => {
const { minappShow, openedKeepAliveMinapps, currentMinappId } = useRuntime()
@@ -116,7 +22,7 @@ export const SidebarOpenedMinappTabs: FC = () => {
const { t } = useTranslation()
const { isLeftNavbar } = useNavbarPosition()
const handleOnClick = (app) => {
const handleOnClick = (app: MinAppType) => {
if (minappShow && currentMinappId === app.id) {
hideMinappPopup()
} else {
@@ -329,50 +235,3 @@ const TabsWrapper = styled.div`
border-radius: 20px;
overflow: hidden;
`
const TopNavContainer = styled.div`
display: flex;
align-items: center;
padding: 2px;
gap: 4px;
background-color: var(--color-list-item);
border-radius: 20px;
margin: 0 5px;
position: relative;
overflow: hidden;
&::after {
content: '';
position: absolute;
left: var(--indicator-left, 0);
bottom: var(--indicator-bottom, 0);
width: 8px;
height: 4px;
background-color: var(--color-primary);
transition:
left 0.3s cubic-bezier(0.4, 0, 0.2, 1),
bottom 0.3s ease-in-out;
border-radius: 2px;
}
`
const TopNavMenus = styled.div`
display: flex;
align-items: center;
gap: 8px;
height: 100%;
`
const TopNavIcon = styled(Icon)`
width: 22px;
height: 22px;
`
const TopNavItemContainer = styled.div`
display: flex;
transition: border 0.2s ease;
border-radius: 18px;
cursor: pointer;
border-radius: 50%;
padding: 2px;
`

View File

@@ -107,7 +107,7 @@ const Sidebar: FC = () => {
<StyledLink
onClick={async () => {
hideMinappPopup()
await to('/settings/provider')
await to('/settings/user')
}}>
<Icon theme={theme} className={pathname.startsWith('/settings') && !minappShow ? 'active' : ''}>
<Settings size={20} className="icon" />
@@ -154,7 +154,9 @@ const MainMenus: FC = () => {
notes: '/notes'
}
return sidebarIcons.visible.map((icon) => {
const visibleIcons = sidebarIcons.visible.filter((icon) => icon !== 'paintings')
return visibleIcons.map((icon) => {
const path = pathMap[icon]
const isActive = path === '/' ? isRoute(path) : isRoutes(path)

View File

@@ -0,0 +1,34 @@
import { Configuration, DefaultApi } from '@cherrystudio/api-sdk'
import { logout } from '@renderer/hooks/useAuth'
import axios from 'axios'
const config = new Configuration({
basePath: '',
accessToken: localStorage.getItem('auth_token') || ''
})
// Create axios instance with interceptor
const axiosInstance = axios.create()
// Add response interceptor to handle 401 unauthorized responses
axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
logout()
}
return Promise.reject(error)
}
)
const api = new DefaultApi(config, undefined, axiosInstance as any)
export const updateApiToken = (token: string) => {
config.accessToken = token
}
export const updateApiBasePath = (basePath: string) => {
config.basePath = basePath
}
export default api

View File

@@ -1,5 +1,5 @@
export { default as UserAvatar } from '@renderer/assets/images/avatar.png'
export { default as AppLogo } from '@renderer/assets/images/logo.png'
export const APP_NAME = 'Cherry Studio'
export const APP_NAME = 'Cherry Studio 企业版'
export const isLocalAi = false

View File

@@ -661,7 +661,7 @@ export const PROVIDER_LOGO_MAP: AtLeast<SystemProviderId, string> = {
vertexai: VertexAIProviderLogo,
'new-api': NewAPIProviderLogo,
'aws-bedrock': AwsProviderLogo,
poe: 'svg' // use svg icon component
poe: 'poe' // use svg icon component
} as const
export function getProviderLogo(providerId: string) {

View File

@@ -0,0 +1,16 @@
import LoginPage from '@renderer/pages/auth/login'
import { RootState } from '@renderer/store'
import { FC, PropsWithChildren } from 'react'
import { useSelector } from 'react-redux'
const AuthProvider: FC<PropsWithChildren> = ({ children }) => {
const { isLogin } = useSelector((state: RootState) => state.auth)
if (!isLogin) {
return <LoginPage />
}
return children
}
export default AuthProvider

View File

@@ -15,7 +15,7 @@ const NavigationHandler: React.FC = () => {
if (location.pathname.startsWith('/settings')) {
return
}
navigate('/settings/provider')
navigate('/settings/user')
},
{
splitKey: '!',

View File

@@ -6,21 +6,21 @@ import db from '@renderer/databases'
import i18n from '@renderer/i18n'
import KnowledgeQueue from '@renderer/queue/KnowledgeQueue'
import MemoryService from '@renderer/services/MemoryService'
import { useAppDispatch } from '@renderer/store'
import { useAppSelector } from '@renderer/store'
import { syncConfig } from '@renderer/services/sync/sync'
import { handleSaveData } from '@renderer/store'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { selectMemoryConfig } from '@renderer/store/memory'
import { setAvatar, setFilesPath, setResourcesPath, setUpdateState } from '@renderer/store/runtime'
import { delay, runAsyncFunction } from '@renderer/utils'
import { defaultLanguage } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { useLiveQuery } from 'dexie-react-hooks'
import { useEffect } from 'react'
import { useEffect, useRef } from 'react'
import { useDefaultModel } from './useAssistant'
import useFullScreenNotice from './useFullScreenNotice'
import { useRuntime } from './useRuntime'
import { useSettings } from './useSettings'
import { useNavbarPosition, useSettings } from './useSettings'
import useUpdateHandler from './useUpdateHandler'
const logger = loggerService.withContext('useAppInit')
@@ -37,11 +37,14 @@ export function useAppInit() {
customCss,
enableDataCollection
} = useSettings()
const { isLeftNavbar } = useNavbarPosition()
const { minappShow } = useRuntime()
const { setDefaultModel, setQuickModel, setTranslateModel } = useDefaultModel()
const avatar = useLiveQuery(() => db.settings.get('image://avatar'))
const { theme } = useTheme()
const memoryConfig = useAppSelector(selectMemoryConfig)
const syncInterval = useAppSelector((state) => state.auth.syncInterval)
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
document.getElementById('spinner')?.remove()
@@ -100,16 +103,15 @@ export function useAppInit() {
}, [language])
useEffect(() => {
const transparentWindow = windowStyle === 'transparent' && isMac && !minappShow
const isMacTransparentWindow = windowStyle === 'transparent' && isMac
if (minappShow) {
window.root.style.background =
windowStyle === 'transparent' && isMac ? 'var(--color-background)' : 'var(--navbar-background)'
if (minappShow && isLeftNavbar) {
window.root.style.background = isMacTransparentWindow ? 'var(--color-background)' : 'var(--navbar-background)'
return
}
window.root.style.background = transparentWindow ? 'var(--navbar-background-mac)' : 'var(--navbar-background)'
}, [windowStyle, minappShow, theme])
window.root.style.background = isMacTransparentWindow ? 'var(--navbar-background-mac)' : 'var(--navbar-background)'
}, [windowStyle, minappShow, theme, isLeftNavbar])
useEffect(() => {
if (isLocalAi) {
@@ -158,4 +160,45 @@ export function useAppInit() {
logger.error('Failed to update memory config:', error)
})
}, [memoryConfig])
useEffect(() => {
// 清除之前的定时器
if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current)
syncTimeoutRef.current = null
}
// 首次同步
syncConfig()
const initAppSync = () => {
// 清除之前的定时器
if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current)
syncTimeoutRef.current = null
}
syncTimeoutRef.current = setTimeout(async () => {
try {
logger.info('[Init] App Sync Start')
await syncConfig()
await delay(1)
initAppSync()
} catch (error) {
logger.error('[Init] App Sync Error:', error as Error)
initAppSync()
}
}, syncInterval * 1000)
}
initAppSync()
// 清理函数
return () => {
if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current)
syncTimeoutRef.current = null
}
}
}, [syncInterval])
}

View File

@@ -0,0 +1,18 @@
import NavigationService from '@renderer/services/NavigationService'
import store, { useAppSelector } from '@renderer/store'
import { setAccessToken, setIsLogin, setUser } from '@renderer/store/auth'
import { resetTabs } from '@renderer/store/tabs'
export const useAuth = () => {
const { user, username, accessToken, serverUrl, isLogin, lastLoginMethod } = useAppSelector((state) => state.auth)
return { user, username, accessToken, serverUrl, isLogin, lastLoginMethod }
}
export const logout = () => {
NavigationService.navigate?.('/')
store.dispatch(setUser(undefined))
store.dispatch(setAccessToken(undefined))
store.dispatch(setIsLogin(false))
store.dispatch(resetTabs())
}

View File

@@ -253,6 +253,12 @@ export const useKnowledge = (baseId: string) => {
useEffect(() => {
const notes = base?.items.filter((item) => item.type === 'note') || []
if (base?.isServer) {
setNoteItems(notes)
return
}
runAsyncFunction(async () => {
const newNoteItems = await Promise.all(
notes.map(async (item) => {

View File

@@ -1,6 +1,7 @@
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings' // 使用设置中的值
import TabsService from '@renderer/services/TabsService'
import { useAppDispatch } from '@renderer/store'
import {
setCurrentMinappId,
@@ -9,6 +10,7 @@ import {
setOpenedOneOffMinapp
} from '@renderer/store/runtime'
import { MinAppType } from '@renderer/types'
import { clearWebviewState } from '@renderer/utils/webviewStateManager'
import { LRUCache } from 'lru-cache'
import { useCallback } from 'react'
@@ -36,7 +38,18 @@ export const useMinappPopup = () => {
const createLRUCache = useCallback(() => {
return new LRUCache<string, MinAppType>({
max: maxKeepAliveMinapps,
disposeAfter: () => {
disposeAfter: (_value, key) => {
// Clean up WebView state when app is disposed from cache
clearWebviewState(key)
// Close corresponding tab if it exists
const tabs = TabsService.getTabs()
const tabToClose = tabs.find((tab) => tab.path === `/apps/${key}`)
if (tabToClose) {
TabsService.closeTab(tabToClose.id)
}
// Update Redux state
dispatch(setOpenedKeepAliveMinapps(Array.from(minAppsCache.values())))
},
onInsert: () => {
@@ -158,6 +171,8 @@ export const useMinappPopup = () => {
openMinappById,
closeMinapp,
hideMinappPopup,
closeAllMinapps
closeAllMinapps,
// Expose cache instance for TabsService integration
minAppsCache
}
}

View File

@@ -11,6 +11,7 @@ export const useMinapps = () => {
minapps: enabled.map((app) => DEFAULT_MIN_APPS.find((item) => item.id === app.id) || app),
disabled: disabled.map((app) => DEFAULT_MIN_APPS.find((item) => item.id === app.id) || app),
pinned: pinned.map((app) => DEFAULT_MIN_APPS.find((item) => item.id === app.id) || app),
serverMinapps: enabled.filter((app) => app.isServer),
updateMinapps: (minapps: MinAppType[]) => {
dispatch(setMinApps(minapps))
},

View File

@@ -815,6 +815,51 @@
"openai": "OpenAI",
"openai-response": "OpenAI-Response"
},
"enterprise": {
"auth": {
"logout": "Logout",
"logout_confirm": "Logging out will not delete your data, continue?",
"password_login": "Username/Password Login",
"sso_login": "SSO Login",
"switch_to_password_login": "Use Username/Password Login",
"switch_to_sso_login": "Use SSO Login"
},
"enterprise": "Enterprise",
"login": {
"admin_forbidden": "Administrators are not allowed to log in to the client",
"button": {
"login": "Login",
"sso_login": "SSO Login"
},
"error": {
"invalid_credentials": "Invalid username or password",
"login_failed": "Login failed",
"sso_failed": "SSO login failed",
"sso_timeout": "SSO login timeout, please try again",
"sync_config_failed": "Failed to sync configuration"
},
"placeholder": {
"password": "Password",
"server_url": "Server URL",
"username": "Username"
},
"switch": {
"password_mode": "Username/Password Login",
"sso_mode": "SSO Login"
},
"validation": {
"password_no_spaces": "Password cannot contain spaces",
"password_required": "Please enter password",
"server_url_empty": "Please enter server URL",
"server_url_invalid": "Please enter a valid server URL",
"server_url_invalid_format": "Please enter a valid URL",
"server_url_invalid_protocol": "Please enter a valid HTTP or HTTPS URL",
"server_url_required": "Please enter server URL first",
"username_no_spaces": "Username cannot contain spaces",
"username_required": "Please enter username"
}
}
},
"error": {
"backup": {
"file_format": "Backup file format error"
@@ -3081,6 +3126,9 @@
"version_options": "Version Options"
},
"title": "General Settings",
"user": {
"label": "User Settings"
},
"user_name": {
"label": "User Name",
"placeholder": "Enter your name"
@@ -4034,6 +4082,7 @@
"title": {
"agents": "Agents",
"apps": "Apps",
"chat": "Chat",
"code": "Code",
"files": "Files",
"home": "Home",

View File

@@ -815,6 +815,51 @@
"openai": "OpenAI",
"openai-response": "OpenAI-Response"
},
"enterprise": {
"auth": {
"logout": "ログアウト",
"logout_confirm": "ログアウトしませんか?",
"password_login": "ユーザー名/パスワードログイン",
"sso_login": "SSOログイン",
"switch_to_password_login": "ユーザー名/パスワードでログイン",
"switch_to_sso_login": "SSOでログイン"
},
"enterprise": "企業",
"login": {
"admin_forbidden": "管理者はクライアントへのログインが禁止されています",
"button": {
"login": "ログイン",
"sso_login": "SSOログイン"
},
"error": {
"invalid_credentials": "ユーザー名またはパスワードが無効です",
"login_failed": "ログインに失敗しました",
"sso_failed": "SSOログインに失敗しました",
"sso_timeout": "SSOログインがタイムアウトしました。もう一度お試しください",
"sync_config_failed": "設定の同期に失敗しました"
},
"placeholder": {
"password": "パスワード",
"server_url": "サーバーURL",
"username": "ユーザー名"
},
"switch": {
"password_mode": "ユーザー名/パスワードログイン",
"sso_mode": "SSOログイン"
},
"validation": {
"password_no_spaces": "パスワードにスペースを含めることはできません",
"password_required": "パスワードを入力してください",
"server_url_empty": "サーバーURLを入力してください",
"server_url_invalid": "有効なサーバーURLを入力してください",
"server_url_invalid_format": "有効なURLを入力してください",
"server_url_invalid_protocol": "有効なHTTPまたはHTTPS URLを入力してください",
"server_url_required": "まずサーバーURLを入力してください",
"username_no_spaces": "ユーザー名にスペースを含めることはできません",
"username_required": "ユーザー名を入力してください"
}
}
},
"error": {
"backup": {
"file_format": "バックアップファイルの形式エラー"
@@ -3081,6 +3126,9 @@
"version_options": "バージョンオプション"
},
"title": "一般設定",
"user": {
"label": "ユーザー設定"
},
"user_name": {
"label": "ユーザー名",
"placeholder": "ユーザー名を入力"
@@ -4034,6 +4082,7 @@
"title": {
"agents": "エージェント",
"apps": "アプリ",
"chat": "チャット",
"code": "Code",
"files": "ファイル",
"home": "ホーム",

View File

@@ -815,6 +815,51 @@
"openai": "OpenAI",
"openai-response": "OpenAI-Response"
},
"enterprise": {
"auth": {
"logout": "Выйти",
"logout_confirm": "Выйти не удалит ваши данные, продолжить?",
"password_login": "Вход по логину/паролю",
"sso_login": "Вход через SSO",
"switch_to_password_login": "Использовать логин/пароль",
"switch_to_sso_login": "Использовать SSO"
},
"enterprise": "Предприятие",
"login": {
"admin_forbidden": "Администраторам запрещен вход в клиент",
"button": {
"login": "Войти",
"sso_login": "Вход через SSO"
},
"error": {
"invalid_credentials": "Неверное имя пользователя или пароль",
"login_failed": "Не удалось войти",
"sso_failed": "Не удалось войти через SSO",
"sso_timeout": "Истекло время ожидания входа через SSO, попробуйте еще раз",
"sync_config_failed": "Не удалось синхронизировать конфигурацию"
},
"placeholder": {
"password": "Пароль",
"server_url": "URL сервера",
"username": "Имя пользователя"
},
"switch": {
"password_mode": "Вход по логину/паролю",
"sso_mode": "Вход через SSO"
},
"validation": {
"password_no_spaces": "Пароль не может содержать пробелы",
"password_required": "Пожалуйста, введите пароль",
"server_url_empty": "Пожалуйста, введите URL сервера",
"server_url_invalid": "Пожалуйста, введите корректный URL сервера",
"server_url_invalid_format": "Пожалуйста, введите корректный URL",
"server_url_invalid_protocol": "Пожалуйста, введите корректный HTTP или HTTPS URL",
"server_url_required": "Пожалуйста, сначала введите URL сервера",
"username_no_spaces": "Имя пользователя не может содержать пробелы",
"username_required": "Пожалуйста, введите имя пользователя"
}
}
},
"error": {
"backup": {
"file_format": "Ошибка формата файла резервной копии"
@@ -3081,6 +3126,9 @@
"version_options": "Варианты версии"
},
"title": "Общие настройки",
"user": {
"label": "Настройки пользователя"
},
"user_name": {
"label": "Имя пользователя",
"placeholder": "Введите ваше имя"
@@ -4034,6 +4082,7 @@
"title": {
"agents": "Агенты",
"apps": "Приложения",
"chat": "Чат",
"code": "Code",
"files": "Файлы",
"home": "Главная",

View File

@@ -815,6 +815,51 @@
"openai": "OpenAI",
"openai-response": "OpenAI-Response"
},
"enterprise": {
"auth": {
"logout": "退出登录",
"logout_confirm": "退出登录不会删除您的数据,是否继续?",
"password_login": "用户名密码登录",
"sso_login": "一键认证登录",
"switch_to_password_login": "使用用户名密码登录",
"switch_to_sso_login": "使用一键认证登录"
},
"enterprise": "企业",
"login": {
"admin_forbidden": "管理员禁止登录客户端",
"button": {
"login": "登录",
"sso_login": "一键认证登录"
},
"error": {
"invalid_credentials": "用户名或密码错误",
"login_failed": "登录失败",
"sso_failed": "SSO登录失败",
"sso_timeout": "SSO登录超时请重试",
"sync_config_failed": "同步配置失败"
},
"placeholder": {
"password": "密码",
"server_url": "服务端地址",
"username": "用户名"
},
"switch": {
"password_mode": "用户名密码登录",
"sso_mode": "一键认证登录"
},
"validation": {
"password_no_spaces": "密码不能包含空格",
"password_required": "请输入密码",
"server_url_empty": "请输入服务端地址",
"server_url_invalid": "请输入有效的服务端地址",
"server_url_invalid_format": "请输入有效的 URL 地址",
"server_url_invalid_protocol": "请输入有效的 HTTP 或 HTTPS URL",
"server_url_required": "请先输入服务端地址",
"username_no_spaces": "用户名不能包含空格",
"username_required": "请输入用户名"
}
}
},
"error": {
"backup": {
"file_format": "备份文件格式错误"
@@ -3081,6 +3126,9 @@
"version_options": "版本选择"
},
"title": "常规设置",
"user": {
"label": "用户设置"
},
"user_name": {
"label": "用户名",
"placeholder": "输入您的姓名"
@@ -4034,6 +4082,7 @@
"title": {
"agents": "智能体",
"apps": "小程序",
"chat": "对话",
"code": "Code",
"files": "文件",
"home": "首页",

View File

@@ -815,6 +815,51 @@
"openai": "OpenAI",
"openai-response": "OpenAI-Response"
},
"enterprise": {
"auth": {
"logout": "登出",
"logout_confirm": "登出不會刪除您的資料,是否繼續?",
"password_login": "使用者名稱密碼登入",
"sso_login": "一鍵認證登入",
"switch_to_password_login": "使用使用者名稱密碼登入",
"switch_to_sso_login": "使用一鍵認證登入"
},
"enterprise": "企業",
"login": {
"admin_forbidden": "管理員禁止登入客戶端",
"button": {
"login": "登入",
"sso_login": "一鍵認證登入"
},
"error": {
"invalid_credentials": "使用者名稱或密碼錯誤",
"login_failed": "登入失敗",
"sso_failed": "SSO登入失敗",
"sso_timeout": "SSO登入逾時請重試",
"sync_config_failed": "同步設定失敗"
},
"placeholder": {
"password": "密碼",
"server_url": "伺服器位址",
"username": "使用者名稱"
},
"switch": {
"password_mode": "使用者名稱密碼登入",
"sso_mode": "一鍵認證登入"
},
"validation": {
"password_no_spaces": "密碼不能包含空格",
"password_required": "請輸入密碼",
"server_url_empty": "請輸入伺服器位址",
"server_url_invalid": "請輸入有效的伺服器位址",
"server_url_invalid_format": "請輸入有效的 URL 位址",
"server_url_invalid_protocol": "請輸入有效的 HTTP 或 HTTPS URL",
"server_url_required": "請先輸入伺服器位址",
"username_no_spaces": "使用者名稱不能包含空格",
"username_required": "請輸入使用者名稱"
}
}
},
"error": {
"backup": {
"file_format": "備份檔案格式錯誤"
@@ -3081,6 +3126,9 @@
"version_options": "版本選項"
},
"title": "一般設定",
"user": {
"label": "使用者設定"
},
"user_name": {
"label": "使用者名稱",
"placeholder": "輸入您的名稱"
@@ -4034,6 +4082,7 @@
"title": {
"agents": "智能體",
"apps": "小程序",
"chat": "對話",
"code": "Code",
"files": "文件",
"home": "主頁",

View File

@@ -1,4 +1,5 @@
import { loggerService } from '@logger'
import api from '@renderer/config/api'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import store from '@renderer/store'
@@ -49,18 +50,34 @@ export function useSystemAgents() {
}
}
// 如果没有远程配置或获取失败,加载本地代理
if (resourcesPath) {
try {
const fileName = currentLanguage === 'zh-CN' ? 'agents-zh.json' : 'agents-en.json'
const localAgentsData = await window.api.fs.read(`${resourcesPath}/data/${fileName}`, 'utf-8')
_agents = JSON.parse(localAgentsData) as Agent[]
} catch (error) {
logger.error('Failed to load local agents:', error as Error)
try {
if (_agents.length === 0) {
const serverAgents = await api.agentFindAll({ group: '' })
_agents = serverAgents.data as unknown as Agent[]
}
}
setAgents(_agents)
} catch (error) {
logger.error('Failed to load server agents:', error as Error)
// 如果没有远程配置或获取失败,加载本地代理
if (resourcesPath) {
try {
let fileName = 'agents.json'
if (currentLanguage === 'zh-CN') {
fileName = 'agents-zh.json'
} else {
fileName = 'agents-en.json'
}
setAgents(_agents)
const localAgentsData = await window.api.fs.read(`${resourcesPath}/data/${fileName}`, 'utf-8')
_agents = JSON.parse(localAgentsData) as Agent[]
} catch (error) {
const localAgentsData = await window.api.fs.read(resourcesPath + '/data/agents.json', 'utf-8')
_agents = JSON.parse(localAgentsData) as Agent[]
}
}
setAgents(_agents)
}
} catch (error) {
logger.error('Failed to load agents:', error as Error)
// 发生错误时使用已加载的本地 agents

View File

@@ -0,0 +1,460 @@
import { LinkOutlined, LockOutlined, UserOutlined } from '@ant-design/icons'
import { loggerService } from '@logger'
import api, { updateApiBasePath, updateApiToken } from '@renderer/config/api'
import { APP_NAME, AppLogo } from '@renderer/config/env'
import { useAuth } from '@renderer/hooks/useAuth'
import { syncConfig } from '@renderer/services/sync/sync'
import { useAppDispatch } from '@renderer/store'
import {
setAccessToken,
setIsLogin,
setLastLoginMethod,
setServerUrl,
setUser,
setUsername
} from '@renderer/store/auth'
import { setUserName } from '@renderer/store/settings'
import { isAdmin } from '@renderer/utils/auth'
import { IpcChannel } from '@shared/IpcChannel'
import { Button, Card, Form, Input, notification as Notification } from 'antd'
import { AxiosError } from 'axios'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const logger = loggerService.withContext('LoginPage')
interface LoginFormValues {
username: string
password: string
serverUrl: string
}
const BACKGROUND_IMAGE = 'https://api.dujin.org/bing/1920.php'
const LoginPage: FC = () => {
const [loading, setLoading] = useState(false)
const [ssoLoading, setSsoLoading] = useState(false)
const [currentServerUrl, setCurrentServerUrl] = useState('')
const dispatch = useAppDispatch()
const { serverUrl, username, lastLoginMethod } = useAuth()
const [loginMode, setLoginMode] = useState<'sso' | 'password'>(lastLoginMethod || 'sso') // 使用上次成功的登录方式
const [notification, contextHolder] = Notification.useNotification()
const [form] = Form.useForm()
const { t } = useTranslation()
// 验证URL是否合法
const isValidUrl = (url: string): boolean => {
try {
if (!url?.trim()) return false
const urlObj = new URL(url.trim())
return ['http:', 'https:'].includes(urlObj.protocol)
} catch (error) {
return false
}
}
// 获取当前表单中的服务器URL
const getCurrentServerUrl = (): string => {
return form.getFieldValue('serverUrl') || ''
}
// 检查SSO按钮是否应该禁用
const isSSOButtonDisabled = (): boolean => {
return !isValidUrl(currentServerUrl)
}
// 监听表单值变化
useEffect(() => {
const serverUrlValue = getCurrentServerUrl()
setCurrentServerUrl(serverUrlValue)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [form.getFieldValue('serverUrl')])
// 监听表单字段变化
const handleFormValuesChange = (changedValues: any) => {
if (changedValues.serverUrl !== undefined) {
setCurrentServerUrl(changedValues.serverUrl || '')
}
}
// 切换登录模式
const switchToPasswordMode = () => {
setLoginMode('password')
// 切换到密码模式时,设置表单初始值
form.setFieldsValue({
serverUrl: serverUrl || currentServerUrl,
username: username || '',
password: ''
})
}
const switchToSSOMode = () => {
setSsoLoading(false)
setLoginMode('sso')
// 切换到SSO模式时只保留服务器地址
form.setFieldsValue({
serverUrl: serverUrl || currentServerUrl
})
}
// 清理SSO回调处理器
useEffect(() => {
const cleanup = window.electron.ipcRenderer.on(IpcChannel.OAuth_Casdoor, async (_, data) => {
logger.debug('OAuth_Casdoor', data)
if (data?.token && data?.user) {
const { user, token } = data
if (isAdmin(user)) {
notification.error({ message: t('enterprise.login.admin_forbidden'), duration: 5 })
setSsoLoading(false)
return
}
updateApiToken(token)
dispatch(setUser(user))
dispatch(setUsername(user.username))
dispatch(setAccessToken(token))
dispatch(setUserName(user.username))
dispatch(setLastLoginMethod('sso')) // 记录SSO登录成功
// 同步配置
try {
await syncConfig()
dispatch(setIsLogin(true))
} catch (error: any) {
logger.error('[SSO Login] syncConfig error', error)
notification.error({
message: t('enterprise.login.error.sync_config_failed'),
description: getMessage(error, t),
duration: 5
})
setSsoLoading(false)
}
setSsoLoading(false)
} else {
// 如果没有收到有效数据也要重置loading状态
setSsoLoading(false)
}
})
return () => {
cleanup()
// 组件卸载时重置loading状态
setSsoLoading(false)
}
}, [dispatch, notification, t])
// 组件卸载时清理loading状态
useEffect(() => {
return () => {
setSsoLoading(false)
}
}, [])
const validateUrl = (_: any, value: string) => {
try {
if (!value) {
return Promise.reject(t('enterprise.login.validation.server_url_empty'))
}
const url = new URL(value.trim())
if (!['http:', 'https:'].includes(url.protocol)) {
return Promise.reject(t('enterprise.login.validation.server_url_invalid_protocol'))
}
return Promise.resolve()
} catch (error) {
return Promise.reject(t('enterprise.login.validation.server_url_invalid_format'))
}
}
const handleSSOLogin = async () => {
// 如果已经在loading状态不允许重复点击
if (ssoLoading) {
return
}
try {
// 使用当前状态中的服务器URL
const serverUrlValue = currentServerUrl
if (!serverUrlValue?.trim()) {
notification.error({ message: t('enterprise.login.validation.server_url_required'), duration: 5 })
return
}
if (!isValidUrl(serverUrlValue)) {
notification.error({ message: t('enterprise.login.validation.server_url_invalid'), duration: 5 })
return
}
const serverUrl = serverUrlValue.trim().replace(/\/+$/, '')
const ssoUrl = `${serverUrl}/auth/casdoor?client=desktop`
// 设置服务端地址
dispatch(setServerUrl(serverUrl))
// 使用默认浏览器打开SSO登录页面
window.api.openWebsite(ssoUrl)
setSsoLoading(true)
// 设置超时
setTimeout(() => {
setSsoLoading((prevLoading) => {
if (prevLoading) {
notification.error({ message: t('enterprise.login.error.sso_timeout'), duration: 5 })
return false
}
return prevLoading
})
}, 300000) // 5分钟超时
} catch (error: any) {
logger.error('[SSO Login] error', error)
notification.error({
message: t('enterprise.login.error.sso_failed'),
description: getMessage(error, t),
duration: 5
})
setSsoLoading(false)
}
}
const onFinish = async (values: LoginFormValues) => {
setLoading(true)
try {
const serverUrl = values.serverUrl.trim().replace(/\/+$/, '')
// 设置服务端地址
dispatch(setServerUrl(serverUrl))
// 设置 API 基路径
updateApiBasePath(serverUrl)
const { data } = await api.authLogin({
authLoginRequest: {
username: values.username,
password: values.password
}
})
const { user, access_token } = data
if (isAdmin(user)) {
notification.error({ message: t('enterprise.login.admin_forbidden'), duration: 5 })
return
}
updateApiToken(access_token)
if (user && access_token) {
dispatch(setUser(user))
dispatch(setUsername(user.username))
dispatch(setAccessToken(access_token))
dispatch(setUserName(user.username))
dispatch(setLastLoginMethod('password')) // 记录密码登录成功
access_token && updateApiToken(access_token)
serverUrl && updateApiBasePath(serverUrl)
}
try {
await syncConfig()
} catch (error) {
logger.error('syncConfig error', error as Error)
notification.error({
message: t('enterprise.login.error.sync_config_failed'),
description: getMessage(error, t),
duration: 5
})
return
}
dispatch(setIsLogin(true))
} catch (error) {
logger.error('Login Error', error as Error)
notification.error({
message: t('enterprise.login.error.login_failed'),
description: getMessage(error, t),
duration: 5
})
} finally {
setLoading(false)
}
}
// 渲染SSO登录界面
const renderSSOLogin = () => (
<>
<Form
form={form}
name="sso-login"
initialValues={{ serverUrl }}
size="large"
onValuesChange={handleFormValuesChange}>
<Form.Item name="serverUrl" rules={[{ validator: validateUrl }]}>
<Input prefix={<LinkOutlined />} placeholder={t('enterprise.login.placeholder.server_url')} />
</Form.Item>
<Form.Item style={{ marginBottom: 16 }}>
<SSOButton
type="primary"
block
loading={ssoLoading}
onClick={handleSSOLogin}
disabled={isSSOButtonDisabled()}>
{t('enterprise.login.button.sso_login')}
</SSOButton>
</Form.Item>
</Form>
<SwitchLink onClick={switchToPasswordMode}>{t('enterprise.login.switch.password_mode')}</SwitchLink>
</>
)
// 渲染用户名密码登录界面
const renderPasswordLogin = () => (
<>
<Form
form={form}
name="password-login"
initialValues={{ remember: true, serverUrl, username }}
onFinish={onFinish}
size="large"
onValuesChange={handleFormValuesChange}>
<Form.Item name="serverUrl" rules={[{ validator: validateUrl }]}>
<Input prefix={<LinkOutlined />} placeholder={t('enterprise.login.placeholder.server_url')} />
</Form.Item>
<Form.Item
name="username"
rules={[
{ required: true, message: t('enterprise.login.validation.username_required') },
{ pattern: /^\S+$/, message: t('enterprise.login.validation.username_no_spaces') }
]}>
<Input prefix={<UserOutlined />} placeholder={t('enterprise.login.placeholder.username')} />
</Form.Item>
<Form.Item
name="password"
rules={[
{ required: true, message: t('enterprise.login.validation.password_required') },
{ pattern: /^\S+$/, message: t('enterprise.login.validation.password_no_spaces') }
]}>
<Input.Password prefix={<LockOutlined />} placeholder={t('enterprise.login.placeholder.password')} />
</Form.Item>
<Form.Item style={{ marginBottom: 16 }}>
<LoginButton type="primary" htmlType="submit" block loading={loading}>
{t('enterprise.login.button.login')}
</LoginButton>
</Form.Item>
</Form>
<SwitchLink onClick={switchToSSOMode}>{t('enterprise.login.switch.sso_mode')}</SwitchLink>
</>
)
return (
<Container>
<TitleBar />
{contextHolder}
<LoginCard>
<LogoContainer>
<img src={AppLogo} alt="App Logo" />
<BrandTitle>{APP_NAME}</BrandTitle>
</LogoContainer>
{loginMode === 'sso' ? renderSSOLogin() : renderPasswordLogin()}
</LoginCard>
</Container>
)
}
const getMessage = (error: any, t: any) => {
if (error instanceof AxiosError) {
if (error.response?.status === 401) {
return t('enterprise.login.error.invalid_credentials')
}
return error.response?.data.message
}
return error.message
}
const Container = styled.div`
display: flex;
justify-content: center;
align-items: center;
width: 100%;
min-height: 100vh;
background:
linear-gradient(rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.4)),
url(${BACKGROUND_IMAGE}) center/cover no-repeat;
-webkit-app-region: no-drag;
position: relative;
`
const TitleBar = styled.div`
position: fixed;
top: 0;
left: 0;
right: 0;
height: 50px;
background-color: transparent;
-webkit-app-region: drag;
z-index: 1000;
`
const LoginCard = styled(Card)`
width: 100%;
max-width: 450px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
border-radius: 25px;
padding: 15px 24px;
-webkit-app-region: no-drag;
backdrop-filter: blur(10px);
background: var(--color-background-opacity);
border: 1px solid rgba(255, 255, 255, 0.2);
`
const LogoContainer = styled.div`
text-align: center;
margin-bottom: 24px;
img {
height: 80px;
width: auto;
border-radius: 50%;
margin-bottom: 12px;
}
`
const BrandTitle = styled.h1`
font-size: 18px;
font-weight: 600;
color: var(--text-color);
margin: 0;
padding: 0;
`
const LoginButton = styled(Button)`
height: 40px;
`
const SSOButton = styled(Button)`
height: 40px;
`
const SwitchLink = styled.div`
text-align: center;
color: #1890ff;
cursor: pointer;
font-size: 14px;
transition: all 0.3s ease;
&:hover {
color: #40a9ff;
text-decoration: underline;
}
`
export default LoginPage

View File

@@ -17,9 +17,9 @@ export interface ToolEnvironmentConfig {
// CLI 工具选项
export const CLI_TOOLS = [
{ value: codeTools.claudeCode, label: 'Claude Code' },
{ value: codeTools.qwenCode, label: 'Qwen Code' },
{ value: codeTools.geminiCli, label: 'Gemini CLI' },
{ value: codeTools.openaiCodex, label: 'OpenAI Codex' }
{ value: codeTools.qwenCode, label: 'Qwen Code' }
// { value: codeTools.geminiCli, label: 'Gemini CLI' },
// { value: codeTools.openaiCodex, label: 'OpenAI Codex' }
]
export const GEMINI_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api']
@@ -110,11 +110,12 @@ export const generateToolEnvironment = ({
baseUrl: string
}): Record<string, string> => {
const env: Record<string, string> = {}
const modelId = model.id + '@' + modelProvider.id
switch (tool) {
case codeTools.claudeCode:
env.ANTHROPIC_BASE_URL = getCodeToolsApiBaseUrl(model, 'anthropic') || modelProvider.apiHost
env.ANTHROPIC_MODEL = model.id
env.ANTHROPIC_MODEL = modelId
if (modelProvider.type === 'anthropic') {
env.ANTHROPIC_API_KEY = apiKey
} else {
@@ -127,7 +128,7 @@ export const generateToolEnvironment = ({
env.GEMINI_API_KEY = apiKey
env.GEMINI_BASE_URL = apiBaseUrl
env.GOOGLE_GEMINI_BASE_URL = apiBaseUrl
env.GEMINI_MODEL = model.id
env.GEMINI_MODEL = modelId
break
}
@@ -135,7 +136,7 @@ export const generateToolEnvironment = ({
case codeTools.openaiCodex:
env.OPENAI_API_KEY = apiKey
env.OPENAI_BASE_URL = baseUrl
env.OPENAI_MODEL = model.id
env.OPENAI_MODEL = modelId
break
}

View File

@@ -779,6 +779,10 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
(!WebSearchService.isWebSearchEnabled(assistant.webSearchProviderId) || isMandatoryWebSearchModel(model))
) {
updateAssistant({ ...assistant, webSearchProviderId: undefined })
window.modal.info({
title: t('settings.tool.websearch.provider_not_configured'),
centered: true
})
}
if (!isGenerateImageModel(model) && assistant.enableGenerateImage) {
updateAssistant({ ...assistant, enableGenerateImage: false })
@@ -786,7 +790,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
if (isAutoEnableImageGenerationModel(model) && !assistant.enableGenerateImage) {
updateAssistant({ ...assistant, enableGenerateImage: true })
}
}, [assistant, model, updateAssistant])
}, [assistant, model, t, updateAssistant])
const onMentionModel = useCallback(
(model: Model) => {

View File

@@ -361,6 +361,8 @@ const InputbarTools = ({
onSelect={handleKnowledgeBaseSelect}
ToolbarButton={ToolbarButton}
disabled={files.length > 0}
assistant={assistant}
serverBases={assistant.knowledge_bases?.filter((base) => base.isServer)}
/>
),
condition: showKnowledgeIcon

View File

@@ -1,6 +1,6 @@
import { QuickPanelListItem, useQuickPanel } from '@renderer/components/QuickPanel'
import { useAppSelector } from '@renderer/store'
import { KnowledgeBase } from '@renderer/types'
import { Assistant, KnowledgeBase } from '@renderer/types'
import { Tooltip } from 'antd'
import { CircleX, FileSearch, Plus } from 'lucide-react'
import { FC, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
@@ -15,11 +15,21 @@ interface Props {
ref?: React.RefObject<KnowledgeBaseButtonRef | null>
selectedBases?: KnowledgeBase[]
onSelect: (bases: KnowledgeBase[]) => void
serverBases?: KnowledgeBase[]
assistant: Assistant
disabled?: boolean
ToolbarButton: any
}
const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled, ToolbarButton }) => {
const KnowledgeBaseButton: FC<Props> = ({
ref,
selectedBases,
onSelect,
disabled,
serverBases = [],
assistant,
ToolbarButton
}) => {
const { t } = useTranslation()
const navigate = useNavigate()
const quickPanel = useQuickPanel()
@@ -44,6 +54,20 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
)
const baseItems = useMemo<QuickPanelListItem[]>(() => {
if (assistant.isServer) {
return serverBases.map((base) => {
const items = knowledgeState.bases.find((b) => b.id === base.id)?.items || []
return {
label: base.name,
description: `${items.length} ${t('files.count')}`,
icon: <FileSearch />,
action: () => {},
disabled: true,
isSelected: true
}
})
}
const items: QuickPanelListItem[] = knowledgeState.bases.map((base) => ({
label: base.name,
description: `${base.items.length} ${t('files.count')}`,
@@ -71,7 +95,17 @@ const KnowledgeBaseButton: FC<Props> = ({ ref, selectedBases, onSelect, disabled
})
return items
}, [knowledgeState.bases, t, selectedBases, handleBaseSelect, navigate, onSelect, quickPanel])
}, [
assistant.isServer,
handleBaseSelect,
knowledgeState.bases,
navigate,
onSelect,
quickPanel,
selectedBases,
serverBases,
t
])
const openQuickPanel = useCallback(() => {
quickPanel.open({

View File

@@ -7,7 +7,8 @@ import styled from 'styled-components'
const KnowledgeBaseInput: FC<{
selectedKnowledgeBases: KnowledgeBase[]
onRemoveKnowledgeBase: (knowledgeBase: KnowledgeBase) => void
}> = ({ selectedKnowledgeBases, onRemoveKnowledgeBase }) => {
closable?: boolean
}> = ({ selectedKnowledgeBases, onRemoveKnowledgeBase, closable = true }) => {
return (
<Container>
{selectedKnowledgeBases.map((knowledgeBase) => (
@@ -15,7 +16,7 @@ const KnowledgeBaseInput: FC<{
icon={<FileSearchOutlined />}
color="#3d9d0f"
key={knowledgeBase.id}
closable
closable={closable}
onClose={() => onRemoveKnowledgeBase(knowledgeBase)}>
{knowledgeBase.name}
</CustomTag>

View File

@@ -201,9 +201,14 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
description: server.description || server.baseUrl,
icon: <Hammer />,
action: () => EventEmitter.emit('mcp-server-select', server),
isSelected: assistantMcpServers.some((s) => s.id === server.id)
isSelected: assistantMcpServers.some((s) => s.id === server.id),
disabled: assistant.isServer
}))
if (assistant.isServer) {
return newList
}
newList.push({
label: t('settings.mcp.addServer.label') + '...',
icon: <Plus />,
@@ -222,7 +227,7 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
})
return newList
}, [activedMcpServers, t, assistantMcpServers, navigate, updateMcpEnabled, quickPanel])
}, [activedMcpServers, t, assistant.isServer, assistantMcpServers, navigate, updateMcpEnabled, quickPanel])
const openQuickPanel = useCallback(() => {
quickPanel.open({
@@ -447,7 +452,8 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
label: resource.name,
description: resource.description,
icon: <Hammer />,
action: () => handleResourceSelect(resource)
action: () => handleResourceSelect(resource),
disabled: assistant.mcpServers?.some((s) => s.isServer)
}))
)
}

View File

@@ -6,7 +6,7 @@ import { isGeminiModel, isWebSearchModel } from '@renderer/config/models'
import { isGeminiWebSearchProvider } from '@renderer/config/providers'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useTimer } from '@renderer/hooks/useTimer'
import { useWebSearchProviders } from '@renderer/hooks/useWebSearchProviders'
import { useDefaultWebSearchProvider, useWebSearchProviders } from '@renderer/hooks/useWebSearchProviders'
import { getProviderByModel } from '@renderer/services/AssistantService'
import WebSearchService from '@renderer/services/WebSearchService'
import { Assistant, WebSearchProvider, WebSearchProviderId } from '@renderer/types'
@@ -36,6 +36,8 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
const { updateAssistant } = useAssistant(assistant.id)
const { setTimeoutTimer } = useTimer()
const { provider: defaultProvider } = useDefaultWebSearchProvider()
// 注意assistant.enableWebSearch 有不同的语义
/** 表示是否启用网络搜索 */
const enableWebSearch = assistant?.webSearchProviderId || assistant.enableWebSearch
@@ -118,9 +120,9 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
setTimeoutTimer('updateSelectedWebSearchBuiltin', () => updateAssistant(update), 200)
}, [assistant, setTimeoutTimer, t, updateAssistant])
const providerItems = useMemo<QuickPanelListItem[]>(() => {
const isWebSearchModelEnabled = assistant.model && isWebSearchModel(assistant.model)
const isWebSearchModelEnabled = assistant.model && isWebSearchModel(assistant.model)
const providerItems = useMemo<QuickPanelListItem[]>(() => {
const items: QuickPanelListItem[] = providers
.map((p) => ({
label: p.name,
@@ -153,8 +155,8 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
}, [
WebSearchIcon,
assistant.enableWebSearch,
assistant.model,
assistant?.webSearchProviderId,
isWebSearchModelEnabled,
providers,
t,
updateQuickPanelItem,
@@ -162,13 +164,21 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
])
const openQuickPanel = useCallback(() => {
quickPanel.open({
title: t('chat.input.web_search.label'),
list: providerItems,
symbol: '?',
pageSize: 9
})
}, [quickPanel, t, providerItems])
if (!defaultProvider) {
return quickPanel.open({
title: t('chat.input.web_search.label'),
list: providerItems,
symbol: '?',
pageSize: 9
})
}
if (isWebSearchModelEnabled) {
return updateAssistant({ ...assistant, enableWebSearch: true })
}
return updateAssistant({ ...assistant, webSearchProviderId: defaultProvider?.id })
}, [defaultProvider, isWebSearchModelEnabled, updateAssistant, assistant, quickPanel, t, providerItems])
const handleOpenQuickPanel = useCallback(() => {
if (quickPanel.isVisible && quickPanel.symbol === '?') {

View File

@@ -89,6 +89,7 @@ const Alert = styled(AntdAlert)`
margin: 0.5rem 0 !important;
padding: 10px;
font-size: 12px;
align-items: center;
& .ant-alert-close-icon {
margin: 5px;
}

View File

@@ -1,6 +1,7 @@
import { useTheme } from '@renderer/context/ThemeProvider'
import { usePromptProcessor } from '@renderer/hooks/usePromptProcessor'
import AssistantSettingsPopup from '@renderer/pages/settings/AssistantSettings'
import AssistantSettingsPreviewPopup from '@renderer/pages/settings/AssistantSettings/AssistantSettingsPreviewPopup'
import { Assistant, Topic } from '@renderer/types'
import { containsSupportedVariables } from '@renderer/utils/prompt'
import { FC, useEffect, useState } from 'react'
@@ -61,8 +62,16 @@ const Prompt: FC<Props> = ({ assistant, topic }) => {
return null
}
const handleClick = () => {
if (assistant.isServer) {
AssistantSettingsPreviewPopup.show({ assistant })
} else {
AssistantSettingsPopup.show({ assistant })
}
}
return (
<Container className="system-prompt" onClick={() => AssistantSettingsPopup.show({ assistant })} $isDark={isDark}>
<Container className="system-prompt" onClick={handleClick} $isDark={isDark}>
<Text $isVisible={isVisible}>{displayText}</Text>
</Container>
)
@@ -73,7 +82,7 @@ const Container = styled.div<{ $isDark: boolean }>`
border-radius: 10px;
cursor: pointer;
border: 0.5px solid var(--color-border);
margin: 15px 24px;
margin: 15px 20px;
margin-bottom: 0;
`

View File

@@ -179,128 +179,130 @@ const SettingsTab: FC<Props> = (props) => {
return (
<Container className="settings-tab">
<CollapsibleSettingGroup
title={t('assistants.settings.title')}
defaultExpanded={true}
extra={
<HStack alignItems="center" gap={2}>
<Button
type="text"
size="small"
icon={<Settings2 size={16} />}
onClick={() => AssistantSettingsPopup.show({ assistant, tab: 'model' })}
/>
</HStack>
}>
<SettingGroup style={{ marginTop: 5 }}>
<Row align="middle">
<SettingRowTitleSmall>
{t('chat.settings.temperature.label')}
<HelpTooltip title={t('chat.settings.temperature.tip')} />
</SettingRowTitleSmall>
<Switch
size="small"
style={{ marginLeft: 'auto' }}
checked={enableTemperature}
onChange={(enabled) => {
setEnableTemperature(enabled)
onUpdateAssistantSettings({ enableTemperature: enabled })
}}
/>
</Row>
{enableTemperature ? (
{!assistant.isServer && (
<CollapsibleSettingGroup
title={t('assistants.settings.title')}
defaultExpanded={true}
extra={
<HStack alignItems="center" gap={2}>
<Button
type="text"
size="small"
icon={<Settings2 size={16} />}
onClick={() => AssistantSettingsPopup.show({ assistant, tab: 'model' })}
/>
</HStack>
}>
<SettingGroup style={{ marginTop: 5 }}>
<Row align="middle">
<SettingRowTitleSmall>
{t('chat.settings.temperature.label')}
<HelpTooltip title={t('chat.settings.temperature.tip')} />
</SettingRowTitleSmall>
<Switch
size="small"
style={{ marginLeft: 'auto' }}
checked={enableTemperature}
onChange={(enabled) => {
setEnableTemperature(enabled)
onUpdateAssistantSettings({ enableTemperature: enabled })
}}
/>
</Row>
{enableTemperature ? (
<Row align="middle" gutter={10}>
<Col span={23}>
<Slider
min={0}
max={2}
onChange={setTemperature}
onChangeComplete={onTemperatureChange}
value={typeof temperature === 'number' ? temperature : 0}
step={0.1}
/>
</Col>
</Row>
) : (
<SettingDivider />
)}
<Row align="middle">
<SettingRowTitleSmall>
{t('chat.settings.context_count.label')}
<HelpTooltip title={t('chat.settings.context_count.tip')} />
</SettingRowTitleSmall>
</Row>
<Row align="middle" gutter={10}>
<Col span={23}>
<Slider
min={0}
max={2}
onChange={setTemperature}
onChangeComplete={onTemperatureChange}
value={typeof temperature === 'number' ? temperature : 0}
step={0.1}
max={maxContextCount}
onChange={setContextCount}
onChangeComplete={onContextCountChange}
value={typeof contextCount === 'number' ? contextCount : 0}
step={1}
/>
</Col>
</Row>
) : (
<SettingDivider />
)}
<Row align="middle">
<SettingRowTitleSmall>
{t('chat.settings.context_count.label')}
<HelpTooltip title={t('chat.settings.context_count.tip')} />
</SettingRowTitleSmall>
</Row>
<Row align="middle" gutter={10}>
<Col span={23}>
<Slider
min={0}
max={maxContextCount}
onChange={setContextCount}
onChangeComplete={onContextCountChange}
value={typeof contextCount === 'number' ? contextCount : 0}
step={1}
<SettingRow>
<SettingRowTitleSmall>{t('models.stream_output')}</SettingRowTitleSmall>
<Switch
size="small"
checked={streamOutput}
onChange={(checked) => {
setStreamOutput(checked)
onUpdateAssistantSettings({ streamOutput: checked })
}}
/>
</Col>
</Row>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('models.stream_output')}</SettingRowTitleSmall>
<Switch
size="small"
checked={streamOutput}
onChange={(checked) => {
setStreamOutput(checked)
onUpdateAssistantSettings({ streamOutput: checked })
}}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<Row align="middle">
<SettingRowTitleSmall>
{t('chat.settings.max_tokens.label')}
<HelpTooltip title={t('chat.settings.max_tokens.tip')} />
</SettingRowTitleSmall>
</Row>
<Switch
size="small"
checked={enableMaxTokens}
onChange={async (enabled) => {
if (enabled) {
const confirmed = await modalConfirm({
title: t('chat.settings.max_tokens.confirm'),
content: t('chat.settings.max_tokens.confirm_content'),
okButtonProps: {
danger: true
}
})
if (!confirmed) return
}
setEnableMaxTokens(enabled)
onUpdateAssistantSettings({ enableMaxTokens: enabled })
}}
/>
</SettingRow>
{enableMaxTokens && (
<Row align="middle" gutter={10} style={{ marginTop: 10 }}>
<Col span={24}>
<InputNumber
disabled={!enableMaxTokens}
min={0}
max={10000000}
step={100}
value={typeof maxTokens === 'number' ? maxTokens : 0}
changeOnBlur
onChange={(value) => value && setMaxTokens(value)}
onBlur={() => onMaxTokensChange(maxTokens)}
style={{ width: '100%' }}
/>
</Col>
</Row>
)}
<SettingDivider />
</SettingGroup>
</CollapsibleSettingGroup>
</SettingRow>
<SettingDivider />
<SettingRow>
<Row align="middle">
<SettingRowTitleSmall>
{t('chat.settings.max_tokens.label')}
<HelpTooltip title={t('chat.settings.max_tokens.tip')} />
</SettingRowTitleSmall>
</Row>
<Switch
size="small"
checked={enableMaxTokens}
onChange={async (enabled) => {
if (enabled) {
const confirmed = await modalConfirm({
title: t('chat.settings.max_tokens.confirm'),
content: t('chat.settings.max_tokens.confirm_content'),
okButtonProps: {
danger: true
}
})
if (!confirmed) return
}
setEnableMaxTokens(enabled)
onUpdateAssistantSettings({ enableMaxTokens: enabled })
}}
/>
</SettingRow>
{enableMaxTokens && (
<Row align="middle" gutter={10} style={{ marginTop: 10 }}>
<Col span={24}>
<InputNumber
disabled={!enableMaxTokens}
min={0}
max={10000000}
step={100}
value={typeof maxTokens === 'number' ? maxTokens : 0}
changeOnBlur
onChange={(value) => value && setMaxTokens(value)}
onBlur={() => onMaxTokensChange(maxTokens)}
style={{ width: '100%' }}
/>
</Col>
</Row>
)}
<SettingDivider />
</SettingGroup>
</CollapsibleSettingGroup>
)}
{isOpenAI && (
<OpenAISettingsGroup
model={model}

View File

@@ -11,7 +11,7 @@ import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { Assistant, AssistantsSortType } from '@renderer/types'
import { getLeadingEmoji, uuid } from '@renderer/utils'
import { hasTopicPendingRequests } from '@renderer/utils/queue'
import { Dropdown, MenuProps } from 'antd'
import { Dropdown, MenuProps, Tag as AntdTag } from 'antd'
import { omit } from 'lodash'
import {
AlignJustify,
@@ -136,6 +136,28 @@ const AssistantItem: FC<AssistantItemProps> = ({
[assistant.emoji, assistantName]
)
const ServerTag = () => {
if (assistant.isServer) {
return (
<div>
<AntdTag color="orange" style={{ background: 'inherit', margin: 0 }}>
{t('enterprise.enterprise')}
</AntdTag>
</div>
)
}
if (isActive) {
return (
<MenuButton onClick={() => EventEmitter.emit(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR)}>
<TopicCount className="topics-count">{assistant.topics.length}</TopicCount>
</MenuButton>
)
}
return null
}
return (
<Dropdown
menu={{ items: menuItems }}
@@ -159,11 +181,7 @@ const AssistantItem: FC<AssistantItemProps> = ({
)}
<AssistantName className="text-nowrap">{assistantName}</AssistantName>
</AssistantNameRow>
{isActive && (
<MenuButton onClick={() => EventEmitter.emit(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR)}>
<TopicCount className="topics-count">{assistant.topics.length}</TopicCount>
</MenuButton>
)}
<ServerTag />
</Container>
</Dropdown>
)
@@ -261,7 +279,7 @@ function getMenuItems({
sortByPinyinDesc
}): MenuProps['items'] {
return [
{
!assistant.isServer && {
label: t('assistants.edit.title'),
key: 'edit',
icon: <EditIcon size={14} />,
@@ -292,7 +310,7 @@ function getMenuItems({
})
}
},
{
!assistant.isServer && {
label: t('assistants.save.title'),
key: 'save-to-agent',
icon: <Save size={14} />,
@@ -358,10 +376,10 @@ function getMenuItems({
icon: <ArrowUpAZ size={14} />,
onClick: sortByPinyinDesc
},
{
!assistant.isServer && {
type: 'divider'
},
{
!assistant.isServer && {
label: t('common.delete'),
key: 'delete',
icon: <DeleteIcon size={14} className="lucide-custom" />,
@@ -376,13 +394,14 @@ function getMenuItems({
})
}
}
]
].filter(Boolean) as MenuProps['items']
}
const Container = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 0 8px;
height: 37px;
position: relative;

View File

@@ -3,6 +3,7 @@ import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
import { isLocalAi } from '@renderer/config/env'
import { isEmbeddingModel, isRerankModel, isWebSearchModel } from '@renderer/config/models'
import { useAssistant } from '@renderer/hooks/useAssistant'
import AssistantSettingsPreviewPopup from '@renderer/pages/settings/AssistantSettings/AssistantSettingsPreviewPopup'
import { getProviderName } from '@renderer/services/ProviderService'
import { useAppSelector } from '@renderer/store'
import { Assistant, Model } from '@renderer/types'
@@ -25,6 +26,11 @@ const SelectModelButton: FC<Props> = ({ assistant }) => {
const modelFilter = (model: Model) => !isEmbeddingModel(model) && !isRerankModel(model)
const onSelectModel = async (event: React.MouseEvent<HTMLElement>) => {
if (assistant.isServer) {
AssistantSettingsPreviewPopup.show({ assistant })
return
}
event.currentTarget.blur()
const selectedModel = await SelectModelPopup.show({ model, filter: modelFilter })
if (selectedModel) {
@@ -61,7 +67,7 @@ const SelectModelButton: FC<Props> = ({ assistant }) => {
{model ? model.name : t('button.select_model')} {providerName ? ' | ' + providerName : ''}
</ModelName>
</ButtonContent>
<ChevronsUpDown size={14} color="var(--color-icon)" />
{!assistant.isServer && <ChevronsUpDown size={14} color="var(--color-icon)" />}
{!provider && <Tag color="error">{t('models.invalid_model')}</Tag>}
</DropdownButton>
)

View File

@@ -5,7 +5,7 @@ import CustomTag from '@renderer/components/Tags/CustomTag'
import { useKnowledge } from '@renderer/hooks/useKnowledge'
import { NavbarIcon } from '@renderer/pages/home/ChatNavbar'
import { getProviderName } from '@renderer/services/ProviderService'
import { KnowledgeBase } from '@renderer/types'
import { KnowledgeBase, KnowledgeItem } from '@renderer/types'
import { Button, Empty, Tabs, Tag, Tooltip } from 'antd'
import { Book, Folder, Globe, Link, Notebook, Search, Settings } from 'lucide-react'
import { FC, useEffect, useState } from 'react'
@@ -78,7 +78,7 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
items: noteItems,
content: <KnowledgeNotes selectedBase={selectedBase} />
},
{
!base?.isServer && {
key: 'directories',
title: t('knowledge.directories'),
icon: activeKey === 'directories' ? <Folder size={16} color="var(--color-primary)" /> : <Folder size={16} />,
@@ -99,7 +99,13 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
items: sitemapItems,
content: <KnowledgeSitemaps selectedBase={selectedBase} />
}
]
].filter(Boolean) as {
key: string
title: string
icon: React.ReactNode
items: KnowledgeItem[]
content: React.ReactNode
}[]
if (!base) {
return null
@@ -123,12 +129,14 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
<MainContainer>
<HeaderContainer>
<ModelInfo>
<Button
type="text"
icon={<Settings size={18} color="var(--color-icon)" />}
onClick={() => EditKnowledgeBasePopup.show({ base })}
size="small"
/>
{!base.isServer && (
<Button
type="text"
icon={<Settings size={18} color="var(--color-icon)" />}
onClick={() => EditKnowledgeBasePopup.show({ base })}
size="small"
/>
)}
<div className="model-row">
<div className="label-column">
<label>{t('models.embedding_model')}</label>

View File

@@ -8,7 +8,7 @@ import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
import { useShortcut } from '@renderer/hooks/useShortcuts'
import KnowledgeSearchPopup from '@renderer/pages/knowledge/components/KnowledgeSearchPopup'
import { KnowledgeBase } from '@renderer/types'
import { Dropdown, Empty, MenuProps } from 'antd'
import { Dropdown, Empty, MenuProps, Tag } from 'antd'
import { Book, Plus, Settings } from 'lucide-react'
import { FC, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -118,6 +118,7 @@ const KnowledgePage: FC = () => {
icon={<Book size={16} />}
title={base.name}
onClick={() => setSelectedBase(base)}
rightContent={base.isServer ? <Tag color="orange"></Tag> : undefined}
/>
</div>
</Dropdown>

View File

@@ -44,7 +44,7 @@ const KnowledgeDirectories: FC<KnowledgeContentProps> = ({ selectedBase, progres
)
const providerName = getProviderName(base?.model)
const disabled = !base?.version || !providerName
const disabled = !base?.version || !providerName || base.isServer
const reversedItems = useMemo(() => [...directoryItems].reverse(), [directoryItems])
const estimateSize = useCallback(() => 75, [])
@@ -66,16 +66,18 @@ const KnowledgeDirectories: FC<KnowledgeContentProps> = ({ selectedBase, progres
return (
<ItemContainer>
<ItemHeader>
<Button
type="primary"
icon={<PlusIcon size={16} />}
onClick={(e) => {
e.stopPropagation()
handleAddDirectory()
}}
disabled={disabled}>
{t('knowledge.add_directory')}
</Button>
{!base.isServer && (
<Button
type="primary"
icon={<PlusIcon size={16} />}
onClick={(e) => {
e.stopPropagation()
handleAddDirectory()
}}
disabled={disabled}>
{t('knowledge.add_directory')}
</Button>
)}
</ItemHeader>
<ItemFlexColumn>
{directoryItems.length === 0 && <KnowledgeEmptyView />}
@@ -99,7 +101,7 @@ const KnowledgeDirectories: FC<KnowledgeContentProps> = ({ selectedBase, progres
),
ext: '.folder',
extra: getDisplayTime(item),
actions: (
actions: !base.isServer && (
<FlexAlignCenter>
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
<StatusIconWrapper>

View File

@@ -65,7 +65,7 @@ const KnowledgeFiles: FC<KnowledgeContentProps> = ({ selectedBase, progressMap,
}, [])
const providerName = getProviderName(base?.model)
const disabled = !base?.version || !providerName
const disabled = !base?.version || !providerName || base.isServer
const estimateSize = useCallback(() => 75, [])
@@ -193,7 +193,7 @@ const KnowledgeFiles: FC<KnowledgeContentProps> = ({ selectedBase, progressMap,
),
ext: file.ext,
extra: `${getDisplayTime(item)} · ${formatFileSize(file.size)}`,
actions: (
actions: !base.isServer && (
<FlexAlignCenter>
{item.uniqueId && (
<Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />

View File

@@ -33,7 +33,7 @@ const KnowledgeNotes: FC<KnowledgeContentProps> = ({ selectedBase }) => {
)
const providerName = getProviderName(base?.model)
const disabled = !base?.version || !providerName
const disabled = !base?.version || !providerName || base.isServer
const reversedItems = useMemo(() => [...noteItems].reverse(), [noteItems])
const estimateSize = useCallback(() => 75, [])
@@ -73,16 +73,18 @@ const KnowledgeNotes: FC<KnowledgeContentProps> = ({ selectedBase }) => {
return (
<ItemContainer>
<ItemHeader>
<Button
type="primary"
icon={<PlusIcon size={16} />}
onClick={(e) => {
e.stopPropagation()
handleAddNote()
}}
disabled={disabled}>
{t('knowledge.add_note')}
</Button>
{!base.isServer && (
<Button
type="primary"
icon={<PlusIcon size={16} />}
onClick={(e) => {
e.stopPropagation()
handleAddNote()
}}
disabled={disabled}>
{t('knowledge.add_note')}
</Button>
)}
</ItemHeader>
<ItemFlexColumn>
{noteItems.length === 0 && <KnowledgeEmptyView />}
@@ -104,7 +106,7 @@ const KnowledgeNotes: FC<KnowledgeContentProps> = ({ selectedBase }) => {
),
ext: isMarkdownContent(note.content as string) ? '.md' : '.txt',
extra: getDisplayTime(note),
actions: (
actions: !base.isServer && (
<FlexAlignCenter>
<Button type="text" onClick={() => handleEditNote(note)} icon={<EditIcon size={14} />} />
<StatusIconWrapper>

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