Compare commits
124 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f05626a8f | ||
|
|
2094e2201a | ||
|
|
e0fcdf43c5 | ||
|
|
affc866c17 | ||
|
|
799267049f | ||
|
|
cb8d47a17b | ||
|
|
c494288f7b | ||
|
|
2c3f89dbde | ||
|
|
4721a660fa | ||
|
|
6aaa3def0d | ||
|
|
045708d9b3 | ||
|
|
9ffe92d378 | ||
|
|
7159481217 | ||
|
|
d07e136037 | ||
|
|
b38a9c954a | ||
|
|
7139d5093a | ||
|
|
9e283d6930 | ||
|
|
c9a4e12765 | ||
|
|
7bd644451b | ||
|
|
5a00bdcbc6 | ||
|
|
3c958c3d11 | ||
|
|
1d5ace0fb2 | ||
|
|
f8fce871da | ||
|
|
de76d3fedc | ||
|
|
b2c6662192 | ||
|
|
bf8a7c01b0 | ||
|
|
fb8ed35b59 | ||
|
|
7c4d81c108 | ||
|
|
7199f73e06 | ||
|
|
869e56b53c | ||
|
|
f99851fb6b | ||
|
|
c94450db44 | ||
|
|
195ef92acc | ||
|
|
a67370426b | ||
|
|
9d35205681 | ||
|
|
98087e50db | ||
|
|
bedac4f59d | ||
|
|
aba3874797 | ||
|
|
3383280726 | ||
|
|
0c13e708b9 | ||
|
|
bc77c423b3 | ||
|
|
4821756301 | ||
|
|
78290ca70e | ||
|
|
7feeb07624 | ||
|
|
93e28ed916 | ||
|
|
b4aaf052fe | ||
|
|
b37e0389fc | ||
|
|
e1ebe069a5 | ||
|
|
d73912ee3b | ||
|
|
f81c7c7a6c | ||
|
|
5a7bcd5997 | ||
|
|
09a347cae4 | ||
|
|
266f909045 | ||
|
|
bad2f15c1f | ||
|
|
e3115d00bf | ||
|
|
0c0ccf3d11 | ||
|
|
2076e6f998 | ||
|
|
b49d80b78d | ||
|
|
ab5e830ed1 | ||
|
|
e0eca97053 | ||
|
|
d175212d9a | ||
|
|
642ce160a1 | ||
|
|
574d02a8c9 | ||
|
|
7764507d74 | ||
|
|
fa8bf61532 | ||
|
|
30e8cef9cc | ||
|
|
1a2861e81a | ||
|
|
653e5d82ed | ||
|
|
5be0e0ae72 | ||
|
|
b92b46f2b0 | ||
|
|
23686d4926 | ||
|
|
b340b40bcf | ||
|
|
253fc6f4e1 | ||
|
|
99aa0d3255 | ||
|
|
23a2a6b57c | ||
|
|
a869857fc1 | ||
|
|
4ecedcb267 | ||
|
|
cbd6a30e14 | ||
|
|
5f2cddee09 | ||
|
|
c0e0e924f7 | ||
|
|
b6ad7eeb9a | ||
|
|
9cf74317a6 | ||
|
|
82fcc2292e | ||
|
|
4eb0c25682 | ||
|
|
9e128d2524 | ||
|
|
1473cb3123 | ||
|
|
2c5fe01fbf | ||
|
|
d574a09529 | ||
|
|
f20bccfd7d | ||
|
|
5dcc892f31 | ||
|
|
26e3871688 | ||
|
|
9a6aad35b0 | ||
|
|
16feb49e9e | ||
|
|
30959e2380 | ||
|
|
2c17f75f4f | ||
|
|
2d1a930bfe | ||
|
|
320d27059f | ||
|
|
31014aa8a6 | ||
|
|
b468ecfce7 | ||
|
|
c53d63f7af | ||
|
|
dabff0a847 | ||
|
|
26a5ae0086 | ||
|
|
88e0d293a2 | ||
|
|
0c97b52c53 | ||
|
|
2449a22c69 | ||
|
|
028f9d88d9 | ||
|
|
a07c6cdffb | ||
|
|
5a647b0d61 | ||
|
|
007e6419ba | ||
|
|
caa473639c | ||
|
|
b6825a6ea2 | ||
|
|
710180997f | ||
|
|
fd4334f331 | ||
|
|
80dedc149a | ||
|
|
8eacaa281a | ||
|
|
6e75140939 | ||
|
|
5a3a97135f | ||
|
|
44d42d64ef | ||
|
|
fad3f67678 | ||
|
|
65b30b3b0d | ||
|
|
0278228a84 | ||
|
|
bb0cb1cecc | ||
|
|
f5cd6ecb50 | ||
|
|
76c0ad9985 |
@@ -16,6 +16,7 @@ module.exports = {
|
||||
'react/prop-types': 'off',
|
||||
'simple-import-sort/imports': 'error',
|
||||
'simple-import-sort/exports': 'error',
|
||||
'react/no-is-mounted': 'off'
|
||||
'react/no-is-mounted': 'off',
|
||||
'prettier/prettier': ['error', { endOfLine: 'auto' }]
|
||||
}
|
||||
}
|
||||
|
||||
50
.github/ISSUE_TEMPLATE/#1_feature_request.yml
vendored
@@ -1,50 +0,0 @@
|
||||
name: 💡 功能建议
|
||||
description: 为项目提出新的想法
|
||||
title: '[功能]: '
|
||||
labels: ['enhancement']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢您花时间提出新的功能建议!
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Issue 检查清单
|
||||
description: |
|
||||
在提交 Issue 前请确保您已经完成了以下所有步骤
|
||||
options:
|
||||
- label: 我已经查看了置顶 Issue 并搜索了现有的 Issue,但没有找到类似的问题。
|
||||
required: true
|
||||
- label: 正确填写了 Issue 标题。
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: 您的功能建议是否与某个问题相关?
|
||||
description: 请简明扼要地描述您遇到的问题
|
||||
placeholder: 我总是感到沮丧,因为...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: 请描述您希望实现的解决方案
|
||||
description: 请简明扼要地描述您希望发生的情况
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: 请描述您考虑过的其他方案
|
||||
description: 请简明扼要地描述您考虑过的任何其他解决方案或功能
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: 其他补充信息
|
||||
description: 在此添加任何其他与功能建议相关的上下文或截图
|
||||
15
.github/ISSUE_TEMPLATE/0_bug_report.yml
vendored
@@ -6,7 +6,8 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report!
|
||||
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
|
||||
@@ -15,9 +16,11 @@ body:
|
||||
description: |
|
||||
Before submitting an issue, please make sure you have completed the following steps
|
||||
options:
|
||||
- label: I have viewed the pinned issues and searched existing issues but couldn't find anything similar.
|
||||
- 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: I have filled out the issue title correctly.
|
||||
- label: I've looked at pinned issues and searched for 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), no similar issue 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
|
||||
|
||||
- type: dropdown
|
||||
@@ -45,7 +48,7 @@ body:
|
||||
id: description
|
||||
attributes:
|
||||
label: Bug Description
|
||||
description: A clear and concise description of what the bug is
|
||||
description: Please be as detailed as possible when describing the problem
|
||||
placeholder: Tell us what happened...
|
||||
validations:
|
||||
required: true
|
||||
@@ -54,7 +57,7 @@ body:
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Steps To Reproduce
|
||||
description: Steps to reproduce the behavior
|
||||
description: Provide detailed steps to reproduce the issue so that our developers can reproduce the issue accurately
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
@@ -82,4 +85,4 @@ body:
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context about the problem here
|
||||
description: Anything that gives us a better understanding of the problem you're experiencing
|
||||
|
||||
50
.github/ISSUE_TEMPLATE/1_feature_request.yml
vendored
@@ -6,7 +6,8 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to suggest a new feature!
|
||||
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
|
||||
@@ -15,36 +16,61 @@ body:
|
||||
description: |
|
||||
Before submitting an issue, please make sure you have completed the following steps
|
||||
options:
|
||||
- label: I have viewed the pinned issues and searched existing issues but couldn't find anything similar.
|
||||
- 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 filled out the issue title correctly.
|
||||
- label: I have checked the pinned issues and searched through the 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) 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 a problem?
|
||||
description: A clear and concise description of what the problem is
|
||||
placeholder: I'm always frustrated when...
|
||||
label: Is your feature request related to an existing issue?
|
||||
description: Please briefly describe the problem you are experiencing.
|
||||
placeholder: I often feel frustrated because...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
description: A clear and concise description of what you want to happen
|
||||
label: Desired Solution
|
||||
description: Please briefly describe what you would like to happen.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Describe alternatives you've considered
|
||||
description: A clear and concise description of any alternative solutions or features you've considered
|
||||
label: Alternative Solutions
|
||||
description: Please briefly describe any alternative solutions or features you have considered.
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context or screenshots about the feature request here
|
||||
label: Additional Information
|
||||
description: Add any other context or screenshots related to your feature request.
|
||||
|
||||
41
.github/ISSUE_TEMPLATE/2_question.yml
vendored
@@ -1,12 +1,12 @@
|
||||
name: ❓ Question
|
||||
description: Ask a question or seek help
|
||||
title: '[Question]: '
|
||||
name: ❓ Discussion & Questions
|
||||
description: Seeking help, discussing issues, asking questions, etc...
|
||||
title: '[Discussion]: '
|
||||
labels: ['question']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for asking a question! Please provide as much detail as possible so we can better assist you.
|
||||
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
|
||||
@@ -15,17 +15,38 @@ body:
|
||||
description: |
|
||||
Before submitting an issue, please make sure you have completed the following steps
|
||||
options:
|
||||
- label: I have viewed the pinned issues and searched existing issues but couldn't find anything similar.
|
||||
- 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 filled out the issue title correctly.
|
||||
- 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 question in detail
|
||||
placeholder: Please explain your question as clearly as possible...
|
||||
description: Please describe your issue in detail.
|
||||
placeholder: Please explain your issue as clearly as possible...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -47,9 +68,9 @@ body:
|
||||
id: priority
|
||||
attributes:
|
||||
label: Priority
|
||||
description: How urgent is this question for you?
|
||||
description: How urgent is this issue for you?
|
||||
options:
|
||||
- Low (Can wait)
|
||||
- Low (Review when available)
|
||||
- Medium (Would like a response soon)
|
||||
- High (Blocking progress)
|
||||
validations:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: 🐛 错误报告
|
||||
name: 🐛 错误报告 (中文)
|
||||
description: 创建一个报告以帮助我们改进
|
||||
title: '[错误]: '
|
||||
labels: ['bug']
|
||||
@@ -7,17 +7,20 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
感谢您花时间填写此错误报告!
|
||||
在提交此问题之前,请确保您已经了解了[常见问题](https://docs.cherry-ai.com/question-contact/questions)和[知识科普](https://docs.cherry-ai.com/question-contact/knowledge)
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Issue 检查清单
|
||||
label: 提交前检查
|
||||
description: |
|
||||
在提交 Issue 前请确保您已经完成了以下所有步骤
|
||||
options:
|
||||
- label: 我已经查看了置顶 Issue 并搜索了现有的 Issue,但没有找到类似的问题。
|
||||
- label: 我理解 Issue 是用于反馈和解决问题的,而非吐槽评论区,将尽可能提供更多信息帮助问题解决。
|
||||
required: true
|
||||
- label: 正确填写了 Issue 标题。
|
||||
- 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
|
||||
|
||||
- type: dropdown
|
||||
@@ -45,7 +48,7 @@ body:
|
||||
id: description
|
||||
attributes:
|
||||
label: 错误描述
|
||||
description: 清晰简洁地描述错误是什么
|
||||
description: 描述问题时请尽可能详细
|
||||
placeholder: 告诉我们发生了什么...
|
||||
validations:
|
||||
required: true
|
||||
@@ -54,7 +57,7 @@ body:
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: 重现步骤
|
||||
description: 重现行为的步骤
|
||||
description: 提供详细的重现步骤,以便于我们可以准确地重现问题
|
||||
placeholder: |
|
||||
1. 转到 '...'
|
||||
2. 点击 '....'
|
||||
@@ -82,4 +85,4 @@ body:
|
||||
id: additional
|
||||
attributes:
|
||||
label: 附加信息
|
||||
description: 在此添加有关问题的任何其他上下文
|
||||
description: 任何能让我们对你所遇到的问题有更多了解的东西
|
||||
76
.github/issues/#1_feature_request.yml
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
name: 💡 功能建议 (中文)
|
||||
description: 为项目提出新的想法
|
||||
title: '[功能]: '
|
||||
labels: ['enhancement']
|
||||
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: 在此添加任何其他与功能建议相关的上下文或截图
|
||||
@@ -1,6 +1,6 @@
|
||||
name: ❓ 提问
|
||||
description: 提出一个问题或寻求帮助
|
||||
title: '[问题]: '
|
||||
name: ❓ 讨论 & 提问 (中文)
|
||||
description: 寻求帮助、讨论问题、提出疑问等...
|
||||
title: '[讨论]: '
|
||||
labels: ['question']
|
||||
body:
|
||||
- type: markdown
|
||||
@@ -15,11 +15,32 @@ body:
|
||||
description: |
|
||||
在提交 Issue 前请确保您已经完成了以下所有步骤
|
||||
options:
|
||||
- label: 我已经查看了置顶 Issue 并搜索了现有的 Issue,但没有找到类似的问题。
|
||||
- label: 我理解 Issue 是用于反馈和解决问题的,而非吐槽评论区,将尽可能提供更多信息帮助问题解决。
|
||||
required: true
|
||||
- label: 正确填写了 Issue 标题。
|
||||
- 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:
|
||||
2
.gitignore
vendored
@@ -44,3 +44,5 @@ stats.html
|
||||
|
||||
# Local
|
||||
local
|
||||
.aider*
|
||||
.cursorrules
|
||||
|
||||
@@ -30,7 +30,7 @@ Cherry Studio is a desktop client that supports for multiple LLM providers, avai
|
||||
|
||||
- ☁️ Major LLM Cloud Services: OpenAI, Gemini, Anthropic, and more
|
||||
- 🔗 AI Web Service Integration: Claude, Peplexity, Poe, and others
|
||||
- 💻 Local Model Support with Ollama
|
||||
- 💻 Local Model Support with Ollama, LM Studio
|
||||
|
||||
2. **AI Assistants & Conversations**:
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ Cherry Studioは、複数のLLMプロバイダーをサポートするデスク
|
||||
|
||||
- ☁️ 主要な LLM クラウドサービス対応:OpenAI、Gemini、Anthropic など
|
||||
- 🔗 AI Web サービス統合:Claude、Peplexity、Poe など
|
||||
- 💻 Ollama によるローカルモデル実行対応
|
||||
- 💻 Ollama、LM Studio によるローカルモデル実行対応
|
||||
|
||||
2. **AI アシスタントと対話**:
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ Cherry Studio 是一款支持多个大语言模型(LLM)服务商的桌面客
|
||||
|
||||
- ☁️ 支持主流 LLM 云服务:OpenAI、Gemini、Anthropic、硅基流动等
|
||||
- 🔗 集成流行 AI Web 服务:Claude、Peplexity、Poe、腾讯元宝、知乎直答等
|
||||
- 💻 支持 Ollama 本地模型部署
|
||||
- 💻 支持 Ollama、LM Studio 本地模型部署
|
||||
|
||||
2. **智能助手与对话**:
|
||||
|
||||
|
||||
@@ -80,11 +80,11 @@ afterPack: scripts/after-pack.js
|
||||
afterSign: scripts/notarize.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
知识库增加更多文件类型支持
|
||||
使用@呼出模型选择列表
|
||||
添加话题固定功能
|
||||
增加导出话题至Notion的功能
|
||||
增加 Google AI Studio 小程序
|
||||
增加 Gitee 服务商
|
||||
增加 PPIO 服务商
|
||||
为 OpenAI 请求添加引用来源数据显示
|
||||
消息分组支持网格模式
|
||||
知识库支持多选
|
||||
知识库添加目录支持显示进度
|
||||
知识库支持 DRAFTS, EPUB、代码等
|
||||
知识库支持调节匹配度阈值
|
||||
添加 NotebookLM, Coze 小程序
|
||||
增加话题提示词
|
||||
OpenRouter 支持 Web 搜索
|
||||
|
||||
@@ -51,7 +51,7 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['chunk-PZ64DZKH.js', 'chunk-JMKENWIY.js']
|
||||
exclude: ['chunk-PZ64DZKH.js', 'chunk-JMKENWIY.js', 'chunk-UXYB6GHG.js']
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "0.9.23",
|
||||
"version": "0.9.27",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -71,6 +71,7 @@
|
||||
"electron-store": "^8.2.0",
|
||||
"electron-updater": "^6.3.9",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"epub": "^1.3.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"markdown-it": "^14.1.0",
|
||||
@@ -136,7 +137,7 @@
|
||||
"redux": "^5.0.1",
|
||||
"redux-persist": "^6.0.0",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-mathjax": "^6.0.0",
|
||||
"rehype-mathjax": "^7.0.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
|
||||
@@ -2,6 +2,8 @@ export const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']
|
||||
export const videoExts = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv']
|
||||
export const audioExts = ['.mp3', '.wav', '.ogg', '.flac', '.aac']
|
||||
export const documentExts = ['.pdf', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods']
|
||||
export const thirdPartyApplicationExts = ['.draftsExport']
|
||||
export const bookExts = ['.epub']
|
||||
export const textExts = [
|
||||
'.txt', // 普通文本文件
|
||||
'.md', // Markdown 文件
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import { app } from 'electron'
|
||||
import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer'
|
||||
|
||||
import { registerIpc } from './ipc'
|
||||
@@ -46,15 +46,13 @@ if (!app.requestSingleInstanceLock()) {
|
||||
new TrayService()
|
||||
|
||||
app.on('activate', function () {
|
||||
// On macOS it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
windowService.createMainWindow()
|
||||
} else {
|
||||
windowService.showMainWindow()
|
||||
}
|
||||
})
|
||||
|
||||
registerShortcuts(mainWindow)
|
||||
|
||||
registerIpc(mainWindow, app)
|
||||
@@ -68,12 +66,7 @@ if (!app.requestSingleInstanceLock()) {
|
||||
|
||||
// Listen for second instance
|
||||
app.on('second-instance', () => {
|
||||
const mainWindow = BrowserWindow.getAllWindows()[0]
|
||||
if (mainWindow) {
|
||||
mainWindow.isMinimized() && mainWindow.restore()
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
windowService.showMainWindow()
|
||||
})
|
||||
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
|
||||
22
src/main/loader/draftsExportLoader.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as fs from 'node:fs'
|
||||
|
||||
import { JsonLoader } from '@llm-tools/embedjs'
|
||||
|
||||
/**
|
||||
* Drafts 应用导出的笔记文件加载器
|
||||
* 原始文件是一个 JSON 数组。每条笔记只保留 content、tags、modified_at 三个字段
|
||||
*/
|
||||
export class DraftsExportLoader extends JsonLoader {
|
||||
constructor(filePath: string) {
|
||||
const fileContent = fs.readFileSync(filePath, 'utf-8')
|
||||
const rawJson = JSON.parse(fileContent) as any[]
|
||||
const json = rawJson.map((item) => {
|
||||
return {
|
||||
content: item.content?.replace(/\n/g, '<br>'),
|
||||
tags: item.tags,
|
||||
modified_at: item.created_at
|
||||
}
|
||||
})
|
||||
super({ object: json })
|
||||
}
|
||||
}
|
||||
228
src/main/loader/epubLoader.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'
|
||||
import { BaseLoader } from '@llm-tools/embedjs-interfaces'
|
||||
import { cleanString } from '@llm-tools/embedjs-utils'
|
||||
import Logger from 'electron-log'
|
||||
import EPub from 'epub'
|
||||
import * as fs from 'fs'
|
||||
|
||||
/**
|
||||
* epub 加载器的配置选项
|
||||
*/
|
||||
interface EpubLoaderOptions {
|
||||
/** epub 文件路径 */
|
||||
filePath: string
|
||||
/** 文本分块大小 */
|
||||
chunkSize: number
|
||||
/** 分块重叠大小 */
|
||||
chunkOverlap: number
|
||||
}
|
||||
|
||||
/**
|
||||
* epub 文件的元数据信息
|
||||
*/
|
||||
interface EpubMetadata {
|
||||
/** 作者显示名称(例如:"Lewis Carroll") */
|
||||
creator?: string
|
||||
/** 作者规范化名称,用于排序和索引(例如:"Carroll, Lewis") */
|
||||
creatorFileAs?: string
|
||||
/** 书籍标题(例如:"Alice's Adventures in Wonderland") */
|
||||
title?: string
|
||||
/** 语言代码(例如:"en" 或 "zh-CN") */
|
||||
language?: string
|
||||
/** 主题或分类(例如:"Fantasy"、"Fiction") */
|
||||
subject?: string
|
||||
/** 创建日期(例如:"2024-02-14") */
|
||||
date?: string
|
||||
/** 书籍描述或简介 */
|
||||
description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* epub 章节信息
|
||||
*/
|
||||
interface EpubChapter {
|
||||
/** 章节 ID */
|
||||
id: string
|
||||
/** 章节标题 */
|
||||
title?: string
|
||||
/** 章节顺序 */
|
||||
order?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* epub 文件加载器
|
||||
* 用于解析 epub 电子书文件,提取文本内容和元数据
|
||||
*/
|
||||
export class EpubLoader extends BaseLoader<Record<string, string | number | boolean>, Record<string, unknown>> {
|
||||
protected filePath: string
|
||||
protected chunkSize: number
|
||||
protected chunkOverlap: number
|
||||
private extractedText: string
|
||||
private metadata: EpubMetadata | null
|
||||
|
||||
/**
|
||||
* 创建 epub 加载器实例
|
||||
* @param options 加载器配置选项
|
||||
*/
|
||||
constructor(options: EpubLoaderOptions) {
|
||||
super(options.filePath, {
|
||||
chunkSize: options.chunkSize,
|
||||
chunkOverlap: options.chunkOverlap
|
||||
})
|
||||
this.filePath = options.filePath
|
||||
this.chunkSize = options.chunkSize
|
||||
this.chunkOverlap = options.chunkOverlap
|
||||
this.extractedText = ''
|
||||
this.metadata = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待 epub 文件初始化完成
|
||||
* epub 库使用事件机制,需要等待 'end' 事件触发后才能访问文件内容
|
||||
* @param epub epub 实例
|
||||
* @returns 元数据和章节信息
|
||||
*/
|
||||
private waitForEpubInit(epub: any): Promise<{ metadata: EpubMetadata; chapters: EpubChapter[] }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
epub.on('end', () => {
|
||||
// 提取元数据
|
||||
const metadata: EpubMetadata = {
|
||||
creator: epub.metadata.creator,
|
||||
creatorFileAs: epub.metadata.creatorFileAs,
|
||||
title: epub.metadata.title,
|
||||
language: epub.metadata.language,
|
||||
subject: epub.metadata.subject,
|
||||
date: epub.metadata.date,
|
||||
description: epub.metadata.description
|
||||
}
|
||||
|
||||
// 提取章节信息
|
||||
const chapters: EpubChapter[] = epub.flow.map((chapter: any, index: number) => ({
|
||||
id: chapter.id,
|
||||
title: chapter.title || `Chapter ${index + 1}`,
|
||||
order: index + 1
|
||||
}))
|
||||
|
||||
resolve({ metadata, chapters })
|
||||
})
|
||||
|
||||
epub.on('error', (error: Error) => {
|
||||
reject(error)
|
||||
})
|
||||
|
||||
epub.parse()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取章节内容
|
||||
* @param epub epub 实例
|
||||
* @param chapterId 章节 ID
|
||||
* @returns 章节文本内容
|
||||
*/
|
||||
private getChapter(epub: any, chapterId: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
epub.getChapter(chapterId, (error: Error | null, text: string) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve(text)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 epub 文件中提取文本内容
|
||||
* 1. 检查文件是否存在
|
||||
* 2. 初始化 epub 并获取元数据
|
||||
* 3. 遍历所有章节并提取文本
|
||||
* 4. 清理 HTML 标签
|
||||
* 5. 合并所有章节文本
|
||||
*/
|
||||
private async extractTextFromEpub() {
|
||||
try {
|
||||
// 检查文件是否存在
|
||||
if (!fs.existsSync(this.filePath)) {
|
||||
throw new Error(`File not found: ${this.filePath}`)
|
||||
}
|
||||
|
||||
const epub = new EPub(this.filePath)
|
||||
|
||||
// 等待 epub 初始化完成并获取元数据
|
||||
const { metadata, chapters } = await this.waitForEpubInit(epub)
|
||||
this.metadata = metadata
|
||||
|
||||
if (!epub.flow || epub.flow.length === 0) {
|
||||
throw new Error('No content found in epub file')
|
||||
}
|
||||
|
||||
const chapterTexts: string[] = []
|
||||
|
||||
// 遍历所有章节
|
||||
for (const chapter of chapters) {
|
||||
try {
|
||||
const content = await this.getChapter(epub, chapter.id)
|
||||
|
||||
if (!content) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 移除 HTML 标签并清理文本
|
||||
const text = content
|
||||
.replace(/<[^>]*>/g, ' ') // 移除所有 HTML 标签
|
||||
.replace(/\s+/g, ' ') // 将多个空白字符替换为单个空格
|
||||
.trim() // 移除首尾空白
|
||||
|
||||
if (text) {
|
||||
chapterTexts.push(text)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`[EpubLoader] Error processing chapter ${chapter.id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// 使用双换行符连接所有章节文本
|
||||
this.extractedText = chapterTexts.join('\n\n')
|
||||
} catch (error) {
|
||||
Logger.error('[EpubLoader] Error in extractTextFromEpub:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文本块
|
||||
* 重写 BaseLoader 的方法,将提取的文本分割成适当大小的块
|
||||
* 每个块都包含源文件和元数据信息
|
||||
*/
|
||||
override async *getUnfilteredChunks() {
|
||||
// 如果还没有提取文本,先提取
|
||||
if (!this.extractedText) {
|
||||
await this.extractTextFromEpub()
|
||||
}
|
||||
|
||||
Logger.info('[EpubLoader] 书名:', this.metadata?.title || '未知书名', ' 文本大小:', this.extractedText.length)
|
||||
|
||||
// 创建文本分块器
|
||||
const chunker = new RecursiveCharacterTextSplitter({
|
||||
chunkSize: this.chunkSize,
|
||||
chunkOverlap: this.chunkOverlap
|
||||
})
|
||||
|
||||
// 清理并分割文本
|
||||
const chunks = await chunker.splitText(cleanString(this.extractedText))
|
||||
|
||||
// 为每个文本块添加元数据
|
||||
for (const chunk of chunks) {
|
||||
yield {
|
||||
pageContent: chunk,
|
||||
metadata: {
|
||||
source: this.filePath,
|
||||
title: this.metadata?.title || '',
|
||||
creator: this.metadata?.creator || '',
|
||||
language: this.metadata?.language || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
import * as fs from 'node:fs'
|
||||
|
||||
import { LocalPathLoader, RAGApplication, TextLoader } from '@llm-tools/embedjs'
|
||||
import { JsonLoader, LocalPathLoader, RAGApplication, TextLoader } from '@llm-tools/embedjs'
|
||||
import type { AddLoaderReturn } from '@llm-tools/embedjs-interfaces'
|
||||
import { WebLoader } from '@llm-tools/embedjs-loader-web'
|
||||
import { LoaderReturn } from '@shared/config/types'
|
||||
import { FileType, KnowledgeBaseParams } from '@types'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
import { DraftsExportLoader } from './draftsExportLoader'
|
||||
import { EpubLoader } from './epubLoader'
|
||||
import { OdLoader, OdType } from './odLoader'
|
||||
|
||||
// embedjs内置loader类型
|
||||
const commonExts = ['.pdf', '.csv', '.json', '.docx', '.pptx', '.xlsx', '.md']
|
||||
const commonExts = ['.pdf', '.csv', '.docx', '.pptx', '.xlsx', '.md']
|
||||
|
||||
export async function addOdLoader(
|
||||
ragApplication: RAGApplication,
|
||||
@@ -69,8 +72,68 @@ export async function addFileLoader(
|
||||
} as LoaderReturn
|
||||
}
|
||||
|
||||
// 文本类型
|
||||
// epub 文件处理
|
||||
if (file.ext === '.epub') {
|
||||
const loaderReturn = await ragApplication.addLoader(
|
||||
new EpubLoader({
|
||||
filePath: file.path,
|
||||
chunkSize: base.chunkSize ?? 1000,
|
||||
chunkOverlap: base.chunkOverlap ?? 200
|
||||
}) as any,
|
||||
forceReload
|
||||
)
|
||||
return {
|
||||
entriesAdded: loaderReturn.entriesAdded,
|
||||
uniqueId: loaderReturn.uniqueId,
|
||||
uniqueIds: [loaderReturn.uniqueId],
|
||||
loaderType: loaderReturn.loaderType
|
||||
} as LoaderReturn
|
||||
}
|
||||
|
||||
// DraftsExport类型 (file.ext会自动转换成小写)
|
||||
if (['.draftsexport'].includes(file.ext)) {
|
||||
const loaderReturn = await ragApplication.addLoader(new DraftsExportLoader(file.path) as any, forceReload)
|
||||
return {
|
||||
entriesAdded: loaderReturn.entriesAdded,
|
||||
uniqueId: loaderReturn.uniqueId,
|
||||
uniqueIds: [loaderReturn.uniqueId],
|
||||
loaderType: loaderReturn.loaderType
|
||||
}
|
||||
}
|
||||
|
||||
const fileContent = fs.readFileSync(file.path, 'utf-8')
|
||||
|
||||
// HTML类型
|
||||
if (['.html', '.htm'].includes(file.ext)) {
|
||||
const loaderReturn = await ragApplication.addLoader(
|
||||
new WebLoader({
|
||||
urlOrContent: fileContent,
|
||||
chunkSize: base.chunkSize,
|
||||
chunkOverlap: base.chunkOverlap
|
||||
}) as any,
|
||||
forceReload
|
||||
)
|
||||
return {
|
||||
entriesAdded: loaderReturn.entriesAdded,
|
||||
uniqueId: loaderReturn.uniqueId,
|
||||
uniqueIds: [loaderReturn.uniqueId],
|
||||
loaderType: loaderReturn.loaderType
|
||||
}
|
||||
}
|
||||
|
||||
// JSON类型
|
||||
if (['.json'].includes(file.ext)) {
|
||||
const jsonObject = JSON.parse(fileContent)
|
||||
const loaderReturn = await ragApplication.addLoader(new JsonLoader({ object: jsonObject }))
|
||||
return {
|
||||
entriesAdded: loaderReturn.entriesAdded,
|
||||
uniqueId: loaderReturn.uniqueId,
|
||||
uniqueIds: [loaderReturn.uniqueId],
|
||||
loaderType: loaderReturn.loaderType
|
||||
}
|
||||
}
|
||||
|
||||
// 文本类型
|
||||
const loaderReturn = await ragApplication.addLoader(
|
||||
new TextLoader({ text: fileContent, chunkSize: base.chunkSize, chunkOverlap: base.chunkOverlap }) as any,
|
||||
forceReload
|
||||
|
||||
@@ -15,6 +15,8 @@ import { FileType, KnowledgeBaseParams, KnowledgeItem } from '@types'
|
||||
import { app } from 'electron'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
class KnowledgeService {
|
||||
private storageDir = path.join(app.getPath('userData'), 'Data', 'KnowledgeBase')
|
||||
|
||||
@@ -83,10 +85,23 @@ class KnowledgeService {
|
||||
): Promise<LoaderReturn> => {
|
||||
const ragApplication = await this.getRagApplication(base)
|
||||
|
||||
const sendDirectoryProcessingPercent = (totalFiles: number, processedFiles: number) => {
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
mainWindow?.webContents.send(base.id, (processedFiles / totalFiles) * 100)
|
||||
}
|
||||
|
||||
if (item.type === 'directory') {
|
||||
const directory = item.content as string
|
||||
const files = getAllFiles(directory)
|
||||
const loaderPromises = files.map((file) => addFileLoader(ragApplication, file, base, forceReload))
|
||||
const totalFiles = files.length
|
||||
let processedFiles = 0
|
||||
const loaderPromises = files.map(async (file) => {
|
||||
const result = await addFileLoader(ragApplication, file, base, forceReload)
|
||||
processedFiles++
|
||||
|
||||
sendDirectoryProcessingPercent(totalFiles, processedFiles)
|
||||
return result
|
||||
})
|
||||
const loaderResults = await Promise.all(loaderPromises)
|
||||
const uniqueIds = loaderResults.map((result) => result.uniqueId)
|
||||
return {
|
||||
|
||||
@@ -22,7 +22,11 @@ function getShortcutHandler(shortcut: Shortcut) {
|
||||
case 'show_app':
|
||||
return (window: BrowserWindow) => {
|
||||
if (window.isVisible()) {
|
||||
window.hide()
|
||||
if (window.isFocused()) {
|
||||
window.hide()
|
||||
} else {
|
||||
window.focus()
|
||||
}
|
||||
} else {
|
||||
window.show()
|
||||
window.focus()
|
||||
@@ -43,8 +47,8 @@ function formatShortcutKey(shortcut: string[]): string {
|
||||
|
||||
function handleZoom(delta: number) {
|
||||
return (window: BrowserWindow) => {
|
||||
const currentZoom = window.webContents.getZoomFactor()
|
||||
const newZoom = currentZoom + delta
|
||||
const currentZoom = configManager.getZoomFactor()
|
||||
const newZoom = Number((currentZoom + delta).toFixed(1))
|
||||
if (newZoom >= 0.1 && newZoom <= 5.0) {
|
||||
window.webContents.setZoomFactor(newZoom)
|
||||
configManager.setZoomFactor(newZoom)
|
||||
@@ -52,8 +56,65 @@ function handleZoom(delta: number) {
|
||||
}
|
||||
}
|
||||
|
||||
const convertShortcutRecordedByKeyboardEventKeyValueToElectronGlobalShortcutFormat = (
|
||||
shortcut: string | string[]
|
||||
): string => {
|
||||
const accelerator = (() => {
|
||||
if (Array.isArray(shortcut)) {
|
||||
return shortcut
|
||||
} else {
|
||||
return shortcut.split('+').map((key) => key.trim())
|
||||
}
|
||||
})()
|
||||
|
||||
return accelerator
|
||||
.map((key) => {
|
||||
switch (key) {
|
||||
case 'Command':
|
||||
return 'CommandOrControl'
|
||||
case 'Control':
|
||||
return 'Control'
|
||||
case 'Ctrl':
|
||||
return 'Control'
|
||||
case 'ArrowUp':
|
||||
return 'Up'
|
||||
case 'ArrowDown':
|
||||
return 'Down'
|
||||
case 'ArrowLeft':
|
||||
return 'Left'
|
||||
case 'ArrowRight':
|
||||
return 'Right'
|
||||
case 'AltGraph':
|
||||
return 'Alt'
|
||||
case 'Slash':
|
||||
return '/'
|
||||
case 'Semicolon':
|
||||
return ';'
|
||||
case 'BracketLeft':
|
||||
return '['
|
||||
case 'BracketRight':
|
||||
return ']'
|
||||
case 'Backslash':
|
||||
return '\\'
|
||||
case 'Quote':
|
||||
return "'"
|
||||
case 'Comma':
|
||||
return ','
|
||||
case 'Minus':
|
||||
return '-'
|
||||
case 'Equal':
|
||||
return '='
|
||||
default:
|
||||
return key
|
||||
}
|
||||
})
|
||||
.join('+')
|
||||
}
|
||||
|
||||
export function registerShortcuts(window: BrowserWindow) {
|
||||
window.webContents.setZoomFactor(configManager.getZoomFactor())
|
||||
window.once('ready-to-show', () => {
|
||||
window.webContents.setZoomFactor(configManager.getZoomFactor())
|
||||
})
|
||||
|
||||
const register = () => {
|
||||
if (window.isDestroyed()) return
|
||||
@@ -75,11 +136,11 @@ export function registerShortcuts(window: BrowserWindow) {
|
||||
|
||||
const accelerator = formatShortcutKey(shortcut.shortcut)
|
||||
|
||||
if (shortcut.key === 'show_app') {
|
||||
if (shortcut.key === 'show_app' && shortcut.enabled) {
|
||||
showAppAccelerator = accelerator
|
||||
}
|
||||
|
||||
if (shortcut.key === 'mini_window') {
|
||||
if (shortcut.key === 'mini_window' && shortcut.enabled) {
|
||||
showMiniWindowAccelerator = accelerator
|
||||
}
|
||||
|
||||
@@ -100,7 +161,10 @@ export function registerShortcuts(window: BrowserWindow) {
|
||||
}
|
||||
|
||||
if (shortcut.enabled) {
|
||||
globalShortcut.register(formatShortcutKey(shortcut.shortcut), () => handler(window))
|
||||
const accelerator = convertShortcutRecordedByKeyboardEventKeyValueToElectronGlobalShortcutFormat(
|
||||
shortcut.shortcut
|
||||
)
|
||||
globalShortcut.register(accelerator, () => handler(window))
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`[ShortcutService] Failed to register shortcut ${shortcut.key}`)
|
||||
@@ -116,12 +180,16 @@ export function registerShortcuts(window: BrowserWindow) {
|
||||
|
||||
if (showAppAccelerator) {
|
||||
const handler = getShortcutHandler({ key: 'show_app' } as Shortcut)
|
||||
handler && globalShortcut.register(showAppAccelerator, () => handler(window))
|
||||
const accelerator =
|
||||
convertShortcutRecordedByKeyboardEventKeyValueToElectronGlobalShortcutFormat(showAppAccelerator)
|
||||
handler && globalShortcut.register(accelerator, () => handler(window))
|
||||
}
|
||||
|
||||
if (showMiniWindowAccelerator) {
|
||||
const handler = getShortcutHandler({ key: 'mini_window' } as Shortcut)
|
||||
handler && globalShortcut.register(showMiniWindowAccelerator, () => handler(window))
|
||||
const accelerator =
|
||||
convertShortcutRecordedByKeyboardEventKeyValueToElectronGlobalShortcutFormat(showMiniWindowAccelerator)
|
||||
handler && globalShortcut.register(accelerator, () => handler(window))
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('[ShortcutService] Failed to unregister shortcuts')
|
||||
|
||||
@@ -28,6 +28,7 @@ export class WindowService {
|
||||
|
||||
public createMainWindow(): BrowserWindow {
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.show()
|
||||
return this.mainWindow
|
||||
}
|
||||
|
||||
@@ -248,17 +249,32 @@ export class WindowService {
|
||||
event.preventDefault()
|
||||
mainWindow.hide()
|
||||
})
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
this.mainWindow = null
|
||||
})
|
||||
|
||||
mainWindow.on('show', () => {
|
||||
if (this.miniWindow && !this.miniWindow.isDestroyed()) {
|
||||
this.miniWindow.hide()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public showMainWindow() {
|
||||
if (this.mainWindow) {
|
||||
if (this.miniWindow && !this.miniWindow.isDestroyed()) {
|
||||
this.miniWindow.hide()
|
||||
}
|
||||
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
if (this.mainWindow.isMinimized()) {
|
||||
return this.mainWindow.restore()
|
||||
this.mainWindow.restore()
|
||||
}
|
||||
this.mainWindow.show()
|
||||
this.mainWindow.focus()
|
||||
} else {
|
||||
this.createMainWindow()
|
||||
this.mainWindow = this.createMainWindow()
|
||||
this.mainWindow.focus()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +285,10 @@ export class WindowService {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.selectionMenuWindow) {
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.hide()
|
||||
}
|
||||
if (this.selectionMenuWindow && !this.selectionMenuWindow.isDestroyed()) {
|
||||
this.selectionMenuWindow.hide()
|
||||
}
|
||||
|
||||
|
||||
3
src/preload/index.d.ts
vendored
@@ -119,6 +119,9 @@ declare global {
|
||||
encrypt: (text: string, secretKey: string, iv: string) => Promise<{ iv: string; encryptedData: string }>
|
||||
decrypt: (encryptedData: string, iv: string, secretKey: string) => Promise<string>
|
||||
}
|
||||
shell: {
|
||||
openExternal: (url: string, options?: OpenExternalOptions) => Promise<void>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import { FileType, KnowledgeBaseParams, KnowledgeItem, Shortcut, WebDavConfig } from '@types'
|
||||
import { contextBridge, ipcRenderer, OpenDialogOptions } from 'electron'
|
||||
import { contextBridge, ipcRenderer, OpenDialogOptions, shell } from 'electron'
|
||||
|
||||
// Custom APIs for renderer
|
||||
const api = {
|
||||
@@ -104,6 +104,9 @@ const api = {
|
||||
encrypt: (text: string, secretKey: string, iv: string) => ipcRenderer.invoke('aes:encrypt', text, secretKey, iv),
|
||||
decrypt: (encryptedData: string, iv: string, secretKey: string) =>
|
||||
ipcRenderer.invoke('aes:decrypt', encryptedData, iv, secretKey)
|
||||
},
|
||||
shell: {
|
||||
openExternal: shell.openExternal
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio</title>
|
||||
|
||||
<style>
|
||||
html,
|
||||
|
||||
BIN
src/renderer/src/assets/images/apps/coze.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
BIN
src/renderer/src/assets/images/apps/nm.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
7
src/renderer/src/assets/images/apps/notebooklm.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="512px" height="512px" viewBox="0 0 512 512" version="1.1">
|
||||
<g id="surface1">
|
||||
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(85.09804%,85.09804%,85.09804%);fill-opacity:1;" d="M 512 256 C 512 114.613281 397.386719 0 256 0 C 114.613281 0 0 114.613281 0 256 C 0 397.386719 114.613281 512 256 512 C 397.386719 512 512 397.386719 512 256 Z M 512 256 "/>
|
||||
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,0%);fill-opacity:1;" d="M 256.011719 114.753906 C 167.050781 114.753906 94.945312 186.261719 94.945312 274.507812 L 94.945312 350.988281 L 124.628906 350.988281 L 124.628906 343.359375 C 124.628906 307.574219 153.867188 278.558594 189.941406 278.558594 C 226.015625 278.558594 255.253906 307.585938 255.253906 343.359375 L 255.253906 350.988281 L 284.9375 350.988281 L 284.9375 343.359375 C 284.9375 291.308594 242.390625 249.140625 189.929688 249.140625 C 169.503906 249.140625 150.582031 255.53125 135.082031 266.433594 C 151.296875 234.464844 184.691406 212.535156 223.242188 212.535156 C 277.707031 212.535156 321.867188 256.339844 321.867188 310.355469 L 321.867188 350.996094 L 351.5625 350.996094 L 351.5625 310.355469 C 351.5625 240.074219 294.113281 183.082031 223.242188 183.082031 C 191.382812 183.082031 162.230469 194.601562 139.785156 213.683594 C 161.824219 172.375 205.578125 144.214844 256 144.214844 C 328.566406 144.214844 387.382812 202.550781 387.382812 274.515625 L 387.382812 350.996094 L 417.066406 350.996094 L 417.066406 274.515625 C 417.066406 186.28125 344.960938 114.761719 256 114.761719 Z M 256.011719 114.753906 "/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src/renderer/src/assets/images/apps/xiaoyi.webp
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src/renderer/src/assets/images/models/perplexity.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
src/renderer/src/assets/images/providers/DMXAPI.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
src/renderer/src/assets/images/providers/cohere.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
BIN
src/renderer/src/assets/images/providers/lmstudio.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src/renderer/src/assets/images/providers/modelscope.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 6.0 KiB |
BIN
src/renderer/src/assets/images/providers/perplexity.webp
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
@@ -256,6 +256,10 @@ body,
|
||||
border: 1px solid var(--color-background-mute);
|
||||
}
|
||||
}
|
||||
.group-menu-bar {
|
||||
margin-left: 0;
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
code {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@@ -64,6 +64,10 @@
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&:has(+ ul) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
|
||||
@@ -46,23 +46,28 @@ const DragableList: FC<Props<any>> = ({
|
||||
<DragDropContext onDragStart={onDragStart} onDragEnd={_onDragEnd}>
|
||||
<Droppable droppableId="droppable" {...droppableProps}>
|
||||
{(provided) => (
|
||||
<div {...provided.droppableProps} ref={provided.innerRef} style={{ ...style }}>
|
||||
<div {...provided.droppableProps} ref={provided.innerRef} style={style}>
|
||||
{list.map((item, index) => {
|
||||
const id = item.id || item
|
||||
return (
|
||||
<Draggable key={`draggable_${id}_${index}`} draggableId={id} index={index} {...droppableProps}>
|
||||
<Draggable key={`draggable_${id}_${index}`} draggableId={id} index={index}>
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
style={{ ...provided.draggableProps.style, marginBottom: 8, ...listStyle }}>
|
||||
style={{
|
||||
...listStyle,
|
||||
...provided.draggableProps.style,
|
||||
marginBottom: 8
|
||||
}}>
|
||||
{children(item, index)}
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
)
|
||||
})}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
|
||||
26
src/renderer/src/components/Ellipsis/index.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
type Props = {
|
||||
text: string | number
|
||||
maxLine?: number
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
const Ellipsis = (props: Props) => {
|
||||
const { text, maxLine = 1, ...rest } = props
|
||||
return (
|
||||
<EllipsisContainer maxLine={maxLine} {...rest}>
|
||||
{text}
|
||||
</EllipsisContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const EllipsisContainer = styled.div<{ maxLine: number }>`
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: ${({ maxLine }) => maxLine};
|
||||
`
|
||||
|
||||
export default Ellipsis
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable react/no-unknown-property */
|
||||
import { CloseOutlined, ExportOutlined, PushpinOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { isMac, isWindows } from '@renderer/config/constant'
|
||||
import { AppLogo } from '@renderer/config/env'
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { useBridge } from '@renderer/hooks/useBridge'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
@@ -49,7 +50,10 @@ const PopupContainer: React.FC<Props> = ({ app, resolve }) => {
|
||||
}
|
||||
|
||||
const onOpenLink = () => {
|
||||
window.api.openWebsite(app.url)
|
||||
if (webviewRef.current) {
|
||||
const currentUrl = webviewRef.current.getURL()
|
||||
window.api.openWebsite(currentUrl)
|
||||
}
|
||||
}
|
||||
|
||||
const onTogglePin = () => {
|
||||
@@ -236,6 +240,10 @@ export default class MinApp {
|
||||
await delay(0)
|
||||
}
|
||||
|
||||
if (!app.logo) {
|
||||
app.logo = AppLogo
|
||||
}
|
||||
|
||||
MinApp.app = app
|
||||
store.dispatch(setMinappShow(true))
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import App from '@renderer/pages/apps/App'
|
||||
import { Popover } from 'antd'
|
||||
import { Empty } from 'antd'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { FC, useState } from 'react'
|
||||
import { FC, useState, useEffect } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -26,8 +26,22 @@ const MinAppsPopover: FC<Props> = ({ children }) => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const [maxHeight, setMaxHeight] = useState(window.innerHeight - 100);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setMaxHeight(window.innerHeight - 100);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const content = (
|
||||
<PopoverContent>
|
||||
<PopoverContent maxHeight={maxHeight}>
|
||||
<AppsContainer>
|
||||
{minapps.map((app) => (
|
||||
<App key={app.id} app={app} onClick={handleClose} size={50} />
|
||||
@@ -54,12 +68,15 @@ const MinAppsPopover: FC<Props> = ({ children }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const PopoverContent = styled(Scrollbar)``
|
||||
|
||||
const AppsContainer = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(90px, 1fr));
|
||||
gap: 18px;
|
||||
const PopoverContent = styled(Scrollbar)<{ maxHeight: number }>`
|
||||
max-height: ${(props) => props.maxHeight}px;
|
||||
overflow-y: auto;
|
||||
`
|
||||
|
||||
const AppsContainer = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(90px, 1fr));
|
||||
gap: 18px;
|
||||
`;
|
||||
|
||||
export default MinAppsPopover
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Input, Modal } from 'antd'
|
||||
import { TextAreaProps } from 'antd/es/input'
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
import { Box } from '../Layout'
|
||||
import { TopView } from '../TopView'
|
||||
@@ -27,6 +27,7 @@ const PromptPopupContainer: React.FC<Props> = ({
|
||||
}) => {
|
||||
const [value, setValue] = useState(defaultValue)
|
||||
const [open, setOpen] = useState(true)
|
||||
const textAreaRef = useRef<any>(null)
|
||||
|
||||
const onOk = () => {
|
||||
setOpen(false)
|
||||
@@ -41,17 +42,35 @@ const PromptPopupContainer: React.FC<Props> = ({
|
||||
resolve(null)
|
||||
}
|
||||
|
||||
const handleAfterOpenChange = (visible: boolean) => {
|
||||
if (visible) {
|
||||
const textArea = textAreaRef.current?.resizableTextArea?.textArea
|
||||
if (textArea) {
|
||||
textArea.focus()
|
||||
const length = textArea.value.length
|
||||
textArea.setSelectionRange(length, length)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PromptPopup.hide = onCancel
|
||||
|
||||
return (
|
||||
<Modal title={title} open={open} onOk={onOk} onCancel={onCancel} afterClose={onClose} centered>
|
||||
<Modal
|
||||
title={title}
|
||||
open={open}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
afterOpenChange={handleAfterOpenChange}
|
||||
centered>
|
||||
<Box mb={8}>{message}</Box>
|
||||
<Input.TextArea
|
||||
ref={textAreaRef}
|
||||
placeholder={inputPlaceholder}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
allowClear
|
||||
autoFocus
|
||||
onPressEnter={onOk}
|
||||
rows={1}
|
||||
{...inputProps}
|
||||
|
||||
@@ -51,6 +51,17 @@ const PopupContainer: React.FC<Props> = ({ text, textareaProps, modalProps, reso
|
||||
setTimeout(resizeTextArea, 0)
|
||||
}, [])
|
||||
|
||||
const handleAfterOpenChange = (visible: boolean) => {
|
||||
if (visible) {
|
||||
const textArea = textareaRef.current?.resizableTextArea?.textArea
|
||||
if (textArea) {
|
||||
textArea.focus()
|
||||
const length = textArea.value.length
|
||||
textArea.setSelectionRange(length, length)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextEditPopup.hide = onCancel
|
||||
|
||||
return (
|
||||
@@ -65,6 +76,7 @@ const PopupContainer: React.FC<Props> = ({ text, textareaProps, modalProps, reso
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
afterOpenChange={handleAfterOpenChange}
|
||||
centered>
|
||||
<TextArea
|
||||
ref={textareaRef}
|
||||
|
||||
@@ -50,6 +50,7 @@ const Sidebar: FC = () => {
|
||||
|
||||
const onOpenDocs = () => {
|
||||
MinApp.start({
|
||||
id: 'docs',
|
||||
name: t('docs.title'),
|
||||
url: 'https://docs.cherry-ai.com/',
|
||||
logo: AppLogo
|
||||
@@ -77,9 +78,11 @@ const Sidebar: FC = () => {
|
||||
</AppsContainer>
|
||||
)}
|
||||
</MainMenusContainer>
|
||||
<Menus onClick={MinApp.onClose}>
|
||||
<Menus>
|
||||
<Tooltip title={t('docs.title')} mouseEnterDelay={0.8} placement="right">
|
||||
<Icon onClick={onOpenDocs}>
|
||||
<Icon
|
||||
onClick={onOpenDocs}
|
||||
className={minappShow && MinApp.app?.url === 'https://docs.cherry-ai.com/' ? 'active' : ''}>
|
||||
<QuestionCircleOutlined />
|
||||
</Icon>
|
||||
</Tooltip>
|
||||
@@ -93,8 +96,14 @@ const Sidebar: FC = () => {
|
||||
</Icon>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('settings.title')} mouseEnterDelay={0.8} placement="right">
|
||||
<StyledLink onClick={() => to(isLocalAi ? '/settings/assistant' : '/settings/provider')}>
|
||||
<Icon className={pathname.startsWith('/settings') ? 'active' : ''}>
|
||||
<StyledLink
|
||||
onClick={async () => {
|
||||
if (minappShow) {
|
||||
await MinApp.close()
|
||||
}
|
||||
await to(isLocalAi ? '/settings/assistant' : '/settings/provider')
|
||||
}}>
|
||||
<Icon className={pathname.startsWith('/settings') && !minappShow ? 'active' : ''}>
|
||||
<i className="iconfont icon-setting" />
|
||||
</Icon>
|
||||
</StyledLink>
|
||||
@@ -108,10 +117,11 @@ const MainMenus: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { pathname } = useLocation()
|
||||
const { sidebarIcons } = useSettings()
|
||||
const { minappShow } = useRuntime()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const isRoute = (path: string): string => (pathname === path ? 'active' : '')
|
||||
const isRoutes = (path: string): string => (pathname.startsWith(path) ? 'active' : '')
|
||||
const isRoute = (path: string): string => (pathname === path && !minappShow ? 'active' : '')
|
||||
const isRoutes = (path: string): string => (pathname.startsWith(path) && !minappShow ? 'active' : '')
|
||||
|
||||
const iconMap = {
|
||||
assistants: <i className="iconfont icon-chat" />,
|
||||
@@ -139,7 +149,13 @@ const MainMenus: FC = () => {
|
||||
|
||||
return (
|
||||
<Tooltip key={icon} title={t(`${icon}.title`)} mouseEnterDelay={0.8} placement="right">
|
||||
<StyledLink onClick={() => navigate(path)}>
|
||||
<StyledLink
|
||||
onClick={async () => {
|
||||
if (minappShow) {
|
||||
await MinApp.close()
|
||||
}
|
||||
navigate(path)
|
||||
}}>
|
||||
<Icon className={isActive}>{iconMap[icon]}</Icon>
|
||||
</StyledLink>
|
||||
</Tooltip>
|
||||
@@ -150,6 +166,7 @@ const MainMenus: FC = () => {
|
||||
const PinnedApps: FC = () => {
|
||||
const { pinned, updatePinnedMinapps } = useMinapps()
|
||||
const { t } = useTranslation()
|
||||
const { minappShow } = useRuntime()
|
||||
|
||||
return (
|
||||
<DragableList list={pinned} onUpdate={updatePinnedMinapps} listStyle={{ marginBottom: 5 }}>
|
||||
@@ -164,11 +181,12 @@ const PinnedApps: FC = () => {
|
||||
}
|
||||
}
|
||||
]
|
||||
const isActive = minappShow && MinApp.app?.id === app.id
|
||||
return (
|
||||
<Tooltip key={app.id} title={app.name} mouseEnterDelay={0.8} placement="right">
|
||||
<StyledLink>
|
||||
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
|
||||
<Icon onClick={() => MinApp.start(app)}>
|
||||
<Icon onClick={() => MinApp.start(app)} className={isActive ? 'active' : ''}>
|
||||
<MinAppIcon size={20} app={app} style={{ borderRadius: 6 }} />
|
||||
</Icon>
|
||||
</Dropdown>
|
||||
|
||||
@@ -2,6 +2,7 @@ export const DEFAULT_TEMPERATURE = 1.0
|
||||
export const DEFAULT_CONTEXTCOUNT = 5
|
||||
export const DEFAULT_MAX_TOKENS = 4096
|
||||
export const DEFAULT_KNOWLEDGE_DOCUMENT_COUNT = 6
|
||||
export const DEFAULT_KNOWLEDGE_THRESHOLD = 0.0
|
||||
export const FONT_FAMILY =
|
||||
"Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif"
|
||||
|
||||
|
||||
@@ -226,6 +226,18 @@ export const EMBEDDING_MODELS = [
|
||||
{
|
||||
id: 'text-embedding-004',
|
||||
max_context: 2048
|
||||
},
|
||||
{
|
||||
id: 'deepset-mxbai-embed-de-large-v1',
|
||||
max_context: 512
|
||||
},
|
||||
{
|
||||
id: 'mxbai-embed-large-v1',
|
||||
max_context: 512
|
||||
},
|
||||
{
|
||||
id: 'mxbai-embed-2d-large-v1',
|
||||
max_context: 512
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import AIStudioLogo from '@renderer/assets/images/apps/aistudio.svg?url'
|
||||
import BaiduAiAppLogo from '@renderer/assets/images/apps/baidu-ai.png?url'
|
||||
import BaicuanAppLogo from '@renderer/assets/images/apps/baixiaoying.webp?url'
|
||||
import BoltAppLogo from '@renderer/assets/images/apps/bolt.svg?url'
|
||||
import CozeAppLogo from '@renderer/assets/images/apps/coze.webp?url'
|
||||
import DevvAppLogo from '@renderer/assets/images/apps/devv.png?url'
|
||||
import DoubaoAppLogo from '@renderer/assets/images/apps/doubao.png?url'
|
||||
import DuckDuckGoAppLogo from '@renderer/assets/images/apps/duckduckgo.webp?url'
|
||||
@@ -16,7 +17,9 @@ import HikaLogo from '@renderer/assets/images/apps/hika.webp?url'
|
||||
import HuggingChatLogo from '@renderer/assets/images/apps/huggingchat.svg?url'
|
||||
import KimiAppLogo from '@renderer/assets/images/apps/kimi.jpg?url'
|
||||
import MetasoAppLogo from '@renderer/assets/images/apps/metaso.webp?url'
|
||||
import NamiAiSearchLogo from '@renderer/assets/images/apps/nm.webp?url'
|
||||
import NamiAiLogo from '@renderer/assets/images/apps/nm.png?url'
|
||||
import NamiAiSearchLogo from '@renderer/assets/images/apps/nm-search.webp?url'
|
||||
import NotebookLMAppLogo from '@renderer/assets/images/apps/notebooklm.svg?url'
|
||||
import PerplexityAppLogo from '@renderer/assets/images/apps/perplexity.webp?url'
|
||||
import PoeAppLogo from '@renderer/assets/images/apps/poe.webp?url'
|
||||
import ZhipuProviderLogo from '@renderer/assets/images/apps/qingyan.png?url'
|
||||
@@ -26,6 +29,7 @@ import SparkDeskAppLogo from '@renderer/assets/images/apps/sparkdesk.png?url'
|
||||
import ThinkAnyLogo from '@renderer/assets/images/apps/thinkany.webp?url'
|
||||
import TiangongAiLogo from '@renderer/assets/images/apps/tiangong.png?url'
|
||||
import WanZhiAppLogo from '@renderer/assets/images/apps/wanzhi.jpg?url'
|
||||
import XiaoYiAppLogo from '@renderer/assets/images/apps/xiaoyi.webp?url'
|
||||
import TencentYuanbaoAppLogo from '@renderer/assets/images/apps/yuanbao.png?url'
|
||||
import YuewenAppLogo from '@renderer/assets/images/apps/yuewen.png?url'
|
||||
import ZhihuAppLogo from '@renderer/assets/images/apps/zhihu.png?url'
|
||||
@@ -167,7 +171,7 @@ export const DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
},
|
||||
{
|
||||
id: 'perplexity',
|
||||
name: 'perplexity',
|
||||
name: 'Perplexity',
|
||||
logo: PerplexityAppLogo,
|
||||
url: 'https://www.perplexity.ai/'
|
||||
},
|
||||
@@ -220,6 +224,13 @@ export const DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
},
|
||||
{
|
||||
id: 'nm',
|
||||
name: '纳米AI',
|
||||
logo: NamiAiLogo,
|
||||
url: 'https://bot.n.cn/',
|
||||
bodered: true
|
||||
},
|
||||
{
|
||||
id: 'nm-search',
|
||||
name: '纳米AI搜索',
|
||||
logo: NamiAiSearchLogo,
|
||||
url: 'https://www.n.cn/',
|
||||
@@ -283,6 +294,26 @@ export const DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
name: 'AI Studio',
|
||||
logo: AIStudioLogo,
|
||||
url: 'https://aistudio.google.com/'
|
||||
},
|
||||
{
|
||||
id: 'xiaoyi',
|
||||
name: '小艺',
|
||||
logo: XiaoYiAppLogo,
|
||||
url: 'https://xiaoyi.huawei.com/chat/',
|
||||
bodered: true
|
||||
},
|
||||
{
|
||||
id: 'notebooklm',
|
||||
name: 'NotebookLM',
|
||||
logo: NotebookLMAppLogo,
|
||||
url: 'https://notebooklm.google.com/'
|
||||
},
|
||||
{
|
||||
id: 'coze',
|
||||
name: 'Coze',
|
||||
logo: CozeAppLogo,
|
||||
url: 'https://www.coze.com/space',
|
||||
bodered: true
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -99,6 +99,8 @@ import NvidiaModelLogo from '@renderer/assets/images/models/nvidia.png'
|
||||
import NvidiaModelLogoDark from '@renderer/assets/images/models/nvidia_dark.png'
|
||||
import PalmModelLogo from '@renderer/assets/images/models/palm.png'
|
||||
import PalmModelLogoDark from '@renderer/assets/images/models/palm_dark.png'
|
||||
import PerplexityModelLogo from '@renderer/assets/images/models/perplexity.png'
|
||||
import PerplexityModelLogoDark from '@renderer/assets/images/models/perplexity.png'
|
||||
import PixtralModelLogo from '@renderer/assets/images/models/pixtral.png'
|
||||
import PixtralModelLogoDark from '@renderer/assets/images/models/pixtral_dark.png'
|
||||
import QwenModelLogo from '@renderer/assets/images/models/qwen.png'
|
||||
@@ -140,6 +142,7 @@ const visionAllowedModels = [
|
||||
'glm-4v',
|
||||
'qwen-vl',
|
||||
'qwen2-vl',
|
||||
'qwen2.5-vl',
|
||||
'internvl2',
|
||||
'grok-vision-beta',
|
||||
'pixtral',
|
||||
@@ -147,7 +150,8 @@ const visionAllowedModels = [
|
||||
'gpt-4o(?:-[\\w-]+)?',
|
||||
'chatgpt-4o(?:-[\\w-]+)?',
|
||||
'o1(?:-[\\w-]+)?',
|
||||
'deepseek-vl(?:[\\w-]+)?'
|
||||
'deepseek-vl(?:[\\w-]+)?',
|
||||
'kimi-latest'
|
||||
]
|
||||
|
||||
const visionExcludedModels = ['gpt-4-\\d+-preview', 'gpt-4-turbo-preview', 'gpt-4-32k', 'gpt-4-\\d+']
|
||||
@@ -158,7 +162,7 @@ export const VISION_REGEX = new RegExp(
|
||||
)
|
||||
|
||||
export const TEXT_TO_IMAGE_REGEX = /flux|diffusion|stabilityai|sd-|dall|cogview|janus/i
|
||||
export const REASONING_REGEX = /^(o\d+(?:-[\w-]+)?|.*\breasoner\b.*|.*-[rR]\d+.*)$/i
|
||||
export const REASONING_REGEX = /^(o\d+(?:-[\w-]+)?|.*\b(?:reasoner|thinking)\b.*|.*-[rR]\d+.*)$/i
|
||||
|
||||
export const EMBEDDING_REGEX =
|
||||
/(?:^text-|embed|rerank|davinci|babbage|bge-|e5-|LLM2Vec|retrieval|uae-|gte-|jina-clip|jina-embeddings)/i
|
||||
@@ -175,6 +179,7 @@ export function getModelLogo(modelId: string) {
|
||||
pixtral: isLight ? PixtralModelLogo : PixtralModelLogoDark,
|
||||
jina: isLight ? JinaModelLogo : JinaModelLogoDark,
|
||||
abab: isLight ? MinimaxModelLogo : MinimaxModelLogoDark,
|
||||
minimax: isLight ? MinimaxModelLogo : MinimaxModelLogoDark,
|
||||
o3: isLight ? ChatGPTo1ModelLogo : ChatGPTo1ModelLogoDark,
|
||||
o1: isLight ? ChatGPTo1ModelLogo : ChatGPTo1ModelLogoDark,
|
||||
'gpt-3': isLight ? ChatGPT35ModelLogo : ChatGPT35ModelLogoDark,
|
||||
@@ -184,18 +189,23 @@ export function getModelLogo(modelId: string) {
|
||||
'babbage-': isLight ? ChatGptModelLogo : ChatGptModelLogoDakr,
|
||||
'sora-': isLight ? ChatGptModelLogo : ChatGptModelLogoDakr,
|
||||
'omni-': isLight ? ChatGptModelLogo : ChatGptModelLogoDakr,
|
||||
'Embedding-V1': isLight ? WenxinModelLogo : WenxinModelLogoDark,
|
||||
'text-embedding-v': isLight ? QwenModelLogo : QwenModelLogoDark,
|
||||
'text-embedding': isLight ? ChatGptModelLogo : ChatGptModelLogoDakr,
|
||||
'davinci-': isLight ? ChatGptModelLogo : ChatGptModelLogoDakr,
|
||||
glm: isLight ? ChatGLMModelLogo : ChatGLMModelLogoDark,
|
||||
deepseek: isLight ? DeepSeekModelLogo : DeepSeekModelLogoDark,
|
||||
qwen: isLight ? QwenModelLogo : QwenModelLogoDark,
|
||||
qwq: isLight ? QwenModelLogo : QwenModelLogoDark,
|
||||
"qwq-": isLight ? QwenModelLogo : QwenModelLogoDark,
|
||||
"qvq-": isLight ? QwenModelLogo : QwenModelLogoDark,
|
||||
Omni: isLight ? QwenModelLogo : QwenModelLogoDark,
|
||||
gemma: isLight ? GemmaModelLogo : GemmaModelLogoDark,
|
||||
'yi-': isLight ? YiModelLogo : YiModelLogoDark,
|
||||
llama: isLight ? LlamaModelLogo : LlamaModelLogoDark,
|
||||
mixtral: isLight ? MistralModelLogo : MistralModelLogo,
|
||||
mistral: isLight ? MistralModelLogo : MistralModelLogoDark,
|
||||
moonshot: isLight ? MoonshotModelLogo : MoonshotModelLogoDark,
|
||||
kimi: isLight ? MoonshotModelLogo : MoonshotModelLogoDark,
|
||||
phi: isLight ? MicrosoftModelLogo : MicrosoftModelLogoDark,
|
||||
baichuan: isLight ? BaichuanModelLogo : BaichuanModelLogoDark,
|
||||
claude: isLight ? ClaudeModelLogo : ClaudeModelLogoDark,
|
||||
@@ -265,6 +275,8 @@ export function getModelLogo(modelId: string) {
|
||||
'google/': isLight ? GoogleModelLogo : GoogleModelLogoDark,
|
||||
hugging: isLight ? HuggingfaceModelLogo : HuggingfaceModelLogoDark,
|
||||
embedding: isLight ? EmbeddingModelLogo : EmbeddingModelLogoDark,
|
||||
perplexity: isLight ? PerplexityModelLogo : PerplexityModelLogoDark,
|
||||
sonar: isLight ? PerplexityModelLogo : PerplexityModelLogoDark,
|
||||
'bge-': BgeModelLogo
|
||||
}
|
||||
|
||||
@@ -317,6 +329,7 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
}
|
||||
],
|
||||
ollama: [],
|
||||
lmstudio: [],
|
||||
silicon: [
|
||||
{
|
||||
id: 'deepseek-ai/DeepSeek-R1',
|
||||
@@ -658,6 +671,42 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
}
|
||||
],
|
||||
ocoolai: [
|
||||
{
|
||||
id: 'deepseek-chat',
|
||||
provider: 'ocoolai',
|
||||
name: 'deepseek-chat',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'deepseek-reasoner',
|
||||
provider: 'ocoolai',
|
||||
name: 'deepseek-reasoner',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'deepseek-ai/DeepSeek-R1',
|
||||
provider: 'ocoolai',
|
||||
name: 'deepseek-ai/DeepSeek-R1',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'HiSpeed/DeepSeek-R1',
|
||||
provider: 'ocoolai',
|
||||
name: 'HiSpeed/DeepSeek-R1',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'ocoolAI/DeepSeek-R1',
|
||||
provider: 'ocoolai',
|
||||
name: 'ocoolAI/DeepSeek-R1',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'Azure/DeepSeek-R1',
|
||||
provider: 'ocoolai',
|
||||
name: 'Azure/DeepSeek-R1',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'gpt-4o',
|
||||
provider: 'ocoolai',
|
||||
@@ -670,12 +719,6 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
name: 'gpt-4o-all',
|
||||
group: 'OpenAI'
|
||||
},
|
||||
{
|
||||
id: 'gpt-4-all',
|
||||
provider: 'ocoolai',
|
||||
name: 'gpt-4-all',
|
||||
group: 'OpenAI'
|
||||
},
|
||||
{
|
||||
id: 'gpt-4o-mini',
|
||||
provider: 'ocoolai',
|
||||
@@ -688,12 +731,6 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
name: 'gpt-4',
|
||||
group: 'OpenAI'
|
||||
},
|
||||
{
|
||||
id: 'gpt-4-turbo',
|
||||
provider: 'ocoolai',
|
||||
name: 'gpt-4-turbo',
|
||||
group: 'OpenAI'
|
||||
},
|
||||
{
|
||||
id: 'o1-preview',
|
||||
provider: 'ocoolai',
|
||||
@@ -706,12 +743,6 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
name: 'o1-mini',
|
||||
group: 'OpenAI'
|
||||
},
|
||||
{
|
||||
id: 'gpt-3.5-turbo',
|
||||
provider: 'ocoolai',
|
||||
name: 'gpt-3.5-turbo',
|
||||
group: 'OpenAI'
|
||||
},
|
||||
{
|
||||
id: 'claude-3-5-sonnet-20240620',
|
||||
provider: 'ocoolai',
|
||||
@@ -719,21 +750,9 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
group: 'Anthropic'
|
||||
},
|
||||
{
|
||||
id: 'claude-3-opus-20240229',
|
||||
id: 'claude-3-5-haiku-20241022',
|
||||
provider: 'ocoolai',
|
||||
name: 'claude-3-opus-20240229',
|
||||
group: 'Anthropic'
|
||||
},
|
||||
{
|
||||
id: 'claude-3-sonnet-20240229',
|
||||
provider: 'ocoolai',
|
||||
name: 'claude-3-sonnet-20240229',
|
||||
group: 'Anthropic'
|
||||
},
|
||||
{
|
||||
id: 'claude-3-haiku-20240307',
|
||||
provider: 'ocoolai',
|
||||
name: 'claude-3-haiku-20240307',
|
||||
name: 'claude-3-5-haiku-20241022',
|
||||
group: 'Anthropic'
|
||||
},
|
||||
{
|
||||
@@ -777,6 +796,30 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
provider: 'ocoolai',
|
||||
name: 'gemma-2-9b-it',
|
||||
group: 'Gemma'
|
||||
},
|
||||
{
|
||||
id: 'Doubao-embedding',
|
||||
provider: 'ocoolai',
|
||||
name: 'Doubao-embedding',
|
||||
group: 'Doubao'
|
||||
},
|
||||
{
|
||||
id: 'text-embedding-3-large',
|
||||
provider: 'ocoolai',
|
||||
name: 'text-embedding-3-large',
|
||||
group: 'Embedding'
|
||||
},
|
||||
{
|
||||
id: 'text-embedding-3-small',
|
||||
provider: 'ocoolai',
|
||||
name: 'text-embedding-3-small',
|
||||
group: 'Embedding'
|
||||
},
|
||||
{
|
||||
id: 'text-embedding-v2',
|
||||
provider: 'ocoolai',
|
||||
name: 'text-embedding-v2',
|
||||
group: 'Embedding'
|
||||
}
|
||||
],
|
||||
github: [
|
||||
@@ -896,6 +939,38 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
group: 'Baichuan3'
|
||||
}
|
||||
],
|
||||
modelscope: [
|
||||
{
|
||||
id: 'Qwen/Qwen2.5-72B-Instruct',
|
||||
name: 'Qwen/Qwen2.5-72B-Instruct',
|
||||
provider: 'modelscope',
|
||||
group: 'Qwen'
|
||||
},
|
||||
{
|
||||
id: 'Qwen/Qwen2.5-VL-72B-Instruct',
|
||||
name: 'Qwen/Qwen2.5-VL-72B-Instruct',
|
||||
provider: 'modelscope',
|
||||
group: 'Qwen'
|
||||
},
|
||||
{
|
||||
id: 'Qwen/Qwen2.5-Coder-32B-Instruct',
|
||||
name: 'Qwen/Qwen2.5-Coder-32B-Instruct',
|
||||
provider: 'modelscope',
|
||||
group: 'Qwen'
|
||||
},
|
||||
{
|
||||
id: 'deepseek-ai/DeepSeek-R1',
|
||||
name: 'deepseek-ai/DeepSeek-R1',
|
||||
provider: 'modelscope',
|
||||
group: 'deepseek-ai'
|
||||
},
|
||||
{
|
||||
id: 'deepseek-ai/DeepSeek-V3',
|
||||
name: 'deepseek-ai/DeepSeek-V3',
|
||||
provider: 'modelscope',
|
||||
group: 'deepseek-ai'
|
||||
}
|
||||
],
|
||||
bailian: [
|
||||
{ id: 'qwen-vl-plus', name: 'qwen-vl-plus', provider: 'dashscope', group: 'qwen-vl', owned_by: 'system' },
|
||||
{ id: 'qwen-coder-plus', name: 'qwen-coder-plus', provider: 'dashscope', group: 'qwen-coder', owned_by: 'system' },
|
||||
@@ -1253,6 +1328,192 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
name: 'BGE Large EN',
|
||||
group: 'Embedding'
|
||||
}
|
||||
],
|
||||
dmxapi: [
|
||||
{
|
||||
id: 'Qwen/Qwen2.5-7B-Instruct',
|
||||
provider: 'dmxapi',
|
||||
name: 'Qwen/Qwen2.5-7B-Instruct',
|
||||
group: '免费模型'
|
||||
},
|
||||
{
|
||||
id: 'ERNIE-Speed-128K',
|
||||
provider: 'dmxapi',
|
||||
name: 'ERNIE-Speed-128K',
|
||||
group: '免费模型'
|
||||
},
|
||||
{
|
||||
id: 'THUDM/glm-4-9b-chat',
|
||||
provider: 'dmxapi',
|
||||
name: 'THUDM/glm-4-9b-chat',
|
||||
group: '免费模型'
|
||||
},
|
||||
{
|
||||
id: 'glm-4-flash',
|
||||
provider: 'dmxapi',
|
||||
name: 'glm-4-flash',
|
||||
group: '免费模型'
|
||||
},
|
||||
{
|
||||
id: 'hunyuan-lite',
|
||||
provider: 'dmxapi',
|
||||
name: 'hunyuan-lite',
|
||||
group: '免费模型'
|
||||
},
|
||||
{
|
||||
id: 'gpt-4o',
|
||||
provider: 'dmxapi',
|
||||
name: 'gpt-4o',
|
||||
group: 'OpenAI'
|
||||
},
|
||||
{
|
||||
id: 'gpt-4o-mini',
|
||||
provider: 'dmxapi',
|
||||
name: 'gpt-4o-mini',
|
||||
group: 'OpenAI'
|
||||
},
|
||||
{
|
||||
id: 'DMXAPI-DeepSeek-R1',
|
||||
provider: 'dmxapi',
|
||||
name: 'DMXAPI-DeepSeek-R1',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'DMXAPI-DeepSeek-V3',
|
||||
provider: 'dmxapi',
|
||||
name: 'DMXAPI-DeepSeek-V3',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'claude-3-5-sonnet-20241022',
|
||||
provider: 'dmxapi',
|
||||
name: 'claude-3-5-sonnet-20241022',
|
||||
group: 'Claude'
|
||||
},
|
||||
{
|
||||
id: 'gemini-2.0-flash',
|
||||
provider: 'dmxapi',
|
||||
name: 'gemini-2.0-flash',
|
||||
group: 'Gemini'
|
||||
}
|
||||
],
|
||||
perplexity: [
|
||||
{
|
||||
id: 'sonar-reasoning-pro',
|
||||
provider: 'perplexity',
|
||||
name: 'sonar-reasoning-pro',
|
||||
group: 'Sonar'
|
||||
},
|
||||
{
|
||||
id: 'sonar-reasoning',
|
||||
provider: 'perplexity',
|
||||
name: 'sonar-reasoning',
|
||||
group: 'Sonar'
|
||||
},
|
||||
{
|
||||
id: 'sonar-pro',
|
||||
provider: 'perplexity',
|
||||
name: 'sonar-pro',
|
||||
group: 'Sonar'
|
||||
},
|
||||
{
|
||||
id: 'sonar',
|
||||
provider: 'perplexity',
|
||||
name: 'sonar',
|
||||
group: 'Sonar'
|
||||
}
|
||||
],
|
||||
infini: [
|
||||
{
|
||||
id: 'deepseek-r1',
|
||||
provider: 'infini',
|
||||
name: 'deepseek-r1',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'deepseek-r1-distill-qwen-32b',
|
||||
provider: 'infini',
|
||||
name: 'deepseek-r1-distill-qwen-32b',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'deepseek-v3',
|
||||
provider: 'infini',
|
||||
name: 'deepseek-v3',
|
||||
group: 'DeepSeek'
|
||||
},
|
||||
{
|
||||
id: 'qwen2.5-72b-instruct',
|
||||
provider: 'infini',
|
||||
name: 'qwen2.5-72b-instruct',
|
||||
group: 'Qwen'
|
||||
},
|
||||
{
|
||||
id: 'qwen2.5-32b-instruct',
|
||||
provider: 'infini',
|
||||
name: 'qwen2.5-32b-instruct',
|
||||
group: 'Qwen'
|
||||
},
|
||||
{
|
||||
id: 'qwen2.5-14b-instruct',
|
||||
provider: 'infini',
|
||||
name: 'qwen2.5-14b-instruct',
|
||||
group: 'Qwen'
|
||||
},
|
||||
{
|
||||
id: 'qwen2.5-7b-instruct',
|
||||
provider: 'infini',
|
||||
name: 'qwen2.5-7b-instruct',
|
||||
group: 'Qwen'
|
||||
},
|
||||
{
|
||||
id: 'qwen2-72b-instruct',
|
||||
provider: 'infini',
|
||||
name: 'qwen2-72b-instruct',
|
||||
group: 'Qwen'
|
||||
},
|
||||
{
|
||||
id: 'qwq-32b-preview',
|
||||
provider: 'infini',
|
||||
name: 'qwq-32b-preview',
|
||||
group: 'Qwen'
|
||||
},
|
||||
{
|
||||
id: 'qwen2.5-coder-32b-instruct',
|
||||
provider: 'infini',
|
||||
name: 'qwen2.5-coder-32b-instruct',
|
||||
group: 'Qwen'
|
||||
},
|
||||
{
|
||||
id: 'llama-3.3-70b-instruct',
|
||||
provider: 'infini',
|
||||
name: 'llama-3.3-70b-instruct',
|
||||
group: 'Llama'
|
||||
},
|
||||
{
|
||||
id: 'bge-m3',
|
||||
provider: 'infini',
|
||||
name: 'bge-m3',
|
||||
group: 'BAAI'
|
||||
},
|
||||
{
|
||||
id: 'gemma-2-27b-it',
|
||||
provider: 'infini',
|
||||
name: 'gemma-2-27b-it',
|
||||
group: 'Gemma'
|
||||
},
|
||||
{
|
||||
id: 'jina-embeddings-v2-base-zh',
|
||||
provider: 'infini',
|
||||
name: 'jina-embeddings-v2-base-zh',
|
||||
group: 'Jina'
|
||||
},
|
||||
{
|
||||
id: 'jina-embeddings-v2-base-code',
|
||||
provider: 'infini',
|
||||
name: 'jina-embeddings-v2-base-code',
|
||||
group: 'Jina'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1411,6 +1672,16 @@ export function isWebSearchModel(model: Model): boolean {
|
||||
return model?.id?.startsWith('glm-4-')
|
||||
}
|
||||
|
||||
if (provider.id === 'dashscope') {
|
||||
const models = ['qwen-turbo', 'qwen-max', 'qwen-plus']
|
||||
// matches id like qwen-max-0919, qwen-max-latest
|
||||
return models.some((i) => model.id.startsWith(i))
|
||||
}
|
||||
|
||||
if (provider.id === 'openrouter') {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1423,6 +1694,21 @@ export function getOpenAIWebSearchParams(assistant: Assistant, model: Model): Re
|
||||
return { enable_enhancement: true }
|
||||
}
|
||||
|
||||
if (model.provider === 'dashscope') {
|
||||
return {
|
||||
enable_search: true,
|
||||
search_options: {
|
||||
forced_search: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (model.provider === 'openrouter') {
|
||||
return {
|
||||
plugins: [{ id: 'web' }]
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tools: webSearchTools
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import AnthropicProviderLogo from '@renderer/assets/images/providers/anthropic.p
|
||||
import BaichuanProviderLogo from '@renderer/assets/images/providers/baichuan.png'
|
||||
import BaiduCloudProviderLogo from '@renderer/assets/images/providers/baidu-cloud.svg'
|
||||
import BailianProviderLogo from '@renderer/assets/images/providers/bailian.png'
|
||||
import BytedanceProviderLogo from '@renderer/assets/images/providers/bytedance.png'
|
||||
import DeepSeekProviderLogo from '@renderer/assets/images/providers/deepseek.png'
|
||||
import DmxapiProviderLogo from '@renderer/assets/images/providers/DMXAPI.png'
|
||||
import FireworksProviderLogo from '@renderer/assets/images/providers/fireworks.png'
|
||||
import GiteeAIProviderLogo from '@renderer/assets/images/providers/gitee-ai.png'
|
||||
import GithubProviderLogo from '@renderer/assets/images/providers/github.png'
|
||||
@@ -16,22 +16,26 @@ import GraphRagProviderLogo from '@renderer/assets/images/providers/graph-rag.pn
|
||||
import GrokProviderLogo from '@renderer/assets/images/providers/grok.png'
|
||||
import GroqProviderLogo from '@renderer/assets/images/providers/groq.png'
|
||||
import HyperbolicProviderLogo from '@renderer/assets/images/providers/hyperbolic.png'
|
||||
import InfiniProviderLogo from '@renderer/assets/images/providers/infini.png'
|
||||
import JinaProviderLogo from '@renderer/assets/images/providers/jina.png'
|
||||
import LMStudioProviderLogo from '@renderer/assets/images/providers/lmstudio.png'
|
||||
import MinimaxProviderLogo from '@renderer/assets/images/providers/minimax.png'
|
||||
import MistralProviderLogo from '@renderer/assets/images/providers/mistral.png'
|
||||
import ModelScopeProviderLogo from '@renderer/assets/images/providers/modelscope.png'
|
||||
import MoonshotProviderLogo from '@renderer/assets/images/providers/moonshot.png'
|
||||
import NvidiaProviderLogo from '@renderer/assets/images/providers/nvidia.png'
|
||||
import OcoolAiProviderLogo from '@renderer/assets/images/providers/ocoolai.png'
|
||||
import OllamaProviderLogo from '@renderer/assets/images/providers/ollama.png'
|
||||
import OpenAiProviderLogo from '@renderer/assets/images/providers/openai.png'
|
||||
import OpenRouterProviderLogo from '@renderer/assets/images/providers/openrouter.png'
|
||||
import PerplexityProviderLogo from '@renderer/assets/images/providers/perplexity.png'
|
||||
import PPIOProviderLogo from '@renderer/assets/images/providers/ppio.png'
|
||||
import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png'
|
||||
import StepProviderLogo from '@renderer/assets/images/providers/step.png'
|
||||
import TogetherProviderLogo from '@renderer/assets/images/providers/together.png'
|
||||
import BytedanceProviderLogo from '@renderer/assets/images/providers/volcengine.png'
|
||||
import ZeroOneProviderLogo from '@renderer/assets/images/providers/zero-one.png'
|
||||
import ZhipuProviderLogo from '@renderer/assets/images/providers/zhipu.png'
|
||||
|
||||
export function getProviderLogo(providerId: string) {
|
||||
switch (providerId) {
|
||||
case 'openai':
|
||||
@@ -50,6 +54,8 @@ export function getProviderLogo(providerId: string) {
|
||||
return ZhipuProviderLogo
|
||||
case 'ollama':
|
||||
return OllamaProviderLogo
|
||||
case 'lmstudio':
|
||||
return LMStudioProviderLogo
|
||||
case 'moonshot':
|
||||
return MoonshotProviderLogo
|
||||
case 'openrouter':
|
||||
@@ -58,6 +64,8 @@ export function getProviderLogo(providerId: string) {
|
||||
return BaichuanProviderLogo
|
||||
case 'dashscope':
|
||||
return BailianProviderLogo
|
||||
case 'modelscope':
|
||||
return ModelScopeProviderLogo
|
||||
case 'anthropic':
|
||||
return AnthropicProviderLogo
|
||||
case 'aihubmix':
|
||||
@@ -100,6 +108,12 @@ export function getProviderLogo(providerId: string) {
|
||||
return PPIOProviderLogo
|
||||
case 'baidu-cloud':
|
||||
return BaiduCloudProviderLogo
|
||||
case 'dmxapi':
|
||||
return DmxapiProviderLogo
|
||||
case 'perplexity':
|
||||
return PerplexityProviderLogo
|
||||
case 'infini':
|
||||
return InfiniProviderLogo
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
@@ -181,8 +195,8 @@ export const PROVIDER_CONFIG = {
|
||||
websites: {
|
||||
official: 'https://one.ocoolai.com/',
|
||||
apiKey: 'https://one.ocoolai.com/token',
|
||||
docs: 'https://docs.ooo.cool/',
|
||||
models: 'https://docs.ooo.cool/guides/jiage/'
|
||||
docs: 'https://docs.ocoolai.com/',
|
||||
models: 'https://api.ocoolai.com/info/models/'
|
||||
}
|
||||
},
|
||||
together: {
|
||||
@@ -196,6 +210,39 @@ export const PROVIDER_CONFIG = {
|
||||
models: 'https://docs.together.ai/docs/chat-models'
|
||||
}
|
||||
},
|
||||
dmxapi: {
|
||||
api: {
|
||||
url: 'https://www.dmxapi.cn'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://www.dmxapi.cn/register?aff=bwwY',
|
||||
apiKey: 'https://www.dmxapi.cn/register?aff=bwwY',
|
||||
docs: 'https://dmxapi.cn/models.html#code-block',
|
||||
models: 'https://www.dmxapi.cn/pricing'
|
||||
}
|
||||
},
|
||||
perplexity: {
|
||||
api: {
|
||||
url: 'https://api.perplexity.ai/'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://perplexity.ai/',
|
||||
apiKey: 'https://www.perplexity.ai/settings/api',
|
||||
docs: 'https://docs.perplexity.ai/home',
|
||||
models: 'https://docs.perplexity.ai/guides/model-cards'
|
||||
}
|
||||
},
|
||||
infini: {
|
||||
api: {
|
||||
url: 'https://cloud.infini-ai.com'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://cloud.infini-ai.com/',
|
||||
apiKey: 'https://cloud.infini-ai.com/iam/secret/key',
|
||||
docs: 'https://docs.infini-ai.com/gen-studio/api/maas.html#/operations/chatCompletions',
|
||||
models: 'https://cloud.infini-ai.com/genstudio/model'
|
||||
}
|
||||
},
|
||||
github: {
|
||||
api: {
|
||||
url: 'https://models.inference.ai.azure.com/'
|
||||
@@ -251,6 +298,17 @@ export const PROVIDER_CONFIG = {
|
||||
models: 'https://platform.baichuan-ai.com/price'
|
||||
}
|
||||
},
|
||||
modelscope: {
|
||||
api: {
|
||||
url: 'https://api-inference.modelscope.cn/v1/'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://modelscope.cn',
|
||||
apiKey: 'https://modelscope.cn/my/myaccesstoken',
|
||||
docs: 'https://modelscope.cn/docs/model-service/API-Inference/intro',
|
||||
models: 'https://modelscope.cn/models'
|
||||
}
|
||||
},
|
||||
dashscope: {
|
||||
api: {
|
||||
url: 'https://dashscope.aliyuncs.com/compatible-mode/v1/'
|
||||
@@ -332,6 +390,16 @@ export const PROVIDER_CONFIG = {
|
||||
models: 'https://ollama.com/library'
|
||||
}
|
||||
},
|
||||
lmstudio: {
|
||||
api: {
|
||||
url: 'http://localhost:1234'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://lmstudio.ai/',
|
||||
docs: 'https://lmstudio.ai/docs',
|
||||
models: 'https://lmstudio.ai/models'
|
||||
}
|
||||
},
|
||||
anthropic: {
|
||||
api: {
|
||||
url: 'https://api.anthropic.com/'
|
||||
@@ -458,7 +526,7 @@ export const PROVIDER_CONFIG = {
|
||||
},
|
||||
websites: {
|
||||
official: 'https://cloud.baidu.com/',
|
||||
apiKey: 'https://cloud.baidu.com/console/qianfan/apikey',
|
||||
apiKey: 'https://console.bce.baidu.com/iam/#/iam/apikey/list',
|
||||
docs: 'https://cloud.baidu.com/doc/index.html',
|
||||
models: 'https://cloud.baidu.com/doc/WENXINWORKSHOP/s/Fm2vrveyu'
|
||||
}
|
||||
|
||||
@@ -55,5 +55,20 @@ export const TranslateLanguageOptions = [
|
||||
value: 'arabic',
|
||||
label: i18n.t('languages.arabic'),
|
||||
emoji: '🇸🇦'
|
||||
},
|
||||
{
|
||||
value: 'german',
|
||||
label: i18n.t('languages.german'),
|
||||
emoji: '🇩🇪'
|
||||
}
|
||||
]
|
||||
|
||||
export const translateLanguageOptions = (): typeof TranslateLanguageOptions => {
|
||||
return TranslateLanguageOptions.map((option) => {
|
||||
return {
|
||||
value: option.value,
|
||||
label: i18n.t(`languages.${option.value}`),
|
||||
emoji: option.emoji
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ export const SyntaxHighlighterProvider: React.FC<PropsWithChildren> = ({ childre
|
||||
|
||||
const mappedLanguage = languageMap[language] || language
|
||||
|
||||
code = code.trimEnd()
|
||||
const escapedCode = code?.replace(/[<>]/g, (char) => ({ '<': '<', '>': '>' })[char]!)
|
||||
|
||||
try {
|
||||
|
||||
@@ -44,7 +44,7 @@ export function useAssistant(id: string) {
|
||||
|
||||
return {
|
||||
assistant,
|
||||
model: assistant?.model ?? defaultModel,
|
||||
model: assistant?.model ?? assistant?.defaultModel ?? defaultModel,
|
||||
addTopic: (topic: Topic) => dispatch(addTopic({ assistantId: assistant.id, topic })),
|
||||
removeTopic: (topic: Topic) => {
|
||||
TopicManager.removeTopic(topic.id)
|
||||
|
||||
@@ -198,6 +198,27 @@ export const useKnowledge = (baseId: string) => {
|
||||
return base?.items.filter((item) => item.type === type && item.processingStatus !== undefined) || []
|
||||
}
|
||||
|
||||
// 获取目录处理进度
|
||||
const getDirectoryProcessingPercent = (itemId?: string) => {
|
||||
const [percent, setPercent] = useState<number>(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!itemId) {
|
||||
return
|
||||
}
|
||||
|
||||
const cleanup = window.electron.ipcRenderer.on(itemId, (_, progressingPercent: number) => {
|
||||
setPercent(progressingPercent)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cleanup()
|
||||
}
|
||||
}, [itemId])
|
||||
|
||||
return percent
|
||||
}
|
||||
|
||||
// 清除已完成的项目
|
||||
const clearCompleted = () => {
|
||||
dispatch(clearCompletedProcessing({ baseId }))
|
||||
@@ -280,6 +301,7 @@ export const useKnowledge = (baseId: string) => {
|
||||
refreshItem,
|
||||
getProcessingStatus,
|
||||
getProcessingItemsByType,
|
||||
getDirectoryProcessingPercent,
|
||||
clearCompleted,
|
||||
clearAll,
|
||||
removeItem,
|
||||
@@ -307,16 +329,22 @@ export const useKnowledgeBases = () => {
|
||||
|
||||
// remove assistant knowledge_base
|
||||
const _assistants = assistants.map((assistant) => {
|
||||
if (assistant.knowledge_base?.id === baseId) {
|
||||
return { ...assistant, knowledge_base: undefined }
|
||||
if (assistant.knowledge_bases?.find((kb) => kb.id === baseId)) {
|
||||
return {
|
||||
...assistant,
|
||||
knowledge_bases: assistant.knowledge_bases.filter((kb) => kb.id !== baseId)
|
||||
}
|
||||
}
|
||||
return assistant
|
||||
})
|
||||
|
||||
// remove agent knowledge_base
|
||||
const _agents = agents.map((agent) => {
|
||||
if (agent.knowledge_base?.id === baseId) {
|
||||
return { ...agent, knowledge_base: undefined }
|
||||
if (agent.knowledge_bases?.find((kb) => kb.id === baseId)) {
|
||||
return {
|
||||
...agent,
|
||||
knowledge_bases: agent.knowledge_bases.filter((kb) => kb.id !== baseId)
|
||||
}
|
||||
}
|
||||
return agent
|
||||
})
|
||||
|
||||
18
src/renderer/src/hooks/useLMStudio.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import store, { useAppSelector } from '@renderer/store'
|
||||
import { setLMStudioKeepAliveTime } from '@renderer/store/llm'
|
||||
import { useDispatch } from 'react-redux'
|
||||
|
||||
export function useLMStudioSettings() {
|
||||
const settings = useAppSelector((state) => state.llm.settings.lmstudio)
|
||||
const dispatch = useDispatch()
|
||||
|
||||
return { ...settings, setKeepAliveTime: (time: number) => dispatch(setLMStudioKeepAliveTime(time)) }
|
||||
}
|
||||
|
||||
export function getLMStudioSettings() {
|
||||
return store.getState().llm.settings.lmstudio
|
||||
}
|
||||
|
||||
export function getLMStudioKeepAliveTime() {
|
||||
return store.getState().llm.settings.lmstudio.keepAliveTime + 'm'
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useProviders } from './useProvider'
|
||||
|
||||
export function useModel(id?: string) {
|
||||
export function useModel(id?: string, providerId?: string) {
|
||||
const { providers } = useProviders()
|
||||
const allModels = providers.map((p) => p.models).flat()
|
||||
return allModels.find((m) => m.id === id)
|
||||
return allModels.find((m) => {
|
||||
if (providerId) {
|
||||
return m.id === id && m.provider === providerId
|
||||
} else {
|
||||
return m.id === id
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ export function useActiveTopic(_assistant: Assistant, topic?: Topic) {
|
||||
return { activeTopic, setActiveTopic }
|
||||
}
|
||||
|
||||
export function useTopic(assistant: Assistant, topicId?: string) {
|
||||
return assistant?.topics.find((topic) => topic.id === topicId)
|
||||
}
|
||||
|
||||
export function getTopic(assistant: Assistant, topicId: string) {
|
||||
return assistant?.topics.find((topic) => topic.id === topicId)
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"settings.reasoning_effort.high": "high",
|
||||
"settings.reasoning_effort.low": "low",
|
||||
"settings.reasoning_effort.medium": "medium",
|
||||
"settings.reasoning_effort.off": "off",
|
||||
"settings.reasoning_effort.tip": "Only supports reasoning models",
|
||||
"title": "Assistants"
|
||||
},
|
||||
@@ -136,7 +137,12 @@
|
||||
"topics.pinned": "Pinned Topics",
|
||||
"topics.title": "Topics",
|
||||
"topics.unpinned": "Unpinned Topics",
|
||||
"translate": "Translate"
|
||||
"translate": "Translate",
|
||||
"topics.prompt": "Topic Prompts",
|
||||
"topics.prompt.tips": "Topic Prompts: Additional supplementary prompts provided for the current topic",
|
||||
"topics.prompt.edit.title": "Edit Topic Prompts",
|
||||
"artifacts.button.openExternal": "Open in external browser",
|
||||
"artifacts.preview.openExternal.error.content": "Error opening the external browser."
|
||||
},
|
||||
"common": {
|
||||
"add": "Add",
|
||||
@@ -293,7 +299,12 @@
|
||||
"title": "Knowledge Base",
|
||||
"url_added": "URL added",
|
||||
"url_placeholder": "Enter URL, multiple URLs separated by Enter",
|
||||
"urls": "URLs"
|
||||
"urls": "URLs",
|
||||
"threshold_tooltip": "Used to evaluate the relevance between the user's question and the content in the knowledge base (0-1)",
|
||||
"threshold_placeholder": "Not set",
|
||||
"threshold_too_large_or_small": "Threshold cannot be greater than 1 or less than 0",
|
||||
"no_match": "No matching content found in the knowledge base.",
|
||||
"threshold": "Matching threshold"
|
||||
},
|
||||
"languages": {
|
||||
"arabic": "Arabic",
|
||||
@@ -306,7 +317,8 @@
|
||||
"korean": "Korean",
|
||||
"portuguese": "Portuguese",
|
||||
"russian": "Russian",
|
||||
"spanish": "Spanish"
|
||||
"spanish": "Spanish",
|
||||
"german": "German"
|
||||
},
|
||||
"mermaid": {
|
||||
"download": {
|
||||
@@ -344,7 +356,7 @@
|
||||
"error.invalid.enter.model": "Please select a model",
|
||||
"error.invalid.proxy.url": "Invalid proxy URL",
|
||||
"error.invalid.webdav": "Invalid WebDAV settings",
|
||||
"error.notion.export": "Notion import failed",
|
||||
"error.notion.export": "Failed to export to Notion. Please check connection status and configuration according to documentation",
|
||||
"error.notion.no_api_key": "Notion ApiKey or Notion DatabaseID is not configured",
|
||||
"group.delete.content": "Deleting a group message will delete the user's question and all assistant's answers",
|
||||
"group.delete.title": "Delete Group Message",
|
||||
@@ -356,6 +368,7 @@
|
||||
"message.multi_model_style.fold": "Fold",
|
||||
"message.multi_model_style.horizontal": "Horizontal",
|
||||
"message.multi_model_style.vertical": "Vertical",
|
||||
"message.multi_model_style.grid": "Grid",
|
||||
"message.style": "Message style",
|
||||
"message.style.bubble": "Bubble",
|
||||
"message.style.plain": "Plain",
|
||||
@@ -365,13 +378,13 @@
|
||||
"reset.double.confirm.title": "DATA LOST !!!",
|
||||
"restore.success": "Restored successfully",
|
||||
"save.success.title": "Saved successfully",
|
||||
"success.notion.export": "Notion import successful",
|
||||
"success.notion.export": "Successfully exported to Notion",
|
||||
"switch.disabled": "Please wait for the current reply to complete",
|
||||
"topic.added": "New topic added",
|
||||
"upgrade.success.button": "Restart",
|
||||
"upgrade.success.content": "Please restart the application to complete the upgrade",
|
||||
"upgrade.success.title": "Upgrade successfully",
|
||||
"warn.notion.exporting": "Notion is importing, please do not import repeatedly",
|
||||
"warn.notion.exporting": "Exporting to Notion, please do not request export repeatedly!",
|
||||
"error.invalid.api.host": "Invalid API Host",
|
||||
"error.invalid.api.key": "Invalid API Key"
|
||||
},
|
||||
@@ -439,6 +452,12 @@
|
||||
"keep_alive_time.title": "Keep Alive Time",
|
||||
"title": "Ollama"
|
||||
},
|
||||
"lmstudio": {
|
||||
"keep_alive_time.description": "The time in minutes to keep the connection alive, default is 5 minutes.",
|
||||
"keep_alive_time.placeholder": "Minutes",
|
||||
"keep_alive_time.title": "Keep Alive Time",
|
||||
"title": "LM Studio"
|
||||
},
|
||||
"paintings": {
|
||||
"button.delete.image": "Delete Image",
|
||||
"button.delete.image.confirm": "Are you sure you want to delete this image?",
|
||||
@@ -466,14 +485,18 @@
|
||||
"title": "You are an assistant who is good at conversation. You need to summarize the user's conversation into a title of 10 characters or less, ensuring it matches the user's primary language without using punctuation or other special symbols."
|
||||
},
|
||||
"provider": {
|
||||
"infini": "Infini",
|
||||
"perplexity": "Perplexity",
|
||||
"dmxapi": "DMXAPI",
|
||||
"aihubmix": "AiHubMix",
|
||||
"anthropic": "Anthropic",
|
||||
"azure-openai": "Azure OpenAI",
|
||||
"baichuan": "Baichuan",
|
||||
"baidu-cloud": "Baidu Cloud",
|
||||
"dashscope": "Alibaba Cloud",
|
||||
"modelscope": "ModelScope",
|
||||
"deepseek": "DeepSeek",
|
||||
"doubao": "Doubao",
|
||||
"doubao": "Volcengine",
|
||||
"fireworks": "Fireworks",
|
||||
"gemini": "Gemini",
|
||||
"gitee-ai": "Gitee AI",
|
||||
@@ -490,6 +513,7 @@
|
||||
"nvidia": "Nvidia",
|
||||
"ocoolai": "ocoolAI",
|
||||
"ollama": "Ollama",
|
||||
"lmstudio": "LM Studio",
|
||||
"openai": "OpenAI",
|
||||
"openrouter": "OpenRouter",
|
||||
"ppio": "PPIO",
|
||||
@@ -540,28 +564,43 @@
|
||||
},
|
||||
"data.title": "Data Directory",
|
||||
"notion.api_key": "Notion API Key",
|
||||
"notion.api_key_placeholder": "Enter Notion API Key",
|
||||
"notion.database_id": "Notion Database ID",
|
||||
"notion.database_id_placeholder": "Enter Notion Database ID",
|
||||
"notion.title": "Notion Configuration",
|
||||
"notion.help": "Notion Configuration Documentation",
|
||||
"notion.check": {
|
||||
"button": "Check",
|
||||
"fail": "Connection failed, please check network and Api_key and Database_id",
|
||||
"success": "Connection successful",
|
||||
"error": "Connection error, please check network configuration and Api_key and Database_id",
|
||||
"empty_api_key": "Api_key is not configured",
|
||||
"empty_database_id": "Database_id is not configured"
|
||||
},
|
||||
"title": "Data Settings",
|
||||
"webdav.autoSync": "Auto Backup",
|
||||
"webdav.autoSync.off": "Off",
|
||||
"webdav.backup.button": "Backup to WebDAV",
|
||||
"webdav.host": "WebDAV Host",
|
||||
"webdav.host.placeholder": "http://localhost:8080",
|
||||
"webdav.hours": "Hours",
|
||||
"webdav.lastSync": "Last Backup",
|
||||
"webdav.minutes": "Minutes",
|
||||
"webdav.noSync": "Waiting for next backup",
|
||||
"webdav.password": "WebDAV Password",
|
||||
"webdav.path": "WebDAV Path",
|
||||
"webdav.path.placeholder": "/backup",
|
||||
"webdav.restore.button": "Restore from WebDAV",
|
||||
"webdav.restore.content": "Restore from WebDAV will overwrite the current data, continue?",
|
||||
"webdav.restore.title": "Restore from WebDAV",
|
||||
"webdav.syncError": "Backup Error",
|
||||
"webdav.syncStatus": "Backup Status",
|
||||
"webdav.title": "WebDAV",
|
||||
"webdav.user": "WebDAV User"
|
||||
"webdav": {
|
||||
"autoSync": "Auto Backup",
|
||||
"autoSync.off": "Off",
|
||||
"backup.button": "Backup to WebDAV",
|
||||
"host": "WebDAV Host",
|
||||
"host.placeholder": "http://localhost:8080",
|
||||
"minute_interval_one": "{{count}} minute",
|
||||
"minute_interval_other": "{{count}} minutes",
|
||||
"hour_interval_one": "{{count}} hour",
|
||||
"hour_interval_other": "{{count}} hours",
|
||||
"lastSync": "Last Backup",
|
||||
"noSync": "Waiting for next backup",
|
||||
"password": "WebDAV Password",
|
||||
"path": "WebDAV Path",
|
||||
"path.placeholder": "/backup",
|
||||
"restore.button": "Restore from WebDAV",
|
||||
"restore.content": "Restore from WebDAV will overwrite the current data, continue?",
|
||||
"restore.title": "Restore from WebDAV",
|
||||
"syncError": "Backup Error",
|
||||
"syncStatus": "Backup Status",
|
||||
"title": "WebDAV",
|
||||
"user": "WebDAV User"
|
||||
}
|
||||
},
|
||||
"display.custom.css": "Custom CSS",
|
||||
"display.custom.css.placeholder": "/* Put custom CSS here */",
|
||||
@@ -609,6 +648,10 @@
|
||||
"messages.input.title": "Input Settings",
|
||||
"messages.markdown_rendering_input_message": "Markdown render input message",
|
||||
"messages.math_engine": "Math engine",
|
||||
"messages.grid_columns": "Message grid display columns",
|
||||
"messages.grid_popover_trigger": "Grid detail trigger",
|
||||
"messages.grid_popover_trigger.hover": "Hover to display",
|
||||
"messages.grid_popover_trigger.click": "Click to display",
|
||||
"messages.metrics": "{{time_first_token_millsec}}ms to first token | {{token_speed}} tok/sec",
|
||||
"messages.model.title": "Model Settings",
|
||||
"messages.title": "Message Settings",
|
||||
@@ -681,7 +724,7 @@
|
||||
},
|
||||
"shortcuts": {
|
||||
"action": "Action",
|
||||
"alt_warning": "Mac does not support Option + letters as shortcuts",
|
||||
"alt_warning": "On Mac, Option key combinations only work with the Space key",
|
||||
"clear_shortcut": "Clear Shortcut",
|
||||
"clear_topic": "Clear Messages",
|
||||
"copy_last_message": "Copy Last Message",
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"settings.reasoning_effort.high": "長い",
|
||||
"settings.reasoning_effort.low": "短い",
|
||||
"settings.reasoning_effort.medium": "中程度",
|
||||
"settings.reasoning_effort.off": "オフ",
|
||||
"settings.reasoning_effort.tip": "この設定は推論モデルのみサポートしています"
|
||||
},
|
||||
"auth": {
|
||||
@@ -136,7 +137,12 @@
|
||||
"topics.pinned": "トピックを固定",
|
||||
"topics.title": "トピック",
|
||||
"topics.unpinned": "固定解除",
|
||||
"translate": "翻訳"
|
||||
"translate": "翻訳",
|
||||
"topics.prompt": "トピック提示語",
|
||||
"topics.prompt.tips": "トピック提示語:現在のトピックに対して追加の補足提示語を提供",
|
||||
"topics.prompt.edit.title": "トピック提示語を編集する",
|
||||
"artifacts.button.openExternal": "外部ブラウザで開く",
|
||||
"artifacts.preview.openExternal.error.content": "外部ブラウザの起動に失敗しました。"
|
||||
},
|
||||
"common": {
|
||||
"add": "追加",
|
||||
@@ -293,7 +299,12 @@
|
||||
"title": "ナレッジベース",
|
||||
"url_added": "URLが追加されました",
|
||||
"url_placeholder": "URLを入力, 複数のURLはEnterで区切る",
|
||||
"urls": "URL"
|
||||
"urls": "URL",
|
||||
"threshold_tooltip": "ユーザーの質問と知識ベースの内容の関連性を評価するためのしきい値(0-1)",
|
||||
"threshold_placeholder": "未設置",
|
||||
"threshold_too_large_or_small": "しきい値は0より大きく1より小さい必要があります",
|
||||
"no_match": "知識ベースの内容が見つかりませんでした。",
|
||||
"threshold": "マッチング度閾値"
|
||||
},
|
||||
"languages": {
|
||||
"arabic": "アラビア語",
|
||||
@@ -301,6 +312,7 @@
|
||||
"chinese-traditional": "繁体字中国語",
|
||||
"english": "英語",
|
||||
"french": "フランス語",
|
||||
"german": "ドイツ語",
|
||||
"italian": "イタリア語",
|
||||
"japanese": "日本語",
|
||||
"korean": "韓国語",
|
||||
@@ -343,7 +355,7 @@
|
||||
"error.invalid.enter.model": "モデルを選択してください",
|
||||
"error.invalid.proxy.url": "無効なプロキシURL",
|
||||
"error.invalid.webdav": "無効なWebDAV設定",
|
||||
"error.notion.export": "Notion インポートに失敗",
|
||||
"error.notion.export": "Notionへのエクスポートに失敗しました。接続状態と設定を確認してください",
|
||||
"error.notion.no_api_key": "Notion ApiKey または Notion DatabaseID が設定されていません",
|
||||
"group.delete.content": "分組メッセージを削除するとユーザーの質問と助け手の回答がすべて削除されます",
|
||||
"group.delete.title": "分組メッセージを削除",
|
||||
@@ -355,6 +367,7 @@
|
||||
"message.multi_model_style.fold": "折りたたむ",
|
||||
"message.multi_model_style.horizontal": "水平",
|
||||
"message.multi_model_style.vertical": "垂直",
|
||||
"message.multi_model_style.grid": "グリッド",
|
||||
"message.style": "メッセージスタイル",
|
||||
"message.style.bubble": "バブル",
|
||||
"message.style.plain": "プレーン",
|
||||
@@ -364,13 +377,13 @@
|
||||
"reset.double.confirm.title": "データが失われます!!!",
|
||||
"restore.success": "復元に成功しました",
|
||||
"save.success.title": "保存に成功しました",
|
||||
"success.notion.export": "Notion へのインポートに成功",
|
||||
"success.notion.export": "Notionへのエクスポートに成功しました",
|
||||
"switch.disabled": "現在の応答が完了するまで切り替えを無効にします",
|
||||
"topic.added": "新しいトピックが追加されました",
|
||||
"upgrade.success.button": "再起動",
|
||||
"upgrade.success.content": "アップグレードを完了するためにアプリケーションを再起動してください",
|
||||
"upgrade.success.title": "アップグレードに成功しました",
|
||||
"warn.notion.exporting": "Notion 正在インポート中です。重複インポートしないでください。",
|
||||
"warn.notion.exporting": "Notionにエクスポート中です。重複してエクスポートしないでください! ",
|
||||
"error.enter.name": "ナレッジベース名を入力してください",
|
||||
"error.invalid.api.host": "無効なAPIアドレスです",
|
||||
"error.invalid.api.key": "無効なAPIキーです"
|
||||
@@ -439,6 +452,12 @@
|
||||
"keep_alive_time.title": "保持時間",
|
||||
"title": "Ollama"
|
||||
},
|
||||
"lmstudio": {
|
||||
"keep_alive_time.description": "モデルがメモリに保持される時間(デフォルト:5分)",
|
||||
"keep_alive_time.placeholder": "分",
|
||||
"keep_alive_time.title": "保持時間",
|
||||
"title": "LM Studio"
|
||||
},
|
||||
"paintings": {
|
||||
"button.delete.image": "画像を削除",
|
||||
"button.delete.image.confirm": "この画像を削除してもよろしいですか?",
|
||||
@@ -466,14 +485,18 @@
|
||||
"title": "あなたは会話を得意とするアシスタントです。ユーザーの会話を10文字以内のタイトルに要約し、ユーザーの主言語と一致していることを確認してください。句読点や特殊記号は使用しないでください。"
|
||||
},
|
||||
"provider": {
|
||||
"infini": "Infini",
|
||||
"perplexity": "Perplexity",
|
||||
"dmxapi": "DMXAPI",
|
||||
"aihubmix": "AiHubMix",
|
||||
"anthropic": "Anthropic",
|
||||
"azure-openai": "Azure OpenAI",
|
||||
"baichuan": "百川",
|
||||
"baidu-cloud": "Baidu Cloud",
|
||||
"dashscope": "Alibaba Cloud",
|
||||
"modelscope": "ModelScope",
|
||||
"deepseek": "DeepSeek",
|
||||
"doubao": "豆包",
|
||||
"doubao": "Volcengine",
|
||||
"fireworks": "Fireworks",
|
||||
"gemini": "Gemini",
|
||||
"gitee-ai": "Gitee AI",
|
||||
@@ -490,6 +513,7 @@
|
||||
"nvidia": "NVIDIA",
|
||||
"ocoolai": "ocoolAI",
|
||||
"ollama": "Ollama",
|
||||
"lmstudio": "LM Studio",
|
||||
"openai": "OpenAI",
|
||||
"openrouter": "OpenRouter",
|
||||
"qwenlm": "QwenLM",
|
||||
@@ -540,9 +564,43 @@
|
||||
},
|
||||
"data.title": "データディレクトリ",
|
||||
"notion.api_key": "Notion APIキー",
|
||||
"notion.api_key_placeholder": "Notion APIキーを入力してください",
|
||||
"notion.database_id": "Notion データベースID",
|
||||
"notion.database_id_placeholder": "Notion データベースIDを入力してください",
|
||||
"notion.title": "Notion 設定",
|
||||
"notion.help": "Notion 設定ドキュメント",
|
||||
"notion.check": {
|
||||
"button": "確認",
|
||||
"fail": "接続エラー、ネットワーク設定とApi_keyとDatabase_idを確認してください",
|
||||
"success": "接続に成功しました。",
|
||||
"error": "接続エラー、ネットワーク設定とApi_keyとDatabase_idを確認してください",
|
||||
"empty_api_key": "Api_keyが設定されていません",
|
||||
"empty_database_id": "Database_idが設定されていません"
|
||||
},
|
||||
"title": "データ設定",
|
||||
"webdav": {
|
||||
"autoSync": "自動バックアップ",
|
||||
"autoSync.off": "オフ",
|
||||
"backup.button": "WebDAVにバックアップ",
|
||||
"host": "WebDAVホスト",
|
||||
"host.placeholder": "http://localhost:8080",
|
||||
"minute_interval_one": "{{count}} 分",
|
||||
"minute_interval_other": "{{count}} 分",
|
||||
"hour_interval_one": "{{count}} 時間",
|
||||
"hour_interval_other": "{{count}} 時間",
|
||||
"lastSync": "最終バックアップ",
|
||||
"noSync": "次回のバックアップを待機中",
|
||||
"password": "WebDAVパスワード",
|
||||
"path": "WebDAVパス",
|
||||
"path.placeholder": "/backup",
|
||||
"restore.button": "WebDAVから復元",
|
||||
"restore.content": "WebDAVから復元すると現在のデータが上書きされます。続行しますか?",
|
||||
"restore.title": "WebDAVから復元",
|
||||
"syncError": "バックアップエラー",
|
||||
"syncStatus": "バックアップ状態",
|
||||
"title": "WebDAV",
|
||||
"user": "WebDAVユーザー"
|
||||
},
|
||||
"webdav.autoSync": "自動バックアップ",
|
||||
"webdav.autoSync.off": "オフ",
|
||||
"webdav.backup.button": "WebDAVにバックアップ",
|
||||
@@ -561,7 +619,11 @@
|
||||
"webdav.syncError": "バックアップエラー",
|
||||
"webdav.syncStatus": "バックアップ状態",
|
||||
"webdav.title": "WebDAV",
|
||||
"webdav.user": "WebDAVユーザー"
|
||||
"webdav.user": "WebDAVユーザー",
|
||||
"minute_interval_one": "{{count}} 分",
|
||||
"minute_interval_other": "{{count}} 分",
|
||||
"hour_interval_one": "{{count}} 時間",
|
||||
"hour_interval_other": "{{count}} 時間"
|
||||
},
|
||||
"display.custom.css": "カスタムCSS",
|
||||
"display.custom.css.placeholder": "/* ここにカスタムCSSを入力 */",
|
||||
@@ -609,6 +671,10 @@
|
||||
"messages.input.title": "入力設定",
|
||||
"messages.markdown_rendering_input_message": "Markdownで入力メッセージをレンダリング",
|
||||
"messages.math_engine": "数式エンジン",
|
||||
"messages.grid_columns": "メッセージグリッドの表示列数",
|
||||
"messages.grid_popover_trigger": "グリッド詳細トリガー",
|
||||
"messages.grid_popover_trigger.hover": "ホバーで表示",
|
||||
"messages.grid_popover_trigger.click": "クリックで表示",
|
||||
"messages.metrics": "最初のトークンまでの時間 {{time_first_token_millsec}}ms | トークン速度 {{token_speed}} tok/sec",
|
||||
"messages.model.title": "モデル設定",
|
||||
"messages.title": "メッセージ設定",
|
||||
@@ -681,7 +747,7 @@
|
||||
},
|
||||
"shortcuts": {
|
||||
"action": "操作",
|
||||
"alt_warning": "MacではOption + 文字をショートカットとして使用できません",
|
||||
"alt_warning": "MacではOptionキーとの組み合わせは、スペースキーのみ使用可能です",
|
||||
"clear_shortcut": "ショートカットをクリア",
|
||||
"clear_topic": "メッセージを消去",
|
||||
"copy_last_message": "最後のメッセージをコピー",
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"settings.reasoning_effort.high": "Длинная",
|
||||
"settings.reasoning_effort.low": "Короткая",
|
||||
"settings.reasoning_effort.medium": "Средняя",
|
||||
"settings.reasoning_effort.off": "Выключено",
|
||||
"settings.reasoning_effort.tip": "Эта настройка поддерживается только моделями с рассуждением"
|
||||
},
|
||||
"auth": {
|
||||
@@ -136,7 +137,12 @@
|
||||
"topics.pinned": "Закрепленные темы",
|
||||
"topics.title": "Топики",
|
||||
"topics.unpinned": "Открепленные темы",
|
||||
"translate": "Перевести"
|
||||
"translate": "Перевести",
|
||||
"topics.prompt": "Тематические подсказки",
|
||||
"topics.prompt.tips": "Тематические подсказки: Дополнительные подсказки, предоставленные для текущей темы",
|
||||
"topics.prompt.edit.title": "Редактировать подсказки темы",
|
||||
"artifacts.button.openExternal": "Открыть во внешнем браузере",
|
||||
"artifacts.preview.openExternal.error.content": "Внешний браузер открылся с ошибкой"
|
||||
},
|
||||
"common": {
|
||||
"add": "Добавить",
|
||||
@@ -293,7 +299,12 @@
|
||||
"title": "База знаний",
|
||||
"url_added": "URL добавлен",
|
||||
"url_placeholder": "Введите URL, несколько URL через Enter",
|
||||
"urls": "URL-адреса"
|
||||
"urls": "URL-адреса",
|
||||
"threshold_tooltip": "Используется для оценки соответствия между пользовательским вопросом и содержимым в базе знаний (0-1)",
|
||||
"threshold_placeholder": "Не установлено",
|
||||
"threshold_too_large_or_small": "Порог не может быть больше 1 или меньше 0",
|
||||
"no_match": "Не найдено содержимого в базе знаний.",
|
||||
"threshold": "Порог соответствия"
|
||||
},
|
||||
"languages": {
|
||||
"arabic": "Арабский",
|
||||
@@ -301,6 +312,7 @@
|
||||
"chinese-traditional": "Китайский традиционный",
|
||||
"english": "Английский",
|
||||
"french": "Французский",
|
||||
"german": "Немецкий",
|
||||
"italian": "Итальянский",
|
||||
"japanese": "Японский",
|
||||
"korean": "Корейский",
|
||||
@@ -344,7 +356,7 @@
|
||||
"error.invalid.enter.model": "Пожалуйста, выберите модель",
|
||||
"error.invalid.proxy.url": "Неверный URL прокси",
|
||||
"error.invalid.webdav": "Неверные настройки WebDAV",
|
||||
"error.notion.export": "Импорт в Notion не удался",
|
||||
"error.notion.export": "Ошибка экспорта в Notion, пожалуйста, проверьте состояние подключения и настройки в документации",
|
||||
"error.notion.no_api_key": "Notion ApiKey или Notion DatabaseID не настроен",
|
||||
"group.delete.content": "Удаление группы сообщений удалит пользовательский вопрос и все ответы помощника",
|
||||
"group.delete.title": "Удалить группу сообщений",
|
||||
@@ -356,6 +368,7 @@
|
||||
"message.multi_model_style.fold": "Свернуть",
|
||||
"message.multi_model_style.horizontal": "Горизонтальный",
|
||||
"message.multi_model_style.vertical": "Вертикальный",
|
||||
"message.multi_model_style.grid": "клетчатый вид",
|
||||
"message.style": "Стиль сообщения",
|
||||
"message.style.bubble": "Пузырь",
|
||||
"message.style.plain": "Простой",
|
||||
@@ -365,13 +378,13 @@
|
||||
"reset.double.confirm.title": "ДАННЫЕ БУДУТ УТЕРЯНЫ !!!",
|
||||
"restore.success": "Успешно восстановлено",
|
||||
"save.success.title": "Успешно сохранено",
|
||||
"success.notion.export": "Импорт в Notion выполнен успешно",
|
||||
"success.notion.export": "Успешный экспорт в Notion",
|
||||
"switch.disabled": "Пожалуйста, дождитесь завершения текущего ответа",
|
||||
"topic.added": "Новый топик добавлен",
|
||||
"upgrade.success.button": "Перезапустить",
|
||||
"upgrade.success.content": "Пожалуйста, перезапустите приложение для завершения обновления",
|
||||
"upgrade.success.title": "Обновление успешно",
|
||||
"warn.notion.exporting": "Идет импорт в Notion, пожалуйста, не повторяйте импорт",
|
||||
"warn.notion.exporting": "Экспортируется в Notion, пожалуйста, не отправляйте повторные запросы!",
|
||||
"error.invalid.api.host": "Неверный API адрес",
|
||||
"error.invalid.api.key": "Неверный API ключ"
|
||||
},
|
||||
@@ -439,6 +452,12 @@
|
||||
"keep_alive_time.title": "Время жизни модели",
|
||||
"title": "Ollama"
|
||||
},
|
||||
"lmstudio": {
|
||||
"keep_alive_time.description": "Время в минутах, в течение которого модель остается активной, по умолчанию 5 минут.",
|
||||
"keep_alive_time.placeholder": "Минуты",
|
||||
"keep_alive_time.title": "Время жизни модели",
|
||||
"title": "LM Studio"
|
||||
},
|
||||
"paintings": {
|
||||
"button.delete.image": "Удалить изображение",
|
||||
"button.delete.image.confirm": "Вы уверены, что хотите удалить это изображение?",
|
||||
@@ -466,14 +485,18 @@
|
||||
"title": "Вы - эксперт в общении, который суммирует разговоры пользователя в 10-символьном заголовке, совпадающем с языком пользователя, без использования знаков препинания и других специальных символов"
|
||||
},
|
||||
"provider": {
|
||||
"infini": "Infini",
|
||||
"perplexity": "Perplexity",
|
||||
"dmxapi": "DMXAPI",
|
||||
"aihubmix": "AiHubMix",
|
||||
"anthropic": "Anthropic",
|
||||
"azure-openai": "Azure OpenAI",
|
||||
"baichuan": "Baichuan",
|
||||
"baidu-cloud": "Baidu Cloud",
|
||||
"dashscope": "Alibaba Cloud",
|
||||
"modelscope": "ModelScope",
|
||||
"deepseek": "DeepSeek",
|
||||
"doubao": "Doubao",
|
||||
"doubao": "Volcengine",
|
||||
"fireworks": "Fireworks",
|
||||
"gemini": "Gemini",
|
||||
"gitee-ai": "Gitee AI",
|
||||
@@ -490,6 +513,7 @@
|
||||
"nvidia": "Nvidia",
|
||||
"ocoolai": "ocoolAI",
|
||||
"ollama": "Ollama",
|
||||
"lmstudio": "LM Studio",
|
||||
"openai": "OpenAI",
|
||||
"openrouter": "OpenRouter",
|
||||
"qwenlm": "QwenLM",
|
||||
@@ -540,28 +564,45 @@
|
||||
},
|
||||
"data.title": "Каталог данных",
|
||||
"notion.api_key": "Ключ API Notion",
|
||||
"notion.api_key_placeholder": "Введите ключ API Notion",
|
||||
"notion.database_id": "ID базы данных Notion",
|
||||
"notion.database_id_placeholder": "Введите ID базы данных Notion",
|
||||
"notion.title": "Настройки Notion",
|
||||
"notion.help": "Документация по настройке Notion",
|
||||
"notion.check": {
|
||||
"button": "Проверить",
|
||||
"fail": "Не удалось подключиться, пожалуйста, проверьте сеть и правильность Api_key и Database_id",
|
||||
"success": "Подключение успешно",
|
||||
"error": "Аномалия в подключении, пожалуйста, проверьте настройки сети, а также правильность Api_key и Database_id",
|
||||
"empty_api_key": "Не настроен Api_key",
|
||||
"empty_database_id": "Не настроен Database_id"
|
||||
},
|
||||
"title": "Настройки данных",
|
||||
"webdav.autoSync": "Автоматическое резервное копирование",
|
||||
"webdav.autoSync.off": "Выключено",
|
||||
"webdav.backup.button": "Резервное копирование на WebDAV",
|
||||
"webdav.host": "Хост WebDAV",
|
||||
"webdav.host.placeholder": "http://localhost:8080",
|
||||
"webdav.hours": "часов",
|
||||
"webdav.lastSync": "Последняя синхронизация",
|
||||
"webdav.minutes": "минут",
|
||||
"webdav.noSync": "Ожидание следующего резервного копирования",
|
||||
"webdav.password": "Пароль WebDAV",
|
||||
"webdav.path": "Путь WebDAV",
|
||||
"webdav.path.placeholder": "/backup",
|
||||
"webdav.restore.button": "Восстановление с WebDAV",
|
||||
"webdav.restore.content": "Восстановление с WebDAV перезапишет текущие данные, продолжить?",
|
||||
"webdav.restore.title": "Восстановление с WebDAV",
|
||||
"webdav.syncError": "Ошибка резервного копирования",
|
||||
"webdav.syncStatus": "Статус резервного копирования",
|
||||
"webdav.title": "WebDAV",
|
||||
"webdav.user": "Пользователь WebDAV"
|
||||
"webdav": {
|
||||
"autoSync": "Автоматическое резервное копирование",
|
||||
"autoSync.off": "Выключено",
|
||||
"backup.button": "Резервное копирование на WebDAV",
|
||||
"host": "Хост WebDAV",
|
||||
"host.placeholder": "http://localhost:8080",
|
||||
"minute_interval_one": "{{count}} минута",
|
||||
"minute_interval_few": "{{count}} минуты",
|
||||
"minute_interval_many": "{{count}} минут",
|
||||
"hour_interval_one": "{{count}} час",
|
||||
"hour_interval_few": "{{count}} часа",
|
||||
"hour_interval_many": "{{count}} часов",
|
||||
"lastSync": "Последняя синхронизация",
|
||||
"noSync": "Ожидание следующего резервного копирования",
|
||||
"password": "Пароль WebDAV",
|
||||
"path": "Путь WebDAV",
|
||||
"path.placeholder": "/backup",
|
||||
"restore.button": "Восстановление с WebDAV",
|
||||
"restore.content": "Восстановление с WebDAV перезапишет текущие данные, продолжить?",
|
||||
"restore.title": "Восстановление с WebDAV",
|
||||
"syncError": "Ошибка резервного копирования",
|
||||
"syncStatus": "Статус резервного копирования",
|
||||
"title": "WebDAV",
|
||||
"user": "Пользователь WebDAV"
|
||||
}
|
||||
},
|
||||
"display.custom.css": "Пользовательский CSS",
|
||||
"display.custom.css.placeholder": "/* Здесь введите пользовательский CSS */",
|
||||
@@ -610,6 +651,10 @@
|
||||
"messages.math_engine": "Математический движок",
|
||||
"messages.metrics": "{{time_first_token_millsec}}ms до первого токена | {{token_speed}} tok/sec",
|
||||
"messages.model.title": "Настройки модели",
|
||||
"messages.grid_columns": "Количество столбцов сетки сообщений",
|
||||
"messages.grid_popover_trigger": "Триггер для отображения подробной информации в сетке",
|
||||
"messages.grid_popover_trigger.hover": "Наведение для отображения",
|
||||
"messages.grid_popover_trigger.click": "Нажатие для отображения",
|
||||
"messages.title": "Настройки сообщений",
|
||||
"messages.use_serif_font": "Использовать serif шрифт",
|
||||
"model": "Модель по умолчанию",
|
||||
@@ -680,7 +725,7 @@
|
||||
},
|
||||
"shortcuts": {
|
||||
"action": "Действие",
|
||||
"alt_warning": "Mac не поддерживает Option + буквы как горячие клавиши",
|
||||
"alt_warning": "В Mac сочетания с клавишей Option работают только с пробелом",
|
||||
"clear_shortcut": "Очистить сочетание клавиш",
|
||||
"clear_topic": "Очистить все сообщения",
|
||||
"copy_last_message": "Копировать последнее сообщение",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"settings.reasoning_effort.high": "长",
|
||||
"settings.reasoning_effort.low": "短",
|
||||
"settings.reasoning_effort.medium": "中",
|
||||
"settings.reasoning_effort.off": "关",
|
||||
"settings.reasoning_effort.tip": "该设置仅支持推理模型",
|
||||
"title": "助手"
|
||||
},
|
||||
@@ -73,6 +74,8 @@
|
||||
"add.assistant.title": "添加助手",
|
||||
"artifacts.button.download": "下载",
|
||||
"artifacts.button.preview": "预览",
|
||||
"artifacts.button.openExternal": "外部浏览器打开",
|
||||
"artifacts.preview.openExternal.error.content": "外部浏览器打开出错",
|
||||
"assistant.search.placeholder": "搜索",
|
||||
"deeply_thought": "已深度思考(用时 {{secounds}} 秒)",
|
||||
"default.description": "你好,我是默认助手。你可以立刻开始跟我聊天。",
|
||||
@@ -136,7 +139,10 @@
|
||||
"topics.pinned": "固定话题",
|
||||
"topics.title": "话题",
|
||||
"topics.unpinned": "取消固定",
|
||||
"translate": "翻译"
|
||||
"translate": "翻译",
|
||||
"topics.prompt": "话题提示词",
|
||||
"topics.prompt.tips": "话题提示词: 针对当前话题提供额外的补充提示词",
|
||||
"topics.prompt.edit.title": "编辑话题提示词"
|
||||
},
|
||||
"common": {
|
||||
"add": "添加",
|
||||
@@ -272,6 +278,7 @@
|
||||
"invalid_url": "无效的网址",
|
||||
"model_info": "模型信息",
|
||||
"no_bases": "暂无知识库",
|
||||
"no_match": "未匹配到知识库内容",
|
||||
"no_provider": "知识库模型服务商丢失,该知识库将不再支持,请重新创建知识库",
|
||||
"not_set": "未设置",
|
||||
"not_support": "知识库数据库引擎已更新,该知识库将不再支持,请重新创建知识库",
|
||||
@@ -290,6 +297,10 @@
|
||||
"status_new": "已添加",
|
||||
"status_pending": "等待中",
|
||||
"status_processing": "处理中",
|
||||
"threshold": "匹配度阈值",
|
||||
"threshold_tooltip": "用于衡量用户问题与知识库内容之间的相关性(0-1)",
|
||||
"threshold_placeholder": "未设置",
|
||||
"threshold_too_large_or_small": "阈值不能大于1或小于0",
|
||||
"title": "知识库",
|
||||
"url_added": "网址已添加",
|
||||
"url_placeholder": "请输入网址, 多个网址用回车分隔",
|
||||
@@ -306,7 +317,8 @@
|
||||
"korean": "韩文",
|
||||
"portuguese": "葡萄牙文",
|
||||
"russian": "俄文",
|
||||
"spanish": "西班牙文"
|
||||
"spanish": "西班牙文",
|
||||
"german": "德文"
|
||||
},
|
||||
"mermaid": {
|
||||
"download": {
|
||||
@@ -346,7 +358,7 @@
|
||||
"error.invalid.enter.model": "请选择一个模型",
|
||||
"error.invalid.proxy.url": "无效的代理地址",
|
||||
"error.invalid.webdav": "无效的 WebDAV 设置",
|
||||
"error.notion.export": "Notion 导入失败",
|
||||
"error.notion.export": "导出Notion错误,请检查连接状态并对照文档检查配置",
|
||||
"error.notion.no_api_key": "未配置Notion ApiKey或Notion DatabaseID",
|
||||
"group.delete.content": "删除分组消息会删除用户提问和所有助手的回答",
|
||||
"group.delete.title": "删除分组消息",
|
||||
@@ -358,6 +370,7 @@
|
||||
"message.multi_model_style.fold": "折叠",
|
||||
"message.multi_model_style.horizontal": "水平",
|
||||
"message.multi_model_style.vertical": "垂直",
|
||||
"message.multi_model_style.grid": "网格",
|
||||
"message.style": "消息样式",
|
||||
"message.style.bubble": "气泡",
|
||||
"message.style.plain": "简洁",
|
||||
@@ -367,13 +380,13 @@
|
||||
"reset.double.confirm.title": "数据丢失!!!",
|
||||
"restore.success": "恢复成功",
|
||||
"save.success.title": "保存成功",
|
||||
"success.notion.export": "导入Notion成功",
|
||||
"success.notion.export": "成功导出到Notion",
|
||||
"switch.disabled": "请等待当前回复完成后操作",
|
||||
"topic.added": "话题添加成功",
|
||||
"upgrade.success.button": "重启",
|
||||
"upgrade.success.content": "重启用以完成升级",
|
||||
"upgrade.success.title": "升级成功",
|
||||
"warn.notion.exporting": "Notion正在导入,请勿重复导入"
|
||||
"warn.notion.exporting": "正在导出到Notion, 请勿重复请求导出!"
|
||||
},
|
||||
"minapp": {
|
||||
"sidebar.add.title": "添加到侧边栏",
|
||||
@@ -439,6 +452,12 @@
|
||||
"keep_alive_time.title": "保持活跃时间",
|
||||
"title": "Ollama"
|
||||
},
|
||||
"lmstudio": {
|
||||
"keep_alive_time.description": "对话后模型在内存中保持的时间(默认:5分钟)",
|
||||
"keep_alive_time.placeholder": "分钟",
|
||||
"keep_alive_time.title": "保持活跃时间",
|
||||
"title": "LM Studio"
|
||||
},
|
||||
"paintings": {
|
||||
"button.delete.image": "删除图片",
|
||||
"button.delete.image.confirm": "确定要删除此图片吗?",
|
||||
@@ -466,14 +485,18 @@
|
||||
"title": "你是一名擅长会话的助理,你需要将用户的会话总结为 10 个字以内的标题,标题语言与用户的首要语言一致,不要使用标点符号和其他特殊符号"
|
||||
},
|
||||
"provider": {
|
||||
"infini": "无问芯穹",
|
||||
"perplexity": "Perplexity",
|
||||
"dmxapi": "DMXAPI",
|
||||
"aihubmix": "AiHubMix",
|
||||
"anthropic": "Anthropic",
|
||||
"azure-openai": "Azure OpenAI",
|
||||
"baichuan": "百川",
|
||||
"baidu-cloud": "百度云千帆",
|
||||
"dashscope": "阿里云百炼",
|
||||
"modelscope": "ModelScope 魔搭",
|
||||
"deepseek": "深度求索",
|
||||
"doubao": "豆包",
|
||||
"doubao": "火山引擎",
|
||||
"fireworks": "Fireworks",
|
||||
"gemini": "Gemini",
|
||||
"gitee-ai": "Gitee AI",
|
||||
@@ -490,6 +513,7 @@
|
||||
"nvidia": "英伟达",
|
||||
"ocoolai": "ocoolAI",
|
||||
"ollama": "Ollama",
|
||||
"lmstudio": "LM Studio",
|
||||
"openai": "OpenAI",
|
||||
"openrouter": "OpenRouter",
|
||||
"ppio": "PPIO 派欧云",
|
||||
@@ -540,28 +564,47 @@
|
||||
},
|
||||
"data.title": "数据目录",
|
||||
"notion.api_key": "Notion 密钥",
|
||||
"notion.database_id": "Notion 数据库ID",
|
||||
"notion.api_key_placeholder": "请输入Notion 密钥",
|
||||
"notion.database_id": "Notion 数据库 ID",
|
||||
"notion.database_id_placeholder": "请输入Notion 数据库 ID",
|
||||
"notion.title": "Notion 配置",
|
||||
"notion.help" : "Notion 配置文档",
|
||||
"notion.check": {
|
||||
"button": "检查",
|
||||
"fail": "连接失败,请检查网络及Api_key和Database_id是否正确",
|
||||
"success": "连接成功",
|
||||
"error": "连接异常,请检查网络及Api_key和Database_id是否正确",
|
||||
"empty_api_key": "未配置Api_key",
|
||||
"empty_database_id": "未配置Database_id"
|
||||
},
|
||||
"title": "数据设置",
|
||||
"webdav.autoSync": "自动备份",
|
||||
"webdav.autoSync.off": "关闭",
|
||||
"webdav.backup.button": "备份到 WebDAV",
|
||||
"webdav.host": "WebDAV 地址",
|
||||
"webdav.host.placeholder": "http://localhost:8080",
|
||||
"webdav.hours": "小时",
|
||||
"webdav.lastSync": "上次备份时间",
|
||||
"webdav.minutes": "分钟",
|
||||
"webdav.noSync": "等待下次备份",
|
||||
"webdav.password": "WebDAV 密码",
|
||||
"webdav.path": "WebDAV 路径",
|
||||
"webdav.path.placeholder": "/backup",
|
||||
"webdav.restore.button": "从 WebDAV 恢复",
|
||||
"webdav.restore.content": "从 WebDAV 恢复将覆盖当前数据,是否继续?",
|
||||
"webdav.restore.title": "从 WebDAV 恢复",
|
||||
"webdav.syncError": "备份错误",
|
||||
"webdav.syncStatus": "备份状态",
|
||||
"webdav.title": "WebDAV",
|
||||
"webdav.user": "WebDAV 用户名"
|
||||
"webdav": {
|
||||
"autoSync": "自动备份",
|
||||
"autoSync.off": "关闭",
|
||||
"backup.button": "备份到 WebDAV",
|
||||
"host": "WebDAV 地址",
|
||||
"host.placeholder": "http://localhost:8080",
|
||||
"minute_interval_one": "{{count}} 分钟",
|
||||
"minute_interval_other": "{{count}} 分钟",
|
||||
"hour_interval_one": "{{count}} 小时",
|
||||
"hour_interval_other": "{{count}} 小时",
|
||||
"lastSync": "上次备份时间",
|
||||
"noSync": "等待下次备份",
|
||||
"password": "WebDAV 密码",
|
||||
"path": "WebDAV 路径",
|
||||
"path.placeholder": "/backup",
|
||||
"restore.button": "从 WebDAV 恢复",
|
||||
"restore.content": "从 WebDAV 恢复将覆盖当前数据,是否继续?",
|
||||
"restore.title": "从 WebDAV 恢复",
|
||||
"syncError": "备份错误",
|
||||
"syncStatus": "备份状态",
|
||||
"title": "WebDAV",
|
||||
"user": "WebDAV 用户名"
|
||||
},
|
||||
"minute_interval_one": "{{count}} 分钟",
|
||||
"minute_interval_other": "{{count}} 分钟",
|
||||
"hour_interval_one": "{{count}} 小时",
|
||||
"hour_interval_other": "{{count}} 小时"
|
||||
},
|
||||
"display.custom.css": "自定义 CSS",
|
||||
"display.custom.css.placeholder": "/* 这里写自定义CSS */",
|
||||
@@ -609,6 +652,10 @@
|
||||
"messages.input.title": "输入设置",
|
||||
"messages.markdown_rendering_input_message": "Markdown 渲染输入消息",
|
||||
"messages.math_engine": "数学公式引擎",
|
||||
"messages.grid_columns": "消息网格展示列数",
|
||||
"messages.grid_popover_trigger": "网格详情触发",
|
||||
"messages.grid_popover_trigger.hover": "悬停显示",
|
||||
"messages.grid_popover_trigger.click": "点击显示",
|
||||
"messages.metrics": "首字时延 {{time_first_token_millsec}}ms | 每秒 {{token_speed}} tokens",
|
||||
"messages.model.title": "模型设置",
|
||||
"messages.title": "消息设置",
|
||||
@@ -681,7 +728,7 @@
|
||||
},
|
||||
"shortcuts": {
|
||||
"action": "操作",
|
||||
"alt_warning": "Mac 系统不能使用 Option + 字母作为快捷键",
|
||||
"alt_warning": "Mac 系统中 Option 键只能与空格键组合使用",
|
||||
"clear_shortcut": "清除快捷键",
|
||||
"clear_topic": "清空消息",
|
||||
"copy_last_message": "复制上一条消息",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"settings.reasoning_effort.high": "長",
|
||||
"settings.reasoning_effort.low": "短",
|
||||
"settings.reasoning_effort.medium": "中",
|
||||
"settings.reasoning_effort.off": "關",
|
||||
"settings.reasoning_effort.tip": "該設置僅支持推理模型",
|
||||
"title": "助手"
|
||||
},
|
||||
@@ -136,7 +137,12 @@
|
||||
"topics.pinned": "固定話題",
|
||||
"topics.title": "話題",
|
||||
"topics.unpinned": "取消固定",
|
||||
"translate": "翻譯"
|
||||
"translate": "翻譯",
|
||||
"topics.prompt": "話題提示詞",
|
||||
"topics.prompt.tips": "話題提示詞:針對目前話題提供額外的補充提示詞",
|
||||
"topics.prompt.edit.title": "編輯話題提示詞",
|
||||
"artifacts.button.openExternal": "外部瀏覽器打開",
|
||||
"artifacts.preview.openExternal.error.content": "外部瀏覽器打開出錯"
|
||||
},
|
||||
"common": {
|
||||
"add": "添加",
|
||||
@@ -293,7 +299,12 @@
|
||||
"title": "知識庫",
|
||||
"url_added": "網址已添加",
|
||||
"url_placeholder": "請輸入網址, 多個網址用回車分隔",
|
||||
"urls": "網址"
|
||||
"urls": "網址",
|
||||
"threshold_tooltip": "用於衡量用戶問題與知識庫內容之間的相關性(0-1)",
|
||||
"threshold_placeholder": "未設置",
|
||||
"threshold_too_large_or_small": "閾值不能大於1或小於0",
|
||||
"no_match": "未匹配到知識庫內容",
|
||||
"threshold": "匹配度閾值"
|
||||
},
|
||||
"languages": {
|
||||
"arabic": "阿拉伯文",
|
||||
@@ -306,7 +317,8 @@
|
||||
"korean": "韓文",
|
||||
"portuguese": "葡萄牙文",
|
||||
"russian": "俄文",
|
||||
"spanish": "西班牙文"
|
||||
"spanish": "西班牙文",
|
||||
"german": "德文"
|
||||
},
|
||||
"mermaid": {
|
||||
"download": {
|
||||
@@ -344,7 +356,7 @@
|
||||
"error.invalid.enter.model": "請選擇一個模型",
|
||||
"error.invalid.proxy.url": "無效的代理 URL",
|
||||
"error.invalid.webdav": "無效的 WebDAV 設定",
|
||||
"error.notion.export": "Notion 匯入失敗",
|
||||
"error.notion.export": "導出Notion錯誤,請檢查連接狀態並對照文檔檢查配置",
|
||||
"error.notion.no_api_key": "未配置 Notion ApiKey 或 Notion DatabaseID",
|
||||
"group.delete.content": "刪除分組消息會刪除用戶提問和所有助手的回答",
|
||||
"group.delete.title": "刪除分組消息",
|
||||
@@ -356,6 +368,7 @@
|
||||
"message.multi_model_style.fold": "折疊",
|
||||
"message.multi_model_style.horizontal": "水平",
|
||||
"message.multi_model_style.vertical": "垂直",
|
||||
"message.multi_model_style.grid": "网格",
|
||||
"message.style": "消息樣式",
|
||||
"message.style.bubble": "氣泡",
|
||||
"message.style.plain": "簡潔",
|
||||
@@ -365,13 +378,13 @@
|
||||
"reset.double.confirm.title": "資料將會丟失!!!",
|
||||
"restore.success": "恢復成功",
|
||||
"save.success.title": "保存成功",
|
||||
"success.notion.export": "匯入 Notion 成功",
|
||||
"success.notion.export": "成功導出到Notion",
|
||||
"switch.disabled": "請等待當前回覆完成",
|
||||
"topic.added": "新話題已添加",
|
||||
"upgrade.success.button": "重新啟動",
|
||||
"upgrade.success.content": "請重新啟動應用以完成升級",
|
||||
"upgrade.success.title": "升級成功",
|
||||
"warn.notion.exporting": "Notion 正在匯入,請勿重複匯入",
|
||||
"warn.notion.exporting": "正在導出到Notion,請勿重複請求導出!",
|
||||
"error.invalid.api.host": "無效的 API 位址",
|
||||
"error.invalid.api.key": "無效的 API 密鑰"
|
||||
},
|
||||
@@ -439,6 +452,12 @@
|
||||
"keep_alive_time.title": "保持活躍時間",
|
||||
"title": "Ollama"
|
||||
},
|
||||
"lmstudio": {
|
||||
"keep_alive_time.description": "對話後模型在記憶體中保持的時間(預設為 5 分鐘)。",
|
||||
"keep_alive_time.placeholder": "分鐘",
|
||||
"keep_alive_time.title": "保持活躍時間",
|
||||
"title": "LM Studio"
|
||||
},
|
||||
"paintings": {
|
||||
"button.delete.image": "刪除繪圖",
|
||||
"button.delete.image.confirm": "確定要刪除此繪圖嗎?",
|
||||
@@ -472,8 +491,9 @@
|
||||
"baichuan": "百川",
|
||||
"baidu-cloud": "百度云千帆",
|
||||
"dashscope": "阿里雲百鍊",
|
||||
"modelscope": "ModelScope 魔搭",
|
||||
"deepseek": "深度求索",
|
||||
"doubao": "豆包",
|
||||
"doubao": "火山引擎",
|
||||
"fireworks": "Fireworks",
|
||||
"gemini": "Gemini",
|
||||
"gitee-ai": "Gitee AI",
|
||||
@@ -490,6 +510,7 @@
|
||||
"nvidia": "輝達",
|
||||
"ocoolai": "ocoolAI",
|
||||
"ollama": "Ollama",
|
||||
"lmstudio": "LM Studio",
|
||||
"openai": "OpenAI",
|
||||
"openrouter": "OpenRouter",
|
||||
"ppio": "PPIO 派歐雲",
|
||||
@@ -499,7 +520,10 @@
|
||||
"together": "Together",
|
||||
"yi": "零一萬物",
|
||||
"zhinao": "360智腦",
|
||||
"zhipu": "智譜AI"
|
||||
"zhipu": "智譜AI",
|
||||
"infini": "無問芯穹",
|
||||
"perplexity": "Perplexity",
|
||||
"dmxapi": "DMXAPI"
|
||||
},
|
||||
"settings": {
|
||||
"about": "關於與回饋",
|
||||
@@ -537,31 +561,50 @@
|
||||
"title": "清除緩存"
|
||||
},
|
||||
"data.title": "數據目錄",
|
||||
"notion.api_key": "Notion 金鑰",
|
||||
"notion.api_key": "Notion 密鑰",
|
||||
"notion.api_key_placeholder": "請輸入Notion 密鑰",
|
||||
"notion.database_id": "Notion 資料庫 ID",
|
||||
"notion.database_id_placeholder": "請輸入Notion 資料庫 ID",
|
||||
"notion.title": "Notion 配置",
|
||||
"notion.help": "Notion 配置文檔",
|
||||
"notion.check": {
|
||||
"button": "檢查",
|
||||
"fail": "連接失敗,請檢查網絡及Api_key和Database_id是否正確",
|
||||
"success": "連線成功",
|
||||
"error": "連接異常,請檢查網絡及Api_key和Database_id是否正確",
|
||||
"empty_api_key": "未配置Api_key",
|
||||
"empty_database_id": "未配置Database_id"
|
||||
},
|
||||
"title": "數據設定",
|
||||
"webdav.autoSync": "自動備份",
|
||||
"webdav.autoSync.off": "關閉",
|
||||
"webdav.backup.button": "從 WebDAV 備份",
|
||||
"webdav.host": "WebDAV 主機位址",
|
||||
"webdav.host.placeholder": "http://localhost:8080",
|
||||
"webdav.hours": "小時",
|
||||
"webdav.lastSync": "上次同步時間",
|
||||
"webdav.minutes": "分鐘",
|
||||
"webdav.noSync": "等待下次備份",
|
||||
"webdav.password": "WebDAV 密碼",
|
||||
"webdav.path": "WebDAV Path",
|
||||
"webdav.path.placeholder": "/backup",
|
||||
"webdav.restore.button": "從 WebDAV 恢復",
|
||||
"webdav.restore.content": "從 WebDAV 恢復將覆蓋當前資料,是否繼續?",
|
||||
"webdav.restore.title": "從 WebDAV 恢復",
|
||||
"webdav.syncError": "備份錯誤",
|
||||
"webdav.syncStatus": "備份狀態",
|
||||
"webdav.title": "WebDAV",
|
||||
"webdav.user": "WebDAV 使用者名稱",
|
||||
"webdav": {
|
||||
"autoSync": "自動備份",
|
||||
"autoSync.off": "關閉",
|
||||
"backup.button": "備份到 WebDAV",
|
||||
"host": "WebDAV 主機位址",
|
||||
"host.placeholder": "http://localhost:8080",
|
||||
"minute_interval_one": "{{count}} 分鐘",
|
||||
"minute_interval_other": "{{count}} 分鐘",
|
||||
"hour_interval_one": "{{count}} 小時",
|
||||
"hour_interval_other": "{{count}} 小時",
|
||||
"lastSync": "上次備份時間",
|
||||
"noSync": "等待下次備份",
|
||||
"password": "WebDAV 密碼",
|
||||
"path": "WebDAV 路徑",
|
||||
"path.placeholder": "/backup",
|
||||
"restore.button": "從 WebDAV 恢復",
|
||||
"restore.content": "從 WebDAV 恢復將覆蓋當前資料,是否繼續?",
|
||||
"restore.title": "從 WebDAV 恢復",
|
||||
"syncError": "備份錯誤",
|
||||
"syncStatus": "備份狀態",
|
||||
"title": "WebDAV",
|
||||
"user": "WebDAV 使用者名稱"
|
||||
},
|
||||
"app_data": "應用數據",
|
||||
"app_logs": "應用日誌"
|
||||
"app_logs": "應用日誌",
|
||||
"minute_interval_one": "{{count}} 分鐘",
|
||||
"minute_interval_other": "{{count}} 分鐘",
|
||||
"hour_interval_one": "{{count}} 小時",
|
||||
"hour_interval_other": "{{count}} 小時"
|
||||
},
|
||||
"display.custom.css": "自定義 CSS",
|
||||
"display.custom.css.placeholder": "/* 這裡寫自定義 CSS */",
|
||||
@@ -608,6 +651,10 @@
|
||||
"messages.input.show_estimated_tokens": "顯示預估 Token 數",
|
||||
"messages.input.title": "輸入設定",
|
||||
"messages.math_engine": "Markdown 渲染輸入訊息",
|
||||
"messages.grid_columns": "消息網格展示列數",
|
||||
"messages.grid_popover_trigger": "網格詳情觸發",
|
||||
"messages.grid_popover_trigger.hover": "懸停顯示",
|
||||
"messages.grid_popover_trigger.click": "點擊顯示",
|
||||
"messages.metrics": "首字時延 {{time_first_token_millsec}}ms | 每秒 {{token_speed}} tokens",
|
||||
"messages.model.title": "模型設定",
|
||||
"messages.title": "訊息設定",
|
||||
@@ -680,7 +727,7 @@
|
||||
},
|
||||
"shortcuts": {
|
||||
"action": "操作",
|
||||
"alt_warning": "Mac 不能使用 Option + 字母作為快捷鍵",
|
||||
"alt_warning": "Mac 系統中 Option 鍵只能與空白鍵組合使用",
|
||||
"clear_shortcut": "清除快捷鍵",
|
||||
"clear_topic": "清除所有訊息",
|
||||
"copy_last_message": "複製上一条消息",
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon'
|
||||
import { fetchGenerate } from '@renderer/services/ApiService'
|
||||
import { getDefaultModel } from '@renderer/services/AssistantService'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { Agent } from '@renderer/types'
|
||||
import { Agent, KnowledgeBase } from '@renderer/types'
|
||||
import { getLeadingEmoji, uuid } from '@renderer/utils'
|
||||
import { Button, Form, FormInstance, Input, Modal, Popover, Select, SelectProps } from 'antd'
|
||||
import TextArea from 'antd/es/input/TextArea'
|
||||
@@ -25,7 +25,7 @@ type FieldType = {
|
||||
id: string
|
||||
name: string
|
||||
prompt: string
|
||||
knowledge_base_id: string
|
||||
knowledge_base_ids: string[]
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
@@ -37,8 +37,8 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
const [emoji, setEmoji] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const knowledgeState = useAppSelector((state) => state.knowledge)
|
||||
const knowledgeOptions: SelectProps['options'] = []
|
||||
const showKnowledgeIcon = useSidebarIconShow('knowledge')
|
||||
const knowledgeOptions: SelectProps['options'] = []
|
||||
|
||||
knowledgeState.bases.forEach((base) => {
|
||||
knowledgeOptions.push({
|
||||
@@ -57,7 +57,9 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
const _agent: Agent = {
|
||||
id: uuid(),
|
||||
name: values.name,
|
||||
knowledge_base: knowledgeState.bases.find((t) => t.id === values.knowledge_base_id),
|
||||
knowledge_bases: values.knowledge_base_ids
|
||||
?.map((id) => knowledgeState.bases.find((t) => t.id === id))
|
||||
?.filter((base): base is KnowledgeBase => base !== undefined),
|
||||
emoji: _emoji,
|
||||
prompt: values.prompt,
|
||||
defaultModel: getDefaultModel(),
|
||||
@@ -154,12 +156,18 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
/>
|
||||
</div>
|
||||
{showKnowledgeIcon && (
|
||||
<Form.Item name="knowledge_base_id" label={t('agents.add.knowledge_base')} rules={[{ required: false }]}>
|
||||
<Form.Item name="knowledge_base_ids" label={t('agents.add.knowledge_base')} rules={[{ required: false }]}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
placeholder={t('agents.add.knowledge_base.placeholder')}
|
||||
menuItemSelectedIcon={<CheckOutlined />}
|
||||
options={knowledgeOptions}
|
||||
filterOption={(input, option) =>
|
||||
String(option?.label ?? '')
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
@@ -52,7 +52,6 @@ interface Props {
|
||||
|
||||
let _text = ''
|
||||
let _files: FileType[] = []
|
||||
let _base: KnowledgeBase | undefined
|
||||
|
||||
const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
|
||||
const [text, setText] = useState(_text)
|
||||
@@ -83,7 +82,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
|
||||
const [spaceClickCount, setSpaceClickCount] = useState(0)
|
||||
const spaceClickTimer = useRef<NodeJS.Timeout>()
|
||||
const [isTranslating, setIsTranslating] = useState(false)
|
||||
const [selectedKnowledgeBase, setSelectedKnowledgeBase] = useState<KnowledgeBase | undefined>(_base)
|
||||
const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState<KnowledgeBase[]>([])
|
||||
const [mentionModels, setMentionModels] = useState<Model[]>([])
|
||||
const [isMentionPopupOpen, setIsMentionPopupOpen] = useState(false)
|
||||
|
||||
@@ -104,7 +103,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
|
||||
|
||||
_text = text
|
||||
_files = files
|
||||
_base = selectedKnowledgeBase
|
||||
|
||||
const sendMessage = useCallback(async () => {
|
||||
await modelGenerating()
|
||||
@@ -124,8 +122,8 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
|
||||
status: 'success'
|
||||
}
|
||||
|
||||
if (selectedKnowledgeBase) {
|
||||
message.knowledgeBaseIds = [selectedKnowledgeBase.id]
|
||||
if (selectedKnowledgeBases) {
|
||||
message.knowledgeBaseIds = selectedKnowledgeBases.map((base) => base.id)
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
@@ -144,7 +142,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
|
||||
setTimeout(() => resizeTextArea(), 0)
|
||||
|
||||
setExpend(false)
|
||||
}, [inputEmpty, text, assistant.id, assistant.topics, selectedKnowledgeBase, files, mentionModels])
|
||||
}, [inputEmpty, text, assistant.id, assistant.topics, selectedKnowledgeBases, files, mentionModels])
|
||||
|
||||
const translate = async () => {
|
||||
if (isTranslating) {
|
||||
@@ -458,14 +456,15 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedKnowledgeBase(showKnowledgeIcon ? assistant.knowledge_base : undefined)
|
||||
}, [assistant.id, assistant.knowledge_base, showKnowledgeIcon])
|
||||
// if assistant knowledge bases are undefined return []
|
||||
setSelectedKnowledgeBases(showKnowledgeIcon ? (assistant.knowledge_bases ?? []) : [])
|
||||
}, [assistant.id, assistant.knowledge_bases, showKnowledgeIcon])
|
||||
|
||||
const textareaRows = window.innerHeight >= 1000 || isBubbleStyle ? 2 : 1
|
||||
|
||||
const handleKnowledgeBaseSelect = (base?: KnowledgeBase) => {
|
||||
updateAssistant({ ...assistant, knowledge_base: base })
|
||||
setSelectedKnowledgeBase(base)
|
||||
const handleKnowledgeBaseSelect = (bases?: KnowledgeBase[]) => {
|
||||
updateAssistant({ ...assistant, knowledge_bases: bases })
|
||||
setSelectedKnowledgeBases(bases ?? [])
|
||||
}
|
||||
|
||||
const onMentionModel = (model: Model) => {
|
||||
@@ -511,7 +510,14 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
|
||||
ref={textareaRef}
|
||||
style={{ fontSize }}
|
||||
styles={{ textarea: TextareaStyle }}
|
||||
onFocus={() => setInputFocus(true)}
|
||||
onFocus={(e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||
setInputFocus(true)
|
||||
const textArea = e.target
|
||||
if (textArea) {
|
||||
const length = textArea.value.length
|
||||
textArea.setSelectionRange(length, length)
|
||||
}
|
||||
}}
|
||||
onBlur={() => setInputFocus(false)}
|
||||
onInput={onInput}
|
||||
disabled={searching}
|
||||
@@ -566,7 +572,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
|
||||
</Tooltip>
|
||||
{showKnowledgeIcon && (
|
||||
<KnowledgeBaseButton
|
||||
selectedBase={selectedKnowledgeBase}
|
||||
selectedBases={selectedKnowledgeBases}
|
||||
onSelect={handleKnowledgeBaseSelect}
|
||||
ToolbarButton={ToolbarButton}
|
||||
disabled={files.length > 0}
|
||||
|
||||
@@ -1,71 +1,68 @@
|
||||
import { FileSearchOutlined } from '@ant-design/icons'
|
||||
import { CheckOutlined, FileSearchOutlined } from '@ant-design/icons'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { KnowledgeBase } from '@renderer/types'
|
||||
import { Button, Popover, Tooltip } from 'antd'
|
||||
import { Popover, Select, SelectProps, Tooltip } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
selectedBase?: KnowledgeBase
|
||||
onSelect: (base?: KnowledgeBase) => void
|
||||
selectedBases?: KnowledgeBase[]
|
||||
onSelect: (bases: KnowledgeBase[]) => void
|
||||
disabled?: boolean
|
||||
ToolbarButton?: any
|
||||
}
|
||||
|
||||
const KnowledgeBaseSelector: FC<Props> = ({ selectedBase, onSelect }) => {
|
||||
const KnowledgeBaseSelector: FC<Props> = ({ selectedBases, onSelect }) => {
|
||||
const { t } = useTranslation()
|
||||
const knowledgeState = useAppSelector((state) => state.knowledge)
|
||||
const knowledgeOptions: SelectProps['options'] = knowledgeState.bases.map((base) => ({
|
||||
label: base.name,
|
||||
value: base.id
|
||||
}))
|
||||
|
||||
return (
|
||||
<SelectorContainer>
|
||||
{knowledgeState.bases.length === 0 ? (
|
||||
<EmptyMessage>{t('knowledge.no_bases')}</EmptyMessage>
|
||||
) : (
|
||||
<>
|
||||
{selectedBase && (
|
||||
<Button type="link" block onClick={() => onSelect(undefined)} style={{ textAlign: 'left' }}>
|
||||
{t('knowledge.clear_selection')}
|
||||
</Button>
|
||||
)}
|
||||
{knowledgeState.bases.map((base) => (
|
||||
<Button
|
||||
key={base.id}
|
||||
type={selectedBase?.id === base.id ? 'primary' : 'text'}
|
||||
block
|
||||
onClick={() => onSelect(base)}
|
||||
style={{ textAlign: 'left' }}>
|
||||
{base.name}
|
||||
</Button>
|
||||
))}
|
||||
</>
|
||||
<Select
|
||||
mode="multiple"
|
||||
value={selectedBases?.map((base) => base.id)}
|
||||
allowClear
|
||||
placeholder={t('agents.add.knowledge_base.placeholder')}
|
||||
menuItemSelectedIcon={<CheckOutlined />}
|
||||
options={knowledgeOptions}
|
||||
filterOption={(input, option) =>
|
||||
String(option?.label ?? '')
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
onChange={(ids) => {
|
||||
const newSelected = knowledgeState.bases.filter((base) => ids.includes(base.id))
|
||||
onSelect(newSelected)
|
||||
}}
|
||||
style={{ width: '200px' }}
|
||||
/>
|
||||
)}
|
||||
</SelectorContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const KnowledgeBaseButton: FC<Props> = ({ selectedBase, onSelect, disabled, ToolbarButton }) => {
|
||||
const KnowledgeBaseButton: FC<Props> = ({ selectedBases, onSelect, disabled, ToolbarButton }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (selectedBase) {
|
||||
return (
|
||||
<Tooltip placement="top" title={selectedBase.name} arrow>
|
||||
<ToolbarButton type="text" onClick={() => onSelect(undefined)}>
|
||||
<FileSearchOutlined style={{ color: selectedBase ? 'var(--color-link)' : 'var(--color-icon)' }} />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" title={t('chat.input.knowledge_base')} arrow>
|
||||
<Popover
|
||||
placement="top"
|
||||
content={<KnowledgeBaseSelector selectedBase={selectedBase} onSelect={onSelect} />}
|
||||
content={<KnowledgeBaseSelector selectedBases={selectedBases} onSelect={onSelect} />}
|
||||
overlayStyle={{ maxWidth: 400 }}
|
||||
trigger="click">
|
||||
<ToolbarButton type="text" onClick={() => selectedBase && onSelect(undefined)} disabled={disabled}>
|
||||
<FileSearchOutlined style={{ color: selectedBase ? 'var(--color-link)' : 'var(--color-icon)' }} />
|
||||
<ToolbarButton type="text" disabled={disabled}>
|
||||
<FileSearchOutlined
|
||||
style={{ color: selectedBases && selectedBases?.length > 0 ? 'var(--color-link)' : 'var(--color-icon)' }}
|
||||
/>
|
||||
</ToolbarButton>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import { Model, Provider } from '@renderer/types'
|
||||
import { Avatar, Dropdown, Tooltip } from 'antd'
|
||||
import { first, sortBy } from 'lodash'
|
||||
import { FC, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { FC, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled, { createGlobalStyle } from 'styled-components'
|
||||
|
||||
@@ -27,6 +27,13 @@ const MentionModelsButton: FC<Props> = ({ mentionModels, onMentionModel: onSelec
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const itemRefs = useRef<Array<HTMLDivElement | null>>([])
|
||||
// Add a new state to track if menu was dismissed
|
||||
const [menuDismissed, setMenuDismissed] = useState(false)
|
||||
|
||||
const setItemRef = (index: number, el: HTMLDivElement | null) => {
|
||||
itemRefs.current[index] = el
|
||||
}
|
||||
|
||||
const togglePin = async (modelId: string) => {
|
||||
const newPinnedModels = pinnedModels.includes(modelId)
|
||||
@@ -39,7 +46,7 @@ const MentionModelsButton: FC<Props> = ({ mentionModels, onMentionModel: onSelec
|
||||
|
||||
const handleModelSelect = (model: Model) => {
|
||||
// Check if model is already selected
|
||||
if (mentionModels.some((selected) => selected.id === model.id)) {
|
||||
if (mentionModels.some((selected) => getModelUniqId(selected) === getModelUniqId(model))) {
|
||||
return
|
||||
}
|
||||
onSelect(model)
|
||||
@@ -167,12 +174,21 @@ const MentionModelsButton: FC<Props> = ({ mentionModels, onMentionModel: onSelec
|
||||
loadPinnedModels()
|
||||
}, [])
|
||||
|
||||
// Scroll to the first menu item when the mode selection menu opens
|
||||
useLayoutEffect(() => {
|
||||
if (isOpen && flatModelItems.length > 0 && itemRefs.current[0]) {
|
||||
itemRefs.current[0].scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
}, [isOpen, flatModelItems])
|
||||
|
||||
useEffect(() => {
|
||||
const showModelSelector = () => {
|
||||
dropdownRef.current?.click()
|
||||
itemRefs.current = []
|
||||
setIsOpen(true)
|
||||
setSelectedIndex(0)
|
||||
setSearchText('')
|
||||
setMenuDismissed(false) // Reset dismissed flag when manually showing selector
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -180,15 +196,23 @@ const MentionModelsButton: FC<Props> = ({ mentionModels, onMentionModel: onSelec
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => (prev < flatModelItems.length - 1 ? prev + 1 : prev))
|
||||
setSelectedIndex((prev) => {
|
||||
const newIndex = prev < flatModelItems.length - 1 ? prev + 1 : 0
|
||||
itemRefs.current[newIndex]?.scrollIntoView({ block: 'nearest' })
|
||||
return newIndex
|
||||
})
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev))
|
||||
setSelectedIndex((prev) => {
|
||||
const newIndex = prev > 0 ? prev - 1 : flatModelItems.length - 1
|
||||
itemRefs.current[newIndex]?.scrollIntoView({ block: 'nearest' })
|
||||
return newIndex
|
||||
})
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (selectedIndex >= 0 && selectedIndex < flatModelItems.length) {
|
||||
const selectedModel = flatModelItems[selectedIndex].model
|
||||
if (!mentionModels.some((selected) => selected.id === selectedModel.id)) {
|
||||
if (!mentionModels.some((selected) => getModelUniqId(selected) === getModelUniqId(selectedModel))) {
|
||||
flatModelItems[selectedIndex].onClick()
|
||||
}
|
||||
setIsOpen(false)
|
||||
@@ -197,6 +221,7 @@ const MentionModelsButton: FC<Props> = ({ mentionModels, onMentionModel: onSelec
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsOpen(false)
|
||||
setSearchText('')
|
||||
setMenuDismissed(true) // Set dismissed flag when Escape is pressed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,10 +234,14 @@ const MentionModelsButton: FC<Props> = ({ mentionModels, onMentionModel: onSelec
|
||||
if (lastAtIndex === -1 || textBeforeCursor.slice(lastAtIndex + 1).includes(' ')) {
|
||||
setIsOpen(false)
|
||||
setSearchText('')
|
||||
} else if (lastAtIndex !== -1) {
|
||||
// Get the text after @ for search
|
||||
const searchStr = textBeforeCursor.slice(lastAtIndex + 1)
|
||||
setSearchText(searchStr)
|
||||
setMenuDismissed(false) // Reset dismissed flag when @ is removed
|
||||
} else {
|
||||
// Only open menu if it wasn't explicitly dismissed
|
||||
if (!menuDismissed) {
|
||||
setIsOpen(true)
|
||||
const searchStr = textBeforeCursor.slice(lastAtIndex + 1)
|
||||
setSearchText(searchStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,38 +260,42 @@ const MentionModelsButton: FC<Props> = ({ mentionModels, onMentionModel: onSelec
|
||||
textArea.removeEventListener('input', handleTextChange)
|
||||
}
|
||||
}
|
||||
}, [isOpen, selectedIndex, flatModelItems, mentionModels])
|
||||
|
||||
// Hide dropdown if no models available
|
||||
if (flatModelItems.length === 0) {
|
||||
return null
|
||||
}
|
||||
}, [isOpen, selectedIndex, flatModelItems, mentionModels, menuDismissed])
|
||||
|
||||
const menu = (
|
||||
<div ref={menuRef} className="ant-dropdown-menu">
|
||||
{modelMenuItems.map((group, groupIndex) => {
|
||||
if (!group) return null
|
||||
{flatModelItems.length > 0 ? (
|
||||
modelMenuItems.map((group, groupIndex) => {
|
||||
if (!group) return null
|
||||
|
||||
// Calculate the starting index for this group's items
|
||||
const startIndex = modelMenuItems.slice(0, groupIndex).reduce((acc, g) => acc + (g?.children?.length || 0), 0)
|
||||
// Calculate starting index for items in this group
|
||||
const startIndex = modelMenuItems.slice(0, groupIndex).reduce((acc, g) => acc + (g?.children?.length || 0), 0)
|
||||
|
||||
return (
|
||||
<div key={group.key} className="ant-dropdown-menu-item-group">
|
||||
<div className="ant-dropdown-menu-item-group-title">{group.label}</div>
|
||||
<div>
|
||||
{group.children.map((item, idx) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className={`ant-dropdown-menu-item ${selectedIndex === startIndex + idx ? 'ant-dropdown-menu-item-selected' : ''}`}
|
||||
onClick={item.onClick}>
|
||||
<span className="ant-dropdown-menu-item-icon">{item.icon}</span>
|
||||
{item.label}
|
||||
</div>
|
||||
))}
|
||||
return (
|
||||
<div key={group.key} className="ant-dropdown-menu-item-group">
|
||||
<div className="ant-dropdown-menu-item-group-title">{group.label}</div>
|
||||
<div>
|
||||
{group.children.map((item, idx) => (
|
||||
<div
|
||||
key={item.key}
|
||||
ref={(el) => setItemRef(startIndex + idx, el)}
|
||||
className={`ant-dropdown-menu-item ${
|
||||
selectedIndex === startIndex + idx ? 'ant-dropdown-menu-item-selected' : ''
|
||||
}`}
|
||||
onClick={item.onClick}>
|
||||
<span className="ant-dropdown-menu-item-icon">{item.icon}</span>
|
||||
{item.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="ant-dropdown-menu-item-group">
|
||||
<div className="ant-dropdown-menu-item no-results">{t('models.no_matches')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -293,6 +326,7 @@ const DropdownMenuStyle = createGlobalStyle`
|
||||
overflow-x: hidden;
|
||||
padding: 4px 0;
|
||||
margin-bottom: 40px;
|
||||
position: relative;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
@@ -300,13 +334,28 @@ const DropdownMenuStyle = createGlobalStyle`
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--color-scrollbar);
|
||||
border-radius: 3px;
|
||||
border-radius: 10px;
|
||||
background: var(--color-scrollbar-thumb);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-scrollbar-thumb-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
padding: 8px 12px;
|
||||
color: var(--color-text-3);
|
||||
cursor: default;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-dropdown-menu-item-group {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useProviders } from '@renderer/hooks/useProvider'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import { Model } from '@renderer/types'
|
||||
import { Flex, Tag } from 'antd'
|
||||
import { FC } from 'react'
|
||||
@@ -13,14 +14,19 @@ const MentionModelsInput: FC<{
|
||||
const { t } = useTranslation()
|
||||
|
||||
const getProviderName = (model: Model) => {
|
||||
const provider = providers.find((p) => p.models?.some((m) => m.id === model.id))
|
||||
const provider = providers.find((p) => p.id === model?.provider)
|
||||
return provider ? (provider.isSystem ? t(`provider.${provider.id}`) : provider.name) : ''
|
||||
}
|
||||
|
||||
return (
|
||||
<Container gap="4px 0" wrap>
|
||||
{selectedModels.map((model) => (
|
||||
<Tag bordered={false} color="processing" key={model.id} closable onClose={() => onRemoveModel(model)}>
|
||||
<Tag
|
||||
bordered={false}
|
||||
color="processing"
|
||||
key={getModelUniqId(model)}
|
||||
closable
|
||||
onClose={() => onRemoveModel(model)}>
|
||||
@{model.name} ({getProviderName(model)})
|
||||
</Tag>
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DownloadOutlined, ExpandOutlined } from '@ant-design/icons'
|
||||
import { DownloadOutlined, ExpandOutlined, LinkOutlined } from '@ant-design/icons'
|
||||
import MinApp from '@renderer/components/MinApp'
|
||||
import { AppLogo } from '@renderer/config/env'
|
||||
import { extractTitle } from '@renderer/utils/formats'
|
||||
@@ -13,29 +13,55 @@ interface Props {
|
||||
|
||||
const Artifacts: FC<Props> = ({ html }) => {
|
||||
const { t } = useTranslation()
|
||||
const title = extractTitle(html) || 'Artifacts' + ' ' + t('chat.artifacts.button.preview')
|
||||
const title = extractTitle(html) || 'Artifacts ' + t('chat.artifacts.button.preview')
|
||||
|
||||
const onPreview = async () => {
|
||||
/**
|
||||
* 在应用内打开
|
||||
*/
|
||||
const handleOpenInApp = async () => {
|
||||
const path = await window.api.file.create('artifacts-preview.html')
|
||||
await window.api.file.write(path, html)
|
||||
|
||||
const filePath = `file://${path}`
|
||||
MinApp.start({
|
||||
name: title,
|
||||
logo: AppLogo,
|
||||
url: `file://${path}`
|
||||
url: filePath
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 外部链接打开
|
||||
*/
|
||||
const handleOpenExternal = async () => {
|
||||
const path = await window.api.file.create('artifacts-preview.html')
|
||||
await window.api.file.write(path, html)
|
||||
const filePath = `file://${path}`
|
||||
|
||||
if (window.api.shell && window.api.shell.openExternal) {
|
||||
window.api.shell.openExternal(filePath)
|
||||
} else {
|
||||
console.error(t('artifacts.preview.openExternal.error.content'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
*/
|
||||
const onDownload = () => {
|
||||
window.api.file.save(`${title}.html`, html)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Button type="primary" icon={<ExpandOutlined />} onClick={onPreview} size="small">
|
||||
<Button icon={<ExpandOutlined />} onClick={handleOpenInApp}>
|
||||
{t('chat.artifacts.button.preview')}
|
||||
</Button>
|
||||
<Button icon={<DownloadOutlined />} onClick={onDownload} size="small">
|
||||
|
||||
<Button icon={<LinkOutlined />} onClick={handleOpenExternal}>
|
||||
{t('chat.artifacts.button.openExternal')}
|
||||
</Button>
|
||||
|
||||
<Button icon={<DownloadOutlined />} onClick={onDownload}>
|
||||
{t('chat.artifacts.button.download')}
|
||||
</Button>
|
||||
</Container>
|
||||
|
||||
@@ -3,10 +3,12 @@ import db from '@renderer/databases'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useModel } from '@renderer/hooks/useModel'
|
||||
import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useTopic } from '@renderer/hooks/useTopic'
|
||||
import { fetchChatCompletion } from '@renderer/services/ApiService'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { getMessageModelId } from '@renderer/services/MessagesService'
|
||||
import { estimateMessageUsage } from '@renderer/services/TokenService'
|
||||
import { getContextCount, getMessageModelId } from '@renderer/services/MessagesService'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import { estimateHistoryTokens, estimateMessageUsage } from '@renderer/services/TokenService'
|
||||
import { Message, Topic } from '@renderer/types'
|
||||
import { classNames, runAsyncFunction } from '@renderer/utils'
|
||||
import { Divider } from 'antd'
|
||||
@@ -43,7 +45,7 @@ const getMessageBackground = (isBubbleStyle: boolean, isAssistantMessage: boolea
|
||||
|
||||
const MessageItem: FC<Props> = ({
|
||||
message: _message,
|
||||
topic,
|
||||
topic: _topic,
|
||||
index,
|
||||
hidePresetMessages,
|
||||
isGrouped,
|
||||
@@ -55,10 +57,11 @@ const MessageItem: FC<Props> = ({
|
||||
const [message, setMessage] = useState(_message)
|
||||
const { t } = useTranslation()
|
||||
const { assistant, setModel } = useAssistant(message.assistantId)
|
||||
const model = useModel(getMessageModelId(message)) || message.model
|
||||
const model = useModel(getMessageModelId(message), message.model?.provider) || message.model
|
||||
const { isBubbleStyle } = useMessageStyle()
|
||||
const { showMessageDivider, messageFont, fontSize } = useSettings()
|
||||
const messageContainerRef = useRef<HTMLDivElement>(null)
|
||||
const topic = useTopic(assistant, _topic?.id)
|
||||
|
||||
const isLastMessage = index === 0
|
||||
const isAssistantMessage = message.role === 'assistant'
|
||||
@@ -73,13 +76,22 @@ const MessageItem: FC<Props> = ({
|
||||
const messageBackground = getMessageBackground(isBubbleStyle, isAssistantMessage)
|
||||
|
||||
const onEditMessage = useCallback(
|
||||
(msg: Message) => {
|
||||
async (msg: Message) => {
|
||||
const usage = await estimateMessageUsage(msg)
|
||||
msg.usage = usage
|
||||
|
||||
setMessage(msg)
|
||||
const messages = onGetMessages?.()?.map((m) => (m.id === message.id ? msg : m))
|
||||
messages && onSetMessages?.(messages)
|
||||
topic && db.topics.update(topic.id, { messages })
|
||||
|
||||
if (messages) {
|
||||
const tokensCount = await estimateHistoryTokens(assistant, messages)
|
||||
const contextCount = getContextCount(assistant, messages)
|
||||
EventEmitter.emit(EVENT_NAMES.ESTIMATED_TOKEN_COUNT, { tokensCount, contextCount })
|
||||
}
|
||||
},
|
||||
[message.id, onGetMessages, onSetMessages, topic]
|
||||
[message.id, onGetMessages, onSetMessages, topic, assistant]
|
||||
)
|
||||
|
||||
const messageHighlightHandler = (highlight: boolean = true) => {
|
||||
@@ -121,6 +133,12 @@ const MessageItem: FC<Props> = ({
|
||||
const messages = onGetMessages()
|
||||
const assistantWithModel = message.model ? { ...assistant, model: message.model } : assistant
|
||||
|
||||
if (topic.prompt) {
|
||||
assistantWithModel.prompt = assistantWithModel.prompt
|
||||
? `${assistantWithModel.prompt}\n${topic.prompt}`
|
||||
: topic.prompt
|
||||
}
|
||||
|
||||
fetchChatCompletion({
|
||||
message,
|
||||
messages: messages
|
||||
@@ -168,7 +186,7 @@ const MessageItem: FC<Props> = ({
|
||||
})}
|
||||
ref={messageContainerRef}
|
||||
style={{ ...style, alignItems: isBubbleStyle ? (isAssistantMessage ? 'start' : 'end') : undefined }}>
|
||||
<MessageHeader message={message} assistant={assistant} model={model} key={getMessageModelId(message)} />
|
||||
<MessageHeader message={message} assistant={assistant} model={model} key={getModelUniqId(model)} />
|
||||
<MessageContentContainer
|
||||
className="message-content-container"
|
||||
style={{ fontFamily, fontSize, background: messageBackground }}>
|
||||
@@ -232,6 +250,7 @@ const MessageContentContainer = styled.div`
|
||||
justify-content: space-between;
|
||||
margin-left: 46px;
|
||||
margin-top: 5px;
|
||||
overflow-y: auto;
|
||||
`
|
||||
|
||||
const MessageFooter = styled.div`
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { InfoCircleOutlined, SyncOutlined, TranslationOutlined } from '@ant-design/icons'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
import { Message, Model } from '@renderer/types'
|
||||
import { getBriefInfo } from '@renderer/utils'
|
||||
import { withMessageThought } from '@renderer/utils/formats'
|
||||
@@ -77,7 +78,7 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
|
||||
return (
|
||||
<Fragment>
|
||||
<Flex gap="8px" wrap style={{ marginBottom: 10 }}>
|
||||
{message.mentions?.map((model) => <MentionTag key={model.id}>{'@' + model.name}</MentionTag>)}
|
||||
{message.mentions?.map((model) => <MentionTag key={getModelUniqId(model)}>{'@' + model.name}</MentionTag>)}
|
||||
</Flex>
|
||||
<MessageThought message={message} />
|
||||
<Markdown message={{ ...message, content: processedContent }} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Message } from '@renderer/types'
|
||||
import { formatErrorMessage } from '@renderer/utils/error'
|
||||
import { Alert as AntdAlert } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -8,8 +9,16 @@ import Markdown from '../Markdown/Markdown'
|
||||
const MessageError: FC<{ message: Message }> = ({ message }) => {
|
||||
return (
|
||||
<>
|
||||
<MessageErrorInfo message={message} />
|
||||
<Markdown message={message} />
|
||||
{message.error && (
|
||||
<Markdown
|
||||
message={{
|
||||
...message,
|
||||
content: formatErrorMessage(message.error)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<MessageErrorInfo message={message} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -27,7 +36,7 @@ const MessageErrorInfo: FC<{ message: Message }> = ({ message }) => {
|
||||
}
|
||||
|
||||
const Alert = styled(AntdAlert)`
|
||||
margin-bottom: 15px;
|
||||
margin: 15px 0 8px;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
`
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { ColumnHeightOutlined, ColumnWidthOutlined, DeleteOutlined, FolderOutlined } from '@ant-design/icons'
|
||||
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { MultiModelMessageStyle } from '@renderer/store/settings'
|
||||
import { Message, Model, Topic } from '@renderer/types'
|
||||
import { Button, Segmented as AntdSegmented } from 'antd'
|
||||
import { Dispatch, FC, SetStateAction, useEffect, useState } from 'react'
|
||||
import { Message, Topic } from '@renderer/types'
|
||||
import { Popover } from 'antd'
|
||||
import { Dispatch, FC, memo, SetStateAction, useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled, { css } from 'styled-components'
|
||||
|
||||
import MessageItem from './Message'
|
||||
import MessageGroupMenuBar from './MessageGroupMenuBar'
|
||||
|
||||
interface Props {
|
||||
messages: (Message & { index: number })[]
|
||||
@@ -32,7 +29,7 @@ const MessageGroup: FC<Props> = ({
|
||||
onGetMessages,
|
||||
onDeleteGroupMessages
|
||||
}) => {
|
||||
const { multiModelMessageStyle: multiModelMessageStyleSetting } = useSettings()
|
||||
const { multiModelMessageStyle: multiModelMessageStyleSetting, gridColumns, gridPopoverTrigger } = useSettings()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [multiModelMessageStyle, setMultiModelMessageStyle] =
|
||||
@@ -42,8 +39,9 @@ const MessageGroup: FC<Props> = ({
|
||||
const [selectedIndex, setSelectedIndex] = useState(messageLength - 1)
|
||||
|
||||
const isGrouped = messageLength > 1
|
||||
const isHorizontal = multiModelMessageStyle === 'horizontal'
|
||||
|
||||
const onDelete = async () => {
|
||||
const onDelete = useCallback(async () => {
|
||||
window.modal.confirm({
|
||||
title: t('message.group.delete.title'),
|
||||
content: t('message.group.delete.content'),
|
||||
@@ -57,116 +55,144 @@ const MessageGroup: FC<Props> = ({
|
||||
askId && onDeleteGroupMessages?.(askId)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [messages, onDeleteGroupMessages, t])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(messageLength - 1)
|
||||
}, [messageLength])
|
||||
|
||||
const isHorizontal = multiModelMessageStyle === 'horizontal'
|
||||
|
||||
return (
|
||||
<GroupContainer $isGrouped={isGrouped} $layout={multiModelMessageStyle}>
|
||||
<GridContainer $count={messageLength} $layout={multiModelMessageStyle}>
|
||||
{messages.map((message, index) => (
|
||||
<MessageWrapper
|
||||
$layout={multiModelMessageStyle}
|
||||
$selected={index === selectedIndex}
|
||||
$isGrouped={isGrouped}
|
||||
key={message.id}
|
||||
className={message.role === 'assistant' && isHorizontal && isGrouped ? 'group-message-wrapper' : ''}>
|
||||
<MessageItem
|
||||
isGrouped={isGrouped}
|
||||
message={message}
|
||||
topic={topic}
|
||||
index={message.index}
|
||||
hidePresetMessages={hidePresetMessages}
|
||||
style={{ paddingTop: isGrouped && multiModelMessageStyle === 'horizontal' ? 0 : 15 }}
|
||||
onSetMessages={onSetMessages}
|
||||
onDeleteMessage={onDeleteMessage}
|
||||
onGetMessages={onGetMessages}
|
||||
/>
|
||||
</MessageWrapper>
|
||||
))}
|
||||
<GridContainer $count={messageLength} $layout={multiModelMessageStyle} $gridColumns={gridColumns}>
|
||||
{messages.map((message, index) => {
|
||||
const isGridGroupMessage = multiModelMessageStyle === 'grid' && message.role === 'assistant' && isGrouped
|
||||
if (isGridGroupMessage) {
|
||||
return (
|
||||
<Popover
|
||||
content={
|
||||
<MessageWrapper
|
||||
$layout={multiModelMessageStyle}
|
||||
$selected={index === selectedIndex}
|
||||
$isGrouped={isGrouped}
|
||||
$isInPopover={true}
|
||||
key={message.id}>
|
||||
<MessageItem
|
||||
isGrouped={isGrouped}
|
||||
message={message}
|
||||
topic={topic}
|
||||
index={message.index}
|
||||
hidePresetMessages={hidePresetMessages}
|
||||
style={{
|
||||
paddingTop: isGrouped && ['horizontal', 'grid'].includes(multiModelMessageStyle) ? 0 : 15
|
||||
}}
|
||||
onSetMessages={onSetMessages}
|
||||
onDeleteMessage={onDeleteMessage}
|
||||
onGetMessages={onGetMessages}
|
||||
/>
|
||||
</MessageWrapper>
|
||||
}
|
||||
trigger={gridPopoverTrigger}
|
||||
styles={{ root: { maxWidth: '60vw', minWidth: '550px', overflowY: 'auto', zIndex: 1000 } }}
|
||||
getPopupContainer={(triggerNode) => triggerNode.parentNode as HTMLElement}
|
||||
key={message.id}>
|
||||
<MessageWrapper
|
||||
$layout={multiModelMessageStyle}
|
||||
$selected={index === selectedIndex}
|
||||
$isGrouped={isGrouped}
|
||||
key={message.id}>
|
||||
<MessageItem
|
||||
isGrouped={isGrouped}
|
||||
message={message}
|
||||
topic={topic}
|
||||
index={message.index}
|
||||
hidePresetMessages={hidePresetMessages}
|
||||
style={
|
||||
gridPopoverTrigger === 'hover' && isGrouped
|
||||
? {
|
||||
paddingTop: isGrouped && ['horizontal', 'grid'].includes(multiModelMessageStyle) ? 0 : 15,
|
||||
overflow: isGrouped ? 'hidden' : 'auto',
|
||||
maxHeight: isGrouped ? '280px' : 'unset'
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onSetMessages={onSetMessages}
|
||||
onDeleteMessage={onDeleteMessage}
|
||||
onGetMessages={onGetMessages}
|
||||
/>
|
||||
</MessageWrapper>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<MessageWrapper
|
||||
$layout={multiModelMessageStyle}
|
||||
$selected={index === selectedIndex}
|
||||
$isGrouped={isGrouped}
|
||||
key={message.id}
|
||||
className={message.role === 'assistant' && isHorizontal && isGrouped ? 'group-message-wrapper' : ''}>
|
||||
<MessageItem
|
||||
isGrouped={isGrouped}
|
||||
message={message}
|
||||
topic={topic}
|
||||
index={message.index}
|
||||
hidePresetMessages={hidePresetMessages}
|
||||
style={{ paddingTop: isGrouped && ['horizontal', 'grid'].includes(multiModelMessageStyle) ? 0 : 15 }}
|
||||
onSetMessages={onSetMessages}
|
||||
onDeleteMessage={onDeleteMessage}
|
||||
onGetMessages={onGetMessages}
|
||||
/>
|
||||
</MessageWrapper>
|
||||
)
|
||||
})}
|
||||
</GridContainer>
|
||||
{isGrouped && (
|
||||
<GroupMenuBar className="group-menu-bar" $layout={multiModelMessageStyle}>
|
||||
<HStack style={{ alignItems: 'center', flex: 1, overflow: 'hidden' }}>
|
||||
<LayoutContainer>
|
||||
{['fold', 'vertical', 'horizontal'].map((layout) => (
|
||||
<LayoutOption
|
||||
key={layout}
|
||||
active={multiModelMessageStyle === layout}
|
||||
onClick={() => setMultiModelMessageStyle(layout as MultiModelMessageStyle)}>
|
||||
{layout === 'fold' ? (
|
||||
<FolderOutlined />
|
||||
) : layout === 'horizontal' ? (
|
||||
<ColumnWidthOutlined />
|
||||
) : (
|
||||
<ColumnHeightOutlined />
|
||||
)}
|
||||
</LayoutOption>
|
||||
))}
|
||||
</LayoutContainer>
|
||||
{multiModelMessageStyle === 'fold' && (
|
||||
<ModelsContainer>
|
||||
<Segmented
|
||||
value={selectedIndex.toString()}
|
||||
onChange={(value) => {
|
||||
setSelectedIndex(Number(value))
|
||||
EventEmitter.emit(EVENT_NAMES.LOCATE_MESSAGE + ':' + messages[Number(value)].id, false)
|
||||
}}
|
||||
options={messages.map((message, index) => ({
|
||||
label: (
|
||||
<SegmentedLabel>
|
||||
<ModelAvatar model={message.model as Model} size={20} />
|
||||
<ModelName>{message.model?.name}</ModelName>
|
||||
</SegmentedLabel>
|
||||
),
|
||||
value: index.toString()
|
||||
}))}
|
||||
size="small"
|
||||
/>
|
||||
</ModelsContainer>
|
||||
)}
|
||||
</HStack>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DeleteOutlined style={{ color: 'var(--color-error)' }} />}
|
||||
onClick={onDelete}
|
||||
/>
|
||||
</GroupMenuBar>
|
||||
<MessageGroupMenuBar
|
||||
multiModelMessageStyle={multiModelMessageStyle}
|
||||
setMultiModelMessageStyle={setMultiModelMessageStyle}
|
||||
messages={messages}
|
||||
selectedIndex={selectedIndex}
|
||||
setSelectedIndex={setSelectedIndex}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
)}
|
||||
</GroupContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const GroupContainer = styled.div<{ $isGrouped: boolean; $layout: MultiModelMessageStyle }>`
|
||||
padding-top: ${({ $isGrouped, $layout }) => ($isGrouped && $layout === 'horizontal' ? '15px' : '0')};
|
||||
padding-top: ${({ $isGrouped, $layout }) => ($isGrouped && 'horizontal' === $layout ? '15px' : '0')};
|
||||
`
|
||||
|
||||
const GridContainer = styled.div<{ $count: number; $layout: MultiModelMessageStyle }>`
|
||||
const GridContainer = styled.div<{ $count: number; $layout: MultiModelMessageStyle; $gridColumns: number }>`
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(
|
||||
${(props) => (['fold', 'vertical'].includes(props.$layout) ? 1 : props.$count)},
|
||||
${({ $layout, $count }) => (['fold', 'vertical'].includes($layout) ? 1 : $count)},
|
||||
minmax(550px, 1fr)
|
||||
);
|
||||
gap: ${({ $layout }) => ($layout === 'horizontal' ? '16px' : '0')};
|
||||
@media (max-width: 800px) {
|
||||
grid-template-columns: repeat(
|
||||
${(props) => (['fold', 'vertical'].includes(props.$layout) ? 1 : props.$count)},
|
||||
${({ $layout, $count }) => (['fold', 'vertical'].includes($layout) ? 1 : $count)},
|
||||
minmax(400px, 1fr)
|
||||
);
|
||||
}
|
||||
overflow-y: auto;
|
||||
${({ $gridColumns, $layout, $count }) =>
|
||||
$layout === 'grid' &&
|
||||
css`
|
||||
grid-template-columns: repeat(${$count > 1 ? $gridColumns || 2 : 1}, minmax(0, 1fr));
|
||||
grid-template-rows: auto;
|
||||
gap: 16px;
|
||||
margin-top: 20px;
|
||||
`}
|
||||
`
|
||||
|
||||
interface MessageWrapperProps {
|
||||
$layout: 'fold' | 'horizontal' | 'vertical'
|
||||
$layout: 'fold' | 'horizontal' | 'vertical' | 'grid'
|
||||
$selected: boolean
|
||||
$isGrouped: boolean
|
||||
$isInPopover?: boolean
|
||||
}
|
||||
|
||||
const MessageWrapper = styled(Scrollbar)<MessageWrapperProps>`
|
||||
@@ -180,6 +206,7 @@ const MessageWrapper = styled(Scrollbar)<MessageWrapperProps>`
|
||||
}
|
||||
return 'block'
|
||||
}};
|
||||
|
||||
${({ $layout, $isGrouped }) => {
|
||||
if ($layout === 'horizontal' && $isGrouped) {
|
||||
return css`
|
||||
@@ -187,82 +214,26 @@ const MessageWrapper = styled(Scrollbar)<MessageWrapperProps>`
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 10px;
|
||||
`
|
||||
}
|
||||
return ''
|
||||
}}
|
||||
|
||||
${({ $layout, $isInPopover, $isGrouped }) =>
|
||||
$layout === 'grid' && $isGrouped
|
||||
? css`
|
||||
max-height: ${$isInPopover ? '50vh' : '300px'};
|
||||
overflow-y: auto;
|
||||
border: 0.5px solid ${$isInPopover ? 'transparent' : 'var(--color-border)'};
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--color-background);
|
||||
`
|
||||
: css`
|
||||
overflow-y: auto;
|
||||
border-radius: 6px;
|
||||
`}
|
||||
`
|
||||
|
||||
const GroupMenuBar = styled.div<{ $layout: MultiModelMessageStyle }>`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
margin-top: 10px;
|
||||
justify-content: space-between;
|
||||
overflow: hidden;
|
||||
border: 0.5px solid var(--color-border);
|
||||
height: 40px;
|
||||
margin-left: ${({ $layout }) => ($layout === 'horizontal' ? '0' : '40px')};
|
||||
transition: all 0.3s ease;
|
||||
`
|
||||
|
||||
const LayoutContainer = styled.div`
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-direction: row;
|
||||
`
|
||||
|
||||
const LayoutOption = styled.div<{ active: boolean }>`
|
||||
cursor: pointer;
|
||||
padding: 2px 10px;
|
||||
border-radius: 4px;
|
||||
background-color: ${({ active }) => (active ? 'var(--color-background-soft)' : 'transparent')};
|
||||
|
||||
&:hover {
|
||||
background-color: ${({ active }) => (active ? 'var(--color-background-soft)' : 'var(--color-hover)')};
|
||||
}
|
||||
`
|
||||
|
||||
const ModelsContainer = styled(Scrollbar)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`
|
||||
|
||||
const Segmented = styled(AntdSegmented)`
|
||||
.ant-segmented-item {
|
||||
background-color: transparent !important;
|
||||
transition: none !important;
|
||||
&:hover {
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
.ant-segmented-thumb,
|
||||
.ant-segmented-item-selected {
|
||||
background-color: transparent !important;
|
||||
border: 0.5px solid var(--color-border);
|
||||
transition: none !important;
|
||||
}
|
||||
`
|
||||
|
||||
const SegmentedLabel = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 0;
|
||||
`
|
||||
|
||||
const ModelName = styled.span`
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
`
|
||||
|
||||
export default MessageGroup
|
||||
export default memo(MessageGroup)
|
||||
|
||||
161
src/renderer/src/pages/home/Messages/MessageGroupMenuBar.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
ColumnHeightOutlined,
|
||||
ColumnWidthOutlined,
|
||||
DeleteOutlined,
|
||||
FolderOutlined,
|
||||
NumberOutlined
|
||||
} from '@ant-design/icons'
|
||||
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { MultiModelMessageStyle } from '@renderer/store/settings'
|
||||
import { Message, Model } from '@renderer/types'
|
||||
import { Button, Segmented as AntdSegmented } from 'antd'
|
||||
import { FC, memo } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import MessageGroupSettings from './MessageGroupSettings'
|
||||
|
||||
interface Props {
|
||||
multiModelMessageStyle: MultiModelMessageStyle
|
||||
setMultiModelMessageStyle: (style: MultiModelMessageStyle) => void
|
||||
messages: Message[]
|
||||
selectedIndex: number
|
||||
setSelectedIndex: (index: number) => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
const MessageGroupMenuBar: FC<Props> = ({
|
||||
multiModelMessageStyle,
|
||||
setMultiModelMessageStyle,
|
||||
messages,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
onDelete
|
||||
}) => {
|
||||
return (
|
||||
<GroupMenuBar $layout={multiModelMessageStyle}>
|
||||
<HStack style={{ alignItems: 'center', flex: 1, overflow: 'hidden' }}>
|
||||
<LayoutContainer>
|
||||
{['fold', 'vertical', 'horizontal', 'grid'].map((layout) => (
|
||||
<LayoutOption
|
||||
key={layout}
|
||||
$active={multiModelMessageStyle === layout}
|
||||
onClick={() => setMultiModelMessageStyle(layout as MultiModelMessageStyle)}>
|
||||
{layout === 'fold' ? (
|
||||
<FolderOutlined />
|
||||
) : layout === 'horizontal' ? (
|
||||
<ColumnWidthOutlined />
|
||||
) : layout === 'vertical' ? (
|
||||
<ColumnHeightOutlined />
|
||||
) : (
|
||||
<NumberOutlined />
|
||||
)}
|
||||
</LayoutOption>
|
||||
))}
|
||||
</LayoutContainer>
|
||||
{multiModelMessageStyle === 'fold' && (
|
||||
<ModelsContainer>
|
||||
<Segmented
|
||||
value={selectedIndex.toString()}
|
||||
onChange={(value) => {
|
||||
setSelectedIndex(Number(value))
|
||||
EventEmitter.emit(EVENT_NAMES.LOCATE_MESSAGE + ':' + messages[Number(value)].id, false)
|
||||
}}
|
||||
options={messages.map((message, index) => ({
|
||||
label: (
|
||||
<SegmentedLabel>
|
||||
<ModelAvatar model={message.model as Model} size={20} />
|
||||
<ModelName>{message.model?.name}</ModelName>
|
||||
</SegmentedLabel>
|
||||
),
|
||||
value: index.toString()
|
||||
}))}
|
||||
size="small"
|
||||
/>
|
||||
</ModelsContainer>
|
||||
)}
|
||||
{multiModelMessageStyle === 'grid' && <MessageGroupSettings />}
|
||||
</HStack>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DeleteOutlined style={{ color: 'var(--color-error)' }} />}
|
||||
onClick={onDelete}
|
||||
/>
|
||||
</GroupMenuBar>
|
||||
)
|
||||
}
|
||||
|
||||
const GroupMenuBar = styled.div<{ $layout: MultiModelMessageStyle }>`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
margin-top: 10px;
|
||||
justify-content: space-between;
|
||||
overflow: hidden;
|
||||
border: 0.5px solid var(--color-border);
|
||||
height: 40px;
|
||||
transition: all 0.3s ease;
|
||||
background-color: var(--color-background);
|
||||
`
|
||||
|
||||
const LayoutContainer = styled.div`
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-direction: row;
|
||||
`
|
||||
|
||||
const LayoutOption = styled.div<{ $active: boolean }>`
|
||||
cursor: pointer;
|
||||
padding: 2px 10px;
|
||||
border-radius: 4px;
|
||||
background-color: ${({ $active }) => ($active ? 'var(--color-background-soft)' : 'transparent')};
|
||||
|
||||
&:hover {
|
||||
background-color: ${({ $active }) => ($active ? 'var(--color-background-soft)' : 'var(--color-hover)')};
|
||||
}
|
||||
`
|
||||
|
||||
const ModelsContainer = styled(Scrollbar)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`
|
||||
|
||||
const Segmented = styled(AntdSegmented)`
|
||||
.ant-segmented-item {
|
||||
background-color: transparent !important;
|
||||
transition: none !important;
|
||||
&:hover {
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
.ant-segmented-thumb,
|
||||
.ant-segmented-item-selected {
|
||||
background-color: transparent !important;
|
||||
border: 0.5px solid var(--color-border);
|
||||
transition: none !important;
|
||||
}
|
||||
`
|
||||
|
||||
const SegmentedLabel = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 0;
|
||||
`
|
||||
|
||||
const ModelName = styled.span`
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
`
|
||||
|
||||
export default memo(MessageGroupMenuBar)
|
||||
@@ -0,0 +1,59 @@
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { SettingDivider } from '@renderer/pages/settings'
|
||||
import { SettingRow } from '@renderer/pages/settings'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setGridColumns, setGridPopoverTrigger } from '@renderer/store/settings'
|
||||
import { Col, Row, Select, Slider } from 'antd'
|
||||
import { Popover } from 'antd'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const MessageGroupSettings: FC = () => {
|
||||
const dispatch = useAppDispatch()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { gridColumns, gridPopoverTrigger } = useSettings()
|
||||
const [gridColumnsValue, setGridColumnsValue] = useState(gridColumns)
|
||||
|
||||
return (
|
||||
<Popover
|
||||
trigger={undefined}
|
||||
showArrow
|
||||
content={
|
||||
<div style={{ padding: 10 }}>
|
||||
<SettingRow>
|
||||
<div style={{ marginRight: 10 }}>{t('settings.messages.grid_popover_trigger')}</div>
|
||||
<Select
|
||||
value={gridPopoverTrigger || 'hover'}
|
||||
onChange={(value) => dispatch(setGridPopoverTrigger(value as 'hover' | 'click'))}
|
||||
size="small">
|
||||
<Select.Option value="hover">{t('settings.messages.grid_popover_trigger.hover')}</Select.Option>
|
||||
<Select.Option value="click">{t('settings.messages.grid_popover_trigger.click')}</Select.Option>
|
||||
</Select>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<div>{t('settings.messages.grid_columns')}</div>
|
||||
</SettingRow>
|
||||
<Row align="middle" gutter={10}>
|
||||
<Col span={24}>
|
||||
<Slider
|
||||
value={gridColumnsValue}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(value) => setGridColumnsValue(value)}
|
||||
onChangeComplete={(value) => dispatch(setGridColumns(value))}
|
||||
min={2}
|
||||
max={6}
|
||||
step={1}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
}>
|
||||
<SettingOutlined style={{ marginLeft: 15, cursor: 'pointer' }} />
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default MessageGroupSettings
|
||||
@@ -60,12 +60,16 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
|
||||
const isUserMessage = message.role === 'user'
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
navigator.clipboard.writeText(removeTrailingDoubleSpaces(message.content))
|
||||
window.message.success({ content: t('message.copied'), key: 'copy-message' })
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}, [message.content, t])
|
||||
const onCopy = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
navigator.clipboard.writeText(removeTrailingDoubleSpaces(message.content))
|
||||
window.message.success({ content: t('message.copied'), key: 'copy-message' })
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
},
|
||||
[message.content, t]
|
||||
)
|
||||
|
||||
const onNewBranch = useCallback(async () => {
|
||||
await modelGenerating()
|
||||
@@ -195,13 +199,16 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
[message, onEdit, onNewBranch, t]
|
||||
)
|
||||
|
||||
const onRegenerate = async () => {
|
||||
const onRegenerate = async (e: React.MouseEvent | undefined) => {
|
||||
e?.stopPropagation?.()
|
||||
await modelGenerating()
|
||||
const _message: Message = resetAssistantMessage(message, model || assistantModel)
|
||||
const selectedModel = isGrouped ? model : assistantModel
|
||||
const _message = resetAssistantMessage(message, selectedModel)
|
||||
onEditMessage?.(_message)
|
||||
}
|
||||
|
||||
const onMentionModel = async () => {
|
||||
const onMentionModel = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
await modelGenerating()
|
||||
const selectedModel = await SelectModelPopup.show({ model })
|
||||
if (!selectedModel) return
|
||||
@@ -215,9 +222,13 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
onEditMessage?.(_message)
|
||||
}
|
||||
|
||||
const onUseful = useCallback(() => {
|
||||
onEditMessage?.({ ...message, useful: !message.useful })
|
||||
}, [message, onEditMessage])
|
||||
const onUseful = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onEditMessage?.({ ...message, useful: !message.useful })
|
||||
},
|
||||
[message, onEditMessage]
|
||||
)
|
||||
|
||||
return (
|
||||
<MenusBar className={`menubar ${isLastMessage && 'show'}`}>
|
||||
@@ -269,13 +280,14 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
key: 'translate-close',
|
||||
onClick: () => onEditMessage?.({ ...message, translatedContent: undefined })
|
||||
}
|
||||
]
|
||||
],
|
||||
onClick: (e) => e.domEvent.stopPropagation()
|
||||
}}
|
||||
trigger={['click']}
|
||||
placement="topRight"
|
||||
arrow>
|
||||
<Tooltip title={t('chat.translate')} mouseEnterDelay={1.2}>
|
||||
<ActionButton className="message-action-button">
|
||||
<ActionButton className="message-action-button" onClick={(e) => e.stopPropagation()}>
|
||||
<TranslationOutlined />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
@@ -297,14 +309,25 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
<Tooltip title={t('common.delete')} mouseEnterDelay={1}>
|
||||
<ActionButton
|
||||
className="message-action-button"
|
||||
onClick={isGrouped ? () => onDeleteMessage?.(message) : undefined}>
|
||||
onClick={
|
||||
isGrouped
|
||||
? (e) => {
|
||||
e.stopPropagation()
|
||||
onDeleteMessage?.(message)
|
||||
}
|
||||
: (e) => e.stopPropagation()
|
||||
}>
|
||||
<DeleteOutlined />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
{!isUserMessage && (
|
||||
<Dropdown menu={{ items: dropdownItems }} trigger={['click']} placement="topRight" arrow>
|
||||
<ActionButton className="message-action-button">
|
||||
<Dropdown
|
||||
menu={{ items: dropdownItems, onClick: (e) => e.domEvent.stopPropagation() }}
|
||||
trigger={['click']}
|
||||
placement="topRight"
|
||||
arrow>
|
||||
<ActionButton className="message-action-button" onClick={(e) => e.stopPropagation()}>
|
||||
<MenuOutlined />
|
||||
</ActionButton>
|
||||
</Dropdown>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { Message } from '@renderer/types'
|
||||
import { Collapse } from 'antd'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { FC, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import BarLoader from 'react-spinners/BarLoader'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import Markdown from '../Markdown/Markdown'
|
||||
|
||||
interface Props {
|
||||
message: Message
|
||||
}
|
||||
@@ -14,6 +16,12 @@ const MessageThought: FC<Props> = ({ message }) => {
|
||||
const [activeKey, setActiveKey] = useState<'thought' | ''>('thought')
|
||||
const isThinking = !message.content
|
||||
const { t } = useTranslation()
|
||||
const { messageFont, fontSize } = useSettings()
|
||||
const fontFamily = useMemo(() => {
|
||||
return messageFont === 'serif'
|
||||
? 'serif'
|
||||
: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans","Helvetica Neue", sans-serif'
|
||||
}, [messageFont])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isThinking) setActiveKey('')
|
||||
@@ -25,10 +33,12 @@ const MessageThought: FC<Props> = ({ message }) => {
|
||||
|
||||
const thinkingTime = message.metrics?.time_thinking_millsec || 0
|
||||
const thinkingTimeSeconds = (thinkingTime / 1000).toFixed(1)
|
||||
const isPaused = message.status === 'paused'
|
||||
|
||||
return (
|
||||
<CollapseContainer
|
||||
activeKey={activeKey}
|
||||
size="small"
|
||||
onChange={() => setActiveKey((key) => (key ? '' : 'thought'))}
|
||||
className="message-thought-container"
|
||||
items={[
|
||||
@@ -39,10 +49,14 @@ const MessageThought: FC<Props> = ({ message }) => {
|
||||
<TinkingText>
|
||||
{isThinking ? t('chat.thinking') : t('chat.deeply_thought', { secounds: thinkingTimeSeconds })}
|
||||
</TinkingText>
|
||||
{isThinking && <BarLoader color="#9254de" />}
|
||||
{isThinking && !isPaused && <BarLoader color="#9254de" />}
|
||||
</MessageTitleLabel>
|
||||
),
|
||||
children: <ReactMarkdown className="markdown">{message.reasoning_content}</ReactMarkdown>
|
||||
children: (
|
||||
<div style={{ fontFamily, fontSize }}>
|
||||
<Markdown message={{ ...message, content: message.reasoning_content }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -166,7 +166,8 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
|
||||
setMessages([])
|
||||
setDisplayMessages([])
|
||||
const defaultTopic = getDefaultTopic(assistant.id)
|
||||
updateTopic({ ...topic, name: defaultTopic.name, messages: [] })
|
||||
const _topic = getTopic(assistant, topic.id)
|
||||
_topic && updateTopic({ ..._topic, name: defaultTopic.name, messages: [] })
|
||||
TopicManager.clearTopicMessages(topic.id)
|
||||
}),
|
||||
EventEmitter.on(EVENT_NAMES.EXPORT_TOPIC_IMAGE, async () => {
|
||||
@@ -315,7 +316,7 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
|
||||
))}
|
||||
</ScrollContainer>
|
||||
</InfiniteScroll>
|
||||
<Prompt assistant={assistant} key={assistant.prompt} />
|
||||
<Prompt assistant={assistant} key={assistant.prompt} topic={topic} />
|
||||
</NarrowLayout>
|
||||
</Container>
|
||||
)
|
||||
@@ -349,8 +350,7 @@ interface ContainerProps {
|
||||
const Container = styled(Scrollbar)<ContainerProps>`
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
padding: 10px 0;
|
||||
padding-bottom: 20px;
|
||||
padding: 10px 0 20px;
|
||||
overflow-x: hidden;
|
||||
background-color: var(--color-background);
|
||||
`
|
||||
|
||||
@@ -1,36 +1,41 @@
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import AssistantSettingsPopup from '@renderer/pages/settings/AssistantSettings'
|
||||
import { Assistant } from '@renderer/types'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
assistant: Assistant
|
||||
topic?: Topic
|
||||
}
|
||||
|
||||
const Prompt: FC<Props> = ({ assistant }) => {
|
||||
const Prompt: FC<Props> = ({ assistant, topic }) => {
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
|
||||
const prompt = assistant.prompt || t('chat.default.description')
|
||||
const topicPrompt = topic?.prompt || ''
|
||||
const isDark = theme === 'dark'
|
||||
|
||||
if (!prompt) {
|
||||
if (!prompt && !topicPrompt) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="system-prompt" onClick={() => AssistantSettingsPopup.show({ assistant })}>
|
||||
<Container className="system-prompt" onClick={() => AssistantSettingsPopup.show({ assistant })} $isDark={isDark}>
|
||||
<Text>{prompt}</Text>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
const Container = styled.div<{ $isDark: boolean }>`
|
||||
padding: 10px 20px;
|
||||
background-color: var(--color-background-soft);
|
||||
margin: 4px 20px 0 20px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
border: 0.5px solid var(--color-border);
|
||||
background-color: ${({ $isDark }) => ($isDark ? 'var(--color-background-soft)' : 'transparent')};
|
||||
`
|
||||
|
||||
const Text = styled.div`
|
||||
|
||||
@@ -283,6 +283,7 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
<Select.Option value="fold">{t('message.message.multi_model_style.fold')}</Select.Option>
|
||||
<Select.Option value="vertical">{t('message.message.multi_model_style.vertical')}</Select.Option>
|
||||
<Select.Option value="horizontal">{t('message.message.multi_model_style.horizontal')}</Select.Option>
|
||||
<Select.Option value="grid">{t('message.message.multi_model_style.grid')}</Select.Option>
|
||||
</Select>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
EditOutlined,
|
||||
FolderOutlined,
|
||||
PushpinOutlined,
|
||||
QuestionCircleOutlined,
|
||||
UploadOutlined
|
||||
} from '@ant-design/icons'
|
||||
import DragableList from '@renderer/components/DragableList'
|
||||
@@ -20,7 +21,7 @@ import store from '@renderer/store'
|
||||
import { setGenerating } from '@renderer/store/runtime'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import { exportTopicAsMarkdown, exportTopicToNotion, topicToMarkdown } from '@renderer/utils/export'
|
||||
import { Dropdown, MenuProps } from 'antd'
|
||||
import { Dropdown, MenuProps, Tooltip } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { findIndex } from 'lodash'
|
||||
import { FC, useCallback } from 'react'
|
||||
@@ -115,6 +116,28 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('chat.topics.prompt'),
|
||||
key: 'topic-prompt',
|
||||
icon: <i className="iconfont icon-ai-model1" style={{ fontSize: '14px' }} />,
|
||||
extra: (
|
||||
<Tooltip title={t('chat.topics.prompt.tips')}>
|
||||
<QuestionIcon />
|
||||
</Tooltip>
|
||||
),
|
||||
async onClick() {
|
||||
const prompt = await PromptPopup.show({
|
||||
title: t('chat.topics.prompt.edit.title'),
|
||||
message: '',
|
||||
defaultValue: topic?.prompt || '',
|
||||
inputProps: {
|
||||
rows: 8,
|
||||
allowClear: true
|
||||
}
|
||||
})
|
||||
prompt && updateTopic({ ...topic, prompt: prompt.trim() })
|
||||
}
|
||||
},
|
||||
{
|
||||
label: topic.pinned ? t('chat.topics.unpinned') : t('chat.topics.pinned'),
|
||||
key: 'pin',
|
||||
@@ -211,6 +234,11 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
|
||||
onClick={() => onSwitchTopic(topic)}
|
||||
style={{ borderRadius }}>
|
||||
<TopicName className="name">{topic.name.replace('`', '')}</TopicName>
|
||||
{topic.prompt && (
|
||||
<TopicPromptText className="prompt">
|
||||
{t('common.prompt')}: {topic.prompt}
|
||||
</TopicPromptText>
|
||||
)}
|
||||
{showTopicTime && (
|
||||
<TopicTime className="time">{dayjs(topic.createdAt).format('MM/DD HH:mm')}</TopicTime>
|
||||
)}
|
||||
@@ -291,6 +319,18 @@ const TopicName = styled.div`
|
||||
font-size: 13px;
|
||||
`
|
||||
|
||||
const TopicPromptText = styled.div`
|
||||
color: var(--color-text-2);
|
||||
font-size: 12px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
~ .prompt-text {
|
||||
margin-top: 10px;
|
||||
}
|
||||
`
|
||||
|
||||
const TopicTime = styled.div`
|
||||
color: var(--color-text-3);
|
||||
font-size: 11px;
|
||||
@@ -310,5 +350,10 @@ const MenuButton = styled.div`
|
||||
font-size: 12px;
|
||||
}
|
||||
`
|
||||
const QuestionIcon = styled(QuestionCircleOutlined)`
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-3);
|
||||
`
|
||||
|
||||
export default Topics
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
SearchOutlined,
|
||||
SettingOutlined
|
||||
} from '@ant-design/icons'
|
||||
import Ellipsis from '@renderer/components/Ellipsis'
|
||||
import PromptPopup from '@renderer/components/Popups/PromptPopup'
|
||||
import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
@@ -17,8 +18,8 @@ import { useKnowledge } from '@renderer/hooks/useKnowledge'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { getProviderName } from '@renderer/services/ProviderService'
|
||||
import { FileType, FileTypes, KnowledgeBase } from '@renderer/types'
|
||||
import { documentExts, textExts } from '@shared/config/constant'
|
||||
import { Alert, Button, Card, Divider, message, Tag, Typography, Upload } from 'antd'
|
||||
import { bookExts, documentExts, textExts, thirdPartyApplicationExts } from '@shared/config/constant'
|
||||
import { Alert, Button, Card, Divider, message, Tag, Tooltip, Typography, Upload } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@@ -34,7 +35,7 @@ interface KnowledgeContentProps {
|
||||
selectedBase: KnowledgeBase
|
||||
}
|
||||
|
||||
const fileTypes = [...documentExts, ...textExts]
|
||||
const fileTypes = [...bookExts, ...thirdPartyApplicationExts, ...documentExts, ...textExts]
|
||||
const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -52,6 +53,7 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
addSitemap,
|
||||
removeItem,
|
||||
getProcessingStatus,
|
||||
getDirectoryProcessingPercent,
|
||||
addNote,
|
||||
addDirectory
|
||||
} = useKnowledge(selectedBase.id || '')
|
||||
@@ -63,6 +65,8 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
return null
|
||||
}
|
||||
|
||||
const progressingPercent = getDirectoryProcessingPercent(base?.id)
|
||||
|
||||
const handleAddFile = () => {
|
||||
if (disabled) {
|
||||
return
|
||||
@@ -216,7 +220,7 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
style={{ marginTop: 10, background: 'transparent' }}>
|
||||
<p className="ant-upload-text">{t('knowledge.drag_file')}</p>
|
||||
<p className="ant-upload-hint">
|
||||
{t('knowledge.file_hint', { file_types: fileTypes.slice(0, 5).join(', ').replaceAll('.', '') })}
|
||||
{t('knowledge.file_hint', { file_types: 'TXT, MD, HTML, PDF, DOCX, PPTX, XLSX, EPUB...' })}
|
||||
</p>
|
||||
</Dragger>
|
||||
</FileSection>
|
||||
@@ -229,7 +233,11 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
<ItemContent>
|
||||
<ItemInfo>
|
||||
<FileIcon />
|
||||
<ClickableSpan onClick={() => window.api.file.openPath(file.path)}>{file.origin_name}</ClickableSpan>
|
||||
<ClickableSpan onClick={() => window.api.file.openPath(file.path)}>
|
||||
<Tooltip title={file.origin_name}>
|
||||
<Ellipsis text={file.origin_name} />
|
||||
</Tooltip>
|
||||
</ClickableSpan>
|
||||
</ItemInfo>
|
||||
<FlexAlignCenter>
|
||||
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
|
||||
@@ -258,13 +266,20 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
<ItemInfo>
|
||||
<FolderOutlined />
|
||||
<ClickableSpan onClick={() => window.api.file.openPath(item.content as string)}>
|
||||
{item.content as string}
|
||||
<Tooltip title={item.content as string}>
|
||||
<Ellipsis text={item.content as string} />
|
||||
</Tooltip>
|
||||
</ClickableSpan>
|
||||
</ItemInfo>
|
||||
<FlexAlignCenter>
|
||||
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
|
||||
<StatusIconWrapper>
|
||||
<StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} />
|
||||
<StatusIcon
|
||||
sourceId={item.id}
|
||||
base={base}
|
||||
getProcessingStatus={getProcessingStatus}
|
||||
progressingPercent={progressingPercent}
|
||||
/>
|
||||
</StatusIconWrapper>
|
||||
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
||||
</FlexAlignCenter>
|
||||
@@ -288,7 +303,9 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
<ItemInfo>
|
||||
<LinkOutlined />
|
||||
<a href={item.content as string} target="_blank" rel="noopener noreferrer">
|
||||
{item.content as string}
|
||||
<Tooltip title={item.content as string}>
|
||||
<Ellipsis text={item.content as string} />
|
||||
</Tooltip>
|
||||
</a>
|
||||
</ItemInfo>
|
||||
<FlexAlignCenter>
|
||||
@@ -318,7 +335,9 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
<ItemInfo>
|
||||
<GlobalOutlined />
|
||||
<a href={item.content as string} target="_blank" rel="noopener noreferrer">
|
||||
{item.content as string}
|
||||
<Tooltip title={item.content as string}>
|
||||
<Ellipsis text={item.content as string} />
|
||||
</Tooltip>
|
||||
</a>
|
||||
</ItemInfo>
|
||||
<FlexAlignCenter>
|
||||
|
||||
@@ -65,6 +65,7 @@ const KnowledgePage: FC = () => {
|
||||
title: t('knowledge.delete_confirm'),
|
||||
centered: true,
|
||||
onOk: () => {
|
||||
setSelectedBase(undefined)
|
||||
deleteKnowledgeBase(base.id)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { DEFAULT_KNOWLEDGE_THRESHOLD } from '@renderer/config/constant'
|
||||
import { getFileFromUrl, getKnowledgeBaseParams } from '@renderer/services/KnowledgeService'
|
||||
import { FileType, KnowledgeBase } from '@renderer/types'
|
||||
import { Input, List, Modal, Spin, Typography } from 'antd'
|
||||
@@ -45,7 +46,11 @@ const PopupContainer: React.FC<Props> = ({ base, resolve }) => {
|
||||
return { ...item, file }
|
||||
})
|
||||
)
|
||||
setResults(results)
|
||||
const filteredResults = results.filter((item) => {
|
||||
const threshold = base.threshold || DEFAULT_KNOWLEDGE_THRESHOLD
|
||||
return item.score >= threshold
|
||||
})
|
||||
setResults(filteredResults)
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error)
|
||||
} finally {
|
||||
|
||||
@@ -22,6 +22,7 @@ interface FormData {
|
||||
documentCount?: number
|
||||
chunkSize?: number
|
||||
chunkOverlap?: number
|
||||
threshold?: number
|
||||
}
|
||||
|
||||
interface Props extends ShowParams {
|
||||
@@ -66,7 +67,8 @@ const PopupContainer: React.FC<Props> = ({ base: _base, resolve }) => {
|
||||
name: values.name,
|
||||
documentCount: values.documentCount || DEFAULT_KNOWLEDGE_DOCUMENT_COUNT,
|
||||
chunkSize: values.chunkSize,
|
||||
chunkOverlap: values.chunkOverlap
|
||||
chunkOverlap: values.chunkOverlap,
|
||||
threshold: values.threshold ?? undefined
|
||||
}
|
||||
updateKnowledgeBase(newBase)
|
||||
setOpen(false)
|
||||
@@ -174,6 +176,23 @@ const PopupContainer: React.FC<Props> = ({ base: _base, resolve }) => {
|
||||
placeholder={t('knowledge.chunk_overlap_placeholder')}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="threshold"
|
||||
label={t('knowledge.threshold')}
|
||||
tooltip={{ title: t('knowledge.threshold_tooltip') }}
|
||||
initialValue={base.threshold}
|
||||
rules={[
|
||||
{
|
||||
validator(_, value) {
|
||||
if (value && (value > 1 || value < 0)) {
|
||||
return Promise.reject(new Error(t('knowledge.threshold_too_large_or_small')))
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
]}>
|
||||
<InputNumber placeholder={t('knowledge.threshold_placeholder')} step={0.1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Alert message={t('knowledge.chunk_size_change_warning')} type="warning" showIcon icon={<WarningOutlined />} />
|
||||
</Modal>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'
|
||||
import { KnowledgeBase, ProcessingStatus } from '@renderer/types'
|
||||
import { Tooltip } from 'antd'
|
||||
import { Progress, Tooltip } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@@ -9,9 +9,10 @@ interface StatusIconProps {
|
||||
sourceId: string
|
||||
base: KnowledgeBase
|
||||
getProcessingStatus: (sourceId: string) => ProcessingStatus | undefined
|
||||
progressingPercent?: number
|
||||
}
|
||||
|
||||
const StatusIcon: FC<StatusIconProps> = ({ sourceId, base, getProcessingStatus }) => {
|
||||
const StatusIcon: FC<StatusIconProps> = ({ sourceId, base, getProcessingStatus, progressingPercent }) => {
|
||||
const { t } = useTranslation()
|
||||
const status = getProcessingStatus(sourceId)
|
||||
const item = base.items.find((item) => item.id === sourceId)
|
||||
@@ -40,11 +41,7 @@ const StatusIcon: FC<StatusIconProps> = ({ sourceId, base, getProcessingStatus }
|
||||
</Tooltip>
|
||||
)
|
||||
case 'processing':
|
||||
return (
|
||||
<Tooltip title={t('knowledge.status_processing')} placement="left">
|
||||
<StatusDot $status="processing" />
|
||||
</Tooltip>
|
||||
)
|
||||
return <Progress type="circle" size={14} percent={Number(progressingPercent?.toFixed(0))} />
|
||||
case 'completed':
|
||||
return (
|
||||
<Tooltip title={t('knowledge.status_completed')} placement="left">
|
||||
|
||||
@@ -16,18 +16,14 @@ const AssistantKnowledgeBaseSettings: React.FC<Props> = ({ assistant, updateAssi
|
||||
const { t } = useTranslation()
|
||||
|
||||
const knowledgeState = useAppSelector((state) => state.knowledge)
|
||||
const knowledgeOptions: SelectProps['options'] = []
|
||||
|
||||
knowledgeState.bases.forEach((base) => {
|
||||
knowledgeOptions.push({
|
||||
label: base.name,
|
||||
value: base.id
|
||||
})
|
||||
})
|
||||
const knowledgeOptions: SelectProps['options'] = knowledgeState.bases.map((base) => ({
|
||||
label: base.name,
|
||||
value: base.id
|
||||
}))
|
||||
|
||||
const onUpdate = (value) => {
|
||||
const knowledge_base = knowledgeState.bases.find((t) => t.id === value)
|
||||
const _assistant = { ...assistant, knowledge_base }
|
||||
const knowledge_bases = value.map((id) => knowledgeState.bases.find((b) => b.id === id))
|
||||
const _assistant = { ...assistant, knowledge_bases }
|
||||
updateAssistant(_assistant)
|
||||
}
|
||||
|
||||
@@ -37,12 +33,18 @@ const AssistantKnowledgeBaseSettings: React.FC<Props> = ({ assistant, updateAssi
|
||||
{t('common.knowledge_base')}
|
||||
</Box>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
defaultValue={assistant.knowledge_base?.id}
|
||||
value={assistant.knowledge_bases?.map((b) => b.id)}
|
||||
placeholder={t('agents.add.knowledge_base.placeholder')}
|
||||
menuItemSelectedIcon={<CheckOutlined />}
|
||||
options={knowledgeOptions}
|
||||
onChange={(value) => onUpdate(value)}
|
||||
filterOption={(input, option) =>
|
||||
String(option?.label ?? '')
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
|
||||
@@ -23,7 +23,7 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
|
||||
const [contextCount, setContextCount] = useState(assistant?.settings?.contextCount ?? DEFAULT_CONTEXTCOUNT)
|
||||
const [enableMaxTokens, setEnableMaxTokens] = useState(assistant?.settings?.enableMaxTokens ?? false)
|
||||
const [maxTokens, setMaxTokens] = useState(assistant?.settings?.maxTokens ?? 0)
|
||||
const [reasoningEffort, setReasoningEffort] = useState(assistant?.settings?.reasoning_effort ?? 'medium')
|
||||
const [reasoningEffort, setReasoningEffort] = useState(assistant?.settings?.reasoning_effort)
|
||||
const [streamOutput, setStreamOutput] = useState(assistant?.settings?.streamOutput ?? true)
|
||||
const [defaultModel, setDefaultModel] = useState(assistant?.defaultModel)
|
||||
const [topP, setTopP] = useState(assistant?.settings?.topP ?? 1)
|
||||
@@ -391,6 +391,7 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
|
||||
<Radio.Button value="low">{t('assistants.settings.reasoning_effort.low')}</Radio.Button>
|
||||
<Radio.Button value="medium">{t('assistants.settings.reasoning_effort.medium')}</Radio.Button>
|
||||
<Radio.Button value="high">{t('assistants.settings.reasoning_effort.high')}</Radio.Button>
|
||||
<Radio.Button value={undefined}>{t('assistants.settings.reasoning_effort.off')}</Radio.Button>
|
||||
</Radio.Group>
|
||||
</SettingRow>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { FileSearchOutlined, FolderOpenOutlined, SaveOutlined } from '@ant-design/icons'
|
||||
import { FileSearchOutlined, FolderOpenOutlined, InfoCircleOutlined, SaveOutlined } from '@ant-design/icons'
|
||||
import { Client } from '@notionhq/client'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import MinApp from '@renderer/components/MinApp'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { backup, reset, restore } from '@renderer/services/BackupService'
|
||||
import { RootState, useAppDispatch } from '@renderer/store'
|
||||
import { setNotionApiKey, setNotionDatabaseID } from '@renderer/store/settings'
|
||||
import { AppInfo } from '@renderer/types'
|
||||
import { Button, Modal, Typography } from 'antd'
|
||||
import { Button, Modal, Tooltip, Typography } from 'antd'
|
||||
import Input from 'antd/es/input/Input'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -33,36 +35,81 @@ const NotionSettings: FC = () => {
|
||||
const handleNotionDatabaseIdChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
dispatch(setNotionDatabaseID(e.target.value))
|
||||
}
|
||||
const handleNotionConnectionCheck = () => {
|
||||
if (notionApiKey === null) {
|
||||
window.message.error(t('settings.data.notion.check.empty_api_key'))
|
||||
return
|
||||
}
|
||||
if (notionDatabaseID === null) {
|
||||
window.message.error(t('settings.data.notion.check.empty_database_id'))
|
||||
return
|
||||
}
|
||||
const notion = new Client({ auth: notionApiKey })
|
||||
notion.databases
|
||||
.retrieve({
|
||||
database_id: notionDatabaseID
|
||||
})
|
||||
.then((result) => {
|
||||
if (result) {
|
||||
window.message.success(t('settings.data.notion.check.success'))
|
||||
} else {
|
||||
window.message.error(t('settings.data.notion.check.fail'))
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
window.message.error(t('settings.data.notion.check.error'))
|
||||
})
|
||||
}
|
||||
|
||||
const handleNotionTitleClick = () => {
|
||||
MinApp.start({
|
||||
id: 'notion-help',
|
||||
name: 'Notion Help',
|
||||
url: 'https://docs.cherry-ai.com/advanced-basic/notion'
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>{t('settings.data.notion.title')}</SettingTitle>
|
||||
<SettingTitle style={{ justifyContent: 'flex-start', gap: 10 }}>
|
||||
{t('settings.data.notion.title')}
|
||||
<Tooltip title={t('settings.data.notion.help')} placement="right">
|
||||
<InfoCircleOutlined
|
||||
style={{ color: 'var(--color-text-2)', cursor: 'pointer' }}
|
||||
onClick={handleNotionTitleClick}
|
||||
/>
|
||||
</Tooltip>
|
||||
</SettingTitle>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.notion.database_id')}</SettingRowTitle>
|
||||
<HStack alignItems="center" gap="5px" style={{ width: 315 }}>
|
||||
<Input
|
||||
type="text"
|
||||
value={notionDatabaseID || ''}
|
||||
onChange={handleNotionDatabaseIdChange}
|
||||
onBlur={handleNotionDatabaseIdChange}
|
||||
style={{ width: 315 }}
|
||||
placeholder={t('settings.data.notion.database_id_placeholder')}
|
||||
/>
|
||||
</HStack>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.notion.api_key')}</SettingRowTitle>
|
||||
<HStack alignItems="center" gap="5px">
|
||||
<HStack alignItems="center" gap="5px" style={{ width: 315 }}>
|
||||
<Input
|
||||
type="password"
|
||||
value={notionApiKey || ''}
|
||||
onChange={handleNotionTokenChange}
|
||||
onBlur={handleNotionTokenChange}
|
||||
style={{ width: 250 }}
|
||||
placeholder={t('settings.data.notion.api_key_placeholder')}
|
||||
/>
|
||||
<Button onClick={handleNotionConnectionCheck}>{t('settings.data.notion.check.button')}</Button>
|
||||
</HStack>
|
||||
</SettingRow>
|
||||
<SettingDivider /> {/* 添加分割线 */}
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.notion.database_id')}</SettingRowTitle>
|
||||
<HStack alignItems="center" gap="5px">
|
||||
<Input
|
||||
type="text"
|
||||
value={notionDatabaseID || ''}
|
||||
onChange={handleNotionDatabaseIdChange}
|
||||
onBlur={handleNotionDatabaseIdChange}
|
||||
style={{ width: 250 }}
|
||||
/>
|
||||
</HStack>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ const WebDavSettings: FC = () => {
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const { t, i18n } = useTranslation()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { webdavSync } = useRuntime()
|
||||
|
||||
@@ -163,7 +163,6 @@ const WebDavSettings: FC = () => {
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.general.backup.title')}</SettingRowTitle>
|
||||
<HStack gap="5px" justifyContent="space-between">
|
||||
{/* 添加 在线备份 在线还原 按钮 */}
|
||||
<Button onClick={onBackup} icon={<SaveOutlined />} loading={backuping}>
|
||||
{t('settings.data.webdav.backup.button')}
|
||||
</Button>
|
||||
@@ -177,19 +176,15 @@ const WebDavSettings: FC = () => {
|
||||
<SettingRowTitle>{t('settings.data.webdav.autoSync')}</SettingRowTitle>
|
||||
<Select value={syncInterval} onChange={onSyncIntervalChange} disabled={!webdavHost} style={{ width: 120 }}>
|
||||
<Select.Option value={0}>{t('settings.data.webdav.autoSync.off')}</Select.Option>
|
||||
<Select.Option value={1}>
|
||||
1 {i18n.language === 'en-US' ? t('settings.data.webdav.minute') : t('settings.data.webdav.minutes')}
|
||||
</Select.Option>
|
||||
<Select.Option value={5}>5 {t('settings.data.webdav.minutes')}</Select.Option>
|
||||
<Select.Option value={15}>15 {t('settings.data.webdav.minutes')}</Select.Option>
|
||||
<Select.Option value={30}>30 {t('settings.data.webdav.minutes')}</Select.Option>
|
||||
<Select.Option value={60}>
|
||||
1 {i18n.language === 'en-US' ? t('settings.data.webdav.hour') : t('settings.data.webdav.hours')}
|
||||
</Select.Option>
|
||||
<Select.Option value={120}>2 {t('settings.data.webdav.hours')}</Select.Option>
|
||||
<Select.Option value={360}>6 {t('settings.data.webdav.hours')}</Select.Option>
|
||||
<Select.Option value={720}>12 {t('settings.data.webdav.hours')}</Select.Option>
|
||||
<Select.Option value={1440}>24 {t('settings.data.webdav.hours')}</Select.Option>
|
||||
<Select.Option value={1}>{t('settings.data.webdav.minute_interval', { count: 1 })}</Select.Option>
|
||||
<Select.Option value={5}>{t('settings.data.webdav.minute_interval', { count: 5 })}</Select.Option>
|
||||
<Select.Option value={15}>{t('settings.data.webdav.minute_interval', { count: 15 })}</Select.Option>
|
||||
<Select.Option value={30}>{t('settings.data.webdav.minute_interval', { count: 30 })}</Select.Option>
|
||||
<Select.Option value={60}>{t('settings.data.webdav.hour_interval', { count: 1 })}</Select.Option>
|
||||
<Select.Option value={120}>{t('settings.data.webdav.hour_interval', { count: 2 })}</Select.Option>
|
||||
<Select.Option value={360}>{t('settings.data.webdav.hour_interval', { count: 6 })}</Select.Option>
|
||||
<Select.Option value={720}>{t('settings.data.webdav.hour_interval', { count: 12 })}</Select.Option>
|
||||
<Select.Option value={1440}>{t('settings.data.webdav.hour_interval', { count: 24 })}</Select.Option>
|
||||
</Select>
|
||||
</SettingRow>
|
||||
{webdavSync && syncInterval > 0 && (
|
||||
|
||||
@@ -23,50 +23,6 @@ interface MiniAppManagerProps {
|
||||
|
||||
type ListType = 'visible' | 'disabled'
|
||||
|
||||
// 添加 reorderLists 函数的接口定义
|
||||
interface ReorderListsParams {
|
||||
sourceList: MinAppType[]
|
||||
destList: MinAppType[]
|
||||
sourceIndex: number
|
||||
destIndex: number
|
||||
isSameList: boolean
|
||||
}
|
||||
|
||||
interface ReorderListsResult {
|
||||
sourceList: MinAppType[]
|
||||
destList: MinAppType[]
|
||||
}
|
||||
|
||||
// 添加 reorderLists 函数
|
||||
const reorderLists = ({
|
||||
sourceList,
|
||||
destList,
|
||||
sourceIndex,
|
||||
destIndex,
|
||||
isSameList
|
||||
}: ReorderListsParams): ReorderListsResult => {
|
||||
if (isSameList) {
|
||||
// 在同一列表内重新排序
|
||||
const newList = [...sourceList]
|
||||
const [removed] = newList.splice(sourceIndex, 1)
|
||||
newList.splice(destIndex, 0, removed)
|
||||
return {
|
||||
sourceList: newList,
|
||||
destList: destList
|
||||
}
|
||||
} else {
|
||||
// 在不同列表间移动
|
||||
const newSourceList = [...sourceList]
|
||||
const [removed] = newSourceList.splice(sourceIndex, 1)
|
||||
const newDestList = [...destList]
|
||||
newDestList.splice(destIndex, 0, removed)
|
||||
return {
|
||||
sourceList: newSourceList,
|
||||
destList: newDestList
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MiniAppIconsManager: FC<MiniAppManagerProps> = ({
|
||||
visibleMiniApps,
|
||||
disabledMiniApps,
|
||||
@@ -92,25 +48,35 @@ const MiniAppIconsManager: FC<MiniAppManagerProps> = ({
|
||||
if (!result.destination) return
|
||||
|
||||
const { source, destination } = result
|
||||
const sourceList = source.droppableId as ListType
|
||||
const destList = destination.droppableId as ListType
|
||||
|
||||
if (source.droppableId === destination.droppableId) return
|
||||
if (source.droppableId === destination.droppableId) {
|
||||
// 在同一列表内重新排序
|
||||
const list = source.droppableId === 'visible' ? [...visibleMiniApps] : [...disabledMiniApps]
|
||||
const [removed] = list.splice(source.index, 1)
|
||||
list.splice(destination.index, 0, removed)
|
||||
|
||||
const newLists = reorderLists({
|
||||
sourceList: sourceList === 'visible' ? visibleMiniApps : disabledMiniApps,
|
||||
destList: destList === 'visible' ? visibleMiniApps : disabledMiniApps,
|
||||
sourceIndex: source.index,
|
||||
destIndex: destination.index,
|
||||
isSameList: sourceList === destList
|
||||
})
|
||||
if (source.droppableId === 'visible') {
|
||||
handleListUpdate(list, disabledMiniApps)
|
||||
} else {
|
||||
handleListUpdate(visibleMiniApps, list)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
handleListUpdate(
|
||||
sourceList === 'visible' ? newLists.sourceList : newLists.destList,
|
||||
sourceList === 'visible' ? newLists.destList : newLists.sourceList
|
||||
)
|
||||
// 在不同列表间移动
|
||||
const sourceList = source.droppableId === 'visible' ? [...visibleMiniApps] : [...disabledMiniApps]
|
||||
const destList = destination.droppableId === 'visible' ? [...visibleMiniApps] : [...disabledMiniApps]
|
||||
|
||||
const [removed] = sourceList.splice(source.index, 1)
|
||||
const targetList = destList.filter((app) => app.id !== removed.id)
|
||||
targetList.splice(destination.index, 0, removed)
|
||||
|
||||
const newVisibleMiniApps = destination.droppableId === 'visible' ? targetList : sourceList
|
||||
const newDisabledMiniApps = destination.droppableId === 'disabled' ? targetList : sourceList
|
||||
|
||||
handleListUpdate(newVisibleMiniApps, newDisabledMiniApps)
|
||||
},
|
||||
[disabledMiniApps, handleListUpdate, visibleMiniApps]
|
||||
[visibleMiniApps, disabledMiniApps, handleListUpdate]
|
||||
)
|
||||
|
||||
const onMoveMiniApp = useCallback(
|
||||
@@ -153,17 +119,15 @@ const MiniAppIconsManager: FC<MiniAppManagerProps> = ({
|
||||
<Droppable droppableId={listType}>
|
||||
{(provided: DroppableProvided) => (
|
||||
<ProgramList ref={provided.innerRef} {...provided.droppableProps}>
|
||||
<ScrollContainer>
|
||||
{(listType === 'visible' ? visibleMiniApps : disabledMiniApps).map((program, index) => (
|
||||
<Draggable key={program.id} draggableId={String(program.id)} index={index}>
|
||||
{(provided: DraggableProvided) => renderProgramItem(program, provided, listType)}
|
||||
</Draggable>
|
||||
))}
|
||||
{disabledMiniApps.length === 0 && listType === 'disabled' && (
|
||||
<EmptyPlaceholder>{t('settings.display.minApp.empty')}</EmptyPlaceholder>
|
||||
)}
|
||||
{provided.placeholder}
|
||||
</ScrollContainer>
|
||||
{(listType === 'visible' ? visibleMiniApps : disabledMiniApps).map((program, index) => (
|
||||
<Draggable key={program.id} draggableId={String(program.id)} index={index}>
|
||||
{(provided: DraggableProvided) => renderProgramItem(program, provided, listType)}
|
||||
</Draggable>
|
||||
))}
|
||||
{disabledMiniApps.length === 0 && listType === 'disabled' && (
|
||||
<EmptyPlaceholder>{t('settings.display.minApp.empty')}</EmptyPlaceholder>
|
||||
)}
|
||||
{provided.placeholder}
|
||||
</ProgramList>
|
||||
)}
|
||||
</Droppable>
|
||||
@@ -181,12 +145,6 @@ const AppLogo = styled.img`
|
||||
object-fit: contain;
|
||||
`
|
||||
|
||||
const ScrollContainer = styled.div`
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
padding-right: 5px;
|
||||
`
|
||||
|
||||
const ProgramSection = styled.div`
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
@@ -208,13 +166,29 @@ const ProgramList = styled.div`
|
||||
height: 365px;
|
||||
min-height: 365px;
|
||||
padding: 10px;
|
||||
padding-right: 5px;
|
||||
background: var(--color-background-soft);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
scroll-behavior: smooth;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-border-hover);
|
||||
}
|
||||
`
|
||||
|
||||
const ProgramItem = styled.div`
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { LoadingOutlined, MinusOutlined, PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons'
|
||||
import { Center } from '@renderer/components/Layout'
|
||||
import ModelTags from '@renderer/components/ModelTags'
|
||||
import { getModelLogo, isEmbeddingModel, isVisionModel, isWebSearchModel, SYSTEM_MODELS } from '@renderer/config/models'
|
||||
import {
|
||||
getModelLogo,
|
||||
isEmbeddingModel,
|
||||
isReasoningModel,
|
||||
isVisionModel,
|
||||
isWebSearchModel,
|
||||
SYSTEM_MODELS
|
||||
} from '@renderer/config/models'
|
||||
import { useProvider } from '@renderer/hooks/useProvider'
|
||||
import { fetchModels } from '@renderer/services/ApiService'
|
||||
import { Model, Provider } from '@renderer/types'
|
||||
@@ -36,10 +43,16 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
|
||||
const allModels = uniqBy([...systemModels, ...listModels, ...models], 'id')
|
||||
|
||||
const list = allModels.filter((model) => {
|
||||
if (searchText && !model.id.toLocaleLowerCase().includes(searchText.toLocaleLowerCase())) {
|
||||
if (
|
||||
searchText &&
|
||||
!model.id.toLocaleLowerCase().includes(searchText.toLocaleLowerCase()) &&
|
||||
!model.name?.toLocaleLowerCase().includes(searchText.toLocaleLowerCase())
|
||||
) {
|
||||
return false
|
||||
}
|
||||
switch (filterType) {
|
||||
case 'reasoning':
|
||||
return isReasoningModel(model)
|
||||
case 'vision':
|
||||
return isVisionModel(model)
|
||||
case 'websearch':
|
||||
@@ -136,6 +149,7 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
|
||||
<Center>
|
||||
<Radio.Group value={filterType} onChange={(e) => setFilterType(e.target.value)} buttonStyle="solid">
|
||||
<Radio.Button value="all">{t('models.all')}</Radio.Button>
|
||||
<Radio.Button value="reasoning">{t('models.reasoning')}</Radio.Button>
|
||||
<Radio.Button value="vision">{t('models.vision')}</Radio.Button>
|
||||
<Radio.Button value="websearch">{t('models.websearch')}</Radio.Button>
|
||||
<Radio.Button value="free">{t('models.free')}</Radio.Button>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useLMStudioSettings } from '@renderer/hooks/useLMStudio'
|
||||
import { InputNumber } from 'antd'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { SettingHelpText, SettingHelpTextRow, SettingSubtitle } from '..'
|
||||
|
||||
const LMStudioSettings: FC = () => {
|
||||
const { keepAliveTime, setKeepAliveTime } = useLMStudioSettings()
|
||||
const [keepAliveMinutes, setKeepAliveMinutes] = useState(keepAliveTime)
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<SettingSubtitle style={{ marginBottom: 5 }}>{t('lmstudio.keep_alive_time.title')}</SettingSubtitle>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
value={keepAliveMinutes}
|
||||
onChange={(e) => setKeepAliveMinutes(Number(e))}
|
||||
onBlur={() => setKeepAliveTime(keepAliveMinutes)}
|
||||
suffix={t('lmstudio.keep_alive_time.placeholder')}
|
||||
step={5}
|
||||
/>
|
||||
<SettingHelpTextRow>
|
||||
<SettingHelpText>{t('lmstudio.keep_alive_time.description')}</SettingHelpText>
|
||||
</SettingHelpTextRow>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div``
|
||||
|
||||
export default LMStudioSettings
|
||||
@@ -42,6 +42,7 @@ import AddModelPopup from './AddModelPopup'
|
||||
import ApiCheckPopup from './ApiCheckPopup'
|
||||
import EditModelsPopup from './EditModelsPopup'
|
||||
import GraphRAGSettings from './GraphRAGSettings'
|
||||
import LMStudioSettings from './LMStudioSettings'
|
||||
import OllamSettings from './OllamaSettings'
|
||||
import SelectProviderModelPopup from './SelectProviderModelPopup'
|
||||
|
||||
@@ -319,6 +320,7 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
</>
|
||||
)}
|
||||
{provider.id === 'ollama' && <OllamSettings />}
|
||||
{provider.id === 'lmstudio' && <LMStudioSettings />}
|
||||
{provider.id === 'graphrag-kylin-mountain' && provider.models.length > 0 && (
|
||||
<GraphRAGSettings provider={provider} />
|
||||
)}
|
||||
|
||||
@@ -53,7 +53,7 @@ const ProvidersList: FC = () => {
|
||||
}
|
||||
|
||||
const getDropdownMenus = (provider: Provider): MenuProps['items'] => {
|
||||
return [
|
||||
const menus = [
|
||||
{
|
||||
label: t('common.edit'),
|
||||
key: 'edit',
|
||||
@@ -83,6 +83,16 @@ const ProvidersList: FC = () => {
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
if (providers.filter((p) => p.id === provider.id).length > 1) {
|
||||
return menus
|
||||
}
|
||||
|
||||
if (provider.isSystem) {
|
||||
return []
|
||||
}
|
||||
|
||||
return menus
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -102,9 +112,7 @@ const ProvidersList: FC = () => {
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
style={{ ...provided.draggableProps.style, marginBottom: 5 }}>
|
||||
<Dropdown
|
||||
menu={{ items: provider.isSystem ? [] : getDropdownMenus(provider) }}
|
||||
trigger={['contextMenu']}>
|
||||
<Dropdown menu={{ items: getDropdownMenus(provider) }} trigger={['contextMenu']}>
|
||||
<ProviderListItem
|
||||
key={JSON.stringify(provider)}
|
||||
className={provider.id === selectedProvider?.id ? 'active' : ''}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { initialState, resetShortcuts, toggleShortcut, updateShortcut } from '@r
|
||||
import { Shortcut } from '@renderer/types'
|
||||
import { Button, Input, InputRef, Switch, Table as AntTable, Tooltip } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { FC, useRef, useState } from 'react'
|
||||
import React, { FC, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -59,7 +59,8 @@ const ShortcutSettings: FC = () => {
|
||||
const hasModifier = keys.some((key) => ['Control', 'Ctrl', 'Command', 'Alt', 'Shift'].includes(key))
|
||||
const hasNonModifier = keys.some((key) => !['Control', 'Ctrl', 'Command', 'Alt', 'Shift'].includes(key))
|
||||
|
||||
if (isMac && keys.includes('Alt')) {
|
||||
// only allows option + space
|
||||
if (isMac && keys[0] === 'Alt' && !['Space', undefined].includes(keys[1])) {
|
||||
window.message.warning({
|
||||
content: t('settings.shortcuts.alt_warning'),
|
||||
key: 'shortcut-alt-warning'
|
||||
@@ -92,8 +93,32 @@ const ShortcutSettings: FC = () => {
|
||||
return isMac ? '⇧' : 'Shift'
|
||||
case 'CommandOrControl':
|
||||
return isMac ? '⌘' : 'Ctrl'
|
||||
case ' ':
|
||||
return 'Space'
|
||||
case 'ArrowUp':
|
||||
return '↑'
|
||||
case 'ArrowDown':
|
||||
return '↓'
|
||||
case 'ArrowLeft':
|
||||
return '←'
|
||||
case 'ArrowRight':
|
||||
return '→'
|
||||
case 'Slash':
|
||||
return '/'
|
||||
case 'Semicolon':
|
||||
return ';'
|
||||
case 'BracketLeft':
|
||||
return '['
|
||||
case 'BracketRight':
|
||||
return ']'
|
||||
case 'Backslash':
|
||||
return '\\'
|
||||
case 'Quote':
|
||||
return "'"
|
||||
case 'Comma':
|
||||
return ','
|
||||
case 'Minus':
|
||||
return '-'
|
||||
case 'Equal':
|
||||
return '='
|
||||
default:
|
||||
return key.charAt(0).toUpperCase() + key.slice(1)
|
||||
}
|
||||
@@ -101,6 +126,115 @@ const ShortcutSettings: FC = () => {
|
||||
.join(' + ')
|
||||
}
|
||||
|
||||
const usableEndKeys = (event: React.KeyboardEvent): string | null => {
|
||||
const { code } = event
|
||||
// No lock keys
|
||||
// Among the commonly used keys, not including: Escape, NumpadMultiply, NumpadDivide, NumpadSubtract, NumpadAdd, NumpadDecimal
|
||||
// The react-hotkeys-hook library does not differentiate between `Digit` and `Numpad`
|
||||
switch (code) {
|
||||
case 'KeyA':
|
||||
case 'KeyB':
|
||||
case 'KeyC':
|
||||
case 'KeyD':
|
||||
case 'KeyE':
|
||||
case 'KeyF':
|
||||
case 'KeyG':
|
||||
case 'KeyH':
|
||||
case 'KeyI':
|
||||
case 'KeyJ':
|
||||
case 'KeyK':
|
||||
case 'KeyL':
|
||||
case 'KeyM':
|
||||
case 'KeyN':
|
||||
case 'KeyO':
|
||||
case 'KeyP':
|
||||
case 'KeyQ':
|
||||
case 'KeyR':
|
||||
case 'KeyS':
|
||||
case 'KeyT':
|
||||
case 'KeyU':
|
||||
case 'KeyV':
|
||||
case 'KeyW':
|
||||
case 'KeyX':
|
||||
case 'KeyY':
|
||||
case 'KeyZ':
|
||||
case 'Digit0':
|
||||
case 'Digit1':
|
||||
case 'Digit2':
|
||||
case 'Digit3':
|
||||
case 'Digit4':
|
||||
case 'Digit5':
|
||||
case 'Digit6':
|
||||
case 'Digit7':
|
||||
case 'Digit8':
|
||||
case 'Digit9':
|
||||
case 'Numpad0':
|
||||
case 'Numpad1':
|
||||
case 'Numpad2':
|
||||
case 'Numpad3':
|
||||
case 'Numpad4':
|
||||
case 'Numpad5':
|
||||
case 'Numpad6':
|
||||
case 'Numpad7':
|
||||
case 'Numpad8':
|
||||
case 'Numpad9':
|
||||
return code.slice(-1)
|
||||
case 'Space':
|
||||
case 'Enter':
|
||||
case 'Backspace':
|
||||
case 'Tab':
|
||||
case 'Delete':
|
||||
case 'PageUp':
|
||||
case 'PageDown':
|
||||
case 'Insert':
|
||||
case 'Home':
|
||||
case 'End':
|
||||
case 'ArrowUp':
|
||||
case 'ArrowDown':
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowRight':
|
||||
case 'F1':
|
||||
case 'F2':
|
||||
case 'F3':
|
||||
case 'F4':
|
||||
case 'F5':
|
||||
case 'F6':
|
||||
case 'F7':
|
||||
case 'F8':
|
||||
case 'F9':
|
||||
case 'F10':
|
||||
case 'F11':
|
||||
case 'F12':
|
||||
case 'F13':
|
||||
case 'F14':
|
||||
case 'F15':
|
||||
case 'F16':
|
||||
case 'F17':
|
||||
case 'F18':
|
||||
case 'F19':
|
||||
return code
|
||||
case 'Backquote':
|
||||
return '`'
|
||||
case 'Period':
|
||||
return '.'
|
||||
case 'NumpadEnter':
|
||||
return 'Enter'
|
||||
// The react-hotkeys-hook library does not handle the symbol strings for the following keys
|
||||
case 'Slash':
|
||||
case 'Semicolon':
|
||||
case 'BracketLeft':
|
||||
case 'BracketRight':
|
||||
case 'Backslash':
|
||||
case 'Quote':
|
||||
case 'Comma':
|
||||
case 'Minus':
|
||||
case 'Equal':
|
||||
return code
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent, record: Shortcut) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -109,15 +243,9 @@ const ShortcutSettings: FC = () => {
|
||||
if (e.metaKey) keys.push('Command')
|
||||
if (e.altKey) keys.push('Alt')
|
||||
if (e.shiftKey) keys.push('Shift')
|
||||
|
||||
const key = e.key
|
||||
|
||||
if (key.length === 1 && !['Control', 'Alt', 'Shift', 'Meta'].includes(key)) {
|
||||
if (key === ' ') {
|
||||
keys.push('Space')
|
||||
} else {
|
||||
keys.push(key.toUpperCase())
|
||||
}
|
||||
const endKey = usableEndKeys(e)
|
||||
if (endKey) {
|
||||
keys.push(endKey)
|
||||
}
|
||||
|
||||
if (!isValidShortcut(keys)) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { CheckOutlined, SendOutlined, SettingOutlined, SwapOutlined, WarningOutl
|
||||
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||
import CopyIcon from '@renderer/components/Icons/CopyIcon'
|
||||
import { isLocalAi } from '@renderer/config/env'
|
||||
import { TranslateLanguageOptions } from '@renderer/config/translate'
|
||||
import { translateLanguageOptions } from '@renderer/config/translate'
|
||||
import db from '@renderer/databases'
|
||||
import { useDefaultModel } from '@renderer/hooks/useAssistant'
|
||||
import { fetchTranslate } from '@renderer/services/ApiService'
|
||||
@@ -10,9 +10,10 @@ import { getDefaultTranslateAssistant } from '@renderer/services/AssistantServic
|
||||
import { Assistant, Message } from '@renderer/types'
|
||||
import { runAsyncFunction, uuid } from '@renderer/utils'
|
||||
import { Button, Select, Space } from 'antd'
|
||||
import TextArea from 'antd/es/input/TextArea'
|
||||
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { debounce } from 'lodash'
|
||||
import React, { FC, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
@@ -29,11 +30,83 @@ const TranslatePage: FC = () => {
|
||||
const { translateModel } = useDefaultModel()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const contentContainerRef = useRef<HTMLDivElement>(null)
|
||||
const textAreaRef = useRef<TextAreaRef>(null)
|
||||
|
||||
_text = text
|
||||
_result = result
|
||||
_targetLanguage = targetLanguage
|
||||
|
||||
const safetyMarginOfTextarea = (textarea: HTMLTextAreaElement): number => {
|
||||
const defaultSafetyMargin = 30
|
||||
const lineHeight = window.getComputedStyle(textarea).lineHeight
|
||||
if (lineHeight.endsWith('px')) {
|
||||
const safetyMargin = parseInt(lineHeight.slice(0, -2))
|
||||
if (Number.isNaN(safetyMargin)) {
|
||||
return defaultSafetyMargin
|
||||
} else {
|
||||
return safetyMargin + 4
|
||||
}
|
||||
} else {
|
||||
return defaultSafetyMargin
|
||||
}
|
||||
}
|
||||
|
||||
const updateTextareaToMaxHeight = (textarea: HTMLTextAreaElement, safetyMargin: number) => {
|
||||
const { top: textareaTop } = textarea.getBoundingClientRect()
|
||||
textarea.style.height = `${window.innerHeight - safetyMargin - textareaTop}px`
|
||||
}
|
||||
|
||||
const updateTextareaHeight = useCallback((textarea: HTMLTextAreaElement, contentContainer: HTMLDivElement | null) => {
|
||||
textarea.style.height = 'auto'
|
||||
const unlimitedHeightUpdate = () => {
|
||||
textarea.style.height = `${textarea.scrollHeight}px`
|
||||
}
|
||||
const safetyMargin = safetyMarginOfTextarea(textarea)
|
||||
|
||||
if (contentContainer) {
|
||||
const { bottom: textareaBottom, top: textareaTop } = textarea.getBoundingClientRect()
|
||||
const { bottom: contentContainerBottom } = contentContainer.getBoundingClientRect()
|
||||
if (textareaBottom !== 0 && contentContainerBottom !== 0) {
|
||||
if (contentContainerBottom - textareaTop - textarea.scrollHeight < safetyMargin) {
|
||||
updateTextareaToMaxHeight(textarea, safetyMargin)
|
||||
} else {
|
||||
unlimitedHeightUpdate()
|
||||
}
|
||||
} else {
|
||||
unlimitedHeightUpdate()
|
||||
}
|
||||
} else {
|
||||
unlimitedHeightUpdate()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
updateTextareaHeight(event.target, contentContainerRef.current)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize when switching to this page
|
||||
if (textAreaRef?.current?.resizableTextArea?.textArea) {
|
||||
updateTextareaHeight(textAreaRef.current.resizableTextArea.textArea, contentContainerRef.current)
|
||||
}
|
||||
|
||||
const debounceHandleResize = debounce(
|
||||
() => {
|
||||
if (textAreaRef?.current?.resizableTextArea) {
|
||||
updateTextareaHeight(textAreaRef.current.resizableTextArea.textArea, contentContainerRef.current)
|
||||
}
|
||||
},
|
||||
16,
|
||||
{ maxWait: 16 }
|
||||
)
|
||||
|
||||
const handleResize = () => debounceHandleResize()
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [textAreaRef, updateTextareaHeight])
|
||||
|
||||
const onTranslate = async () => {
|
||||
if (!text.trim()) {
|
||||
return
|
||||
@@ -113,7 +186,7 @@ const TranslatePage: FC = () => {
|
||||
<Navbar>
|
||||
<NavbarCenter style={{ borderRight: 'none' }}>{t('translate.title')}</NavbarCenter>
|
||||
</Navbar>
|
||||
<ContentContainer id="content-container">
|
||||
<ContentContainer id="content-container" ref={contentContainerRef}>
|
||||
<MenuContainer>
|
||||
<Select
|
||||
showSearch
|
||||
@@ -129,7 +202,7 @@ const TranslatePage: FC = () => {
|
||||
value={targetLanguage}
|
||||
style={{ width: 180 }}
|
||||
optionFilterProp="label"
|
||||
options={TranslateLanguageOptions}
|
||||
options={translateLanguageOptions()}
|
||||
onChange={(value) => {
|
||||
setTargetLanguage(value)
|
||||
db.settings.put({ id: 'translate:target:language', value })
|
||||
@@ -148,6 +221,8 @@ const TranslatePage: FC = () => {
|
||||
<TranslateInputWrapper>
|
||||
<InputContainer>
|
||||
<Textarea
|
||||
ref={textAreaRef}
|
||||
onInput={handleInput}
|
||||
variant="borderless"
|
||||
placeholder={t('translate.input.placeholder')}
|
||||
value={text}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { REFERENCE_PROMPT } from '@renderer/config/prompts'
|
||||
import { getLMStudioKeepAliveTime } from '@renderer/hooks/useLMStudio'
|
||||
import { getOllamaKeepAliveTime } from '@renderer/hooks/useOllama'
|
||||
import { getKnowledgeReferences } from '@renderer/services/KnowledgeService'
|
||||
import store from '@renderer/store'
|
||||
import { Assistant, GenerateImageParams, Message, Model, Provider, Suggestion } from '@renderer/types'
|
||||
import { delay, isJSON, parseJSON } from '@renderer/utils'
|
||||
import { t } from 'i18next'
|
||||
import OpenAI from 'openai'
|
||||
|
||||
import { CompletionsParams } from '.'
|
||||
@@ -63,7 +65,11 @@ export default abstract class BaseProvider {
|
||||
}
|
||||
|
||||
public get keepAliveTime() {
|
||||
return this.provider.id === 'ollama' ? getOllamaKeepAliveTime() : undefined
|
||||
return this.provider.id === 'ollama'
|
||||
? getOllamaKeepAliveTime()
|
||||
: this.provider.id === 'lmstudio'
|
||||
? getLMStudioKeepAliveTime()
|
||||
: undefined
|
||||
}
|
||||
|
||||
public async fakeCompletions({ onChunk }: CompletionsParams) {
|
||||
@@ -78,16 +84,35 @@ export default abstract class BaseProvider {
|
||||
return message.content
|
||||
}
|
||||
|
||||
const knowledgeId = message.knowledgeBaseIds[0]
|
||||
const base = store.getState().knowledge.bases.find((kb) => kb.id === knowledgeId)
|
||||
const bases = store.getState().knowledge.bases.filter((kb) => message.knowledgeBaseIds?.includes(kb.id))
|
||||
|
||||
if (!base) {
|
||||
if (!bases || bases.length === 0) {
|
||||
return message.content
|
||||
}
|
||||
|
||||
const references = await getKnowledgeReferences(base, message)
|
||||
const allReferencesPromises = bases.map(async (base) => {
|
||||
const references = await getKnowledgeReferences(base, message)
|
||||
|
||||
return REFERENCE_PROMPT.replace('{question}', message.content).replace('{references}', references)
|
||||
return {
|
||||
knowledgeBaseId: base.id,
|
||||
references
|
||||
}
|
||||
})
|
||||
const allReferences = (await Promise.all(allReferencesPromises))
|
||||
.filter((result) => result.references && result.references.length > 0)
|
||||
.flat()
|
||||
|
||||
if (allReferences.length === 0) {
|
||||
window.message.info({
|
||||
content: t('knowledge.no_match'),
|
||||
duration: 4,
|
||||
key: 'knowledge-base-no-match-info'
|
||||
})
|
||||
return message.content
|
||||
}
|
||||
const allReferencesContent = `\`\`\`json\n${JSON.stringify(allReferences, null, 2)}\n\`\`\``
|
||||
|
||||
return REFERENCE_PROMPT.replace('{question}', message.content).replace('{references}', allReferencesContent)
|
||||
}
|
||||
|
||||
protected getCustomParameters(assistant: Assistant) {
|
||||
@@ -98,10 +123,10 @@ export default abstract class BaseProvider {
|
||||
}
|
||||
if (param.type === 'json') {
|
||||
const value = param.value as string
|
||||
return {
|
||||
...acc,
|
||||
[param.name]: isJSON(value) ? parseJSON(value) : value
|
||||
if (value === 'undefined') {
|
||||
return { ...acc, [param.name]: undefined }
|
||||
}
|
||||
return { ...acc, [param.name]: isJSON(value) ? parseJSON(value) : value }
|
||||
}
|
||||
return {
|
||||
...acc,
|
||||
|
||||