Compare commits
16 Commits
fix/react-
...
v1.6.50
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b6682c325 | ||
|
|
fa9f59146e | ||
|
|
c1d8bf38ef | ||
|
|
7217a7216e | ||
|
|
d35998bd74 | ||
|
|
dbd090377d | ||
|
|
2ebcb43d50 | ||
|
|
826b71deba | ||
|
|
7c0a800d9d | ||
|
|
6f9906fe49 | ||
|
|
ba7eec64b0 | ||
|
|
83d2403339 | ||
|
|
bc17dcb911 | ||
|
|
44e93671fa | ||
|
|
a5bfd8f3db | ||
|
|
07c3c33acc |
94
.github/ISSUE_TEMPLATE/#0_bug_report.yml
vendored
@@ -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: 任何能让我们对你所遇到的问题有更多了解的东西
|
||||
76
.github/ISSUE_TEMPLATE/#1_feature_request.yml
vendored
@@ -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: 在此添加任何其他与功能建议相关的上下文或截图
|
||||
77
.github/ISSUE_TEMPLATE/#2_question.yml
vendored
@@ -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
|
||||
76
.github/ISSUE_TEMPLATE/#3_others.yml
vendored
@@ -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: 任何能让我们对您的问题有更多了解的信息,包括截图或相关链接
|
||||
94
.github/ISSUE_TEMPLATE/0_bug_report.yml
vendored
@@ -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
|
||||
76
.github/ISSUE_TEMPLATE/1_feature_request.yml
vendored
@@ -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.
|
||||
79
.github/ISSUE_TEMPLATE/2_question.yml
vendored
@@ -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
|
||||
76
.github/ISSUE_TEMPLATE/3_others.yml
vendored
@@ -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
|
||||
17
.github/dependabot.yml
vendored
@@ -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'
|
||||
252
.github/issue-checker.yml
vendored
@@ -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
|
||||
54
.github/pull_request_template.md
vendored
@@ -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
|
||||
|
||||
```
|
||||
27
.github/workflows/dispatch-docs-update.yml
vendored
@@ -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 }}"}'
|
||||
25
.github/workflows/issue-checker.yml
vendored
@@ -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
|
||||
58
.github/workflows/issue-management.yml
vendored
@@ -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
|
||||
294
.github/workflows/nightly-build.yml
vendored
@@ -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
|
||||
58
.github/workflows/pr-ci.yml
vendored
@@ -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
|
||||
24
.github/workflows/release.yml
vendored
@@ -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
@@ -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="Featured|HelloGitHub" 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-studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry Studio - AI Chatbots, AI Desktop 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
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
# 🌟 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
|
||||

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

|
||||
|
||||
# ⭐️ 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
|
||||
|
||||
BIN
build/icon.icns
BIN
build/icon.ico
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 36 KiB |
BIN
build/icon.png
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 129 KiB |
BIN
build/logo.png
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 3.2 KiB |
@@ -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 Banana(Gemini 2.5 Flash Image)模型支持
|
||||
- 新增系统 OCR 功能 (macOS & Windows)
|
||||
- 新增图片 OCR 识别和翻译功能
|
||||
- 模型切换支持通过标签筛选
|
||||
- 翻译功能增强:历史搜索和收藏
|
||||
- 新增标签页拖拽重新排序功能
|
||||
- 增强笔记编辑器同步功能
|
||||
- 链接预览支持解析 OG 数据
|
||||
- 新增"重试失败消息"按钮
|
||||
|
||||
🔧 性能优化:
|
||||
- 优化历史页面搜索性能
|
||||
- 优化拖拽列表组件交互
|
||||
- 升级 Electron 到 37.4.0
|
||||
- 优化 MCP 服务日志和错误处理
|
||||
- 改进构建配置和依赖管理
|
||||
- 增强 Linux 系统 OCR 构建支持
|
||||
|
||||
🐛 修复问题:
|
||||
- 修复知识库加密 PDF 文档处理
|
||||
- 修复导航栏在左侧时笔记侧边栏按钮缺失
|
||||
- 修复多个模型兼容性问题
|
||||
- 修复 MCP 相关问题
|
||||
- 其他稳定性改进
|
||||
- 修复翻译功能相关问题
|
||||
- 修复 MCP 服务相关问题
|
||||
- 修复导航和标签页显示问题
|
||||
- 修复 Obsidian 集成检测
|
||||
- 其他界面和稳定性改进
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
`
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 299 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 129 KiB |
@@ -165,9 +165,6 @@ ul {
|
||||
}
|
||||
.markdown {
|
||||
display: flow-root;
|
||||
*:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
52
src/renderer/src/components/Avatar/UserAvatar.tsx
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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?.()
|
||||
}
|
||||
|
||||
|
||||
143
src/renderer/src/components/MinApp/MinAppTabsPool.tsx
Normal 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
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
`
|
||||
|
||||
|
||||
144
src/renderer/src/components/ProviderAvatar.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
1
src/renderer/src/components/UserProfileSettings/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './UserProfileSettings'
|
||||
@@ -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;
|
||||
`
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
34
src/renderer/src/config/api.ts
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
16
src/renderer/src/context/AuthProvider.tsx
Normal 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
|
||||
@@ -15,7 +15,7 @@ const NavigationHandler: React.FC = () => {
|
||||
if (location.pathname.startsWith('/settings')) {
|
||||
return
|
||||
}
|
||||
navigate('/settings/provider')
|
||||
navigate('/settings/user')
|
||||
},
|
||||
{
|
||||
splitKey: '!',
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
18
src/renderer/src/hooks/useAuth.ts
Normal 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())
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ホーム",
|
||||
|
||||
@@ -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": "Главная",
|
||||
|
||||
@@ -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": "首页",
|
||||
|
||||
@@ -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": "主頁",
|
||||
|
||||
@@ -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
|
||||
|
||||
460
src/renderer/src/pages/auth/login.tsx
Normal 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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 === '?') {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
`
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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>
|
||||
|
||||