This commit is contained in:
1600822305 2025-04-28 13:29:03 +08:00
parent e2236b48a6
commit 567e54bd75
73 changed files with 5478 additions and 2252 deletions

View File

@ -1,4 +1,4 @@
name: 讨论 & 提问 (中文)
name: 提问 & 讨论 (中文)
description: 寻求帮助、讨论问题、提出疑问等...
title: '[讨论]: '
labels: ['question']

76
.github/ISSUE_TEMPLATE/#3_others.yml vendored Normal file
View File

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

View File

@ -1,4 +1,4 @@
name: Discussion & Questions
name: Questions & Discussion
description: Seeking help, discussing issues, asking questions, etc...
title: '[Discussion]: '
labels: ['question']

76
.github/ISSUE_TEMPLATE/3_others.yml vendored Normal file
View File

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

277
.github/issue-checker.yml vendored Normal file
View File

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

50
.github/workflows/issue-management.yml vendored Normal file
View File

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

View File

@ -71,6 +71,7 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
NODE_OPTIONS: --max-old-space-size=8192
- name: Build Mac
if: matrix.os == 'macos-latest'
@ -85,6 +86,7 @@ jobs:
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192
- name: Build Windows
if: matrix.os == 'windows-latest'
@ -94,6 +96,7 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
NODE_OPTIONS: --max-old-space-size=8192
- name: Replace spaces in filenames
run: node scripts/replace-spaces.js

View File

@ -1,74 +0,0 @@
node:electron/js2c/isolated_bundle:2 Unexpected error while loading URL Error: Error invoking remote method 'GUEST_VIEW_MANAGER_CALL': Error: ERR_ABORTED (-3) loading 'https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=lgZ0TYM3KRXU…l1cvzRgzjWM-1745729582-1.0.1.1-_Q9WkYircZ9.sJjbmssTZZ1o3mYuv4Ow2wIMeuvsSCo'
storage.ts:24 Loaded tabs from storage: 5 tabs, active tab: tab-1745729188027
node:electron/js2c/isolated_bundle:2 Unexpected error while loading URL Error: Error invoking remote method 'GUEST_VIEW_MANAGER_CALL': Error: ERR_ABORTED (-3) loading 'https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=GCNUZYaTSrE.…pVJUudhRU9E-1745729582-1.0.1.1-cOI6OFfdInZDh1SbcsdmR0TUDz5vRX_9ObN3mdFwDIM'
node:electron/js2c/isolated_bundle:2 Unexpected error while loading URL Error: Error invoking remote method 'GUEST_VIEW_MANAGER_CALL': Error: ERR_ABORTED (-3) loading 'https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=GCNUZYaTSrE.…pVJUudhRU9E-1745729582-1.0.1.1-cOI6OFfdInZDh1SbcsdmR0TUDz5vRX_9ObN3mdFwDIM'
3
storage.ts:24 Loaded tabs from storage: 5 tabs, active tab: tab-1745729188027
useWebviewEvents.ts:115 [Tab tab-1745729188027] In-page navigation: https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=INZqycV.To1s…ncOZ60qZVcE-1745729582-1.0.1.1-UL04FD3iguMiqRuBPofPqfu9U_q7Odl1ms9vmH6gl9c, title: 请稍候…
storage.ts:24 Loaded tabs from storage: 5 tabs, active tab: tab-1745729188027
useWebviewEvents.ts:140 [Tab tab-1745729188027] Title updated: 请稍候…
node:electron/js2c/isolated_bundle:2 Unexpected error while loading URL Error: Error invoking remote method 'GUEST_VIEW_MANAGER_CALL': Error: ERR_ABORTED (-3) loading 'https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=INZqycV.To1s…ncOZ60qZVcE-1745729582-1.0.1.1-UL04FD3iguMiqRuBPofPqfu9U_q7Odl1ms9vmH6gl9c'
storage.ts:24 Loaded tabs from storage: 5 tabs, active tab: tab-1745729188027
useWebviewEvents.ts:382 [Tab tab-1745729188027] New window request: undefined, frameName: 未指定, linkOpenMode: newTab
useAnimatedTabs.ts:37 [openUrlInTab] Called with url: undefined, inNewTab: true, title: 加载中...
useAnimatedTabs.ts:41 [openUrlInTab] Called with undefined or empty URL, ignoring.
useWebviewEvents.ts:87 [Tab tab-1745729188027] Navigation: https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=GCNUZYaTSrE.…pVJUudhRU9E-1745729582-1.0.1.1-cOI6OFfdInZDh1SbcsdmR0TUDz5vRX_9ObN3mdFwDIM, title: Just a moment...
useWebviewEvents.ts:313 [Tab tab-1745729188027] handleConsoleMessage called with message: Unable to load preload script: J:\Cherry\cherry-studioTTS\out\preload\index.js
useWebviewEvents.ts:315 [Tab tab-1745729188027] Console message: Unable to load preload script: J:\Cherry\cherry-studioTTS\out\preload\index.js
useWebviewEvents.ts:313 [Tab tab-1745729188027] handleConsoleMessage called with message: TypeError: Cannot read properties of undefined (reading 'openExternal')
useWebviewEvents.ts:315 [Tab tab-1745729188027] Console message: TypeError: Cannot read properties of undefined (reading 'openExternal')
useWebviewEvents.ts:140 [Tab tab-1745729188027] Title updated: Just a moment...
useWebviewEvents.ts:115 [Tab tab-1745729188027] In-page navigation: https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=5VL.95t2wGT8…dw3BSxspj4s-1745729583-1.0.1.1-RaroI4FRyeRaDPaLSSEIpan8Rc0zq4drA9ul2Vc1.2Y, title: Just a moment...
2
storage.ts:24 Loaded tabs from storage: 5 tabs, active tab: tab-1745729188027
2
node:electron/js2c/isolated_bundle:2 Unexpected error while loading URL Error: Error invoking remote method 'GUEST_VIEW_MANAGER_CALL': Error: ERR_ABORTED (-3) loading 'https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=5VL.95t2wGT8…dw3BSxspj4s-1745729583-1.0.1.1-RaroI4FRyeRaDPaLSSEIpan8Rc0zq4drA9ul2Vc1.2Y'
storage.ts:24 Loaded tabs from storage: 5 tabs, active tab: tab-1745729188027
useWebviewEvents.ts:115 [Tab tab-1745729188027] In-page navigation: https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=GCNUZYaTSrE.…pVJUudhRU9E-1745729582-1.0.1.1-cOI6OFfdInZDh1SbcsdmR0TUDz5vRX_9ObN3mdFwDIM, title: 请稍候…
storage.ts:24 Loaded tabs from storage: 5 tabs, active tab: tab-1745729188027
useWebviewEvents.ts:140 [Tab tab-1745729188027] Title updated: 请稍候…
node:electron/js2c/isolated_bundle:2 Unexpected error while loading URL Error: Error invoking remote method 'GUEST_VIEW_MANAGER_CALL': Error: ERR_ABORTED (-3) loading 'https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=GCNUZYaTSrE.…pVJUudhRU9E-1745729582-1.0.1.1-cOI6OFfdInZDh1SbcsdmR0TUDz5vRX_9ObN3mdFwDIM'
node:electron/js2c/isolated_bundle:2 Unexpected error while loading URL Error: Error invoking remote method 'GUEST_VIEW_MANAGER_CALL': Error: ERR_ABORTED (-3) loading 'https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=GCNUZYaTSrE.…pVJUudhRU9E-1745729582-1.0.1.1-cOI6OFfdInZDh1SbcsdmR0TUDz5vRX_9ObN3mdFwDIM'
storage.ts:24 Loaded tabs from storage: 5 tabs, active tab: tab-1745729188027
useWebviewEvents.ts:382 [Tab tab-1745729188027] New window request: undefined, frameName: 未指定, linkOpenMode: newTab
useAnimatedTabs.ts:37 [openUrlInTab] Called with url: undefined, inNewTab: true, title: 加载中...
useAnimatedTabs.ts:41 [openUrlInTab] Called with undefined or empty URL, ignoring.
useWebviewEvents.ts:87 [Tab tab-1745729188027] Navigation: https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=5VL.95t2wGT8…dw3BSxspj4s-1745729583-1.0.1.1-RaroI4FRyeRaDPaLSSEIpan8Rc0zq4drA9ul2Vc1.2Y, title: Just a moment...
storage.ts:24 Loaded tabs from storage: 5 tabs, active tab: tab-1745729188027
useWebviewEvents.ts:313 [Tab tab-1745729188027] handleConsoleMessage called with message: Unable to load preload script: J:\Cherry\cherry-studioTTS\out\preload\index.js
useWebviewEvents.ts:315 [Tab tab-1745729188027] Console message: Unable to load preload script: J:\Cherry\cherry-studioTTS\out\preload\index.js
useWebviewEvents.ts:313 [Tab tab-1745729188027] handleConsoleMessage called with message: TypeError: Cannot read properties of undefined (reading 'openExternal')
useWebviewEvents.ts:315 [Tab tab-1745729188027] Console message: TypeError: Cannot read properties of undefined (reading 'openExternal')
useWebviewEvents.ts:140 [Tab tab-1745729188027] Title updated: Just a moment...
useWebviewEvents.ts:115 [Tab tab-1745729188027] In-page navigation: https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=7WYUVBYXysh_…E4CM2E6FmZQ-1745729583-1.0.1.1-.NKbkenFPZsGeZTPRzYu0iV0KxQaTqvXihK.HA.OGVM, title: Just a moment...
useWebviewEvents.ts:382 [Tab tab-1745721367810] New window request: undefined, frameName: 未指定, linkOpenMode: newTab
useAnimatedTabs.ts:37 [openUrlInTab] Called with url: undefined, inNewTab: true, title: 加载中...
useAnimatedTabs.ts:41 [openUrlInTab] Called with undefined or empty URL, ignoring.
node:electron/js2c/isolated_bundle:2 Unexpected error while loading URL Error: Error invoking remote method 'GUEST_VIEW_MANAGER_CALL': Error: ERR_ABORTED (-3) loading 'https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=5VL.95t2wGT8…dw3BSxspj4s-1745729583-1.0.1.1-RaroI4FRyeRaDPaLSSEIpan8Rc0zq4drA9ul2Vc1.2Y'
storage.ts:24 Loaded tabs from storage: 5 tabs, active tab: tab-1745729188027
node:electron/js2c/isolated_bundle:2 Unexpected error while loading URL Error: Error invoking remote method 'GUEST_VIEW_MANAGER_CALL': Error: ERR_ABORTED (-3) loading 'https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=5VL.95t2wGT8…dw3BSxspj4s-1745729583-1.0.1.1-RaroI4FRyeRaDPaLSSEIpan8Rc0zq4drA9ul2Vc1.2Y'
storage.ts:24 Loaded tabs from storage: 5 tabs, active tab: tab-1745729188027
node:electron/js2c/isolated_bundle:2 Unexpected error while loading URL Error: Error invoking remote method 'GUEST_VIEW_MANAGER_CALL': Error: ERR_ABORTED (-3) loading 'https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=7WYUVBYXysh_…E4CM2E6FmZQ-1745729583-1.0.1.1-.NKbkenFPZsGeZTPRzYu0iV0KxQaTqvXihK.HA.OGVM'
node:electron/js2c/isolated_bundle:2 Unexpected error while loading URL Error: Error invoking remote method 'GUEST_VIEW_MANAGER_CALL': Error: ERR_ABORTED (-3) loading 'https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=7WYUVBYXysh_…E4CM2E6FmZQ-1745729583-1.0.1.1-.NKbkenFPZsGeZTPRzYu0iV0KxQaTqvXihK.HA.OGVM'
storage.ts:24 Loaded tabs from storage: 5 tabs, active tab: tab-1745729188027
useWebviewEvents.ts:115 [Tab tab-1745729188027] In-page navigation: https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=5VL.95t2wGT8…dw3BSxspj4s-1745729583-1.0.1.1-RaroI4FRyeRaDPaLSSEIpan8Rc0zq4drA9ul2Vc1.2Y, title: 请稍候…
storage.ts:24 Loaded tabs from storage: 5 tabs, active tab: tab-1745729188027
useWebviewEvents.ts:140 [Tab tab-1745729188027] Title updated: 请稍候…
node:electron/js2c/isolated_bundle:2 Unexpected error while loading URL Error: Error invoking remote method 'GUEST_VIEW_MANAGER_CALL': Error: ERR_ABORTED (-3) loading 'https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=5VL.95t2wGT8…dw3BSxspj4s-1745729583-1.0.1.1-RaroI4FRyeRaDPaLSSEIpan8Rc0zq4drA9ul2Vc1.2Y'
storage.ts:24 Loaded tabs from storage: 5 tabs, active tab: tab-1745729188027
useWebviewEvents.ts:382 [Tab tab-1745729188027] New window request: undefined, frameName: 未指定, linkOpenMode: newTab
useAnimatedTabs.ts:37 [openUrlInTab] Called with url: undefined, inNewTab: true, title: 加载中...
useAnimatedTabs.ts:41 [openUrlInTab] Called with undefined or empty URL, ignoring.
useWebviewEvents.ts:87 [Tab tab-1745729188027] Navigation: https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=7WYUVBYXysh_…E4CM2E6FmZQ-1745729583-1.0.1.1-.NKbkenFPZsGeZTPRzYu0iV0KxQaTqvXihK.HA.OGVM, title: Just a moment...
useWebviewEvents.ts:313 [Tab tab-1745729188027] handleConsoleMessage called with message: Unable to load preload script: J:\Cherry\cherry-studioTTS\out\preload\index.js
useWebviewEvents.ts:315 [Tab tab-1745729188027] Console message: Unable to load preload script: J:\Cherry\cherry-studioTTS\out\preload\index.js
useWebviewEvents.ts:313 [Tab tab-1745729188027] handleConsoleMessage called with message: TypeError: Cannot read properties of undefined (reading 'openExternal')
useWebviewEvents.ts:315 [Tab tab-1745729188027] Console message: TypeError: Cannot read properties of undefined (reading 'openExternal')
useWebviewEvents.ts:140 [Tab tab-1745729188027] Title updated: Just a moment...
storage.ts:24 Loaded tabs from storage: 5 tabs, active tab: tab-1745729188027
useWebviewEvents.ts:115 [Tab tab-1745729188027] In-page navigation: https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=HspNR8z7A2YV…r7S37QRYDJc-1745729583-1.0.1.1-A2E.8KxOq117yDjAtJ1ROmjVdhUD9cjPqi3v4pq8c7M, title: Just a moment...
node:electron/js2c/isolated_bundle:2 Unexpected error while loading URL Error: Error invoking remote method 'GUEST_VIEW_MANAGER_CALL': Error: ERR_ABORTED (-3) loading 'https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=7WYUVBYXysh_…E4CM2E6FmZQ-1745729583-1.0.1.1-.NKbkenFPZsGeZTPRzYu0iV0KxQaTqvXihK.HA.OGVM'
storage.ts:24 Loaded tabs from storage: 5 tabs, active tab: tab-1745729188027
node:electron/js2c/isolated_bundle:2 Unexpected error while loading URL Error: Error invoking remote method 'GUEST_VIEW_MANAGER_CALL': Error: ERR_ABORTED (-3) loading 'https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=7WYUVBYXysh_…E4CM2E6FmZQ-1745729583-1.0.1.1-.NKbkenFPZsGeZTPRzYu0iV0KxQaTqvXihK.HA.OGVM'
node:electron/js2c/isolated_bundle:2 Unexpected error while loading URL Error: Error invoking remote method 'GUEST_VIEW_MANAGER_CALL': Error: ERR_ABORTED (-3) loading 'https://accounts.x.ai/sign-in?redirect=grok-com&__cf_chl_rt_tk=HspNR8z7A2YV…r7S37QRYDJc-1745729583-1.0.1.1-A2E.8KxOq117yDjAtJ1ROmjVdhUD9cjPqi3v4pq8c7M'


View File

@ -1,385 +0,0 @@
# 内置浏览器功能修改指南
本文档包含对内置浏览器功能的修改说明,包括:
1. 修复新标签页无法打开的问题
2. 处理登录弹窗 (HTTP 认证)
3. 增加切换链接打开方式 (新标签页/独立窗口) 的按钮和逻辑
---
## 1. 修复新标签页无法打开的问题
**问题原因:**
初步诊断发现,新创建的 webview 元素没有正确加载 URL并且 `useWebviewEvents.ts` 文件中存在语法错误,导致事件监听器和链接点击拦截脚本未能正确执行。
**修改步骤:**
1. **修改 `src/renderer/src/pages/Browser/components/WebviewItem.tsx`:**
* 移除 `React.memo` 包裹,确保在父组件状态变化时 `WebviewItem` 总是重新渲染。
* 在 `<webview>` 元素的 `ref` 回调函数中,确保在获取到 webview 引用后,显式调用 `webview.loadURL(tab.url)` 来加载 URL。同时移除 `<webview>` 元素上的 `src` 属性,避免重复加载。
**需要修改的文件:** `src/renderer/src/pages/Browser/components/WebviewItem.tsx`
**修改内容 (使用 replace_in_file 格式):**
```
<<<<<<< SEARCH
export default React.memo(WebviewItem)
=======
export default WebviewItem
>>>>>>> REPLACE
<<<<<<< SEARCH
<webview
src={tab.url}
ref={(el: any) => {
if (el) {
// 保存webview引用到对应的tabId下
webviewRefs.current[tab.id] = el as WebviewTag
// 只有在尚未设置监听器时才设置
if (!hasSetupListenersRef.current) {
console.log(`[WebviewItem] Setting up listeners for tab: ${tab.id}`)
=======
<webview
ref={(el: any) => {
if (el) {
// 保存webview引用到对应的tabId下
webviewRefs.current[tab.id] = el as WebviewTag
// 只有在尚未设置监听器时才设置
if (!hasSetupListenersRef.current) {
console.log(`[WebviewItem] Setting up listeners for tab: ${tab.id}`)
// 显式加载URL
el.loadURL(tab.url);
>>>>>>> REPLACE
```
*(注意: 上述 diff 仅为示例,实际修改时请参考您当前文件的最新内容和格式进行调整。特别是移除 `src={tab.url}` 和添加 `el.loadURL(tab.url);`)*
2. **修改 `src/renderer/src/pages/Browser/hooks/useWebviewEvents.ts`:**
* 修复 `handleDomReady` 函数中注入的链接点击拦截脚本末尾多余的 `})();` 语法错误。
* 将本地变量 `ENABLE_BROWSER_EMULATION` 传递到注入的浏览器模拟脚本中,解决 ESLint 警告。
* 在 `handleNewWindow` 和 `handleConsoleMessage` 中添加日志,用于调试(可选,调试完成后可移除)。
* 在注入的链接点击拦截脚本中添加日志,用于调试(可选,调试完成后可移除)。
**需要修改的文件:** `src/renderer/src/pages/Browser/hooks/useWebviewEvents.ts`
**修改内容 (使用 replace_in_file 格式):**
```
<<<<<<< SEARCH
console.log('Link interceptor script injected successfully');
})();
console.log('Link interceptor script injected successfully');
})();
`)
// 注入浏览器模拟脚本 (在脚本内部检查 ENABLE_BROWSER_EMULATION)
webview.executeJavaScript(`
if (window.ENABLE_BROWSER_EMULATION) {
try {
// 覆盖navigator.userAgent
Object.defineProperty(navigator, 'userAgent', {
value: '${userAgent}',
writable: false
});
=======
console.log('Link interceptor script injected successfully');
})();
`)
// 注入浏览器模拟脚本
webview.executeJavaScript(`
(function() {
// 检查是否启用浏览器模拟
const ENABLE_BROWSER_EMULATION = ${ENABLE_BROWSER_EMULATION};
if (ENABLE_BROWSER_EMULATION) {
try {
// 覆盖navigator.userAgent
Object.defineProperty(navigator, 'userAgent', {
value: '${userAgent}',
writable: false
});
>>>>>>> REPLACE
```
*(注意: 上述 diff 仅为示例,实际修改时请参考您当前文件的最新内容和格式进行调整。特别是移除多余的 `})();` 和传递 `ENABLE_BROWSER_EMULATION` 变量)*
**预期结果:**
完成上述修改后,点击需要新标签页打开的链接应该能够成功创建一个新的标签页并加载对应的 URL。控制台应该能看到链接拦截脚本的日志。
---
## 2. 处理登录弹窗 (HTTP 认证)
**问题描述:**
当访问需要 HTTP 认证的网站时,内置浏览器没有弹出登录对话框。
**实现方案:**
Electron 的 `webview` 标签会触发 `show-login` 事件,当需要进行 HTTP 认证时。我们可以在 `useWebviewEvents.ts` 中监听这个事件,并通过 IPC 通道将认证请求发送到主进程。主进程可以显示一个原生的认证对话框,获取用户输入的用户名和密码,然后通过 IPC 将凭据返回给渲染进程,由渲染进程将凭据发送给 webview 进行认证。
**修改步骤:**
1. **在主进程中添加 IPC 处理:**
* 在主进程 (`src/main/index.ts` 或相关的 IPC 处理文件) 中,添加一个 IPC 监听器,例如 `ipcMain.handle('show-login-dialog', ...)`。
* 在这个处理函数中,使用 Electron 的 `dialog.showLoginDialog()` 方法显示认证对话框。
* 将对话框的结果(用户名和密码)通过 IPC 返回给渲染进程。
**需要修改的文件:** `src/main/index.ts` 或 IPC 处理文件
**示例代码 (主进程):**
```typescript
// src/main/index.ts 或 src/main/ipc.ts
import { ipcMain, dialog } from 'electron';
ipcMain.handle('show-login-dialog', async (event, args) => {
const { url, realm } = args;
const result = await dialog.showLoginDialog({
title: 'Authentication Required',
text: `Enter credentials for ${url}`,
message: `Realm: ${realm}`,
});
return result; // { username, password, response }
});
```
2. **在渲染进程中添加 IPC 调用和 `show-login` 事件处理:**
* 在 `src/renderer/src/pages/Browser/hooks/useWebviewEvents.ts` 中,添加 `show-login` 事件监听器。
* 在 `handleShowLogin` 函数中,通过 `window.api.invoke('show-login-dialog', { url: e.url, realm: e.realm })` 调用主进程的认证对话框。
* 获取对话框结果后,使用 `e.login(username, password)` 将凭据发送给 webview。
**需要修改的文件:** `src/renderer/src/pages/Browser/hooks/useWebviewEvents.ts`
**示例代码 (渲染进程 - `useWebviewEvents.ts`):**
```typescript
// 在 setupWebviewListeners 函数内部添加
const handleShowLogin = async (e: any) => {
console.log(`[Tab ${tabId}] Show login dialog for url: ${e.url}, realm: ${e.realm}`);
e.preventDefault(); // 阻止默认行为
try {
// 调用主进程显示认证对话框
const result = await window.api.invoke('show-login-dialog', { url: e.url, realm: e.realm });
if (result && result.response === 0) { // 0 表示用户点击了登录
// 将凭据发送给webview
e.login(result.username, result.password);
} else {
// 用户取消或关闭对话框
e.cancel();
}
} catch (error) {
console.error('Failed to show login dialog:', error);
e.cancel(); // 发生错误时取消认证
}
};
// 在添加事件监听器的部分添加
webview.addEventListener('show-login', handleShowLogin);
// 在清理函数中添加移除监听器
return () => {
// ... 其他移除监听器 ...
webview.removeEventListener('show-login', handleShowLogin);
};
```
*(注意: 上述示例代码假设您已经设置了 Electron 的 Context Bridge并且在预加载脚本中将 `ipcRenderer.invoke` 暴露给了 `window.api`。如果您的 IPC 设置不同,请根据实际情况调整。)*
**预期结果:**
当访问需要 HTTP 认证的网站时,应该会弹出一个原生的登录对话框,用户输入凭据后可以进行认证。
---
## 3. 增加切换链接打开方式 (新标签页/独立窗口) 的按钮和逻辑
**问题描述:**
目前点击链接默认在新标签页打开,用户希望能够切换为在独立窗口中打开。
**实现方案:**
1. 在浏览器界面的工具栏中添加一个按钮,用于切换链接打开方式的状态。
2. 在状态管理中维护一个状态,记录当前的链接打开方式(例如 'newTab' 或 'newWindow')。
3. 修改链接点击拦截脚本和 `handleNewWindow` 函数,根据当前状态决定是调用 `openUrlInTab` 还是通过 IPC 调用主进程打开新窗口。
4. 在主进程中添加一个 IPC 处理函数,用于创建新的浏览器窗口并加载指定的 URL。
**修改步骤:**
1. **在状态管理中添加链接打开方式状态:**
* 在 `src/renderer/src/pages/Browser/hooks/useAnimatedTabs.ts` 或创建一个新的 Context 中,添加一个状态来存储当前的链接打开方式,例如 `linkOpenMode`,默认值为 `'newTab'`。
* 添加一个函数来切换这个状态,例如 `toggleLinkOpenMode`。
**需要修改的文件:** `src/renderer/src/pages/Browser/hooks/useAnimatedTabs.ts` (或新文件)
**示例代码 (useAnimatedTabs.ts):**
```typescript
// 在 useAnimatedTabs 钩子内部添加状态和切换函数
const [linkOpenMode, setLinkOpenMode] = useState<'newTab' | 'newWindow'>('newTab');
const toggleLinkOpenMode = useCallback(() => {
setLinkOpenMode(prevMode => prevMode === 'newTab' ? 'newWindow' : 'newTab');
}, []);
// 在返回的对象中暴露 linkOpenMode 和 toggleLinkOpenMode
return {
// ... 其他状态和函数 ...
linkOpenMode,
toggleLinkOpenMode,
};
```
2. **在 UI 中添加切换按钮:**
* 在浏览器工具栏组件 (`src/renderer/src/pages/Browser/components/NavBar.tsx`) 中,添加一个按钮。
* 按钮的文本或图标可以根据 `linkOpenMode` 状态变化。
* 按钮的点击事件调用 `toggleLinkOpenMode` 函数。
**需要修改的文件:** `src/renderer/src/pages/Browser/components/NavBar.tsx`
**示例代码 (NavBar.tsx):**
```typescript
// 假设 NavBar 组件接收 linkOpenMode 和 toggleLinkOpenMode 作为 props
interface NavBarProps {
// ... 其他 props ...
linkOpenMode: 'newTab' | 'newWindow';
toggleLinkOpenMode: () => void;
}
const NavBar: React.FC<NavBarProps> = ({ /* ... */ linkOpenMode, toggleLinkOpenMode }) => {
return (
<NavBarContainer>
{/* ... 其他工具栏元素 ... */}
<button onClick={toggleLinkOpenMode}>
{linkOpenMode === 'newTab' ? '新标签页模式' : '独立窗口模式'}
</button>
{/* ... 其他工具栏元素 ... */}
</NavBarContainer>
);
};
```
*(注意: 您需要将 `linkOpenMode` 和 `toggleLinkOpenMode` 从 `useAnimatedTabs` (或新 Context) 传递到 `NavBar` 组件。)*
3. **修改链接点击处理逻辑:**
* 在 `src/renderer/src/pages/Browser/hooks/useWebviewEvents.ts` 的 `handleConsoleMessage` 函数中,当处理 `LINK_CLICKED:` 消息时,根据当前的 `linkOpenMode` 状态决定是调用 `openUrlInTab` 还是触发 IPC 调用打开新窗口。
* 在 `handleNewWindow` 函数中,也需要根据 `linkOpenMode` 状态决定行为。
**需要修改的文件:** `src/renderer/src/pages/Browser/hooks/useWebviewEvents.ts`
**示例代码 (useWebviewEvents.ts - handleConsoleMessage):**
```typescript
// 在 setupWebviewListeners 函数签名中添加 linkOpenMode 参数
const setupWebviewListeners = (
// ... 其他参数 ...
linkOpenMode: 'newTab' | 'newWindow', // 添加 linkOpenMode 参数
// ... 其他参数 ...
) => {
// ...
const handleConsoleMessage = (event: any) => {
// ... (现有日志和 LINK_CLICKED 处理逻辑) ...
if (event.message && event.message.startsWith('LINK_CLICKED:')) {
try {
const dataStr = event.message.replace('LINK_CLICKED:', '')
const data = JSON.parse(dataStr)
console.log(`[Tab ${tabId}] Link clicked:`, data)
// 根据 linkOpenMode 决定打开方式
if (linkOpenMode === 'newTab' && data.url && data.inNewTab) {
console.log(`[Tab ${tabId}] Opening link in new tab:`, data.url)
openUrlInTab(data.url, true, data.title || data.url)
} else if (linkOpenMode === 'newWindow' && data.url) {
console.log(`[Tab ${tabId}] Opening link in new window:`, data.url)
// 调用主进程打开新窗口 (需要实现 IPC)
window.api.invoke('open-new-browser-window', { url: data.url, title: data.title || data.url });
} else if (data.url && !data.inNewTab) {
// 在当前标签页打开 (如果不是新标签页模式且链接没有 target="_blank")
// 这个逻辑已经在注入的脚本中处理了 window.location.href = target.href;
// 这里可以根据需要添加额外的处理或日志
console.log(`[Tab ${tabId}] Link clicked, navigating in current tab:`, data.url);
}
} catch (error) {
console.error('Failed to parse link data:', error)
}
}
// ... (保留对旧消息格式的支持) ...
}
// ...
}
```
**示例代码 (useWebviewEvents.ts - handleNewWindow):**
```typescript
// 在 setupWebviewListeners 函数签名中添加 linkOpenMode 参数 (如果尚未添加)
const setupWebviewListeners = (
// ... 其他参数 ...
linkOpenMode: 'newTab' | 'newWindow', // 确保 linkOpenMode 参数存在
// ... 其他参数 ...
) => {
// ...
// 处理新窗口打开请求
const handleNewWindow = (e: any) => {
console.log(`[Tab ${tabId}] handleNewWindow called for url: ${e.url}`)
e.preventDefault() // 阻止默认行为
console.log(`[Tab ${tabId}] New window request: ${e.url}, frameName: ${e.frameName || '未指定'}`)
// 根据 linkOpenMode 决定打开方式
if (linkOpenMode === 'newTab') {
// 始终在新标签页中打开
openUrlInTab(e.url, true, e.frameName || '加载中...')
} else if (linkOpenMode === 'newWindow') {
// 调用主进程打开新窗口 (需要实现 IPC)
window.api.invoke('open-new-browser-window', { url: e.url, title: e.frameName || e.url });
}
}
// ...
}
```
*(注意: 您需要将 `linkOpenMode` 从使用 `useAnimatedTabs` 的组件传递到 `setupWebviewListeners` 函数中。)*
4. **在主进程中添加打开新窗口的 IPC 处理:**
* 在主进程 (`src/main/index.ts` 或相关的 IPC 处理文件) 中,添加一个 IPC 监听器,例如 `ipcMain.handle('open-new-browser-window', ...)`。
* 在这个处理函数中,创建一个新的 Electron 浏览器窗口 (`new BrowserWindow(...)`) 并加载指定的 URL。
**需要修改的文件:** `src/main/index.ts` 或 IPC 处理文件
**示例代码 (主进程):**
```typescript
// src/main/index.ts 或 src/main/ipc.ts
import { ipcMain, BrowserWindow } from 'electron';
import { join } from 'path';
ipcMain.handle('open-new-browser-window', async (event, args) => {
const { url, title } = args;
// 创建新的浏览器窗口
const newWindow = new BrowserWindow({
width: 1000,
height: 800,
title: title || 'New Window',
webPreferences: {
preload: join(__dirname, '../preload/index.js'), // 根据您的项目结构调整预加载脚本路径
sandbox: false, // 根据您的安全需求调整
nodeIntegration: false, // 根据您的安全需求调整
contextIsolation: true, // 根据您的安全需求调整
},
});
// 加载URL
newWindow.loadURL(url);
// 可选: 打开开发者工具
// newWindow.webContents.openDevTools();
});
```
*(注意: 您需要根据您的项目结构调整 `preload` 路径和 `webPreferences` 设置。)*
**预期结果:**
浏览器工具栏中会出现一个切换按钮,点击可以切换链接打开方式。根据当前模式,点击链接会在新标签页或独立窗口中打开。
---
希望这份修改指南对您有帮助!如果您在修改过程中遇到任何问题,或者需要进一步的帮助,请随时告诉我。

View File

@ -1,4 +1,3 @@
import { sentryVitePlugin } from '@sentry/vite-plugin'
import react from '@vitejs/plugin-react-swc'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import { resolve } from 'path'
@ -65,9 +64,6 @@ export default defineConfig({
]
]
}),
sentryVitePlugin({
authToken: process.env.SENTRY_AUTH_TOKEN
}),
...visualizerPlugin('renderer')
],
resolve: {

View File

@ -1,3 +0,0 @@
@echo off
set ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
yarn add electron@32.3.3 --dev

View File

@ -0,0 +1,239 @@
# MCP 工具内联渲染修改说明
本文件提供了将 MCP 工具调用从消息顶部固定位置改为内联渲染的修改步骤。您需要修改以下两个文件:
1. `src/renderer/src/pages/home/Messages/MessageContent.tsx`
2. `src/renderer/src/pages/home/Markdown/Markdown.tsx`
**重要提示:** 在进行修改之前,请确保您已经备份了这两个文件。
## 步骤 1: 修改 `src/renderer/src/pages/home/Messages/MessageContent.tsx`
这个文件主要负责处理消息内容的整体布局和数据准备。我们需要在这里:
* 移除 MCP 工具块的顶部容器。
* 将 MCP 工具的相关状态(如 `activeKeys`, `copiedMap`, `editingToolId`, `editedParams`)和处理函数(如 `copyContent`, `handleRerun`, `handleEdit`, `handleCancelEdit`, `handleSaveEdit`, `handleParamsChange`)移动或传递给 `Markdown` 组件。
* 修改 `processedContent` 的生成逻辑,将工具 XML 标记替换为自定义的占位符,以便 `Markdown` 组件能够识别并在正确的位置渲染工具块。
以下是需要修改的部分:
1. **移除 `MessageTools` 导入和相关的 JSX**
找到并删除以下导入:
```typescript
import { default as MessageTools } from './MessageTools' // Change to named import (using default alias)
```
找到并删除以下 JSX 结构:
```jsx
<div className="message-content-tools">
{/* Only display thought info at the top */}
<MessageThought message={message} />
{/* Render MessageTools to display tool blocks based on metadata */}
<MessageTools message={message} />
</div>
```
保留 `MessageThought` 组件,它应该独立于工具块渲染。
2. **将工具相关的状态和处理函数移动或传递:**
将 `MessageTools.tsx` 中的以下状态和处理函数定义复制到 `MessageContent.tsx` 中:
* `activeKeys` state 和 `setActiveKeys`
* `copiedMap` state 和 `setCopiedMap`
* `editingToolId` state 和 `setEditingToolId`
* `editedParams` state 和 `setEditedParams`
* `localToolResponses` state 和 `setLocalToolResponses`
* `useEffect` 钩子,用于同步 `localToolResponses`
* `copyContent` useCallback
* `handleRerun` useCallback
* `handleEdit` useCallback
* `handleCancelEdit` useCallback
* `handleSaveEdit` useCallback
* `handleParamsChange` useCallback
* 监听 `onToolRerunUpdate` 的 `useEffect` 钩子
这些状态和函数需要作为 props 传递给 `Markdown` 组件。
3. **修改 `processedContent` 逻辑:**
在 `processedContent` 的 `useMemo` 钩子中,在处理引用标记之后,添加逻辑来查找 `<tool_use>...</tool_use>` 标记,并将其替换为自定义的占位符,例如 `<tool-block id="[tool_call_id]"></tool-block>`。
首先,在文件顶部导入 `MCPToolResponse` 类型:
```typescript
import { Message, Model, MCPToolResponse } from '@renderer/types'
```
然后,在 `processedContent` 的 `useMemo` 内部,在处理完引用标记后,添加以下代码:
```typescript
// ... (之前的引用标记处理逻辑)
// 处理 MCP 工具调用标记
const toolResponses = message.metadata?.mcpTools || [];
if (toolResponses.length > 0) {
toolResponses.forEach(toolCall => {
const toolTagRegex = new RegExp(`<tool_use>(?:[^<]*?${toolCall.id}[\\s\\S]*?)<\\/tool_use>`, 'gi');
content = content.replace(toolTagRegex, `<tool-block id="${toolCall.id}"></tool-block>`);
});
}
return content;
```
请注意,这里的正则表达式 `toolTagRegex` 是一个示例,您可能需要根据实际的工具 XML 格式进行调整,以确保能够准确匹配包含特定 `toolCall.id` 的 `<tool_use>` 标记。
4. **更新 `Markdown` 组件的 props**
在渲染 `Markdown` 组件的地方,将新移动过来的状态和处理函数作为 props 传递下去:
```jsx
<Markdown
message={{ ...message, content: processedContent }} // 传递包含占位符的内容
toolResponses={message.metadata?.mcpTools || []} // 传递工具响应数据
activeToolKeys={activeKeys} // 传递 activeKeys 状态
copiedToolMap={copiedMap} // 传递 copiedMap 状态
editingToolId={editingToolId} // 传递 editingToolId 状态
editedToolParamsString={editedParams} // 传递 editedParams 状态
onToolToggle={setActiveKeys} // 传递 setActiveKeys 函数
onToolCopy={copyContent} // 传递 copyContent 函数
onToolRerun={handleRerun} // 传递 handleRerun 函数
onToolEdit={handleEdit} // 传递 handleEdit 函数
onToolSave={handleSaveEdit} // 传递 handleSaveEdit 函数
onToolCancel={handleCancelEdit} // 传递 handleCancelEdit 函数
onToolParamsChange={handleParamsChange} // 传递 handleParamsChange 函数
/>
```
请注意,我在这里使用了新的 prop 名称(例如 `toolResponses`, `activeToolKeys` 等),以避免与 `Markdown` 组件内部可能已有的 props 冲突。您需要在 `Markdown.tsx` 中接收这些新的 props。
## 步骤 2: 修改 `src/renderer/src/pages/home/Markdown/Markdown.tsx`
这个文件负责将 Markdown 内容渲染为 HTML。我们需要在这里
* 接收从 `MessageContent` 传递过来的工具相关 props。
* 添加一个自定义的渲染器,用于处理我们在步骤 1 中创建的 `<tool-block>` 占位符。
* 在自定义渲染器中,根据占位符中的 `id` 查找对应的工具响应数据,并渲染 `SingleToolCallBlock` 组件。
以下是需要修改的部分:
1. **更新 Props 接口:**
修改 `Props` 接口,添加从 `MessageContent` 传递过来的新 props
```typescript
import { MCPToolResponse, Message } from '@renderer/types' // 导入 MCPToolResponse
interface Props {
message: Message
toolResponses: MCPToolResponse[] // 添加工具响应数据 prop
activeToolKeys: string[] // 添加 activeKeys prop
copiedToolMap: Record<string, boolean> // 添加 copiedMap prop
editingToolId: string | null // 添加 editingToolId prop
editedToolParamsString: string // 添加 editedParams prop
onToolToggle: React.Dispatch<React.SetStateAction<string[]>> // 添加 onToolToggle prop
onToolCopy: (content: string, toolId: string) => void // 添加 onToolCopy prop
onToolRerun: (toolCall: MCPToolResponse, currentParamsString: string) => void // 添加 onToolRerun prop
onToolEdit: (toolCall: MCPToolResponse) => void // 添加 onToolEdit prop
onToolSave: (toolCall: MCPToolResponse) => void // 添加 onToolSave prop
onToolCancel: () => void // 添加 onToolCancel prop
onToolParamsChange: (newParams: string) => void // 添加 onToolParamsChange prop
}
```
2. **导入 `SingleToolCallBlock` 组件:**
在文件顶部导入 `SingleToolCallBlock` 组件:
```typescript
import SingleToolCallBlock from '../Messages/SingleToolCallBlock' // 导入 SingleToolCallBlock
```
3. **添加自定义渲染器:**
在 `components` 的 `useMemo` 钩子中,添加一个针对 `tool-block` 标签的渲染器:
```typescript
const components = useMemo(() => {
const baseComponents = {
// ... (其他现有渲染器)
// 添加 tool-block 渲染器
'tool-block': (props: any) => {
const toolCallId = props.id; // 获取占位符中的 id
const toolResponse = toolResponses.find(tr => tr.id === toolCallId); // 查找对应的工具响应数据
if (!toolResponse) {
return null; // 如果找不到对应的工具响应,则不渲染
}
// 渲染 SingleToolCallBlock 组件,并传递必要的 props
return (
<SingleToolCallBlock
toolResponse={toolResponse}
isActive={activeToolKeys.includes(toolCallId)}
isCopied={copiedToolMap[toolCallId] || false}
isEditing={editingToolId === toolCallId}
editedParamsString={editedToolParamsString}
fontFamily={messageFont === 'serif' ? 'serif' : '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans","Helvetica Neue", sans-serif'} // 传递字体样式
t={t} // 传递翻译函数
onToggle={() => onToolToggle(prev => prev.includes(toolCallId) ? prev.filter(k => k !== toolCallId) : [...prev, toolCallId])} // 传递 onToolToggle 函数
onCopy={onToolCopy} // 传递 onToolCopy 函数
onRerun={onToolRerun} // 传递 onToolRerun 函数
onEdit={onToolEdit} // 传递 onToolEdit 函数
onSave={onToolSave} // 传递 onToolSave 函数
onCancel={onToolCancel} // 传递 onToolCancel 函数
onParamsChange={onToolParamsChange} // 传递 onToolParamsChange 函数
/>
);
},
} as Partial<Components>;
return baseComponents;
}, [
// ... (其他现有依赖)
toolResponses, // 添加 toolResponses 依赖
activeToolKeys, // 添加 activeToolKeys 依赖
copiedToolMap, // 添加 copiedToolMap 依赖
editingToolId, // 添加 editingToolId 依赖
editedToolParamsString, // 添加 editedToolParamsString 依赖
onToolToggle, // 添加 onToolToggle 依赖
onToolCopy, // 添加 onToolCopy 依赖
onToolRerun, // 添加 onToolRerun 依赖
onToolEdit, // 添加 onToolEdit 依赖
onToolSave, // 添加 onToolSave 依赖
onToolCancel, // 添加 onToolCancel 依赖
onToolParamsChange, // 添加 onToolParamsChange 依赖
t, // 添加 t 依赖
messageFont // 添加 messageFont 依赖
]);
```
请确保在 `useMemo` 的依赖数组中包含了所有使用到的外部变量和函数。
## 步骤 3: 修改 `src/renderer/src/pages/home/Messages/MessageTools.tsx`
这个文件将不再负责渲染工具块,它的主要作用将变为处理工具相关的状态和逻辑。
1. **移除渲染工具块的 JSX**
找到并删除 `MessageTools` 组件中的以下 JSX 结构:
```jsx
return (
<>
<ToolsContainer className="message-tools-container">
{collapseItems.map((item) => (
<CustomCollapse
key={item.key}
id={item.key as string}
title={item.label}
isActive={activeKeys.includes(item.key as string)}
onToggle={() => {
setActiveKeys((prev) =>
prev.includes(item.key as string) ? prev.filter((k) => k !== item.key) : [...prev, item.key as string]
)
}}>
{item.children}
</CustomCollapse>
))}
</ToolsContainer>
</>
)
```
以及相关的 `ToolsContainer` 样式定义。
2. **保留工具相关的状态和逻辑:**
保留 `MessageTools` 组件中所有与工具相关的状态(`activeKeys`, `copiedMap`, `editingToolId`, `editedParams`, `localToolResponses`)和处理函数(`copyContent`, `handleRerun`, `handleEdit`, `handleCancelEdit`, `handleSaveEdit`, `handleParamsChange`),以及监听 `onToolRerunUpdate` 的 `useEffect` 钩子。这些状态和逻辑将需要被提升到 `MessageContent.tsx` 中。
3. **修改 `MessageTools` 组件的用途:**
`MessageTools` 组件可能不再需要作为一个 React 组件存在,或者可以修改其用途,例如只包含工具相关的逻辑和状态管理,并通过钩子或上下文提供给其他组件使用。考虑到您希望自己修改,您可以选择将这些逻辑完全移动到 `MessageContent.tsx` 中,然后删除 `MessageTools.tsx` 文件。
完成以上修改后重新运行您的应用程序MCP 工具块应该会以内联的方式显示在消息内容中对应的位置。
请仔细按照步骤进行修改,并根据您的实际代码结构进行调整。如果在修改过程中遇到任何问题,或者需要进一步的帮助,请随时告诉我。

View File

@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.2.6-bate",
"version": "2.0.1-vludi",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@ -101,8 +101,6 @@
"@monaco-editor/react": "^4.7.0",
"@mozilla/readability": "^0.6.0",
"@notionhq/client": "^2.2.15",
"@sentry/electron": "^6.5.0",
"@sentry/react": "^9.14.0",
"@shikijs/markdown-it": "^3.2.2",
"@strongtz/win32-arm64-msvc": "^0.4.7",
"@tryfabric/martian": "^1.2.4",
@ -135,7 +133,7 @@
"monaco-editor": "^0.52.2",
"node-edge-tts": "^1.2.8",
"officeparser": "^4.1.1",
"os-proxy-config": "^1.1.1",
"os-proxy-config": "^1.1.2",
"path-browserify": "^1.0.1",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^5.1.91",
@ -173,7 +171,6 @@
"@modelcontextprotocol/sdk": "^1.10.1",
"@notionhq/client": "^2.2.15",
"@reduxjs/toolkit": "^2.2.5",
"@sentry/vite-plugin": "^3.3.1",
"@swc/plugin-styled-components": "^7.1.3",
"@tavily/core": "patch:@tavily/core@npm%3A0.3.1#~/.yarn/patches/@tavily-core-npm-0.3.1-fe69bf2bea.patch",
"@tryfabric/martian": "^1.2.4",

View File

@ -126,7 +126,7 @@ if (!app.requestSingleInstanceLock()) {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': ["default-src * 'unsafe-inline' 'unsafe-eval' data: blob:;"]
'Content-Security-Policy': ["default-src * 'unsafe-inline' 'unsafe-eval' data: blob: sentry-ipc:;"]
}
})
})

View File

@ -110,6 +110,11 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
configManager.setTrayOnClose(isActive)
})
// 设置数据收集
ipcMain.handle('app:setEnableDataCollection', (_, isActive: boolean) => {
configManager.setEnableDataCollection(isActive)
})
ipcMain.handle(IpcChannel.App_RestartTray, () => TrayService.getInstance().restartTray())
ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any) => {
@ -419,6 +424,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.Mcp_RestartServer, mcpService.restartServer)
ipcMain.handle(IpcChannel.Mcp_StopServer, mcpService.stopServer)
ipcMain.handle(IpcChannel.Mcp_ListTools, mcpService.listTools)
ipcMain.handle(IpcChannel.Mcp_ResetToolsList, mcpService.resetToolsList)
ipcMain.handle(IpcChannel.Mcp_CallTool, mcpService.callTool)
ipcMain.handle(IpcChannel.Mcp_ListPrompts, mcpService.listPrompts)
ipcMain.handle(IpcChannel.Mcp_GetPrompt, mcpService.getPrompt)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,263 @@
// inspired by https://dify.ai/blog/turn-your-dify-app-into-an-mcp-server
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolRequestSchema, ListToolsRequestSchema, ToolSchema } from '@modelcontextprotocol/sdk/types.js'
import { z } from 'zod'
import { zodToJsonSchema } from 'zod-to-json-schema'
interface DifyKnowledgeServerConfig {
difyKey: string
apiHost: string
}
interface DifyListKnowledgeResponse {
id: string
name: string
description: string
}
interface DifySearchKnowledgeResponse {
query: {
content: string
}
records: Array<{
segment: {
id: string
position: number
document_id: string
content: string
keywords: string[]
document?: {
id: string
data_source_type: string
name: string
}
}
score: number
}>
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const ToolInputSchema = ToolSchema.shape.inputSchema
type ToolInput = z.infer<typeof ToolInputSchema>
const SearchKnowledgeArgsSchema = z.object({
id: z.string().describe('Knowledge ID'),
query: z.string().describe('Query string'),
topK: z.number().optional().describe('Number of top results to return')
})
type McpResponse = {
content: Array<{ type: 'text'; text: string }>
isError?: boolean
}
class DifyKnowledgeServer {
public server: Server
private config: DifyKnowledgeServerConfig
constructor(difyKey: string, args: string[]) {
console.log('DifyKnowledgeServer args', args)
if (args.length === 0) {
throw new Error('DifyKnowledgeServer requires at least one argument')
}
this.config = {
difyKey: difyKey,
apiHost: args[0]
}
this.server = new Server(
{
name: '@cherry/dify-knowledge-server',
version: '0.1.0'
},
{
capabilities: {
tools: {}
}
}
)
this.initialize()
}
initialize() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'list_knowledges',
description: 'List all knowledges',
inputSchema: {
type: 'object',
properties: {},
required: []
}
},
{
name: 'search_knowledge',
description: 'Search knowledge by id and query',
inputSchema: zodToJsonSchema(SearchKnowledgeArgsSchema) as ToolInput
}
]
}
})
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
const { name, arguments: args } = request.params
switch (name) {
case 'list_knowledges': {
return await this.performListKnowledges(this.config.difyKey, this.config.apiHost)
}
case 'search_knowledge': {
const parsed = SearchKnowledgeArgsSchema.safeParse(args)
if (!parsed.success) {
const errorDetails = JSON.stringify(parsed.error.format(), null, 2)
throw new Error(`无效的参数:\n${errorDetails}`)
}
console.log('DifyKnowledgeServer search_knowledge parsed', parsed.data)
return await this.performSearchKnowledge(
parsed.data.id,
parsed.data.query,
parsed.data.topK || 6,
this.config.difyKey,
this.config.apiHost
)
}
default:
throw new Error(`Unknown tool: ${name}`)
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
return {
content: [{ type: 'text', text: `Error: ${errorMessage}` }],
isError: true
}
}
})
}
private async performListKnowledges(difyKey: string, apiHost: string): Promise<McpResponse> {
try {
const url = `${apiHost.replace(/\/$/, '')}/datasets`
const response = await fetch(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${difyKey}`
}
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`API 请求失败,状态码 ${response.status}: ${errorText}`)
}
const apiResponse = await response.json()
const knowledges: DifyListKnowledgeResponse[] =
apiResponse?.data?.map((item: any) => ({
id: item.id,
name: item.name,
description: item.description || ''
})) || []
const listText =
knowledges.length > 0
? knowledges.map((k) => `- **${k.name}** (ID: ${k.id})\n ${k.description || 'No Description'}`).join('\n')
: '- No knowledges found.'
const formattedText = `### 可用知识库:\n\n${listText}`
return {
content: [{ type: 'text', text: formattedText }]
}
} catch (error) {
console.error('获取知识库列表时出错:', error)
const errorMessage = error instanceof Error ? error.message : String(error)
// 返回包含错误信息的 MCP 响应
return {
content: [{ type: 'text', text: `Accessing Knowledge Error: ${errorMessage}` }],
isError: true
}
}
}
private async performSearchKnowledge(
id: string,
query: string,
topK: number,
difyKey: string,
apiHost: string
): Promise<McpResponse> {
try {
const url = `${apiHost.replace(/\/$/, '')}/datasets/${id}/retrieve`
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${difyKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: query,
retrieval_model: {
top_k: topK,
// will be error if not set
reranking_enable: null,
score_threshold_enabled: null
}
})
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`API 请求失败,状态码 ${response.status}: ${errorText}`)
}
const searchResponse: DifySearchKnowledgeResponse = await response.json()
if (!searchResponse || !Array.isArray(searchResponse.records)) {
throw new Error(`从 Dify API 收到的响应格式无效: ${JSON.stringify(searchResponse)}`)
}
const header = `### Query: ${query}\n\n`
let body: string
if (searchResponse.records.length === 0) {
body = 'No results found.'
} else {
const resultsText = searchResponse.records
.map((record, index) => {
const docName = record.segment.document?.name || 'Unknown Document'
const content = record.segment.content.trim()
const score = record.score
const keywords = record.segment.keywords || []
let resultEntry = `#### ${index + 1}. ${docName} (Relevant Score: ${(score * 100).toFixed(1)}%)`
resultEntry += `\n${content}`
if (keywords.length > 0) {
resultEntry += `\n*Keywords: ${keywords.join(', ')}*`
}
return resultEntry
})
.join('\n\n')
body = `Found ${searchResponse.records.length} results:\n\n${resultsText}`
}
const formattedText = header + body
return {
content: [{ type: 'text', text: formattedText }]
}
} catch (error) {
console.error('搜索知识库时出错:', error)
const errorMessage = error instanceof Error ? error.message : String(error)
return {
content: [{ type: 'text', text: `Search Knowledge Error: ${errorMessage}` }],
isError: true
}
}
}
}
export default DifyKnowledgeServer

View File

@ -2,6 +2,8 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import Logger from 'electron-log'
import BraveSearchServer from './brave-search'
import CalculatorServer from './calculator'
import DifyKnowledgeServer from './dify-knowledge'
import FetchServer from './fetch'
import FileSystemServer from './filesystem'
import MemoryServer from './memory'
@ -33,6 +35,10 @@ export async function createInMemoryMCPServer(
case '@cherry/filesystem': {
return new FileSystemServer(args).server
}
case '@cherry/dify-knowledge': {
const difyKey = envs.DIFY_KEY
return new DifyKnowledgeServer(difyKey, args).server
}
case '@cherry/simpleremember': {
const envPath = envs.SIMPLEREMEMBER_FILE_PATH
return new SimpleRememberServer(envPath).server
@ -74,6 +80,21 @@ export async function createInMemoryMCPServer(
throw error
}
}
case '@cherry/calculator': {
Logger.info('[MCP] Creating CalculatorServer instance')
try {
// 创建计算器服务器实例
const calculatorServer = new CalculatorServer()
// 返回服务器实例
// 注意:初始化过程已经在构造函数中启动,会异步完成
Logger.info('[MCP] CalculatorServer instance created successfully')
return calculatorServer.server
} catch (error) {
Logger.error('[MCP] Error creating CalculatorServer instance:', error)
throw error
}
}
default:
throw new Error(`Unknown in-memory MCP server: ${name}`)
}

View File

@ -14,7 +14,8 @@ enum ConfigKeys {
ZoomFactor = 'ZoomFactor',
Shortcuts = 'shortcuts',
ClickTrayToShowQuickAssistant = 'clickTrayToShowQuickAssistant',
EnableQuickAssistant = 'enableQuickAssistant'
EnableQuickAssistant = 'enableQuickAssistant',
EnableDataCollection = 'enableDataCollection'
}
export class ConfigManager {
@ -128,6 +129,14 @@ export class ConfigManager {
this.set(ConfigKeys.EnableQuickAssistant, value)
}
getEnableDataCollection(): boolean {
return this.get(ConfigKeys.EnableDataCollection, false)
}
setEnableDataCollection(value: boolean) {
this.set(ConfigKeys.EnableDataCollection, value)
}
set(key: string, value: unknown) {
this.store.set(key, value)
}

View File

@ -94,6 +94,7 @@ class McpService {
this.restartServer = this.restartServer.bind(this)
this.stopServer = this.stopServer.bind(this)
this.cleanup = this.cleanup.bind(this)
this.resetToolsList = this.resetToolsList.bind(this)
}
async initClient(server: MCPServer): Promise<Client> {
@ -341,6 +342,21 @@ class McpService {
await this.initClient(server)
}
/**
*
*/
async resetToolsList(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
Logger.info(`[MCP] Resetting tools list for server: ${server.name}`)
const serverKey = this.getServerKey(server)
// 清除工具列表缓存
CacheService.remove(`mcp:list_tool:${serverKey}`)
Logger.info(`[MCP] Cleared tools list cache for server: ${serverKey}`)
// 重新获取工具列表
return this.listToolsImpl(server)
}
async cleanup() {
for (const [key] of this.clients) {
try {

View File

@ -68,7 +68,9 @@ export class WindowService {
sandbox: false,
webSecurity: false,
webviewTag: true,
allowRunningInsecureContent: true
allowRunningInsecureContent: true,
// 添加 Sentry IPC 协议到 CSP
additionalArguments: ['--disable-features=OutOfBlinkCors', '--allow-file-access-from-files']
}
})
@ -191,9 +193,11 @@ export class WindowService {
const oauthProviderUrls = [
'https://account.siliconflow.cn/oauth',
'https://cloud.siliconflow.cn/bills',
'https://cloud.siliconflow.cn/expensebill',
'https://aihubmix.com/token',
'https://aihubmix.com/topup'
'https://aihubmix.com/topup',
'https://aihubmix.com/statistics'
]
if (oauthProviderUrls.some((link) => url.startsWith(link))) {

View File

@ -12,6 +12,7 @@ export function registerMCPHandlers(): void {
ipcMain.handle(IpcChannel.Mcp_RestartServer, mcpService.restartServer)
ipcMain.handle(IpcChannel.Mcp_StopServer, mcpService.stopServer)
ipcMain.handle(IpcChannel.Mcp_ListTools, mcpService.listTools)
ipcMain.handle(IpcChannel.Mcp_ResetToolsList, mcpService.resetToolsList)
ipcMain.handle(IpcChannel.Mcp_CallTool, mcpService.callTool)
ipcMain.handle(IpcChannel.Mcp_ListPrompts, mcpService.listPrompts)
ipcMain.handle(IpcChannel.Mcp_GetPrompt, mcpService.getPrompt)
@ -25,6 +26,7 @@ export function registerMCPHandlers(): void {
ipcMain.handle('mcp:remove-server', mcpService.removeServer)
ipcMain.handle('mcp:stop-server', mcpService.stopServer)
ipcMain.handle('mcp:list-tools', mcpService.listTools)
ipcMain.handle('mcp:reset-tools-list', mcpService.resetToolsList)
ipcMain.handle('mcp:call-tool', mcpService.callTool)
ipcMain.handle('mcp:list-prompts', mcpService.listPrompts)
ipcMain.handle('mcp:get-prompt', mcpService.getPrompt)

View File

@ -26,6 +26,7 @@ const api = {
setLaunchToTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetLaunchToTray, isActive),
setTray: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTray, isActive),
setTrayOnClose: (isActive: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTrayOnClose, isActive),
setEnableDataCollection: (isActive: boolean) => ipcRenderer.invoke('app:setEnableDataCollection', isActive),
restartTray: () => ipcRenderer.invoke(IpcChannel.App_RestartTray),
setTheme: (theme: 'light' | 'dark') => ipcRenderer.invoke(IpcChannel.App_SetTheme, theme),
openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url),
@ -149,6 +150,7 @@ const api = {
restartServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_RestartServer, server),
stopServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_StopServer, server),
listTools: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListTools, server),
resetToolsList: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ResetToolsList, server),
callTool: ({ server, name, args }: { server: MCPServer; name: string; args?: Record<string, any> }) =>
ipcRenderer.invoke(IpcChannel.Mcp_CallTool, { server, name, args }),
listPrompts: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListPrompts, server),

View File

@ -8,6 +8,7 @@ import DeepClaudeProvider from './components/DeepClaudeProvider'
import GeminiInitializer from './components/GeminiInitializer'
import MemoryProvider from './components/MemoryProvider'
import PDFSettingsInitializer from './components/PDFSettingsInitializer'
import SentryInitializer from './components/SentryInitializer'
import TopViewContainer from './components/TopView'
import WebSearchInitializer from './components/WebSearchInitializer'
import WorkspaceInitializer from './components/WorkspaceInitializer'
@ -29,6 +30,7 @@ function App(): React.ReactElement {
<DeepClaudeProvider />
<GeminiInitializer />
<PDFSettingsInitializer />
<SentryInitializer />
<WebSearchInitializer />
<WorkspaceInitializer />
<TopViewContainer>

View File

@ -11,6 +11,7 @@ interface Props extends ButtonProps {
const OAuthButton: FC<Props> = ({ provider, onSuccess, ...buttonProps }) => {
const { t } = useTranslation()
const onAuth = () => {
const handleSuccess = (key: string) => {
if (key.trim()) {
@ -29,8 +30,8 @@ const OAuthButton: FC<Props> = ({ provider, onSuccess, ...buttonProps }) => {
}
return (
<Button onClick={onAuth} {...buttonProps}>
{t('auth.get_key')}
<Button type="primary" onClick={onAuth} shape="round" {...buttonProps}>
{t('settings.provider.oauth.button', { provider: t(`provider.${provider.id}`) })}
</Button>
)
}

View File

@ -0,0 +1,13 @@
import React from 'react'
/**
* SentryInitializer
* Sentry
* Sentry
*/
const SentryInitializer: React.FC = () => {
// 这是一个空组件不需要渲染任何UI
return null
}
export default SentryInitializer

View File

@ -98,12 +98,13 @@ export function WebdavBackupManager({ visible, onClose, webdavConfig, restoreMet
return
}
Modal.confirm({
window.modal.confirm({
title: t('settings.data.webdav.backup.manager.delete.confirm.title'),
icon: <ExclamationCircleOutlined />,
content: t('settings.data.webdav.backup.manager.delete.confirm.multiple', { count: selectedRowKeys.length }),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
centered: true,
onOk: async () => {
setDeleting(true)
try {
@ -136,12 +137,13 @@ export function WebdavBackupManager({ visible, onClose, webdavConfig, restoreMet
return
}
Modal.confirm({
window.modal.confirm({
title: t('settings.data.webdav.backup.manager.delete.confirm.title'),
icon: <ExclamationCircleOutlined />,
content: t('settings.data.webdav.backup.manager.delete.confirm.single', { fileName }),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
centered: true,
onOk: async () => {
setDeleting(true)
try {
@ -168,12 +170,13 @@ export function WebdavBackupManager({ visible, onClose, webdavConfig, restoreMet
return
}
Modal.confirm({
window.modal.confirm({
title: t('settings.data.webdav.restore.confirm.title'),
icon: <ExclamationCircleOutlined />,
content: t('settings.data.webdav.restore.confirm.content'),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
centered: true,
onOk: async () => {
setRestoring(true)
try {

View File

@ -2277,7 +2277,13 @@ export const TEXT_TO_IMAGES_MODELS_SUPPORT_IMAGE_ENHANCEMENT = [
'stabilityai/stable-diffusion-xl-base-1.0'
]
export const GENERATE_IMAGE_MODELS = ['gemini-2.0-flash-exp-image-generation', 'gemini-2.0-flash-exp']
export const GENERATE_IMAGE_MODELS = [
'gemini-2.0-flash-exp-image-generation',
'gemini-2.0-flash-exp',
'grok-2-image-1212',
'gpt-4o-image',
'gpt-image-1'
]
export const GEMINI_SEARCH_MODELS = [
'gemini-2.0-flash',

View File

@ -120,7 +120,7 @@ export const SEARCH_SUMMARY_PROMPT = `
4. Websearch: Always return the rephrased question inside the 'question' XML block. If there are no links in the follow-up question, do not insert a 'links' XML block in your response.
5. Knowledge: Always return the rephrased question inside the 'question' XML block.
6. Always wrap the rephrased question in the appropriate XML blocks to specify the tool(s) for retrieving information: use <websearch></websearch> for queries requiring real-time or external information, <knowledge></knowledge> for queries that can be answered from a pre-existing knowledge base, or both if the question could be applicable to either tool. Ensure that the rephrased question is always contained within a <question></question> block inside these wrappers.
7. If you are not sure to use knowledge or websearch, you need use both of them.
7. *use {tools} to rephrase the question*
There are several examples attached for your reference inside the below \`examples\` XML block

View File

@ -100,13 +100,13 @@ export const SUPPORTED_REANK_PROVIDERS = ['silicon', 'jina', 'voyageai']
export const PROVIDER_CONFIG = {
openai: {
api: {
url: 'https://api.openai.com'
url: 'https://api.siliconflow.cn'
},
websites: {
official: 'https://openai.com/',
apiKey: 'https://platform.openai.com/api-keys',
docs: 'https://platform.openai.com/docs',
models: 'https://platform.openai.com/docs/models'
official: 'https://www.siliconflow.cn',
apiKey: 'https://cloud.siliconflow.cn/i/d1nTBKXU',
docs: 'https://docs.siliconflow.cn/',
models: 'https://docs.siliconflow.cn/docs/model-names'
}
},
o3: {
@ -148,7 +148,7 @@ export const PROVIDER_CONFIG = {
url: 'https://api.siliconflow.cn'
},
websites: {
official: 'https://www.siliconflow.cn/',
official: 'https://www.siliconflow.cn',
apiKey: 'https://cloud.siliconflow.cn/i/d1nTBKXU',
docs: 'https://docs.siliconflow.cn/',
models: 'https://docs.siliconflow.cn/docs/model-names'

View File

@ -118,7 +118,12 @@ export function useAppInit() {
}, [customCss])
useEffect(() => {
enableDataCollection ? initAnalytics() : disableAnalytics()
if (enableDataCollection) {
initAnalytics()
// TODO: init data collection
} else {
disableAnalytics()
}
}, [enableDataCollection])
// 自动启动ASR服务器

View File

@ -1,42 +1,30 @@
import { useTheme } from '@renderer/context/ThemeProvider'
import { EventEmitter } from '@renderer/services/EventService'
import { ThemeMode } from '@renderer/types'
import { loadScript, runAsyncFunction } from '@renderer/utils'
import { useEffect } from 'react'
import { useRuntime } from './useRuntime'
import { useEffect, useRef } from 'react'
export const useMermaid = () => {
const { theme } = useTheme()
const { generating } = useRuntime()
const mermaidLoaded = useRef(false)
useEffect(() => {
runAsyncFunction(async () => {
if (!window.mermaid) {
await loadScript('https://unpkg.com/mermaid@11.4.0/dist/mermaid.min.js')
await loadScript('https://unpkg.com/mermaid@11.6.0/dist/mermaid.min.js')
}
if (!mermaidLoaded.current) {
await window.mermaid.initialize({
startOnLoad: false,
theme: theme === ThemeMode.dark ? 'dark' : 'default'
})
mermaidLoaded.current = true
EventEmitter.emit('mermaid-loaded')
}
window.mermaid.initialize({
startOnLoad: true,
theme: theme === ThemeMode.dark ? 'dark' : 'default'
})
})
}, [theme])
useEffect(() => {
if (!window.mermaid || generating) return
const renderMermaid = () => {
const mermaidElements = document.querySelectorAll('.mermaid')
mermaidElements.forEach((element) => {
if (!element.querySelector('svg')) {
element.removeAttribute('data-processed')
}
})
window.mermaid.contentLoaded()
}
setTimeout(renderMermaid, 100)
}, [generating])
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
if (e.ctrlKey || e.metaKey) {

View File

@ -3,6 +3,7 @@ import {
AssistantIconType,
SendMessageShortcut,
setAssistantIconType,
setEnableDataCollection as _setEnableDataCollection,
setLaunchOnBoot,
setLaunchToTray,
setSendMessageShortcut as _setSendMessageShortcut,
@ -30,6 +31,7 @@ export function useSettings(): SettingsState & {
updateSidebarVisibleIcons: (icons: SidebarIcon[]) => void
updateSidebarDisabledIcons: (icons: SidebarIcon[]) => void
setAssistantIconType: (assistantIconType: AssistantIconType) => void
setEnableDataCollection: (enabled: boolean) => void
showAgentTaskList: boolean
agentAutoExecutionCount: number
setShowAgentTaskList: (show: boolean) => void
@ -91,6 +93,12 @@ export function useSettings(): SettingsState & {
setAssistantIconType(assistantIconType: AssistantIconType) {
dispatch(setAssistantIconType(assistantIconType))
},
setEnableDataCollection(enabled: boolean) {
dispatch(_setEnableDataCollection(enabled))
// 同步到主进程
window.api.invoke('app:setEnableDataCollection', enabled)
},
// 添加 showAgentTaskList 和 agentAutoExecutionCount 属性
showAgentTaskList: settings.showAgentTaskList, // 使用 Redux store 中的值
agentAutoExecutionCount: settings.agentAutoExecutionCount, // 使用 Redux store 中的值

View File

@ -1428,7 +1428,8 @@
"inputSchema": "Input Schema",
"availableTools": "Available Tools",
"noToolsAvailable": "No tools available",
"loadError": "Get tools Error"
"loadError": "Get tools Error",
"resetToolsList": "Reset Tools List"
},
"prompts": {
"availablePrompts": "Available Prompts",
@ -1483,6 +1484,7 @@
"messages.input.enable_delete_model": "Enable the backspace key to delete models/attachments.",
"messages.markdown_rendering_input_message": "Markdown render input message",
"messages.math_engine": "Math engine",
"messages.math_engine.none": "None",
"messages.metrics": "{{time_first_token_millsec}}ms to first token | {{token_speed}} tok/sec",
"messages.model.title": "Model Settings",
"messages.navigation": "Message Navigation",
@ -1588,10 +1590,16 @@
"api_key": "API Key",
"api_key.tip": "Multiple keys separated by commas",
"api_version": "API Version",
"charge": "Charge",
"charge": "Balance Recharge",
"bills": "Fee Bills",
"check": "Check",
"check_all_keys": "Check All Keys",
"check_multiple_keys": "Check Multiple API Keys",
"oauth": {
"button": "Login with {{provider}}",
"description": "This service is provided by <website>{{provider}}</website>",
"official_website": "Official Website"
},
"copilot": {
"auth_failed": "Github Copilot authentication failed.",
"auth_success": "GitHub Copilot authentication successful.",
@ -1688,7 +1696,7 @@
"websearch": {
"blacklist": "Blacklist",
"blacklist_description": "Results from the following websites will not appear in search results",
"blacklist_tooltip": "Please use the following format (separated by line breaks)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com",
"blacklist_tooltip": "Please use the following format (separated by newlines)\nPattern matching: *://*.example.com/*\nRegular expression: /example\\.(net|org)/",
"check": "Check",
"check_failed": "Verification failed",
"check_success": "Verification successful",

View File

@ -1173,6 +1173,7 @@
"messages.input.enable_delete_model": "バックスペースキーでモデル/添付ファイルを削除します。",
"messages.markdown_rendering_input_message": "Markdownで入力メッセージをレンダリング",
"messages.math_engine": "数式エンジン",
"messages.math_engine.none": "なし",
"messages.metrics": "最初のトークンまでの時間 {{time_first_token_millsec}}ms | トークン速度 {{token_speed}} tok/sec",
"messages.model.title": "モデル設定",
"messages.navigation": "メッセージナビゲーション",
@ -1237,10 +1238,16 @@
"api_key": "APIキー",
"api_key.tip": "複数のキーはカンマで区切ります",
"api_version": "APIバージョン",
"charge": "充電",
"charge": "残高充電",
"bills": "費用帳單",
"check": "チェック",
"check_all_keys": "すべてのキーをチェック",
"check_multiple_keys": "複数のAPIキーをチェック",
"oauth": {
"button": "{{provider}} アカウントでログイン",
"description": "本サービスは<website>{{provider}}</website>によって提供されます",
"official_website": "公式サイト"
},
"copilot": {
"auth_failed": "Github Copilotの認証に失敗しました。",
"auth_success": "Github Copilotの認証が成功しました",
@ -1682,4 +1689,4 @@
"shortcut_key_tip": "このショートカットキーを押すと録音が始まり、キーを離すと録音が終了して送信されます"
}
}
}
}

View File

@ -1176,6 +1176,7 @@
"messages.input.enable_delete_model": "Включите удаление модели/вложения с помощью клавиши Backspace",
"messages.markdown_rendering_input_message": "Отображение ввода в формате Markdown",
"messages.math_engine": "Математический движок",
"messages.math_engine.none": "Нет",
"messages.metrics": "{{time_first_token_millsec}}ms до первого токена | {{token_speed}} tok/sec",
"messages.model.title": "Настройки модели",
"messages.navigation": "Навигация сообщений",
@ -1240,10 +1241,16 @@
"api_key": "Ключ API",
"api_key.tip": "Несколько ключей, разделенных запятыми",
"api_version": "Версия API",
"charge": "Пополнить",
"charge": "Пополнить баланс",
"bills": "Счета за услуги",
"check": "Проверить",
"check_all_keys": "Проверить все ключи",
"check_multiple_keys": "Проверить несколько ключей API",
"oauth": {
"button": "Войти с {{provider}}",
"description": "Сервис предоставляется <website>{{provider}}</website>",
"official_website": "Официальный сайт"
},
"copilot": {
"auth_failed": "Github Copilot认证失败",
"auth_success": "Github Copilot认证成功",
@ -1681,4 +1688,4 @@
"shortcut_key_tip": "Нажмите эту горячую клавишу, чтобы начать запись, отпустите, чтобы закончить запись и отправить"
}
}
}
}

View File

@ -1573,7 +1573,8 @@
"inputSchema": "输入模式",
"availableTools": "可用工具",
"noToolsAvailable": "无可用工具",
"loadError": "获取工具失败"
"loadError": "获取工具失败",
"resetToolsList": "重置工具列表"
},
"prompts": {
"availablePrompts": "可用提示",
@ -1628,6 +1629,7 @@
"messages.input.enable_delete_model": "启用删除键删除输入的模型/附件",
"messages.markdown_rendering_input_message": "Markdown 渲染输入消息",
"messages.math_engine": "数学公式引擎",
"messages.math_engine.none": "无",
"messages.metrics": "首字时延 {{time_first_token_millsec}}ms | 每秒 {{token_speed}} tokens",
"messages.model.title": "模型设置",
"messages.navigation": "对话导航按钮",
@ -1707,6 +1709,11 @@
"check": "检查",
"check_all_keys": "检查所有密钥",
"check_multiple_keys": "检查多个 API 密钥",
"oauth": {
"button": "使用{{provider}}登录",
"description": "此服务由<website>{{provider}}</website>提供",
"official_website": "官方网站"
},
"copilot": {
"auth_failed": "Github Copilot 认证失败",
"auth_success": "Github Copilot 认证成功",

View File

@ -1130,7 +1130,8 @@
"inputSchema": "輸入模式",
"availableTools": "可用工具",
"noToolsAvailable": "無可用工具",
"loadError": "獲取工具失敗"
"loadError": "獲取工具失敗",
"resetToolsList": "重置工具列表"
},
"prompts": {
"availablePrompts": "可用提示",
@ -1172,7 +1173,8 @@
"messages.input.enable_quick_triggers": "啟用 '/' 和 '@' 觸發快捷選單",
"messages.input.enable_delete_model": "啟用刪除鍵刪除模型/附件",
"messages.markdown_rendering_input_message": "Markdown 渲染輸入訊息",
"messages.math_engine": "Markdown 渲染輸入訊息",
"messages.math_engine": "數學公式引擎",
"messages.math_engine.none": "無",
"messages.metrics": "首字延遲 {{time_first_token_millsec}}ms | 每秒 {{token_speed}} tokens",
"messages.model.title": "模型設定",
"messages.navigation": "訊息導航",
@ -1241,6 +1243,11 @@
"check": "檢查",
"check_all_keys": "檢查所有金鑰",
"check_multiple_keys": "檢查多個 API 金鑰",
"oauth": {
"button": "使用{{provider}}登入",
"description": "此服務由<website>{{provider}}</website>提供",
"official_website": "官方網站"
},
"copilot": {
"auth_failed": "Github Copilot認證失敗",
"auth_success": "Github Copilot 認證成功",
@ -1682,4 +1689,4 @@
"shortcut_key_tip": "按下此快速鍵開始錄音,放開快速鍵結束錄音並傳送"
}
}
}
}

View File

@ -43,6 +43,8 @@ function initAutoSync() {
}, 2000)
}
initSpinner()
initKeyv()
initAutoSync()

View File

@ -27,7 +27,18 @@ const AddBookmarkDialog: React.FC<AddBookmarkDialogProps> = ({
// 将文件夹数据转换为树形结构
useEffect(() => {
if (!folders) return
if (!folders || !Array.isArray(folders)) {
// 如果 folders 不存在或不是数组,设置默认的根目录选项
setTreeData([
{
title: 'Bookmarks Bar',
value: null,
key: 'root',
icon: <FolderOutlined />
}
]);
return;
}
// 构建文件夹树
const buildFolderTree = (
@ -35,14 +46,18 @@ const AddBookmarkDialog: React.FC<AddBookmarkDialogProps> = ({
parentId: string | null = null
): any[] => {
return items
.filter((item) => item.parentId === parentId)
.map((item) => ({
title: item.title,
value: item.id,
key: item.id,
icon: <FolderOutlined />,
children: buildFolderTree(items, item.id)
}))
.filter((item) => item && item.parentId === parentId)
.map((item) => {
if (!item) return null;
return {
title: item.title || 'Unnamed Folder',
value: item.id,
key: item.id,
icon: <FolderOutlined />,
children: buildFolderTree(items, item.id)
};
})
.filter(Boolean); // 过滤掉 null 项
}
// 添加根目录选项
@ -120,13 +135,14 @@ const AddBookmarkDialog: React.FC<AddBookmarkDialogProps> = ({
<Form.Item name="folderId" label="Save to">
<TreeSelect
treeData={treeData}
treeData={treeData || []}
placeholder="Select a folder"
loading={loading}
treeDefaultExpandAll
showSearch
allowClear
treeNodeFilterProp="title"
fieldNames={{ label: 'title', value: 'value', children: 'children' }}
/>
</Form.Item>
</Form>

View File

@ -1,5 +1,5 @@
import { AppstoreOutlined, UnorderedListOutlined } from '@ant-design/icons'
import { Card, Empty, Flex, Input, Radio, Tag, Typography } from 'antd'
import { AppstoreOutlined, ReloadOutlined, UnorderedListOutlined } from '@ant-design/icons'
import { Button, Card, Empty, Flex, Input, Radio, Tag, Typography } from 'antd'
import { isEmpty } from 'lodash'
import { Search, SquareTerminal } from 'lucide-react'
import React, { FC, useEffect, useState } from 'react'
@ -23,23 +23,40 @@ const MCPPage: FC = () => {
const [viewMode, setViewMode] = useState<MCPViewMode>('list')
// 获取所有可用工具
useEffect(() => {
const fetchTools = async () => {
setLoading(true)
const allTools: MCPTool[] = []
for (const server of mcpServers.filter((s) => s.isActive)) {
try {
// @ts-ignore - window.api is defined in preload
const serverTools = await window.api.mcp.listTools(server)
allTools.push(...serverTools)
} catch (error) {
console.error(`Error fetching tools for server ${server.name}:`, error)
}
const fetchTools = async () => {
setLoading(true)
const allTools: MCPTool[] = []
for (const server of mcpServers.filter((s) => s.isActive)) {
try {
// @ts-ignore - window.api is defined in preload
const serverTools = await window.api.mcp.listTools(server)
allTools.push(...serverTools)
} catch (error) {
console.error(`Error fetching tools for server ${server.name}:`, error)
}
setTools(allTools)
setLoading(false)
}
setTools(allTools)
setLoading(false)
}
// 重置工具列表
const resetToolsList = async () => {
setLoading(true)
const allTools: MCPTool[] = []
for (const server of mcpServers.filter((s) => s.isActive)) {
try {
// @ts-ignore - window.api is defined in preload
const serverTools = await window.api.mcp.resetToolsList(server)
allTools.push(...serverTools)
} catch (error) {
console.error(`Error resetting tools for server ${server.name}:`, error)
}
}
setTools(allTools)
setLoading(false)
}
useEffect(() => {
if (mcpServers.length > 0) {
fetchTools()
} else {
@ -85,6 +102,15 @@ const MCPPage: FC = () => {
value={search}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
/>
<Button
type="primary"
icon={<ReloadOutlined />}
size="small"
onClick={resetToolsList}
loading={loading}
style={{ marginLeft: 8 }}>
{t('settings.mcp.tools.resetToolsList') || '重置工具列表'}
</Button>
</Flex>
<Spacer />
</StyledNavbarCenter>

View File

@ -6,10 +6,10 @@ import '@renderer/styles/citation.css'
import MarkdownShadowDOMRenderer from '@renderer/components/MarkdownShadowDOMRenderer'
import { useSettings } from '@renderer/hooks/useSettings'
import type { Message } from '@renderer/types'
import type { MCPToolResponse, Message } from '@renderer/types' // Import MCPToolResponse
import { parseJSON } from '@renderer/utils'
import { escapeBrackets, removeSvgEmptyLines, withGeminiGrounding } from '@renderer/utils/formats'
import { findCitationInChildren, sanitizeSchema } from '@renderer/utils/markdown'
import { findCitationInChildren } from '@renderer/utils/markdown'
import { isEmpty } from 'lodash'
import React, { type FC, memo, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@ -19,26 +19,57 @@ import rehypeKatex from 'rehype-katex'
import rehypeMathjax from 'rehype-mathjax'
import rehypeRaw from 'rehype-raw'
// @ts-ignore next-line
import rehypeSanitize from 'rehype-sanitize'
import remarkCjkFriendly from 'remark-cjk-friendly'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import SingleToolCallBlock from '../Messages/SingleToolCallBlock' // 导入 SingleToolCallBlock
import CitationTooltip from './CitationTooltip'
// Removed InlineToolBlock import
import EditableCodeBlock from './EditableCodeBlock'
import ImagePreview from './ImagePreview'
import Link from './Link'
interface Props {
message: Message
toolResponses: MCPToolResponse[] // 添加工具响应数据 prop
activeToolKeys: string[] // 添加 activeKeys prop
copiedToolMap: Record<string, boolean> // 添加 copiedMap prop
editingToolId: string | null // 添加 editingToolId prop
editedToolParamsString: string // 添加 editedParams prop
onToolToggle: React.Dispatch<React.SetStateAction<string[]>> // 添加 onToolToggle prop
onToolCopy: (content: string, toolId: string) => void // 添加 onToolCopy prop
onToolRerun: (toolCall: MCPToolResponse, currentParamsString: string) => void // 添加 onToolRerun prop
onToolEdit: (toolCall: MCPToolResponse) => void // 添加 onToolEdit prop
onToolSave: (toolCall: MCPToolResponse) => void // 添加 onToolSave prop
onToolCancel: () => void // 添加 onToolCancel prop
onToolParamsChange: (newParams: string) => void // 添加 onToolParamsChange prop
}
const remarkPlugins = [remarkMath, remarkGfm, remarkCjkFriendly]
const Markdown: FC<Props> = ({ message }) => {
const Markdown: FC<Props> = ({
message,
toolResponses,
activeToolKeys,
copiedToolMap,
editingToolId,
editedToolParamsString,
onToolToggle,
onToolCopy,
onToolRerun,
onToolEdit,
onToolSave,
onToolCancel,
onToolParamsChange
}) => {
const { t } = useTranslation()
const { renderInputMessageAsMarkdown, mathEngine } = useSettings()
const { renderInputMessageAsMarkdown, messageFont, mathEngine } = useSettings() // Add messageFont
const remarkPlugins = useMemo(() => {
const plugins = [remarkGfm, remarkCjkFriendly]
if (mathEngine && mathEngine !== 'none') {
plugins.push(remarkMath)
}
return plugins
}, [mathEngine])
const messageContent = useMemo(() => {
// 检查消息内容是否为空或未定义
@ -53,8 +84,19 @@ const Markdown: FC<Props> = ({ message }) => {
}, [message, t])
const rehypePlugins = useMemo(() => {
return [rehypeRaw, [rehypeSanitize, sanitizeSchema], mathEngine === 'KaTeX' ? rehypeKatex : rehypeMathjax]
}, [mathEngine])
const plugins: any[] = []
// Check if messageContent contains any of the allowed raw HTML tags
const rawTagsRegex = /<(style|translated|think|tool-block)[\s>]/i
if (rawTagsRegex.test(messageContent)) {
plugins.push(rehypeRaw)
}
if (mathEngine === 'KaTeX') {
plugins.push(rehypeKatex as any)
} else if (mathEngine === 'MathJax') {
plugins.push(rehypeMathjax as any)
}
return plugins
}, [mathEngine, messageContent])
// Remove processToolUse function as it's based on XML tags in content,
// which won't exist with native function calling.
@ -130,11 +172,71 @@ const Markdown: FC<Props> = ({ message }) => {
{props.children}
</span>
)
},
// 添加 tool-block 渲染器
'tool-block': (props: any) => {
console.log('[Markdown] Tool block renderer called with props:', props) // Log renderer call and props
const toolCallId = props.id // 获取占位符中的 id
console.log('[Markdown] Extracted toolCallId:', toolCallId) // Log extracted ID
const toolResponse = toolResponses.find((tr) => tr.id === toolCallId) // 查找对应的工具响应数据
console.log('[Markdown] Found toolResponse:', toolResponse) // Log found tool response
if (!toolResponse) {
return null // 如果找不到对应的工具响应,则不渲染
}
if (!toolResponse) {
console.warn('[Markdown] Tool response not found for id:', toolCallId) // Warn if not found
return null // 如果找不到对应的工具响应,则不渲染
}
console.log('[Markdown] Rendering SingleToolCallBlock for toolCallId:', toolCallId) // Log rendering SingleToolCallBlock
// 渲染 SingleToolCallBlock 组件,并传递必要的 props
return (
<SingleToolCallBlock
toolResponse={toolResponse}
isActive={activeToolKeys.includes(toolCallId)}
isCopied={copiedToolMap[toolCallId] || false}
isEditing={editingToolId === toolCallId}
editedParamsString={editedToolParamsString}
fontFamily={
messageFont === 'serif'
? 'serif'
: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans","Helvetica Neue", sans-serif'
} // 传递字体样式
t={t} // 传递翻译函数
onToggle={() =>
onToolToggle((prev) =>
prev.includes(toolCallId) ? prev.filter((k) => k !== toolCallId) : [...prev, toolCallId]
)
} // 传递 onToolToggle 函数
onCopy={onToolCopy} // 传递 onToolCopy 函数
onRerun={onToolRerun} // 传递 onToolRerun 函数
onEdit={onToolEdit} // 传递 onToolEdit 函数
onSave={() => toolResponse && onToolSave(toolResponse)} // 传递 onToolSave 函数
onCancel={onToolCancel} // 传递 onToolCancel 函数
onParamsChange={onToolParamsChange} // 传递 onToolParamsChange 函数
/>
)
}
// Removed custom div renderer for tool markers
} as Partial<Components> // Keep Components type here
} as Partial<Components>
return baseComponents
}, []) // Removed message.metadata dependency as it's no longer used here
}, [
toolResponses, // 添加 toolResponses 依赖
activeToolKeys, // 添加 activeToolKeys 依赖
copiedToolMap, // 添加 copiedToolMap 依赖
editingToolId, // 添加 editingToolId 依赖
editedToolParamsString, // 添加 editedToolParamsString 依赖
onToolToggle, // 添加 onToolToggle 依赖
onToolCopy, // 添加 onToolCopy 依赖
onToolRerun, // 添加 onToolRerun 依赖
onToolEdit, // 添加 onToolEdit 依赖
onToolSave, // 添加 onToolSave 依赖
onToolCancel, // 添加 onToolCancel 依赖
onToolParamsChange, // 添加 onToolParamsChange 依赖
t, // 添加 t 依赖
messageFont // 添加 messageFont 依赖
])
// 使用useEffect在渲染后添加事件处理
React.useEffect(() => {

View File

@ -1,6 +1,8 @@
import { useTheme } from '@renderer/context/ThemeProvider'
import { EventEmitter } from '@renderer/services/EventService'
import { ThemeMode } from '@renderer/types'
import React, { useEffect, useRef } from 'react'
import { debounce, isEmpty } from 'lodash'
import React, { useCallback, useEffect, useRef } from 'react'
import MermaidPopup from './MermaidPopup'
@ -12,20 +14,44 @@ const Mermaid: React.FC<Props> = ({ chart }) => {
const { theme } = useTheme()
const mermaidRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (mermaidRef.current && window.mermaid) {
const renderMermaidBase = useCallback(async () => {
if (!mermaidRef.current || !window.mermaid || isEmpty(chart)) return
try {
mermaidRef.current.innerHTML = chart
mermaidRef.current.removeAttribute('data-processed')
if (window.mermaid.initialize) {
window.mermaid.initialize({
startOnLoad: true,
theme: theme === ThemeMode.dark ? 'dark' : 'default'
})
}
window.mermaid.contentLoaded()
await window.mermaid.initialize({
startOnLoad: true,
theme: theme === ThemeMode.dark ? 'dark' : 'default'
})
await window.mermaid.run({ nodes: [mermaidRef.current] })
} catch (error) {
console.error('Failed to render mermaid chart:', error)
}
}, [chart, theme])
const renderMermaid = useCallback(debounce(renderMermaidBase, 1000), [renderMermaidBase])
useEffect(() => {
renderMermaid()
// Make sure to cancel any pending debounced calls when unmounting
return () => renderMermaid.cancel()
}, [renderMermaid])
useEffect(() => {
setTimeout(renderMermaidBase, 0)
}, [])
useEffect(() => {
const removeListener = EventEmitter.on('mermaid-loaded', renderMermaid)
return () => {
removeListener()
renderMermaid.cancel()
}
}, [renderMermaid])
const onPreview = () => {
MermaidPopup.show({ chart })
}

View File

@ -1,4 +1,7 @@
import { TopView } from '@renderer/components/TopView'
import { useTheme } from '@renderer/context/ThemeProvider'
import { ThemeMode } from '@renderer/types'
import { runAsyncFunction } from '@renderer/utils'
import { download } from '@renderer/utils/download'
import { Button, Modal, Space, Tabs } from 'antd'
import { useEffect, useState } from 'react'
@ -16,6 +19,7 @@ interface Props extends ShowParams {
const PopupContainer: React.FC<Props> = ({ resolve, chart }) => {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const { theme } = useTheme()
const mermaidId = `mermaid-popup-${Date.now()}`
const [activeTab, setActiveTab] = useState('preview')
const [scale, setScale] = useState(1)
@ -97,19 +101,21 @@ const PopupContainer: React.FC<Props> = ({ resolve, chart }) => {
if (!element) return
const timestamp = Date.now()
const backgroundColor = theme === ThemeMode.dark ? '#1F1F1F' : '#fff'
const svgElement = element.querySelector('svg')
if (!svgElement) return
if (format === 'svg') {
const svgElement = element.querySelector('svg')
if (!svgElement) return
// Add background color to SVG
svgElement.style.backgroundColor = backgroundColor
const svgData = new XMLSerializer().serializeToString(svgElement)
const blob = new Blob([svgData], { type: 'image/svg+xml' })
const url = URL.createObjectURL(blob)
download(url, `mermaid-diagram-${timestamp}.svg`)
URL.revokeObjectURL(url)
} else if (format === 'png') {
const svgElement = element.querySelector('svg')
if (!svgElement) return
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const img = new Image()
@ -119,6 +125,9 @@ const PopupContainer: React.FC<Props> = ({ resolve, chart }) => {
const width = viewBox[2] || svgElement.clientWidth || svgElement.getBoundingClientRect().width
const height = viewBox[3] || svgElement.clientHeight || svgElement.getBoundingClientRect().height
// Add background color to SVG before converting to image
svgElement.style.backgroundColor = backgroundColor
const svgData = new XMLSerializer().serializeToString(svgElement)
const svgBase64 = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgData)))}`
@ -129,6 +138,9 @@ const PopupContainer: React.FC<Props> = ({ resolve, chart }) => {
if (ctx) {
ctx.scale(scale, scale)
// Fill background
ctx.fillStyle = backgroundColor
ctx.fillRect(0, 0, width, height)
ctx.drawImage(img, 0, 0, width, height)
}
@ -142,6 +154,7 @@ const PopupContainer: React.FC<Props> = ({ resolve, chart }) => {
}
img.src = svgBase64
}
svgElement.style.backgroundColor = 'transparent'
} catch (error) {
console.error('Download failed:', error)
}
@ -153,8 +166,30 @@ const PopupContainer: React.FC<Props> = ({ resolve, chart }) => {
}
useEffect(() => {
window?.mermaid?.contentLoaded()
}, [])
runAsyncFunction(async () => {
if (!window.mermaid) return
try {
const element = document.getElementById(mermaidId)
if (!element) return
// Clear previous content
element.innerHTML = chart
element.removeAttribute('data-processed')
await window.mermaid.initialize({
startOnLoad: false,
theme: theme === ThemeMode.dark ? 'dark' : 'default'
})
await window.mermaid.run({
nodes: [element]
})
} catch (error) {
console.error('Failed to render mermaid chart in popup:', error)
}
})
}, [activeTab, theme, mermaidId, chart])
return (
<Modal

View File

@ -1,4 +1,4 @@
import React, { FC, useCallback, useLayoutEffect, useRef, useState } from 'react'
import React, { FC, useLayoutEffect, useRef, useState } from 'react'
import styled from 'styled-components'
interface CustomCollapseProps {
@ -11,61 +11,22 @@ interface CustomCollapseProps {
const CustomCollapse: FC<CustomCollapseProps> = ({ title, children, isActive, onToggle }) => {
const contentRef = useRef<HTMLDivElement>(null)
const [height, setHeight] = useState<number | 'auto'>(0)
const [isContentVisible, setIsContentVisible] = useState(false)
const [isAnimating, setIsAnimating] = useState(false)
const prevActiveRef = useRef(isActive)
// 使用 requestAnimationFrame 来优化动画性能
const animateHeight = useCallback((from: number, to: number, duration: number = 250) => {
setIsAnimating(true)
const startTime = performance.now()
const animate = (currentTime: number) => {
const elapsedTime = currentTime - startTime
const progress = Math.min(elapsedTime / duration, 1)
// 使用缓动函数使动画更平滑
const easeProgress = progress === 1 ? 1 : 1 - Math.pow(2, -10 * progress)
const currentHeight = from + (to - from) * easeProgress
setHeight(currentHeight)
if (progress < 1) {
window.requestAnimationFrame(animate)
} else {
setHeight(to === 0 ? 0 : 'auto')
setIsAnimating(false)
setIsContentVisible(to !== 0)
}
}
window.requestAnimationFrame(animate)
}, [])
// 使用 useLayoutEffect 来测量高度,避免闪烁
const [height, setHeight] = useState<number | 'auto'>(isActive ? 'auto' : 0)
// Use useLayoutEffect to update height based on isActive prop
useLayoutEffect(() => {
// 如果状态没有变化,不做任何处理
if (prevActiveRef.current === isActive) return
// 如果正在动画中,不做处理
if (isAnimating) return
prevActiveRef.current = isActive
console.log('[CustomCollapse] useLayoutEffect triggered. isActive:', isActive) // Log effect trigger
if (isActive) {
// 展开
if (contentRef.current) {
const contentHeight = contentRef.current.scrollHeight
animateHeight(0, contentHeight)
}
// When expanding, set height to 'auto' to allow content to show
setHeight('auto')
console.log('[CustomCollapse] Expanding: setting height to auto.') // Log height set
} else {
// 折叠
if (contentRef.current) {
const currentHeight = contentRef.current.scrollHeight
animateHeight(currentHeight, 0)
}
// When collapsing, set height to 0
setHeight(0)
console.log('[CustomCollapse] Collapsing: setting height to 0.') // Log height set
}
}, [isActive, animateHeight, isAnimating])
}, [isActive])
console.log('[CustomCollapse] Rendering. isActive:', isActive, 'Current Height:', height) // Log rendering state
return (
<CollapseWrapper>
@ -74,26 +35,11 @@ const CustomCollapse: FC<CustomCollapseProps> = ({ title, children, isActive, on
ref={contentRef}
style={{
height: height === 'auto' ? 'auto' : `${height}px`,
// 使用 transform 而不是 height 来触发硬件加速
transform: `translateZ(0)`,
// 使用 will-change 提前告知浏览器将要发生变化
willChange: isAnimating ? 'height' : 'auto',
// 使用 contain 限制重绘范围
contain: 'content',
// 使用 GPU 加速
backfaceVisibility: 'hidden'
overflow: 'hidden' // Ensure content is hidden when collapsed
}}
$isActive={isActive}>
<div
style={{
display: isContentVisible || isActive ? 'block' : 'none',
// 使用 transform 触发硬件加速
transform: 'translateZ(0)',
// 使用 contain 限制重绘范围
contain: 'content'
}}>
{children}
</div>
{/* Render children directly */}
<div>{children}</div>
</CollapseContent>
</CollapseWrapper>
)
@ -103,8 +49,6 @@ const CollapseWrapper = styled.div`
border-bottom: 1px solid var(--color-border);
overflow: hidden;
background-color: var(--color-bg-1);
will-change: transform;
transform: translateZ(0);
&:last-child {
border-bottom: none;
@ -116,7 +60,6 @@ const CollapseHeader = styled.div`
cursor: pointer;
background-color: var(--color-bg-2);
transition: background-color 0.2s;
will-change: transform, background-color;
&:hover {
background-color: var(--color-bg-3);
@ -125,14 +68,13 @@ const CollapseHeader = styled.div`
const CollapseContent = styled.div<{ $isActive: boolean }>`
overflow: hidden;
/* 移除过渡效果,改用 requestAnimationFrame 手动控制动画 */
/* 使用 GPU 加速 */
transition: height 250ms ease-out; /* Add CSS transition for height */
background-color: var(--color-bg-1); /* Add background color */
transform: translateZ(0);
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
perspective: 1000;
-webkit-perspective: 1000;
background-color: var(--color-bg-1); /* 添加背景色 */
`
export default CustomCollapse

View File

@ -2,13 +2,13 @@ import { SyncOutlined, TranslationOutlined } from '@ant-design/icons'
import TTSHighlightedText from '@renderer/components/TTSHighlightedText'
import { isOpenAIWebSearch } from '@renderer/config/models'
import { getModelUniqId } from '@renderer/services/ModelService'
import { Message, Model } from '@renderer/types'
import { MCPToolResponse, Message, Model } from '@renderer/types' // Import MCPToolResponse
import { getBriefInfo } from '@renderer/utils'
import { withMessageThought } from '@renderer/utils/formats'
import { Collapse, Divider, Flex } from 'antd'
import { clone } from 'lodash'
import { Search } from 'lucide-react'
import React, { Fragment, useEffect, useMemo, useState } from 'react'
import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react' // Import useCallback
import { useTranslation } from 'react-i18next'
import BarLoader from 'react-spinners/BarLoader'
import BeatLoader from 'react-spinners/BeatLoader'
@ -20,7 +20,6 @@ import MessageAttachments from './MessageAttachments'
import MessageError from './MessageError'
import MessageImage from './MessageImage'
import MessageThought from './MessageThought'
import { default as MessageTools } from './MessageTools' // Change to named import (using default alias)
interface Props {
message: Message
@ -33,6 +32,214 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
const isWebCitation = model && (isOpenAIWebSearch(model) || model.provider === 'openrouter')
const [isSegmentedPlayback, setIsSegmentedPlayback] = useState(false)
// MCP Tool related states and handlers moved from MessageTools.tsx
const [activeKeys, setActiveKeys] = useState<string[]>([])
const [copiedMap, setCopiedMap] = useState<Record<string, boolean>>({})
const [editingToolId, setEditingToolId] = useState<string | null>(null)
const [editedParams, setEditedParams] = useState<string>('')
// Local state for immediate UI updates, synced with message metadata
const [localToolResponses, setLocalToolResponses] = useState<MCPToolResponse[]>(message.metadata?.mcpTools || [])
// Effect to sync local state when message metadata changes externally
useEffect(() => {
// Only update local state if the incoming metadata is actually different
// This prevents unnecessary re-renders if the message object reference changes but content doesn't
const incomingTools = message.metadata?.mcpTools || []
if (JSON.stringify(incomingTools) !== JSON.stringify(localToolResponses)) {
setLocalToolResponses(incomingTools)
}
}, [message.metadata?.mcpTools, localToolResponses])
const copyContent = useCallback(
(content: string, toolId: string) => {
navigator.clipboard.writeText(content)
window.message.success({ content: t('message.copied'), key: 'copy-message' })
setCopiedMap((prev) => ({ ...prev, [toolId]: true }))
setTimeout(() => setCopiedMap((prev) => ({ ...prev, [toolId]: false })), 2000)
},
[t]
)
// --- Handlers for Edit/Rerun ---
const handleRerun = useCallback(
(toolCall: MCPToolResponse, currentParamsString: string) => {
console.log('Rerunning tool:', toolCall.id, 'with params:', currentParamsString)
try {
const paramsToRun = JSON.parse(currentParamsString)
// Proactively update local state for immediate UI feedback
setLocalToolResponses((prevResponses) =>
prevResponses.map((tc) =>
tc.id === toolCall.id ? { ...tc, args: paramsToRun, status: 'invoking', response: undefined } : tc
)
)
const serverConfig = message.enabledMCPs?.find((server) => server.id === toolCall.tool.serverId)
if (!serverConfig) {
console.error(`[MessageContent] Server config not found for ID ${toolCall.tool.serverId}`)
window.message.error({ content: t('common.rerun_failed_server_not_found'), key: 'rerun-tool' })
return
}
window.api.mcp
.rerunTool(message.id, toolCall.id, serverConfig, toolCall.tool.name, paramsToRun)
.then(() => window.message.success({ content: t('common.rerun_started'), key: 'rerun-tool' }))
.catch((err) => {
console.error('Rerun failed:', err)
window.message.error({ content: t('common.rerun_failed'), key: 'rerun-tool' })
// Optionally revert local state on failure
setLocalToolResponses(
(prevResponses) => prevResponses.map((tc) => (tc.id === toolCall.id ? { ...tc, status: 'done' } : tc)) // Revert status
)
})
} catch (e) {
console.error('Invalid JSON parameters for rerun:', e)
window.message.error(t('common.invalid_json'))
// Revert local state if JSON parsing fails
setLocalToolResponses(
(prevResponses) => prevResponses.map((tc) => (tc.id === toolCall.id ? { ...tc, status: 'done' } : tc)) // Revert status
)
}
},
[message.id, message.enabledMCPs, t]
)
const handleEdit = useCallback((toolCall: MCPToolResponse) => {
setEditingToolId(toolCall.id)
setEditedParams(JSON.stringify(toolCall.args || {}, null, 2))
}, [])
const handleCancelEdit = useCallback(() => {
setEditingToolId(null)
setEditedParams('')
}, [])
const handleSaveEdit = useCallback(
(toolCall: MCPToolResponse) => {
handleRerun(toolCall, editedParams)
setEditingToolId(null)
setEditedParams('')
},
[editedParams, handleRerun]
)
const handleParamsChange = useCallback((newParams: string) => {
setEditedParams(newParams)
}, [])
// --- End Handlers ---
// --- Listener for Rerun Updates & Persistence ---
useEffect(() => {
const cleanupListener = window.api.mcp.onToolRerunUpdate((update) => {
if (update.messageId !== message.id) return // Ignore updates for other messages
console.log('[MessageContent] Received rerun update:', update)
// --- Update Local State for Immediate UI Feedback ---
setLocalToolResponses((currentLocalResponses) => {
return currentLocalResponses.map((toolCall) => {
if (toolCall.id === update.toolCallId) {
let updatedCall: MCPToolResponse
switch (update.status) {
case 'rerunning':
// Note: 'rerunning' status from IPC translates to 'invoking' in UI
updatedCall = { ...toolCall, status: 'invoking', args: update.args, response: undefined }
break
case 'done':
updatedCall = {
...toolCall,
status: 'done',
response: update.response,
// Persist the args used for the successful rerun
args: update.args !== undefined ? update.args : toolCall.args
}
break
case 'error':
updatedCall = {
...toolCall,
status: 'done', // Keep UI status as 'done' even on error
response: { content: [{ type: 'text', text: update.error }], isError: true },
// Persist the args used for the failed rerun
args: update.args !== undefined ? update.args : toolCall.args
}
break
default:
updatedCall = toolCall // Should not happen
}
return updatedCall
}
return toolCall
})
})
// --- End Local State Update ---
// --- Persist Changes to Global Store and DB (only on final states) ---
if (update.status === 'done' || update.status === 'error') {
// IMPORTANT: Use the message prop directly to get the state *before* this update cycle
const previousMcpTools = message.metadata?.mcpTools || []
console.log(
'[MessageContent Persistence] Previous MCP Tools from message.metadata:',
JSON.stringify(previousMcpTools, null, 2)
) // Log previous state
const updatedMcpToolsForPersistence = previousMcpTools.map((toolCall) => {
if (toolCall.id === update.toolCallId) {
console.log(
`[MessageContent Persistence] Updating tool ${toolCall.id} with status ${update.status}, args:`,
update.args,
'response:',
update.response || update.error
) // Log update details
// Apply the final state directly from the update object
return {
...toolCall, // Keep existing id, tool info
status: 'done', // Final status is always 'done' for persistence
args: update.args !== undefined ? update.args : toolCall.args, // Persist the args used for the rerun
response:
update.status === 'error'
? { content: [{ type: 'text', text: update.error }], isError: true } // Create error response object
: update.response // Use the successful response
}
}
return toolCall // Keep other tool calls as they were
})
console.log(
'[MessageContent Persistence] Calculated MCP Tools for Persistence:',
JSON.stringify(updatedMcpToolsForPersistence, null, 2)
) // Log calculated state
// Dispatch the thunk to update the message globally
// Ensure we have the necessary IDs
if (message.topicId && message.id) {
console.log(
`[MessageContent Persistence] Dispatching updateMessageThunk for message ${message.id} in topic ${message.topicId}`
) // Log dispatch attempt
window.api.store.dispatch(
window.api.store.updateMessageThunk(message.topicId, message.id, {
metadata: {
...message.metadata, // Keep other metadata
mcpTools: updatedMcpToolsForPersistence // Provide the correctly calculated final array
}
})
)
console.log(
'[MessageContent] Dispatched updateMessageThunk with calculated persistence data for tool:',
update.toolCallId
)
} else {
console.error('[MessageContent] Missing topicId or messageId, cannot dispatch update.')
}
}
// --- End Persistence Logic ---
})
return () => cleanupListener()
// Ensure all necessary dependencies are included
}, [message.id, message.topicId, message.metadata]) // message.metadata is crucial here
// --- End Listener ---
// 监听分段播放状态变化
useEffect(() => {
const handleSegmentedPlaybackUpdate = (event: CustomEvent) => {
@ -101,12 +308,16 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
}, [message.metadata?.citations, message.metadata?.annotations, model])
// 获取引用数据
// https://github.com/CherryHQ/cherry-studio/issues/5234#issuecomment-2824704499
const citationsData = useMemo(() => {
const citationUrls =
Array.isArray(message.metadata?.citations) &&
(message?.metadata?.annotations?.map((annotation) => annotation.url_citation) ?? [])
const searchResults =
message?.metadata?.webSearch?.results ||
message?.metadata?.webSearchInfo ||
message?.metadata?.groundingMetadata?.groundingChunks?.map((chunk) => chunk?.web) ||
message?.metadata?.annotations?.map((annotation) => annotation.url_citation) ||
citationUrls ||
[]
const citationsUrls = formattedCitations || []
@ -139,7 +350,8 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
message?.metadata?.annotations,
message?.metadata?.groundingMetadata?.groundingChunks,
message?.metadata?.webSearch?.results,
message?.metadata?.webSearchInfo
message?.metadata?.webSearchInfo,
message.metadata?.citations // Added missing dependency
// knowledge 依赖已移除,因为它在 useMemo 中没有被使用
])
@ -320,14 +532,40 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
}
)
}
// 处理 MCP 工具调用标记
// 处理 MCP 工具调用标记
// 处理 MCP 工具调用标记
console.log('[MessageContent] Original message content:', message.content) // Log original content
const toolResponses = message.metadata?.mcpTools || []
console.log('[MessageContent] Tool responses from metadata:', toolResponses) // Log tool responses
if (toolResponses.length > 0) {
let toolIndex = 0
// 使用更通用的正则表达式匹配 <tool_use>...</tool_use> 块
const toolTagRegex = /<tool_use>[\s\S]*?<\/tool_use>/gi
const matches = Array.from(content.matchAll(toolTagRegex)) // Get all matches
console.log('[MessageContent] Regex matches for tool tags:', matches) // Log matches
content = content.replace(toolTagRegex, (match) => {
if (toolIndex < toolResponses.length) {
const toolCall = toolResponses[toolIndex]
toolIndex++
console.log(`[MessageContent] Replacing match ${toolIndex} with tool-block id="${toolCall.id}"`) // Log replacement
return `<tool-block id="${toolCall.id}"></tool-block>`
}
// 如果工具响应数量与标记数量不匹配,记录警告并返回原始匹配
console.warn('[MessageContent] Mismatch between tool tags and tool responses. Returning original tag:', match)
return match
})
console.log('[MessageContent] Content after tool tag replacement:', content) // Log content after replacement
}
return content
}, [
message.metadata?.citations,
message.metadata?.webSearch,
message.metadata?.knowledge,
// 移除不必要的依赖
// message.metadata?.webSearchInfo,
// message.metadata?.annotations,
message.metadata?.mcpTools, // Add mcpTools as dependency
message.content,
citationsData
])
@ -356,7 +594,23 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
if (message.type === '@' && model) {
const content = `[@${model.name}](#) ${getBriefInfo(message.content)}`
return <Markdown message={{ ...message, content }} />
return (
<Markdown
message={{ ...message, content, metadata: message.metadata || {} }} // Ensure metadata is included
toolResponses={localToolResponses} // 传递工具响应数据 (使用 local state)
activeToolKeys={activeKeys} // 传递 activeKeys 状态
copiedToolMap={copiedMap} // 传递 copiedMap 状态
editingToolId={editingToolId} // 传递 editingToolId 状态
editedToolParamsString={editedParams} // 传递 editedParams 状态
onToolToggle={setActiveKeys} // 传递 setActiveKeys 函数
onToolCopy={copyContent} // 传递 copyContent 函数
onToolRerun={handleRerun} // 传递 handleRerun 函数
onToolEdit={handleEdit} // 传递 handleEdit 函数
onToolSave={handleSaveEdit} // 传递 handleSaveEdit 函数
onToolCancel={handleCancelEdit} // 传递 handleCancelEdit 函数
onToolParamsChange={handleParamsChange} // 传递 onToolParamsChange 函数
/>
)
}
// --- MODIFIED LINE BELOW ---
@ -461,18 +715,28 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
]}
/>
)}
<div className="message-content-tools">
{/* Only display thought info at the top */}
<MessageThought message={message} />
{/* Render MessageTools to display tool blocks based on metadata */}
<MessageTools message={message} />
</div>
{/* Only display thought info at the top */}
<MessageThought message={message} />
{isSegmentedPlayback ? (
// Apply regex replacement here for TTS
<TTSHighlightedText text={processedContent.replace(tagsToRemoveRegex, '')} />
) : (
// Remove tool_use XML tags before rendering Markdown
<Markdown message={{ ...message, content: processedContent.replace(tagsToRemoveRegex, '') }} />
// Render Markdown with tool blocks
<Markdown
message={{ ...message, content: processedContent }} // 传递包含占位符的内容
toolResponses={localToolResponses} // 传递工具响应数据 (使用 local state)
activeToolKeys={activeKeys} // 传递 activeKeys 状态
copiedToolMap={copiedMap} // 传递 copiedMap 状态
editingToolId={editingToolId} // 传递 editingToolId 状态
editedToolParamsString={editedParams} // 传递 editedParams 状态
onToolToggle={setActiveKeys} // 传递 setActiveKeys 函数
onToolCopy={copyContent} // 传递 copyContent 函数
onToolRerun={handleRerun} // 传递 handleRerun 函数
onToolEdit={handleEdit} // 传递 handleEdit 函数
onToolSave={handleSaveEdit} // 传递 handleSaveEdit 函数
onToolCancel={handleCancelEdit} // 传递 handleCancelEdit 函数
onToolParamsChange={handleParamsChange} // 传递 onToolParamsChange 函数
/>
)}
{message.metadata?.generateImage && <MessageImage message={message} />}
{message.translatedContent && (
@ -484,7 +748,21 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
<BeatLoader color="var(--color-text-2)" size="10" style={{ marginBottom: 5 }} />
) : (
// Render translated content (assuming it doesn't need tag removal, adjust if needed)
<Markdown message={{ ...message, content: message.translatedContent }} />
<Markdown
message={{ ...message, content: message.translatedContent }}
toolResponses={localToolResponses} // 传递工具响应数据 (使用 local state)
activeToolKeys={activeKeys} // 传递 activeKeys 状态
copiedToolMap={copiedMap} // 传递 copiedMap 状态
editingToolId={editingToolId} // 传递 editingToolId 状态
editedToolParamsString={editedParams} // 传递 editedParams 状态
onToolToggle={setActiveKeys} // 传递 setActiveKeys 函数
onToolCopy={copyContent} // 传递 copyContent 函数
onToolRerun={handleRerun} // 传递 handleRerun 函数
onToolEdit={handleEdit} // 传递 handleEdit 函数
onToolSave={handleSaveEdit} // 传递 handleSaveEdit 函数
onToolCancel={handleCancelEdit} // 传递 handleCancelEdit 函数
onToolParamsChange={handleParamsChange} // 传递 onToolParamsChange 函数
/>
)}
</Fragment>
)}

View File

@ -1,37 +1,17 @@
import {
CheckOutlined,
CopyOutlined,
EditOutlined,
ExpandAltOutlined,
LoadingOutlined,
ReloadOutlined,
WarningOutlined
} from '@ant-design/icons'
import { useSettings } from '@renderer/hooks/useSettings'
import { useAppDispatch } from '@renderer/store'
import { updateMessageThunk } from '@renderer/store/messages'
import { MCPToolResponse, Message } from '@renderer/types'
import { message as antdMessage, Tooltip } from 'antd' // Removed Modal
import { FC, memo, useCallback, useEffect, useMemo, useState } from 'react' // Removed useLayoutEffect, useRef
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
// Removed Modal
import { FC, memo, useEffect, useState } from 'react' // Removed useLayoutEffect, useRef
import CustomCollapse from './CustomCollapse'
// Removed ExpandedResponseContent import
import ToolResponseContent from './ToolResponseContent'
interface Props {
message: Message
}
const MessageTools: FC<Props> = ({ message }) => {
const [activeKeys, setActiveKeys] = useState<string[]>([])
const [copiedMap, setCopiedMap] = useState<Record<string, boolean>>({})
// Removed expandedResponse state
const [editingToolId, setEditingToolId] = useState<string | null>(null)
const [editedParams, setEditedParams] = useState<string>('')
const { t } = useTranslation()
const { messageFont } = useSettings() // Removed fontSize
// Removed state and handlers related to tool block rendering
const dispatch = useAppDispatch()
// Local state for immediate UI updates, synced with message metadata
@ -47,91 +27,8 @@ const MessageTools: FC<Props> = ({ message }) => {
}
}, [message.metadata?.mcpTools]) // Removed localToolResponses from dependency array
const fontFamily = useMemo(() => {
return messageFont === 'serif'
? 'serif'
: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans","Helvetica Neue", sans-serif'
}, [messageFont])
const copyContent = useCallback(
(content: string, toolId: string) => {
navigator.clipboard.writeText(content)
antdMessage.success({ content: t('message.copied'), key: 'copy-message' })
setCopiedMap((prev) => ({ ...prev, [toolId]: true }))
setTimeout(() => setCopiedMap((prev) => ({ ...prev, [toolId]: false })), 2000)
},
[t]
)
// --- Handlers for Edit/Rerun ---
const handleRerun = useCallback(
(toolCall: MCPToolResponse, currentParamsString: string) => {
console.log('Rerunning tool:', toolCall.id, 'with params:', currentParamsString)
try {
const paramsToRun = JSON.parse(currentParamsString)
// Proactively update local state for immediate UI feedback
setLocalToolResponses((prevResponses) =>
prevResponses.map((tc) =>
tc.id === toolCall.id ? { ...tc, args: paramsToRun, status: 'invoking', response: undefined } : tc
)
)
const serverConfig = message.enabledMCPs?.find((server) => server.id === toolCall.tool.serverId)
if (!serverConfig) {
console.error(`[MessageTools] Server config not found for ID ${toolCall.tool.serverId}`)
antdMessage.error({ content: t('common.rerun_failed_server_not_found'), key: 'rerun-tool' })
return
}
window.api.mcp
.rerunTool(message.id, toolCall.id, serverConfig, toolCall.tool.name, paramsToRun)
.then(() => antdMessage.success({ content: t('common.rerun_started'), key: 'rerun-tool' }))
.catch((err) => {
console.error('Rerun failed:', err)
antdMessage.error({ content: t('common.rerun_failed'), key: 'rerun-tool' })
// Optionally revert local state on failure
setLocalToolResponses(
(prevResponses) => prevResponses.map((tc) => (tc.id === toolCall.id ? { ...tc, status: 'done' } : tc)) // Revert status
)
})
} catch (e) {
console.error('Invalid JSON parameters for rerun:', e)
antdMessage.error(t('common.invalid_json'))
// Revert local state if JSON parsing fails
setLocalToolResponses(
(prevResponses) => prevResponses.map((tc) => (tc.id === toolCall.id ? { ...tc, status: 'done' } : tc)) // Revert status
)
}
},
[message.id, message.enabledMCPs, t, dispatch] // Added dispatch
)
const handleEdit = useCallback((toolCall: MCPToolResponse) => {
setEditingToolId(toolCall.id)
setEditedParams(JSON.stringify(toolCall.args || {}, null, 2))
}, [])
const handleCancelEdit = useCallback(() => {
setEditingToolId(null)
setEditedParams('')
}, [])
const handleSaveEdit = useCallback(
(toolCall: MCPToolResponse) => {
handleRerun(toolCall, editedParams)
setEditingToolId(null)
setEditedParams('')
},
[editedParams, handleRerun]
)
const handleParamsChange = useCallback((newParams: string) => {
setEditedParams(newParams)
}, [])
// --- End Handlers ---
// --- Listener for Rerun Updates & Persistence ---
// Keep the listener as it updates the message metadata in the store
useEffect(() => {
const cleanupListener = window.api.mcp.onToolRerunUpdate((update) => {
if (update.messageId !== message.id) return // Ignore updates for other messages
@ -139,42 +36,8 @@ const MessageTools: FC<Props> = ({ message }) => {
console.log('[MessageTools] Received rerun update:', update)
// --- Update Local State for Immediate UI Feedback ---
setLocalToolResponses((currentLocalResponses) => {
return currentLocalResponses.map((toolCall) => {
if (toolCall.id === update.toolCallId) {
let updatedCall: MCPToolResponse
switch (update.status) {
case 'rerunning':
// Note: 'rerunning' status from IPC translates to 'invoking' in UI
updatedCall = { ...toolCall, status: 'invoking', args: update.args, response: undefined }
break
case 'done':
updatedCall = {
...toolCall,
status: 'done',
response: update.response,
// Persist the args used for the successful rerun
args: update.args !== undefined ? update.args : toolCall.args
}
break
case 'error':
updatedCall = {
...toolCall,
status: 'done', // Keep UI status as 'done' even on error
response: { content: [{ type: 'text', text: update.error }], isError: true },
// Persist the args used for the failed rerun
args: update.args !== undefined ? update.args : toolCall.args
}
break
default:
updatedCall = toolCall // Should not happen
}
return updatedCall
}
return toolCall
})
})
// --- End Local State Update ---
// This part is no longer needed in MessageTools as state is in MessageContent
// setLocalToolResponses((currentLocalResponses) => { ... });
// --- Persist Changes to Global Store and DB (only on final states) ---
if (update.status === 'done' || update.status === 'error') {
@ -242,235 +105,12 @@ const MessageTools: FC<Props> = ({ message }) => {
}, [message.id, message.topicId, message.metadata, dispatch]) // message.metadata is crucial here
// --- End Listener ---
// Use localToolResponses for rendering
const toolResponses = localToolResponses
// Removed responseStringsRef and its useLayoutEffect
// Memoize collapse items
const collapseItems = useMemo(() => {
return toolResponses.map((toolResponse) => {
const { id, tool, args, status, response } = toolResponse
const isInvoking = status === 'invoking'
const isDone = status === 'done'
const hasError = isDone && response?.isError === true
const params = args || {}
const toolResult = response
return {
key: id,
label: (
<MessageTitleLabel>
<TitleContent>
<ToolName>{tool.name}</ToolName>
<StatusIndicator $isInvoking={isInvoking} $hasError={hasError}>
{isInvoking
? t('message.tools.invoking')
: hasError
? t('message.tools.error')
: t('message.tools.completed')}
{isInvoking && <LoadingOutlined spin style={{ marginLeft: 6 }} />}
{isDone && !hasError && <CheckOutlined style={{ marginLeft: 6 }} />}
{hasError && <WarningOutlined style={{ marginLeft: 6 }} />}
</StatusIndicator>
</TitleContent>
<ActionButtonsContainer>
{isDone && response && (
<>
<Tooltip
title={activeKeys.includes(id) ? t('common.collapse') : t('common.expand')}
mouseEnterDelay={0.5}>
<ActionButton
onClick={(e) => {
e.stopPropagation()
// Toggle the active key for this item
setActiveKeys((prev) => (prev.includes(id) ? prev.filter((k) => k !== id) : [...prev, id]))
}}>
<ExpandAltOutlined />
{activeKeys.includes(id) ? t('common.collapse') : t('common.expand')}
</ActionButton>
</Tooltip>
<Tooltip title={t('common.rerun')} mouseEnterDelay={0.5}>
<ActionButton
onClick={(e) => {
e.stopPropagation()
const paramsToRun = editingToolId === id ? editedParams : JSON.stringify(args || {}, null, 2)
handleRerun(toolResponse, paramsToRun)
}}>
<ReloadOutlined />
{t('common.rerun')}
</ActionButton>
</Tooltip>
<Tooltip title={t('common.edit')} mouseEnterDelay={0.5}>
<ActionButton
onClick={(e) => {
e.stopPropagation()
handleEdit(toolResponse)
if (!activeKeys.includes(id)) setActiveKeys((prev) => [...prev, id])
}}>
<EditOutlined />
{t('common.edit')}
</ActionButton>
</Tooltip>
<Tooltip title={t('common.copy')} mouseEnterDelay={0.5}>
<ActionButton
onClick={(e) => {
e.stopPropagation()
const combinedData = { params: params, response: toolResult }
copyContent(JSON.stringify(combinedData, null, 2), id)
}}>
{copiedMap[id] ? <CheckOutlined /> : <CopyOutlined />}
{copiedMap[id] ? t('common.copied') : t('common.copy')}
</ActionButton>
</Tooltip>
</>
)}
</ActionButtonsContainer>
</MessageTitleLabel>
),
children: isDone ? (
<ToolResponseContent
params={params} // Use derived params
response={toolResult}
fontFamily={fontFamily}
fontSize="12px"
isEditing={editingToolId === id}
editedParamsString={editedParams}
onParamsChange={handleParamsChange}
onSave={() => handleSaveEdit(toolResponse)}
onCancel={handleCancelEdit}
/>
) : null
}
})
}, [
toolResponses,
t,
copiedMap,
copyContent,
editingToolId,
editedParams,
handleEdit,
handleRerun,
handleSaveEdit,
handleCancelEdit,
handleParamsChange,
activeKeys,
fontFamily // Added fontFamily
])
if (toolResponses.length === 0) return null
return (
<>
<ToolsContainer className="message-tools-container">
{collapseItems.map((item) => (
<CustomCollapse
key={item.key}
id={item.key as string}
title={item.label}
isActive={activeKeys.includes(item.key as string)}
onToggle={() => {
setActiveKeys((prev) =>
prev.includes(item.key as string) ? prev.filter((k) => k !== item.key) : [...prev, item.key as string]
)
}}>
{item.children}
</CustomCollapse>
))}
</ToolsContainer>
{/* Removed Modal component */}
</>
)
// MessageTools component no longer renders the tool blocks directly.
// It only needs to keep the listener for persistence.
// It returns null as it doesn't render any visible elements itself anymore.
return null
}
// --- Styled Components --- (Keep existing styled components definitions)
const MessageTitleLabel = styled.div`
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: 26px;
gap: 10px;
padding: 0;
`
const TitleContent = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
`
const ToolName = styled.span`
color: var(--color-text);
font-weight: 500;
font-size: 13px;
`
const StatusIndicator = styled.span<{ $isInvoking: boolean; $hasError?: boolean }>`
color: ${(props) => {
if (props.$hasError) return 'var(--color-error, #ff4d4f)'
if (props.$isInvoking) return 'var(--color-primary)'
return 'var(--color-success, #52c41a)'
}};
font-size: 11px;
display: flex;
align-items: center;
opacity: 0.85;
border-left: 1px solid var(--color-border);
padding-left: 8px;
`
const ActionButtonsContainer = styled.div`
display: flex;
gap: 8px;
margin-left: auto;
`
const ToolsContainer = styled.div`
margin-bottom: 15px;
border-radius: 8px;
overflow: hidden;
background-color: var(--color-bg-1);
border: 1px solid var(--color-border);
`
const ActionButton = styled.button`
background: none;
border: 1px solid var(--color-border); /* Add border */
color: var(--color-text); /* Use primary text color for better contrast */
cursor: pointer;
padding: 1px 5px; /* Adjust padding for border */
font-size: 12px; /* Smaller font size */
font-weight: 500; /* Increase font weight */
display: inline-flex; /* Use inline-flex for icon + text */
align-items: center;
gap: 4px; /* Add gap between icon and text */
justify-content: center;
user-select: none; /* Prevent text selection */
opacity: 0.8; /* Slightly increase opacity */
transition: all 0.2s;
border-radius: 4px;
&:hover {
opacity: 1;
color: var(--color-text);
background-color: var(--color-bg-1); /* Use a subtle background on hover */
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
opacity: 1;
}
.iconfont {
font-size: 14px;
}
`
// --- End Styled Components ---
// --- Styled Components --- (Removed unused styled components)
export default memo(MessageTools)

View File

@ -0,0 +1,251 @@
import {
CheckOutlined,
CopyOutlined,
EditOutlined,
ExpandAltOutlined,
LoadingOutlined,
ReloadOutlined,
WarningOutlined
} from '@ant-design/icons'
import { MCPToolResponse } from '@renderer/types'
import { Tooltip } from 'antd'
import { FC, memo } from 'react'
import styled from 'styled-components'
import CustomCollapse from './CustomCollapse'
import ToolResponseContent from './ToolResponseContent'
interface Props {
toolResponse: MCPToolResponse
isActive: boolean
isCopied: boolean
isEditing: boolean
editedParamsString: string
fontFamily: string
t: any // Use any type for t to avoid TFunction error
onToggle: () => void
onCopy: (content: string, toolId: string) => void
onRerun: (toolCall: MCPToolResponse, currentParamsString: string) => void
onEdit: (toolCall: MCPToolResponse) => void
onSave: () => void // Changed from (toolCall: MCPToolResponse) => void to match ToolResponseContent
onCancel: () => void
onParamsChange: (newParams: string) => void
}
const SingleToolCallBlock: FC<Props> = ({
toolResponse,
isActive,
isCopied,
isEditing,
editedParamsString,
fontFamily,
t,
onToggle,
onCopy,
onRerun,
onEdit,
onSave,
onCancel,
onParamsChange
}) => {
console.log('[SingleToolCallBlock] Rendering for ID:', toolResponse.id, 'isActive:', isActive) // Log isActive prop
const { id, tool, args, status, response } = toolResponse
const isInvoking = status === 'invoking'
const isDone = status === 'done'
const hasError = isDone && response?.isError === true
const params = args || {}
const toolResult = response
const handleCopyClick = () => {
const combinedData = { params: params, response: toolResult }
onCopy(JSON.stringify(combinedData, null, 2), id)
}
const handleRerunClick = () => {
const paramsToRun = isEditing ? editedParamsString : JSON.stringify(args || {}, null, 2)
onRerun(toolResponse, paramsToRun)
}
const handleEditClick = () => {
onEdit(toolResponse)
if (!isActive) onToggle() // Expand if not active
}
const handleSaveClick = () => {
onSave() // Changed from onSave(toolResponse) to onSave()
}
const handleCancelClick = () => {
onCancel()
}
const handleParamsChangeClick = (e: any) => {
// Removed React.ChangeEvent<HTMLTextAreaElement> type
onParamsChange(e.target.value)
}
return (
<CustomCollapse
key={id}
id={id}
title={
<MessageTitleLabel>
<TitleContent>
<ToolName>{tool.name}</ToolName>
<StatusIndicator $isInvoking={isInvoking} $hasError={hasError}>
{isInvoking
? t('message.tools.invoking')
: hasError
? t('message.tools.error')
: t('message.tools.completed')}
{isInvoking && <LoadingOutlined spin style={{ marginLeft: 6 }} />}
{isDone && !hasError && <CheckOutlined style={{ marginLeft: 6 }} />}
{hasError && <WarningOutlined style={{ marginLeft: 6 }} />}
</StatusIndicator>
</TitleContent>
<ActionButtonsContainer>
{isDone && response && (
<>
<Tooltip title={isActive ? t('common.collapse') : t('common.expand')} mouseEnterDelay={0.5}>
<ActionButton
onClick={(e) => {
e.stopPropagation()
console.log('[SingleToolCallBlock] Expand/Collapse button clicked for ID:', id) // Log button click
onToggle()
console.log('[SingleToolCallBlock] onToggle prop called for ID:', id) // Log onToggle call
}}>
<ExpandAltOutlined />
{isActive ? t('common.collapse') : t('common.expand')}
</ActionButton>
</Tooltip>
<Tooltip title={t('common.rerun')} mouseEnterDelay={0.5}>
<ActionButton
onClick={(e) => {
e.stopPropagation()
console.log('[SingleToolCallBlock] Rerun button clicked for ID:', id) // Log button click
handleRerunClick()
}}>
<ReloadOutlined />
{t('common.rerun')}
</ActionButton>
</Tooltip>
<Tooltip title={t('common.edit')} mouseEnterDelay={0.5}>
<ActionButton
onClick={(e) => {
e.stopPropagation()
console.log('[SingleToolCallBlock] Edit button clicked for ID:', id) // Log button click
handleEditClick()
}}>
<EditOutlined />
{t('common.edit')}
</ActionButton>
</Tooltip>
<Tooltip title={t('common.copy')} mouseEnterDelay={0.5}>
<ActionButton
onClick={(e) => {
e.stopPropagation()
console.log('[SingleToolCallBlock] Copy button clicked for ID:', id) // Log button click
handleCopyClick()
}}>
{isCopied ? <CheckOutlined /> : <CopyOutlined />}
{isCopied ? t('common.copied') : t('common.copy')}
</ActionButton>
</Tooltip>
</>
)}
</ActionButtonsContainer>
</MessageTitleLabel>
}
isActive={isActive}
onToggle={onToggle}>
{isDone ? (
<ToolResponseContent
params={params}
response={toolResult}
fontFamily={fontFamily}
fontSize="12px"
isEditing={isEditing}
editedParamsString={editedParamsString}
onParamsChange={handleParamsChangeClick}
onSave={handleSaveClick}
onCancel={handleCancelClick}
/>
) : null}
</CustomCollapse>
)
}
// --- Styled Components ---
const MessageTitleLabel = styled.div`
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: 26px;
gap: 10px;
padding: 0;
`
const TitleContent = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
`
const ToolName = styled.span`
font-weight: 600;
color: var(--color-primary);
`
const StatusIndicator = styled.span<{ $isInvoking?: boolean; $hasError?: boolean }>`
font-size: 12px;
color: ${(props) =>
props.$isInvoking ? 'var(--color-warning)' : props.$hasError ? 'var(--color-error)' : 'var(--color-success)'};
display: flex;
align-items: center;
`
const ActionButtonsContainer = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
`
const ActionButton = styled.button`
background: none;
border: 1px solid var(--color-border);
color: var(--color-text);
cursor: pointer;
padding: 1px 5px;
font-size: 12px;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 4px;
justify-content: center;
user-select: none;
opacity: 0.8;
transition: all 0.2s;
border-radius: 4px;
&:hover {
opacity: 1;
color: var(--color-text);
background-color: var(--color-bg-1);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
opacity: 1;
}
.iconfont {
font-size: 14px;
}
`
export default memo(SingleToolCallBlock)

View File

@ -1,15 +1,61 @@
import { Button, Input } from 'antd' // Import Button and Input
import { FC, memo, useEffect, useLayoutEffect, useRef, useState } from 'react'
import React, { FC, memo, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { atomOneDark } from 'react-syntax-highlighter/dist/esm/styles/hljs' // Choose a style
import styled, { createGlobalStyle } from 'styled-components'
// 添加全局样式,在合适位置换行
const GlobalStyle = createGlobalStyle`
/* 代码块容器样式 */
.tool-response-syntax-highlighter {
width: 100% !important;
max-width: 100% !important;
}
/* 代码块内容样式 */
.tool-response-syntax-highlighter pre {
white-space: pre-wrap !important; /* 允许在空白处换行 */
word-break: normal !important; /* 使用normal而不是break-all避免过度换行 */
overflow-wrap: anywhere !important; /* 在必要时才换行 */
width: 100% !important;
margin: 0 !important;
padding: 0 !important;
}
/* 代码和span元素样式 */
.tool-response-syntax-highlighter code,
.tool-response-syntax-highlighter code span {
white-space: inherit !important; /* 继承父元素的white-space */
word-break: inherit !important; /* 继承父元素的word-break */
overflow-wrap: inherit !important; /* 继承父元素的overflow-wrap */
}
/* 只对超长字符串应用break-all */
.tool-response-syntax-highlighter .token.string {
word-break: break-word !important; /* 使用break-word而不是break-all */
overflow-wrap: break-word !important;
}
/* 确保JSON结构保持完整 */
.tool-response-syntax-highlighter .token.punctuation,
.tool-response-syntax-highlighter .token.operator {
white-space: pre !important; /* 保持标点符号不换行 */
}
`
// --- Styled Components Definitions ---
// Add FlexContainer style and modify Section style
const FlexContainer = styled.div`
display: flex;
flex-direction: row; // 保持左右布局
gap: 16px;
align-items: stretch; /* Ensure items stretch to fill height */
@media (max-width: 768px) {
flex-direction: column; // 在小屏幕上改为纵向布局
}
`
// Add Divider style
@ -23,21 +69,50 @@ const Section = styled.div<{ flexBasis?: string }>`
flex: 1; /* Allow sections to grow/shrink */
flex-basis: ${(props) => props.flexBasis || 'auto'}; /* Set flex-basis if provided */
min-width: 0; /* Prevent overflow issues with flex items */
max-width: ${(props) => props.flexBasis || 'auto'}; /* 限制最大宽度,防止内容溢出 */
border: 1px solid var(--color-border); /* 减小边框粗细 */
border-radius: 6px; /* 增加圆角 */
padding: 12px; /* 增加内边距 */
background-color: var(--color-bg-2); /* 添加轻微的背景色区分 */
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); /* 添加轻微阴影 */
transition: all 0.2s ease; /* 添加过渡效果 */
overflow: hidden; /* 确保内容不会溢出 */
/* 确保Section内的内容适当换行 */
* {
max-width: 100%;
overflow-wrap: normal;
word-wrap: normal;
}
&:hover {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); /* 悬停时增强阴影 */
}
`
const SectionLabel = styled.div`
font-weight: 500;
color: var(--color-text-2);
margin-bottom: 4px;
font-size: 11px; /* Slightly smaller label */
font-weight: 600;
color: var(--color-primary); /* 使用主题色 */
margin-bottom: 8px;
font-size: 13px; /* 增大标签字体 */
display: flex;
align-items: center;
&::before {
content: '';
display: inline-block;
width: 4px;
height: 14px;
background-color: var(--color-primary);
margin-right: 6px;
border-radius: 2px;
}
`
const ToolResponseContainer = styled.div`
background: var(--color-bg-1);
border-radius: 0 0 4px 4px;
padding: 12px 16px;
/* overflow: auto; Remove overflow here, let sections handle scrolling if needed */
/* max-height: 300px; Remove fixed max-height for the container */
border-radius: 8px; /* 增加圆角 */
padding: 16px; /* 增加内边距 */
border-top: none;
position: relative;
will-change: transform; /* 优化渲染性能 */
@ -48,32 +123,37 @@ const ToolResponseContainer = styled.div`
-webkit-perspective: 1000;
contain: content; /* 限制重绘范围 */
background-color: var(--color-bg-1); /* 确保背景色 */
max-width: 100%; /* 确保不超出父容器 */
`
const CodeBlock = styled.pre`
margin: 0;
white-space: pre-wrap;
word-break: break-word;
color: var(--color-text);
font-family: ubuntu;
contain: content;
max-height: 280px; /* Limit height of code block */
overflow-y: auto; /* Add scrollbar if content exceeds max height */
/* transform: translateZ(0); 移除硬件加速,可能导致编辑时闪烁 */
backface-visibility: hidden; /* 使用 GPU 加速 */
-webkit-backface-visibility: hidden;
`
// Removed CodeBlock styled component as we will use SyntaxHighlighter
const LoadingPlaceholder = styled.div`
display: flex;
justify-content: center;
align-items: center;
height: 50px;
height: 80px;
color: var(--color-text-2);
font-size: 14px;
transform: translateZ(0); /* 启用硬件加速 */
backface-visibility: hidden; /* 使用 GPU 加速 */
-webkit-backface-visibility: hidden;
background-color: var(--color-bg-2);
border-radius: 6px;
border: 1px dashed var(--color-border);
animation: pulse 1.5s infinite ease-in-out;
@keyframes pulse {
0% {
opacity: 0.6;
}
50% {
opacity: 1;
}
100% {
opacity: 0.6;
}
}
`
// --- Component Definition ---
@ -115,20 +195,63 @@ const ToolResponseContent: FC<ToolResponseContentProps> = ({
useLayoutEffect(() => {
const timer = setTimeout(() => {
try {
// Stringify params, handle potential empty objects/null/undefined
paramsStringRef.current =
params && Object.keys(params).length > 0 ? JSON.stringify(params, null, 2) : t('message.tools.no_params') // Display message if no params
// 处理参数
if (params && Object.keys(params).length > 0) {
paramsStringRef.current = JSON.stringify(params, null, 2)
} else {
paramsStringRef.current = t('message.tools.no_params')
}
} catch (error) {
console.error('Error stringifying params:', error)
paramsStringRef.current = String(params)
}
// --- 核心修改部分:处理 responseStringRef ---
let processedResponseString = String(response) // 默认回退到显示原始响应的字符串表示
try {
// Stringify response
responseStringRef.current = JSON.stringify(response, null, 2)
if (response && typeof response === 'object') {
// 尝试判断响应是否是 { content: [{ type: 'text', text: '...' }] } 的特定结构
if (
Array.isArray(response.content) &&
response.content.length > 0 &&
response.content[0].type === 'text' &&
typeof response.content[0].text === 'string'
) {
const rawInnerText = response.content[0].text
try {
// ***尝试将提取到的内部字符串解析为 JSON 对象***
const parsedInnerText = JSON.parse(rawInnerText)
// ***如果解析成功,将解析后的对象重新格式化为带缩进的 JSON 字符串用于显示***
processedResponseString = JSON.stringify(parsedInnerText, null, 2)
console.log('Successfully parsed inner JSON:', processedResponseString) // 调试日志
} catch (innerParseError) {
// 如果内部字符串不是有效的 JSON 格式(或者解析失败)
console.warn('Inner text is not valid JSON, displaying raw string literal:', rawInnerText) // 调试日志
// 回退到显示原始内部字符串
processedResponseString = rawInnerText
}
} else {
// 如果响应结构不是预期的类型,则将整个响应对象格式化为 JSON 字符串显示
processedResponseString = JSON.stringify(response, null, 2)
}
} else {
// 如果响应不是对象类型,直接转换为字符串
processedResponseString = String(response)
}
} catch (error) {
console.error('Error stringifying response:', error)
responseStringRef.current = String(response)
console.error('Error processing response data structure:', error) // 调试日志
// 在任何处理过程中出现错误,都尝试回退到将原始响应格式化为 JSON 字符串
try {
processedResponseString = JSON.stringify(response, null, 2)
} catch (finalStringifyError) {
// 如果连 JSON.stringify 都失败,回退到最原始的字符串表示
processedResponseString = String(response)
}
}
// 将最终处理好的字符串赋值给 ref
responseStringRef.current = processedResponseString
setIsContentReady(true)
}, 0)
@ -157,17 +280,18 @@ const ToolResponseContent: FC<ToolResponseContentProps> = ({
// Render separate sections for params and response in a flex container
return (
<ToolResponseContainer ref={containerRef} style={{ fontFamily, fontSize }}>
<GlobalStyle /> {/* 添加全局样式 */}
{isVisible && isContentReady ? (
<FlexContainer>
<Section flexBasis="40%">
<SectionLabel>{t('message.tools.parameters')}:</SectionLabel>
<SectionLabel>{t('message.tools.parameters')}</SectionLabel>
{isEditing ? (
<EditContainer>
<StyledTextArea
autoSize={{ minRows: 3, maxRows: 10 }}
value={editedParamsString}
onChange={(e) => onParamsChange(e.target.value)}
style={{ fontFamily: 'ubuntu', fontSize: '12px' }} // Ensure consistent font
style={{ fontFamily: 'ubuntu', fontSize: '13px' }} // 增大字体
/>
<EditActions>
<Button size="small" onClick={onCancel}>
@ -179,15 +303,75 @@ const ToolResponseContent: FC<ToolResponseContentProps> = ({
</EditActions>
</EditContainer>
) : (
<CodeBlock>{paramsStringRef.current}</CodeBlock>
<SyntaxHighlighter
language="json"
className="tool-response-syntax-highlighter"
style={atomOneDark} // Apply the chosen style
customStyle={{
margin: 0, // Remove margin
whiteSpace: 'pre-wrap', // 允许在空白处换行
wordBreak: 'normal', // 使用normal而不是break-all避免过度换行
maxHeight: '280px',
overflowY: 'auto', // Add scrollbar
overflowX: 'hidden', // 隐藏水平滚动条
backgroundColor: 'transparent', // Use parent background
borderRadius: '4px', // 添加圆角
width: '100%', // 确保宽度100%
maxWidth: '100%' // 限制最大宽度
}}
codeTagProps={{
style: {
fontFamily: fontFamily, // Use the provided font family
fontSize: '14px', // Increase font size for better readability
color: 'var(--color-text)', // Ensure text color is readable
whiteSpace: 'pre-wrap', // 允许在空白处换行
wordWrap: 'break-word', // 确保长单词换行
wordBreak: 'normal' // 使用normal而不是break-all避免过度换行
}
}}
wrapLines={true} // 启用行包装
wrapLongLines={true} // 包装长行
lineProps={{ style: { whiteSpace: 'pre-wrap', wordBreak: 'break-all' } }} // 为每一行添加样式
>
{paramsStringRef.current}
</SyntaxHighlighter>
)}
</Section>
<Divider />
<Section flexBasis="60%">
{' '}
{/* Adjust flex-basis to 60% */}
<SectionLabel>{t('message.tools.results')}:</SectionLabel>
<CodeBlock>{responseStringRef.current}</CodeBlock>
<SectionLabel>{t('message.tools.results')}</SectionLabel>
<SyntaxHighlighter
language="json"
className="tool-response-syntax-highlighter"
style={atomOneDark} // Apply the chosen style
customStyle={{
margin: 0, // Remove margin
whiteSpace: 'pre-wrap', // 允许在空白处换行
wordBreak: 'normal', // 使用normal而不是break-all避免过度换行
maxHeight: '280px',
overflowY: 'auto', // Add scrollbar
overflowX: 'hidden', // 隐藏水平滚动条
backgroundColor: 'transparent', // Use parent background
borderRadius: '4px', // 添加圆角
width: '100%', // 确保宽度100%
maxWidth: '100%' // 限制最大宽度
}}
codeTagProps={{
style: {
fontFamily: fontFamily, // Use the provided font family
fontSize: '14px', // Increase font size for better readability
color: 'var(--color-text)', // Ensure text color is readable
whiteSpace: 'pre-wrap', // 允许在空白处换行
wordWrap: 'break-word', // 确保长单词换行
wordBreak: 'normal' // 使用normal而不是break-all避免过度换行
}
}}
wrapLines={true} // 启用行包装
wrapLongLines={true} // 包装长行
lineProps={{ style: { whiteSpace: 'pre-wrap', wordBreak: 'break-all' } }} // 为每一行添加样式
>
{responseStringRef.current}
</SyntaxHighlighter>
</Section>
</FlexContainer>
) : (
@ -201,32 +385,52 @@ const ToolResponseContent: FC<ToolResponseContentProps> = ({
const EditContainer = styled.div`
display: flex;
flex-direction: column;
/* height: 100%; Remove fixed height, let content determine height */
background-color: var(--color-bg-1);
border-radius: 6px;
padding: 8px;
box-shadow: inset 0 0 4px rgba(0, 0, 0, 0.05);
`
const StyledTextArea = styled(Input.TextArea)`
flex-grow: 1; /* Allow textarea to fill available space */
resize: vertical; /* Allow vertical resize */
margin-bottom: 8px;
margin-bottom: 12px;
font-family: 'Ubuntu Mono', monospace !important; /* Ensure monospace font */
font-size: 12px !important;
line-height: 1.5;
font-size: 13px !important;
line-height: 1.6;
background-color: var(--color-bg-input); /* Use input background */
border: 1px solid var(--color-border);
color: var(--color-text);
border-radius: 4px;
border-radius: 6px;
padding: 10px 12px;
transition: all 0.2s ease;
&:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-border);
}
&:hover:not(:focus) {
border-color: var(--color-primary-light, #40a9ff);
}
`
const EditActions = styled.div`
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: auto; /* Push actions to the bottom */
gap: 10px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed var(--color-border);
button {
transition: all 0.2s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
}
`
// 使用 memo 包装组件,避免不必要的重渲染

View File

@ -42,7 +42,14 @@ import {
setShowMessageDivider,
setThoughtAutoCollapse
} from '@renderer/store/settings'
import { Assistant, AssistantSettings, CodeStyleVarious, ThemeMode, TranslateLanguageVarious } from '@renderer/types'
import {
Assistant,
AssistantSettings,
CodeStyleVarious,
MathEngine,
ThemeMode,
TranslateLanguageVarious
} from '@renderer/types'
import { modalConfirm } from '@renderer/utils'
import { Button, Col, InputNumber, Row, Segmented, Select, Slider, Switch, Tooltip } from 'antd'
import { CircleHelp, RotateCcw, Settings2 } from 'lucide-react'
@ -512,11 +519,12 @@ const SettingsTab: FC<Props> = (props) => {
<SettingRowTitleSmall>{t('settings.messages.math_engine')}</SettingRowTitleSmall>
<StyledSelect
value={mathEngine}
onChange={(value) => dispatch(setMathEngine(value as 'MathJax' | 'KaTeX'))}
onChange={(value) => dispatch(setMathEngine(value as MathEngine))}
style={{ width: 135 }}
size="small">
<Select.Option value="KaTeX">KaTeX</Select.Option>
<Select.Option value="MathJax">MathJax</Select.Option>
<Select.Option value="none">{t('settings.messages.math_engine.none')}</Select.Option>
</StyledSelect>
</SettingRow>
<SettingDivider />

View File

@ -1,4 +1,5 @@
import { TopView } from '@renderer/components/TopView'
import { DEFAULT_KNOWLEDGE_DOCUMENT_COUNT } from '@renderer/config/constant'
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
import { SUPPORTED_REANK_PROVIDERS } from '@renderer/config/providers'
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
@ -9,7 +10,7 @@ import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService'
import { getModelUniqId } from '@renderer/services/ModelService'
import { Model } from '@renderer/types'
import { getErrorMessage } from '@renderer/utils/error'
import { Form, Input, Modal, Select } from 'antd'
import { Form, Input, Modal, Select, Slider } from 'antd'
import { find, sortBy } from 'lodash'
import { nanoid } from 'nanoid'
import { useRef, useState } from 'react'
@ -23,6 +24,7 @@ interface FormData {
name: string
model: string
rerankModel: string | undefined
documentCount: number | undefined
}
interface Props extends ShowParams {
@ -113,6 +115,7 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
model: selectedModel,
rerankModel: selectedRerankModel,
dimensions,
documentCount: values.documentCount || DEFAULT_KNOWLEDGE_DOCUMENT_COUNT,
items: [],
created_at: Date.now(),
updated_at: Date.now(),
@ -177,6 +180,19 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
provider: SUPPORTED_REANK_PROVIDERS.map((id) => t(`provider.${id}`))
})}
</SettingHelpText>
<Form.Item
name="documentCount"
label={t('knowledge.document_count')}
initialValue={DEFAULT_KNOWLEDGE_DOCUMENT_COUNT} // 设置初始值
tooltip={{ title: t('knowledge.document_count_help') }}>
<Slider
style={{ width: '100%' }}
min={1}
max={30}
step={1}
marks={{ 1: '1', 6: t('knowledge.document_count_default'), 30: '30' }}
/>
</Form.Item>
</Form>
</Modal>
)

View File

@ -2,7 +2,7 @@ import { useTheme } from '@renderer/context/ThemeProvider'
import { useSettings } from '@renderer/hooks/useSettings'
import i18n from '@renderer/i18n'
import { useAppDispatch } from '@renderer/store'
import { setEnableDataCollection, setLanguage } from '@renderer/store/settings'
import { setLanguage } from '@renderer/store/settings'
import { setProxyMode, setProxyUrl as _setProxyUrl } from '@renderer/store/settings'
import { LanguageVarious } from '@renderer/types'
import { isValidProxyUrl } from '@renderer/utils'
@ -25,7 +25,8 @@ const GeneralSettings: FC = () => {
trayOnClose,
tray,
proxyMode: storeProxyMode,
enableDataCollection
enableDataCollection,
setEnableDataCollection
} = useSettings()
const [proxyUrl, setProxyUrl] = useState<string | undefined>(storeProxyUrl)
const { theme: themeMode } = useTheme()
@ -185,7 +186,13 @@ const GeneralSettings: FC = () => {
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.privacy.enable_privacy_mode')}</SettingRowTitle>
<Switch value={enableDataCollection} onChange={(v) => dispatch(setEnableDataCollection(v))} />
<Switch
checked={enableDataCollection}
onChange={(v) => {
setEnableDataCollection(v)
window.api.sentry.init()
}}
/>
</SettingRow>
</SettingGroup>
</SettingContainer>

View File

@ -1,4 +1,4 @@
import { DeleteOutlined, SaveOutlined } from '@ant-design/icons'
import { DeleteOutlined, ReloadOutlined, SaveOutlined } from '@ant-design/icons'
import { useMCPServers } from '@renderer/hooks/useMCPServers'
import MCPDescription from '@renderer/pages/settings/MCPSettings/McpDescription'
import { MCPPrompt, MCPResource, MCPServer, MCPTool } from '@renderer/types'
@ -595,6 +595,14 @@ const McpSettings: React.FC<Props> = ({ server }) => {
loading={loadingServer === server.id}
onChange={onToggleActive}
/>
{server.isActive && ( // Only show refresh button if server is active
<Button
icon={<ReloadOutlined />} // Add ReloadOutlined icon
onClick={fetchTools} // Call fetchTools on click
loading={loadingServer === server.id} // Use loadingServer state
type="text"
/>
)}
<Button
type="primary"
icon={<SaveOutlined />}

View File

@ -26,21 +26,21 @@ const Container = styled.div`
`
const LeftColumn = styled.div`
width: 10%;
border-right: 1px solid var(--color-border);
overflow-y: auto;
background-color: var(--color-background-soft);
`
const MiddleColumn = styled.div`
width: 20%;
border-right: 1px solid var(--color-border);
overflow-y: auto;
background-color: var(--color-background-soft);
`
const RightColumn = styled.div`
const MiddleColumn = styled.div`
width: 70%;
border-right: 1px solid var(--color-border);
overflow-y: auto;
background-color: var(--color-background-soft);
`
const RightColumn = styled.div`
width: 10%;
overflow-y: auto;
padding: 20px;
background-color: var(--color-background-soft);

View File

@ -126,7 +126,7 @@ const MCPSettings: FC = () => {
return (
<Container>
<ThreeColumnLayout leftColumn={renderNavMenu()} middleColumn={renderServerList()} rightColumn={renderContent()} />
<ThreeColumnLayout leftColumn={renderServerList()} middleColumn={renderContent()} rightColumn={renderNavMenu()} />
</Container>
)
}

View File

@ -0,0 +1,92 @@
import AiHubMixProviderLogo from '@renderer/assets/images/providers/aihubmix.webp'
import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png'
import { HStack } from '@renderer/components/Layout'
import OAuthButton from '@renderer/components/OAuth/OAuthButton'
import { PROVIDER_CONFIG } from '@renderer/config/providers'
import { Provider } from '@renderer/types'
import { providerBills, providerCharge } from '@renderer/utils/oauth'
import { Button } from 'antd'
import { isEmpty } from 'lodash'
import { ReceiptText } from 'lucide-react'
import { CircleDollarSign } from 'lucide-react'
import { FC } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface Props {
provider: Provider
setApiKey: (apiKey: string) => void
}
const PROVIDER_LOGO_MAP = {
silicon: SiliconFlowProviderLogo,
aihubmix: AiHubMixProviderLogo
}
const ProviderOAuth: FC<Props> = ({ provider, setApiKey }) => {
const { t } = useTranslation()
const providerWebsite =
PROVIDER_CONFIG[provider.id]?.api?.url.replace('https://', '').replace('api.', '') || provider.name
return (
<Container>
<ProviderLogo src={PROVIDER_LOGO_MAP[provider.id]} />
{isEmpty(provider.apiKey) ? (
<OAuthButton provider={provider} onSuccess={setApiKey}>
{t('settings.provider.oauth.button', { provider: t(`provider.${provider.id}`) })}
</OAuthButton>
) : (
<HStack gap={10}>
<Button shape="round" icon={<CircleDollarSign size={16} />} onClick={() => providerCharge(provider.id)}>
{t('settings.provider.charge')}
</Button>
<Button shape="round" icon={<ReceiptText size={16} />} onClick={() => providerBills(provider.id)}>
{t('settings.provider.bills')}
</Button>
</HStack>
)}
<Description>
<Trans
i18nKey="settings.provider.oauth.description"
components={{
website: (
<OfficialWebsite href={PROVIDER_CONFIG[provider.id].websites.official} target="_blank" rel="noreferrer" />
)
}}
values={{ provider: providerWebsite }}
/>
</Description>
</Container>
)
}
const Container = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 15px;
padding: 20px;
`
const ProviderLogo = styled.img`
width: 60px;
height: 60px;
border-radius: 50%;
`
const Description = styled.div`
font-size: 12px;
color: var(--color-text-2);
display: flex;
align-items: center;
gap: 5px;
`
const OfficialWebsite = styled.a`
text-decoration: none;
color: var(--color-text-2);
`
export default ProviderOAuth

View File

@ -1,7 +1,6 @@
import { CheckOutlined, CopyOutlined, LoadingOutlined } from '@ant-design/icons'
import { StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons/SVGIcon'
import { HStack } from '@renderer/components/Layout'
import OAuthButton from '@renderer/components/OAuth/OAuthButton'
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
import { PROVIDER_CONFIG } from '@renderer/config/providers'
import { useTheme } from '@renderer/context/ThemeProvider'
@ -10,10 +9,9 @@ import i18n from '@renderer/i18n'
import { isOpenAIProvider } from '@renderer/providers/AiProvider/ProviderFactory'
import { checkApi, formatApiKeys } from '@renderer/services/ApiService'
import { checkModelsHealth, ModelCheckStatus } from '@renderer/services/HealthCheckService'
import { isProviderSupportAuth, isProviderSupportCharge } from '@renderer/services/ProviderService'
import { isProviderSupportAuth } from '@renderer/services/ProviderService'
import { Provider } from '@renderer/types'
import { formatApiHost, maskApiKey } from '@renderer/utils/api'
import { providerCharge } from '@renderer/utils/oauth'
import { Button, Divider, Flex, Input, Space, Switch, Tooltip, Typography } from 'antd'
import Link from 'antd/es/typography/Link'
import { debounce, isEmpty } from 'lodash'
@ -39,6 +37,7 @@ import LMStudioSettings from './LMStudioSettings'
import ModelList, { ModelStatus } from './ModelList'
import ModelListSearchBar from './ModelListSearchBar'
import OllamSettings from './OllamaSettings'
import ProviderOAuth from './ProviderOAuth'
import ProviderSettingsPopup from './ProviderSettingsPopup'
import SelectProviderModelPopup from './SelectProviderModelPopup'
@ -320,7 +319,6 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
autoFocus={provider.enabled && apiKey === ''}
disabled={provider.id === 'copilot'}
/>
{isProviderSupportAuth(provider) && <OAuthButton provider={provider} onSuccess={setApiKey} />}
<Button
type={apiValid ? 'primary' : 'default'}
ghost={apiValid}
@ -329,17 +327,13 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
{apiChecking ? <LoadingOutlined spin /> : apiValid ? <CheckOutlined /> : t('settings.provider.check')}
</Button>
</Space.Compact>
{apiKeyWebsite && (
{isProviderSupportAuth(provider) && <ProviderOAuth provider={provider} setApiKey={setApiKey} />}
{apiKeyWebsite && !isProviderSupportAuth(provider) && (
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
<HStack>
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
{t('settings.provider.get_api_key')}
</SettingHelpLink>
{isProviderSupportCharge(provider) && (
<SettingHelpLink onClick={() => providerCharge(provider.id)}>
{t('settings.provider.charge')}
</SettingHelpLink>
)}
</HStack>
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
</SettingHelpTextRow>

View File

@ -537,6 +537,10 @@ export default class AnthropicProvider extends BaseProvider {
return []
}
public async generateImageByChat(): Promise<void> {
throw new Error('Method not implemented.')
}
/**
* Generate suggestions
* @returns The suggestions

View File

@ -49,6 +49,7 @@ export default abstract class BaseProvider {
abstract check(model: Model): Promise<{ valid: boolean; error: Error | null }>
abstract models(): Promise<OpenAI.Models.Model[]>
abstract generateImage(params: GenerateImageParams): Promise<string[]>
abstract generateImageByChat({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams): Promise<void>
abstract getEmbeddingDimensions(model: Model): Promise<number>
public getBaseURL(): string {

View File

@ -530,6 +530,14 @@ ${state.extractedThinking}`
return this.targetProvider.generateImage(params)
}
/**
*
*/
public async generateImageByChat(params: CompletionsParams): Promise<void> {
// 使用目标模型通过聊天生成图像
return this.targetProvider.generateImageByChat(params)
}
/**
*
*/

View File

@ -1416,4 +1416,8 @@ export default class GeminiProvider extends BaseProvider {
return truncated
}
public generateImageByChat(): Promise<void> {
throw new Error('Method not implemented.')
}
}

View File

@ -308,6 +308,10 @@ export default class OpenAIProvider extends BaseProvider {
* @returns The completions
*/
async completions({ messages, assistant, mcpTools, onChunk, onFilterMessages }: CompletionsParams): Promise<void> {
if (assistant.enableGenerateImage) {
await this.generateImageByChat({ messages, assistant, onChunk } as CompletionsParams)
return
}
const defaultModel = getDefaultModel()
const model = assistant.model || defaultModel
const { contextCount, maxTokens, streamOutput } = getAssistantSettings(assistant)
@ -1160,4 +1164,33 @@ export default class OpenAIProvider extends BaseProvider {
const { token } = await window.api.copilot.getToken(defaultHeaders)
this.sdk.apiKey = token
}
public async generateImageByChat({ messages, assistant, onChunk }: CompletionsParams): Promise<void> {
const defaultModel = getDefaultModel()
const model = assistant.model || defaultModel
const lastUserMessage = messages.findLast((m) => m.role === 'user')
const { abortController, signalPromise } = this.createAbortController(lastUserMessage?.id, true)
const { signal } = abortController
const response = await this.sdk.images.generate(
{
model: model.id,
prompt: lastUserMessage?.content || ''
},
{
signal
}
)
await signalPromise?.promise?.catch((error) => {
throw error
})
return onChunk({
text: '',
generateImage: {
type: 'url',
images: response.data.map((item) => item.url).filter((url): url is string => url !== undefined)
}
})
}
}

View File

@ -116,6 +116,15 @@ export default class AiProvider {
return this.sdk.generateImage(params)
}
public async generateImageByChat({
messages,
assistant,
onChunk,
onFilterMessages
}: CompletionsParams): Promise<void> {
return this.sdk.generateImageByChat({ messages, assistant, onChunk, onFilterMessages })
}
public async getEmbeddingDimensions(model: Model): Promise<number> {
return this.sdk.getEmbeddingDimensions(model)
}

View File

@ -1,6 +1,7 @@
import { SearxngClient } from '@agentic/searxng'
import { WebSearchState } from '@renderer/store/websearch'
import { WebSearchProvider, WebSearchResponse } from '@renderer/types'
import { WebSearchProvider, WebSearchResponse, WebSearchResult } from '@renderer/types'
import { fetchWebContent, noContent } from '@renderer/utils/fetch'
import axios from 'axios'
import BaseWebSearchProvider from './BaseWebSearchProvider'
@ -89,15 +90,27 @@ export default class SearxngProvider extends BaseWebSearchProvider {
if (!result || !Array.isArray(result.results)) {
throw new Error('Invalid search results from SearxNG')
}
const validItems = result.results
.filter((item) => item.url.startsWith('http') || item.url.startsWith('https'))
.slice(0, websearch.maxResults)
// console.log('Valid search items:', validItems)
// Fetch content for each URL concurrently
const fetchPromises = validItems.map(async (item) => {
// console.log(`Fetching content for ${item.url}...`)
const result = await fetchWebContent(item.url, 'markdown', this.provider.usingBrowser)
if (websearch.contentLimit && result.content.length > websearch.contentLimit) {
result.content = result.content.slice(0, websearch.contentLimit) + '...'
}
return result
})
// Wait for all fetches to complete
const results: WebSearchResult[] = await Promise.all(fetchPromises)
return {
query: result.query,
results: result.results.slice(0, websearch.maxResults).map((result) => {
return {
title: result.title || 'No title',
content: result.content || '',
url: result.url || ''
}
})
query: query,
results: results.filter((result) => result.content != noContent)
}
} catch (error) {
console.error('Searxng search failed:', error)

View File

@ -4,7 +4,7 @@ import { getEmbeddingMaxContext } from '@renderer/config/embedings'
import AiProvider from '@renderer/providers/AiProvider'
import store from '@renderer/store'
import { FileType, KnowledgeBase, KnowledgeBaseParams, KnowledgeReference, Message } from '@renderer/types'
import { isEmpty, take } from 'lodash'
import { isEmpty } from 'lodash'
import { getProviderByModel } from './AssistantService'
import FileManager from './FileManager'
@ -117,8 +117,11 @@ export const getKnowledgeBaseReference = async (base: KnowledgeBase, message: Me
const documentCount = base.documentCount || DEFAULT_KNOWLEDGE_DOCUMENT_COUNT
// 确保在处理后再截取结果,这样可以保证返回最相关的结果
const limitedResults = processdResults.length > 0 ? processdResults.slice(0, documentCount) : processdResults
const references = await Promise.all(
take(processdResults, documentCount).map(async (item, index) => {
limitedResults.map(async (item, index) => {
const baseItem = base.items.find((i) => i.uniqueId === item.metadata.uniqueLoaderId)
return {
id: index + 1,

View File

@ -98,6 +98,13 @@ export const builtinMCPServers: MCPServer[] = [
description: '实现文件系统操作的模型上下文协议MCP的 Node.js 服务器',
isActive: false
},
{
id: nanoid(),
name: '@cherry/dify-knowledge',
type: 'inMemory',
description: 'Dify 的 MCP 服务器实现,提供了一个简单的 API 来与 Dify 进行交互',
isActive: false
},
{
id: nanoid(),
name: '@cherry/simpleremember',
@ -123,6 +130,13 @@ export const builtinMCPServers: MCPServer[] = [
type: 'inMemory',
description: '时间工具提供获取当前系统时间的功能允许AI随时知道当前日期和时间。',
isActive: true
},
{
id: nanoid(),
name: '@cherry/calculator',
type: 'inMemory',
description: '万能科学计算器,提供数学表达式计算、单位转换、统计计算等功能,支持复杂的科学计算和数据分析。',
isActive: true
}
]

View File

@ -1,6 +1,6 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { TRANSLATE_PROMPT } from '@renderer/config/prompts'
import { CodeStyleVarious, LanguageVarious, Model, ThemeMode, TranslateLanguageVarious } from '@renderer/types'
import { CodeStyleVarious, LanguageVarious, MathEngine, Model, ThemeMode, TranslateLanguageVarious } from '@renderer/types'
import { IpcChannel } from '@shared/IpcChannel'
import { WebDAVSyncState } from './backup'
@ -78,7 +78,7 @@ export interface SettingsState {
codeCacheMaxSize: number
codeCacheTTL: number
codeCacheThreshold: number
mathEngine: 'MathJax' | 'KaTeX'
mathEngine: MathEngine
messageStyle: 'plain' | 'bubble'
codeStyle: CodeStyleVarious
foldDisplayMode: 'expanded' | 'compact'
@ -515,7 +515,7 @@ const settingsSlice = createSlice({
setCodeCacheThreshold: (state, action: PayloadAction<number>) => {
state.codeCacheThreshold = action.payload
},
setMathEngine: (state, action: PayloadAction<'MathJax' | 'KaTeX'>) => {
setMathEngine: (state, action: PayloadAction<MathEngine>) => {
state.mathEngine = action.payload
},
setFoldDisplayMode: (state, action: PayloadAction<'expanded' | 'compact'>) => {

View File

@ -16,6 +16,8 @@ export interface WebSearchState {
searchWithTime: boolean
// 搜索结果的最大数量
maxResults: number
// 搜索结果内容的最大长度
contentLimit?: number
// 要排除的域名列表
excludeDomains: string[]
// 订阅源列表
@ -152,6 +154,7 @@ const initialState: WebSearchState = {
],
searchWithTime: true,
maxResults: 100,
contentLimit: 10000,
excludeDomains: [],
subscribeSources: [],
enhanceMode: true,

View File

@ -633,3 +633,5 @@ export type TTSProvider = {
voice?: string
model?: string
}
export type MathEngine = 'KaTeX' | 'MathJax' | 'none'

View File

@ -63,14 +63,16 @@ export const MARKDOWN_ALLOWED_TAGS = [
'sub',
'sup',
'think',
'translated' // 添加自定义翻译标签
'translated', // 添加自定义翻译标签
'tool-block' // 添加自定义工具块标签
]
// rehype-sanitize配置
export const sanitizeSchema = {
tagNames: MARKDOWN_ALLOWED_TAGS,
tagNames: [...MARKDOWN_ALLOWED_TAGS, 'tool-block'], // 确保 tool-block 在允许的标签中
attributes: {
'*': ['className', 'style', 'id', 'title', 'data-*'],
'tool-block': ['id'], // 允许 tool-block 标签使用 id 属性
svg: ['viewBox', 'width', 'height', 'xmlns', 'fill', 'stroke'],
path: ['d', 'fill', 'stroke', 'strokeWidth', 'strokeLinecap', 'strokeLinejoin'],
circle: ['cx', 'cy', 'r', 'fill', 'stroke'],

View File

@ -80,3 +80,26 @@ export const providerCharge = async (provider: string) => {
`width=${width},height=${height},toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,alwaysOnTop=yes,alwaysRaised=yes`
)
}
export const providerBills = async (provider: string) => {
const billsUrlMap = {
silicon: {
url: 'https://cloud.siliconflow.cn/expensebill',
width: 900,
height: 700
},
aihubmix: {
url: `https://aihubmix.com/bills?client_id=cherry_studio_oauth&lang=${getLanguageCode()}&aff=SJyh`,
width: 720,
height: 900
}
}
const { url, width, height } = billsUrlMap[provider]
window.open(
url,
'oauth',
`width=${width},height=${height},toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,alwaysOnTop=yes,alwaysRaised=yes`
)
}

View File

@ -106,6 +106,7 @@ export enum IpcChannel {
Mcp_RestartServer = 'mcp:restartServer',
Mcp_StopServer = 'mcp:stopServer',
Mcp_ListTools = 'mcp:listTools',
Mcp_ResetToolsList = 'mcp:resetToolsList',
Mcp_CallTool = 'mcp:callTool',
Mcp_ListPrompts = 'mcp:listPrompts',
Mcp_GetPrompt = 'mcp:getPrompt',

1136
yarn.lock

File diff suppressed because it is too large Load Diff