Compare commits

..

124 Commits

Author SHA1 Message Date
kangfenmao
7f05626a8f chore(version): 0.9.27 2025-02-19 10:21:12 +08:00
kangfenmao
2094e2201a feat: Add web search support for OpenRouter provider 2025-02-19 10:11:49 +08:00
icinggslits
e0fcdf43c5 feature: Adaptive height of textarea on translation page 2025-02-19 09:57:26 +08:00
kangfenmao
affc866c17 feat: Add default API host for DMX provider in migration 2025-02-19 09:45:41 +08:00
kangfenmao
799267049f fix: Safely update topic with existing topic data 2025-02-19 09:35:10 +08:00
kangfenmao
cb8d47a17b fix: Improve knowledge base processing and deletion handling 2025-02-19 09:28:36 +08:00
kangfenmao
c494288f7b refactor: Simplify DragableList styling and remove unnecessary margins 2025-02-19 09:21:10 +08:00
suyao
2c3f89dbde fix: update model identification with provider-specific uniqueness 2025-02-19 09:14:45 +08:00
ousugo
4721a660fa feat: Add German language support to translation options 2025-02-19 08:21:00 +08:00
George·Dong
6aaa3def0d feat: 添加Notion文档按钮Tooltip 2025-02-19 08:20:21 +08:00
George·Dong
045708d9b3 fix: 修改导出到Notion的相关提示 2025-02-19 08:20:21 +08:00
icinggslits
9ffe92d378 fix: Update language options promptly 2025-02-19 08:18:41 +08:00
首都爱护动物协会
7159481217 Updated provider information 2025-02-19 07:10:45 +08:00
kangfenmao
d07e136037 fix: Add top margin to 'Add Assistant' button in AssistantsTab 2025-02-18 21:17:13 +08:00
Yrom
b38a9c954a feat: Enable search capability for Qwen commercial version model 2025-02-18 21:14:43 +08:00
kangfenmao
7139d5093a chore(version): 0.9.26 2025-02-18 20:55:04 +08:00
kangfenmao
9e283d6930 fix: Update agent knowledge base field name and handling 2025-02-18 20:55:04 +08:00
Chen Tao
c9a4e12765 feat: artifacts add open external (#1812)
* feat: artifacts add open external

* fix: remove modal
2025-02-18 19:56:39 +08:00
Teo
7bd644451b fix: 解决生成过程中出现错误内容被清空覆盖问题 2025-02-18 19:46:50 +08:00
美兰十三
5a00bdcbc6 fix: 修复mac下快捷键注册control被替换成command的问题 2025-02-18 19:46:28 +08:00
eeee0717
3c958c3d11 feat: 目录进度可视化 2025-02-18 19:45:47 +08:00
kangfenmao
1d5ace0fb2 feat: Add 'off' option for reasoning effort in assistant settings 2025-02-18 18:16:14 +08:00
ousugo
f8fce871da fix: Recalculate token consumption after modifying the message, resolve #1829 2025-02-18 16:34:52 +08:00
ousugo
de76d3fedc fix: Improve DragableList component styling and placeholder handling 2025-02-18 16:26:34 +08:00
lucifer9
b2c6662192 adjust Notion database ID input width in DataSettings 2025-02-18 16:22:53 +08:00
lucifer9
bf8a7c01b0 Refactor WebDAV i18n and UI for improved flexibility and localization
- i18n Updates:
   - Refactored WebDAV-related translations into nested JSON structures for better organization.
   - Added support for pluralization in time intervals (minutes and hours) across all locales (en-us, ja-jp, ru-ru, zh-cn, zh-tw).

 - UI Enhancements:
   - Updated `DataSettings` and `WebDavSettings` components to use the new i18n keys for time intervals.
   - Improved the `Select` dropdown for sync intervals with dynamic pluralization based on locale.
   - Adjusted input field widths for better alignment and consistency.

 - Code Cleanup:
   - Removed redundant comments and unused code in `WebDavSettings.tsx`.
   - Simplified button and input styling for a cleaner layout.
2025-02-18 16:22:53 +08:00
ousugo
fb8ed35b59 fix: Clicking the taskbar icon while enable the Quick Assistant can't open the main window 2025-02-18 16:01:10 +08:00
ousugo
7c4d81c108 feat: Add kimi-latest model support in vision and model logos 2025-02-18 15:50:01 +08:00
kangfenmao
7199f73e06 style: Adjust horizontal message layout display property 2025-02-18 15:48:13 +08:00
Teo
869e56b53c style: 优化聊天窗口UI (#1881) 2025-02-18 11:43:42 +08:00
MyPrototypeWhat
f99851fb6b feat: Conditionally hide thinking loader for paused messages (#1875)
Co-authored-by: lizhixuan <zhixuan.li@banosuperapp.com>
2025-02-18 11:10:48 +08:00
kangfenmao
c94450db44 chore(version): 0.9.25 2025-02-18 10:41:44 +08:00
kangfenmao
195ef92acc feat: Add size prop to MessageThought Collapse component 2025-02-18 10:12:30 +08:00
kangfenmao
a67370426b fix: Handle undefined provider in model name generation 2025-02-18 09:56:26 +08:00
kangfenmao
9d35205681 feat: Improve system prompt styling with theme-aware background 2025-02-18 09:42:52 +08:00
icinggslits
98087e50db feat: Backspace deletes clipboard text in MiniApp 2025-02-18 08:13:56 +08:00
icinggslits
bedac4f59d fix: init zoom 2025-02-18 08:11:38 +08:00
ousugo
aba3874797 refactor: Improve PromptPopup text area focus and cursor placement 2025-02-18 08:10:58 +08:00
ousugo
3383280726 feat: Improve text edit popup focus and cursor placement 2025-02-18 08:10:58 +08:00
kangfenmao
0c13e708b9 refactor: Extract message group menu bar into a separate component 2025-02-17 23:21:24 +08:00
kangfenmao
bc77c423b3 fix: Adjust paragraph margin when followed by list 2025-02-17 23:08:17 +08:00
kangfenmao
4821756301 fix: Conditionally render message group border based on popover state 2025-02-17 23:02:49 +08:00
Chen Tao
78290ca70e feat: add knowledge base filter (#1822)
* feat: add search filter

* chore
2025-02-17 22:18:10 +08:00
kangfenmao
7feeb07624 refactor: Extract message group settings into a separate component 2025-02-17 22:14:47 +08:00
luwux
93e28ed916 improvement(shortcut): Supports Option + Space on Mac
Supports the Option (⌥) + Space shortcut, as it's the default shortcut for ChatGPT Desktop app to show popup.
2025-02-17 19:04:08 +08:00
ousugo
b4aaf052fe feat: Add page title for Cherry Studio, resolve #1222 2025-02-17 19:02:56 +08:00
rebecca554owen
b37e0389fc fix 2025-02-17 18:38:22 +08:00
kangfenmao
e1ebe069a5 feat: Add grid mode settings for message display 2025-02-17 18:35:36 +08:00
kangfenmao
d73912ee3b feat: Enhance Notion settings with placeholders and help icon 2025-02-17 17:19:24 +08:00
kangfenmao
f81c7c7a6c feat: update knowledge base file upload hint text 2025-02-17 16:50:34 +08:00
FischLu
5a7bcd5997 feat: improve model mention autocomplete behavior under IME 2025-02-17 16:38:44 +08:00
duanyongcheng
09a347cae4 feat: show provider in mesage 2025-02-17 16:38:00 +08:00
Chen Tao
266f909045 feat: allow knowledge base multiple search #1346 (#1773)
* feat: agent can select multiple knowledge bases

* feat: basic search multiple knowledge base

* fix bug: knowledge base is delete, assistants and agents sync delete

* fix bug: assistant and knowledge base button sync

* feat: allow to search multiple knowledge base

* chore: finish rebase to upstream/main
2025-02-17 16:36:25 +08:00
cl1107
bad2f15c1f feat: Add a new grid mode for message display. (#1626)
* chore(version): 0.9.23

* feat(renderer): 新增网格模式的消息展示方式

* feat(message): 新增消息网格展示相关设置

* 根据 gridPopoverTrigger 属性动态设置消息分组的样式

* 在 MessageMenubar 组件中,各个按钮 click 事件阻止事件冒泡,避免打开 popover

* 多模型回答样式添加网格模式并优化消息样式

---------

Co-authored-by: kangfenmao <kangfenmao@qq.com>
2025-02-17 16:36:01 +08:00
shniubobo
e3115d00bf fix: Rendering error with MathJax for Chinese text 2025-02-17 16:24:38 +08:00
首都爱护动物协会
0c0ccf3d11 update provider info 2025-02-17 16:21:09 +08:00
ousugo
2076e6f998 fix: open current webview URL when launching external link 2025-02-17 16:20:12 +08:00
ousugo
b49d80b78d fix: Clicking the help button always opens a new webview
(cherry picked from commit 4939afafabcbfb294f00d21053939cad8238731e)
2025-02-17 16:19:31 +08:00
kangfenmao
ab5e830ed1 docs: remove Chinese issue templates for bug reports, feature requests, and questions 2025-02-17 12:02:07 +08:00
ousugo
e0eca97053 fix: update Baidu API key URL in provider configuration, resolve #1794 2025-02-17 11:55:53 +08:00
George·Dong
d175212d9a fix: 修复切换助手时无法正确切换到助手默认模型的问题 (#1776) 2025-02-17 11:28:17 +08:00
Shelly
642ce160a1 fix: 修复同名模型选择问题 (#1772)
1. 同名模型显示的供应商名称问题
2. 同名模型不同供应商不能被同时选择

Co-authored-by: duanyongcheng <duanyongcheng77@gmail.com>
2025-02-17 09:47:01 +08:00
Wenwei Lin
574d02a8c9 feat: support json and draftsExport file in knowledge base (#1717) 2025-02-17 08:25:07 +08:00
ousugo
7764507d74 feat: Expand reasoning model regex to include 'thinking' keyword 2025-02-17 08:16:47 +08:00
ousugo
fa8bf61532 fix: sidebar navigation and active state handling
- 当固定在侧边栏的小程序被打开时,对应图标显示为被选中
- 修复点击两次主题切换按钮会导致当前 Webview 被错误关闭的问题
- 修复当 Webview 处于打开状态,点击侧边栏按钮无法立即跳转到对应界面的问题
- 修复打开帮助文档,其按钮没有显示为被选中的问题
- 修复在设置界面时打开帮助文档,设置按钮继续显示为被选中的问题
2025-02-17 08:15:03 +08:00
ousugo
30e8cef9cc fix: correction of the capitalization of Perplexity names 2025-02-17 08:13:56 +08:00
ousugo
1a2861e81a fix: Fix the miniapp sorting problem, resolve #1725
- 修复小程序拖动排序不生效的问题
- 修复小程序拖动排序时列表滚动排序不生效的问题
2025-02-17 08:13:56 +08:00
Neal_Tan
653e5d82ed docs: optimize issues (#1790) 2025-02-17 08:12:40 +08:00
kangfenmao
5be0e0ae72 style: Enhance scrollbar appearance in mention models dropdown 2025-02-16 13:56:10 +08:00
FischLu
b92b46f2b0 refine code 2025-02-16 13:54:32 +08:00
FischLu
23686d4926 feat: implement select mode menu autoscroll for long mode lists 2025-02-16 13:54:32 +08:00
kangfenmao
b340b40bcf Revert "fix: Improve the @ model list experience"
This reverts commit c53d63f7af.
2025-02-16 13:54:09 +08:00
bfdyanshe
253fc6f4e1 fix: Separate EPUB files into dedicated book file extension category 2025-02-16 13:46:52 +08:00
bC2y5tal
99aa0d3255 feat: Add EPUB file support to document loader 2025-02-16 13:46:52 +08:00
icinggslits
23a2a6b57c improvement(shortcut): Support more keyboard shortcuts 2025-02-16 13:45:03 +08:00
icinggslits
a869857fc1 add usableEndKeys 2025-02-16 13:45:03 +08:00
kangfenmao
4ecedcb267 feat: Enhance topic handling and message prompt generation 2025-02-16 13:41:31 +08:00
kangfenmao
cbd6a30e14 feat: Improve knowledge base threshold tooltip and input 2025-02-16 12:20:08 +08:00
kangfenmao
5f2cddee09 chore: Update store migration for Coze minapp 2025-02-16 12:14:20 +08:00
Chen Tao
c0e0e924f7 feat: 添加知识库匹配度阈值 (#1634)
* feat: 添加知识库匹配度阈值

* fix: 增加问答时知识库阈值

* feat: 当知识库未检索到数据时使用通用对话逻辑

* fix: add toast
2025-02-16 11:38:00 +08:00
Avan
b6ad7eeb9a style: add bot.n.cn logo 2025-02-16 11:36:58 +08:00
Avan
9cf74317a6 feat: add bot.n.cn 2025-02-16 11:36:58 +08:00
George·Dong
82fcc2292e feat: Add Coze minapp 2025-02-16 10:38:28 +08:00
yangtb2024
4eb0c25682 fix: 窗口较小时,工具显示适配问题 2025-02-16 10:35:45 +08:00
kassadin
9e128d2524 fix: unregister global shortcuts 2025-02-16 10:34:24 +08:00
jyeric
1473cb3123 Fix: Font size and Latex problem, resolve CherryHQ#1034 CherryHQ#1596 (#1723) 2025-02-15 22:55:43 +08:00
Wenwei Lin
2c5fe01fbf fix: add ellipsis in knowledge base item (#1718) 2025-02-15 22:51:07 +08:00
Wenwei Lin
d574a09529 fix: support html file in knowledge base (#1703) 2025-02-15 22:50:05 +08:00
美兰十三
f20bccfd7d feature: add topic prompt (#1696)
* feat: 新增话题补充提示词

* feat: 新增话题补充提示词

* feat: 新增话题补充提示词

* feat: 新增话题补充提示词

* feat: 新增话题补充提示词
2025-02-15 08:21:59 +08:00
icinggslits
5dcc892f31 调整show_app快捷键功能的交互逻辑 2025-02-15 08:17:18 +08:00
kangfenmao
26e3871688 Revert "fix: 网页链接附带中文标点解析错误"
This reverts commit 16feb49e9e.
2025-02-15 01:30:13 +08:00
kangfenmao
9a6aad35b0 fix: Improve handling of 'undefined' values in JSON parsing 2025-02-15 01:25:59 +08:00
eeee0717
16feb49e9e fix: 网页链接附带中文标点解析错误 2025-02-15 01:06:32 +08:00
kangfenmao
30959e2380 feat: Add LM Studio and ModelScope as system LLM providers
- Update llm.ts to include LM Studio and ModelScope in initial system providers
- Modify migrate.ts to add migration logic for adding these new providers
- Ensure providers are added only if they don't already exist in the configuration
2025-02-15 01:03:09 +08:00
kangfenmao
2c17f75f4f fix: Correct migration version configuration 2025-02-15 00:55:07 +08:00
Yihong Wang
2d1a930bfe feat: Add NotebookLM to MinApps solve #1679 2025-02-15 00:52:47 +08:00
eeee0717
320d27059f fix: 分组和非分组逻辑修改 2025-02-15 00:33:39 +08:00
eeee0717
31014aa8a6 fix: Switching model does not work 2025-02-15 00:33:39 +08:00
ousugo
b468ecfce7 feat: Improve textarea cursor positioning on focus 2025-02-15 00:31:36 +08:00
ousugo
c53d63f7af fix: Improve the @ model list experience
- 修复使用方向键上下移动时,列表不随之滚动的问题
- 添加滚动条
2025-02-15 00:29:32 +08:00
ousugo
dabff0a847 feat: Add platform and version fields to all issue templates 2025-02-15 00:26:31 +08:00
Konjac-XZ
26a5ae0086 fix: Translation error when passing empty user messages to certain models.(Refined) 2025-02-15 00:24:17 +08:00
kangfenmao
88e0d293a2 chore(version): 0.9.24 2025-02-14 15:04:59 +08:00
kangfenmao
0c97b52c53 refactor: Improve provider removal logic in LLM store 2025-02-14 14:49:34 +08:00
ousugo
2449a22c69 perf: Add new Infini AI models to system models list 2025-02-14 14:37:57 +08:00
ousugo
028f9d88d9 feat: Add reasoning model filter in EditModelsPopup 2025-02-14 14:30:48 +08:00
kangfenmao
a07c6cdffb refactor: Improve provider settings and menu handling 2025-02-14 13:35:58 +08:00
kangfenmao
5a647b0d61 style: Adjust group menu bar styling 2025-02-14 13:18:16 +08:00
kangfenmao
007e6419ba feat: Add ModelScope provider to LLM providers list 2025-02-14 13:13:32 +08:00
Col0ring
caa473639c feat: add modelscope provider (#1563)
Co-authored-by: 亢奋猫 <kangfenmao@qq.com>
2025-02-14 13:12:46 +08:00
kangfenmao
b6825a6ea2 feat(notion): Add divider to Notion settings page 2025-02-14 13:08:47 +08:00
Trey Dong
710180997f feat(notion): 添加 Notion连接检查功能 (#1620)
- 在 Notion 配置页面添加"检查"按钮
- 实现 Notion 连接检查逻辑
- 添加相关国际化文本
2025-02-14 10:52:16 +08:00
hehua2008
fd4334f331 feat: Add LM Studio support (#1572)
Co-authored-by: hehua2008 <hegan2010@gmail.com>
Co-authored-by: 亢奋猫 <kangfenmao@qq.com>
2025-02-14 10:49:57 +08:00
FischLu
80dedc149a feat: Implement circular selection in model selector 2025-02-14 10:40:03 +08:00
duanyongcheng77
8eacaa281a chore: add ignore for .cursoerules 2025-02-14 10:38:51 +08:00
duanyongcheng77
6e75140939 chore: 🤖 add aider gitignore 2025-02-14 10:38:51 +08:00
Asurada
5a3a97135f feat: Add XiaoYi miniapp, resolve #1591 (#1595)
Co-authored-by: 亢奋猫 <kangfenmao@qq.com>
2025-02-14 10:37:42 +08:00
ousugo
44d42d64ef fix: Solve the problem that eslint always reports line break errors on Windows 2025-02-14 10:36:07 +08:00
ousugo
fad3f67678 feat: Improve model search by adding name-based filtering, resolve #1520
搜索模型时,同时搜索模型的名字和 ID
2025-02-14 10:32:34 +08:00
kangfenmao
65b30b3b0d chore: Update Vite config and remove deprecated migration code
- Exclude additional chunk in Electron Vite configuration
- Remove outdated migration logic for providers and MinApps
2025-02-14 10:31:24 +08:00
首都爱护动物协会
0278228a84 add providers
新增服务商:
1.无问芯穹
2Perplexity
3.DMXAPI

补充部分embedding模型信息
2025-02-14 10:28:52 +08:00
shniubobo
bb0cb1cecc fix: Regression on reasoning time
PR #1253 fixed reasoning time calculation for APIs that return reasoning
content in `delta.content`, but introduced a regression for those
returning it in `delta.reasoning_content`. This commit fixes the
regression.

Fixes #1593
2025-02-14 10:26:54 +08:00
shniubobo
f5cd6ecb50 fix: Remove trailing newline in codeblocks 2025-02-14 10:10:30 +08:00
Xin Rui
76c0ad9985 fix: translation error when passing empty user messages to certain models.. (#1612) 2025-02-14 10:09:47 +08:00
114 changed files with 3498 additions and 1103 deletions

View File

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

View File

@@ -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: 在此添加任何其他与功能建议相关的上下文或截图

View File

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

View File

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

View File

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

View File

@@ -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
View 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: 在此添加任何其他与功能建议相关的上下文或截图

View File

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

@@ -44,3 +44,5 @@ stats.html
# Local
local
.aider*
.cursorrules

View File

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

View File

@@ -31,7 +31,7 @@ Cherry Studioは、複数のLLMプロバイダーをサポートするデスク
- ☁️ 主要な LLM クラウドサービス対応OpenAI、Gemini、Anthropic など
- 🔗 AI Web サービス統合Claude、Peplexity、Poe など
- 💻 Ollama によるローカルモデル実行対応
- 💻 Ollama、LM Studio によるローカルモデル実行対応
2. **AI アシスタントと対話**

View File

@@ -31,7 +31,7 @@ Cherry Studio 是一款支持多个大语言模型LLM服务商的桌面客
- ☁️ 支持主流 LLM 云服务OpenAI、Gemini、Anthropic、硅基流动等
- 🔗 集成流行 AI Web 服务Claude、Peplexity、Poe、腾讯元宝、知乎直答等
- 💻 支持 Ollama 本地模型部署
- 💻 支持 Ollama、LM Studio 本地模型部署
2. **智能助手与对话**

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

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

View File

@@ -64,6 +64,10 @@
&:first-child {
margin-top: 0;
}
&:has(+ ul) {
margin-bottom: 0;
}
}
ul {

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => ({ '<': '&lt;', '>': '&gt;' })[char]!)
try {

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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": "最後のメッセージをコピー",

View File

@@ -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": "Копировать последнее сообщение",

View File

@@ -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": "复制上一条消息",

View File

@@ -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": "複製上一条消息",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -65,6 +65,7 @@ const KnowledgePage: FC = () => {
title: t('knowledge.delete_confirm'),
centered: true,
onOk: () => {
setSelectedBase(undefined)
deleteKnowledgeBase(base.id)
}
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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