Compare commits

..

5 Commits

Author SHA1 Message Date
Neal_Tan
370cfd6e9f Merge pull request #4331 from CherryHQ/main
Merge main code
2025-04-02 22:06:47 +01:00
Neal_Tan
d213bc1024 Merge branch 'main' into feat/variable_replace_prompt 2025-04-01 18:57:46 +01:00
Neal_Tan
1187a47698 Merge pull request #4129 from TeacherTan/main
feat(Assistant): Variables replace prompts
2025-03-30 02:15:18 +01:00
Neal_Tan
83d0eb07aa fix(i18n): update locales json file
关联提交 8f6bf113
2025-03-30 02:10:50 +01:00
Neal_Tan
8f6bf11320 feat(Assistant): 增加提示词变量输入
- 在编辑助手处添加了变量
- 保存智能体时可以保存变量
Fixed #4049
2025-03-30 00:51:48 +00:00
29 changed files with 432 additions and 344 deletions

View File

@@ -83,8 +83,7 @@ afterPack: scripts/after-pack.js
afterSign: scripts/notarize.js
releaseInfo:
releaseNotes: |
新增助手级别的 MCP 能力支持
改进 MCP 包的安装与管理
导出单条消息时,增加生成消息标题的功能
快捷助手窗口新增钉住和调整大小的功能
小程序现在支持显示、复制当前实际 URL
小程序支持多开
支持 GPT-4o 图像生成
修复 MCP 服务器无法使用问题
修复升级导致旧版本数据丢失问题

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.1.18",
"version": "1.1.17",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",

View File

@@ -366,7 +366,6 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
open={open}
onCancel={onCancel}
afterClose={onClose}
width={600}
transitionName="ant-move-down"
styles={{
content: {

View File

@@ -0,0 +1,103 @@
import { DeleteOutlined, ImportOutlined } from '@ant-design/icons'
import { VStack } from '@renderer/components/Layout'
import { Variable } from '@renderer/types'
import { Button, Input, Tooltip } from 'antd'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface VariableListProps {
variables: Variable[]
setVariables: (variables: Variable[]) => void
onUpdate?: (variables: Variable[]) => void
onInsertVariable?: (name: string) => void
}
const VariableList: React.FC<VariableListProps> = ({ variables, setVariables, onUpdate, onInsertVariable }) => {
const { t } = useTranslation()
const deleteVariable = (id: string) => {
const updatedVariables = variables.filter((v) => v.id !== id)
setVariables(updatedVariables)
if (onUpdate) {
onUpdate(updatedVariables)
}
}
const updateVariable = (id: string, field: 'name' | 'value', value: string) => {
// Only update the local state when typing, don't call the parent's onUpdate
const updatedVariables = variables.map((v) => (v.id === id ? { ...v, [field]: value } : v))
setVariables(updatedVariables)
}
// This function will be called when input loses focus
const handleInputBlur = () => {
if (onUpdate) {
onUpdate(variables)
}
}
return (
<VariablesContainer>
{variables.length === 0 ? (
<EmptyText>{t('common.no_variables_added')}</EmptyText>
) : (
<VStack gap={8} width="100%">
{variables.map((variable) => (
<VariableItem key={variable.id}>
<Input
placeholder={t('common.variable_name')}
value={variable.name}
onChange={(e) => updateVariable(variable.id, 'name', e.target.value)}
onBlur={handleInputBlur}
style={{ width: '30%' }}
/>
<Input
placeholder={t('common.value')}
value={variable.value}
onChange={(e) => updateVariable(variable.id, 'value', e.target.value)}
onBlur={handleInputBlur}
style={{ flex: 1 }}
/>
{onInsertVariable && (
<Tooltip title={t('common.insert_variable_into_prompt')}>
<Button type="text" onClick={() => onInsertVariable(variable.name)}>
<ImportOutlined />
</Button>
</Tooltip>
)}
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => deleteVariable(variable.id)} />
</VariableItem>
))}
</VStack>
)}
</VariablesContainer>
)
}
const VariablesContainer = styled.div`
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
overflow-y: auto;
max-height: 200px;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 12px;
`
const VariableItem = styled.div`
display: flex;
align-items: center;
gap: 8px;
width: 100%;
`
const EmptyText = styled.div`
color: var(--color-text-2);
opacity: 0.6;
font-style: italic;
`
export default VariableList

View File

@@ -1,5 +1,6 @@
import ThreeMinTopAppLogo from '@renderer/assets/images/apps/3mintop.png?url'
import AbacusLogo from '@renderer/assets/images/apps/abacus.webp?url'
import AIStudioLogo from '@renderer/assets/images/apps/aistudio.svg?url'
import BaiduAiAppLogo from '@renderer/assets/images/apps/baidu-ai.png?url'
import BaiduAiSearchLogo from '@renderer/assets/images/apps/baidu-ai-search.webp?url'
import BaicuanAppLogo from '@renderer/assets/images/apps/baixiaoying.webp?url'
@@ -307,6 +308,12 @@ export const DEFAULT_MIN_APPS: MinAppType[] = [
url: 'https://3min.top',
bodered: false
},
{
id: 'aistudio',
name: 'AI Studio',
logo: AIStudioLogo,
url: 'https://aistudio.google.com/'
},
{
id: 'xiaoyi',
name: '小艺',

View File

@@ -286,7 +286,13 @@
"select": "Select",
"topics": "Topics",
"warning": "Warning",
"you": "You"
"you": "You",
"variable_name": "Variable Name",
"value": "Value",
"no_variables_added": "No variables added",
"insert_variable_into_prompt": "Insert variable into prompt",
"variables": "Variables",
"variables_help": "Add variables that need to be replaced in the text, triggered by {{variable_name}} in the replacement document"
},
"docs": {
"title": "Docs"

View File

@@ -286,7 +286,13 @@
"select": "選択",
"topics": "トピック",
"warning": "警告",
"you": "あなた"
"you": "あなた",
"variable_name": "変数名",
"value": "値",
"no_variables_added": "変数がありません",
"insert_variable_into_prompt": "プロンプトに変数を挿入",
"variables": "変数",
"variables_help": "テキスト内で置換が必要な変数を追加し、置換ドキュメント内で{{variable_name}}の形式でトリガーします"
},
"docs": {
"title": "ドキュメント"

View File

@@ -286,7 +286,13 @@
"select": "Выбрать",
"topics": "Топики",
"warning": "Предупреждение",
"you": "Вы"
"you": "Вы",
"variable_name": "Имя переменной",
"value": "Значение",
"no_variables_added": "Нет переменных",
"insert_variable_into_prompt": "Вставить переменную в промпт",
"variables": "Переменные",
"variables_help": "Добавьте переменные, которые нужно заменить в тексте, замена срабатывает в формате {{variable_name}} в документе замены"
},
"docs": {
"title": "Документация"

View File

@@ -286,7 +286,13 @@
"select": "选择",
"topics": "话题",
"warning": "警告",
"you": "用户"
"you": "用户",
"variable_name": "变量名称",
"value": "值",
"no_variables_added": "没有添加变量",
"insert_variable_into_prompt": "插入变量到提示词",
"variables": "变量",
"variables_help": "添加需要替换的变量名字和值即可"
},
"docs": {
"title": "帮助文档"

View File

@@ -286,7 +286,13 @@
"select": "選擇",
"topics": "話題",
"warning": "警告",
"you": "您"
"you": "您",
"variable_name": "變量名稱",
"value": "值",
"no_variables_added": "沒有添加變量",
"insert_variable_into_prompt": "插入變量到提示詞",
"variables": "變量",
"variables_help": "添加需要替換的變量名字和值即可"
},
"docs": {
"title": "說明文件"

View File

@@ -182,8 +182,10 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
}
if (isFunctionCallingModel(model)) {
if (!isEmpty(enabledMCPs) && !isEmpty(activedMcpServers)) {
userMessage.enabledMCPs = activedMcpServers.filter((server) => enabledMCPs?.some((s) => s.id === server.id))
if (!isEmpty(assistant.mcpServers) && !isEmpty(activedMcpServers)) {
userMessage.enabledMCPs = activedMcpServers.filter((server) =>
assistant.mcpServers?.some((s) => s.id === server.id)
)
}
}
@@ -206,6 +208,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
console.error('Failed to send message:', error)
}
}, [
activedMcpServers,
assistant,
dispatch,
files,
@@ -216,9 +219,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
resizeTextArea,
selectedKnowledgeBases,
text,
topic,
enabledMCPs,
activedMcpServers
topic
])
const translate = async () => {

View File

@@ -255,15 +255,19 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
if (now - lastMoveTime.current < 50) return
lastMoveTime.current = now
const triggerWidth = 10
let rightOffset = 5
// Calculate if the mouse is in the trigger area
const triggerWidth = 80 // Same as the width in styled component
// Safe way to calculate position when using calc expressions
let rightOffset = 16 // Default right offset
if (showRightTopics) {
rightOffset = 5 + 300
// When topics are shown on right, we need to account for topic list width
rightOffset = 16 + 300 // Assuming topic list width is 300px, adjust if different
}
const rightPosition = window.innerWidth - rightOffset - triggerWidth
const topPosition = window.innerHeight * 0.35
const height = window.innerHeight * 0.3
const topPosition = window.innerHeight * 0.3 // 30% from top
const height = window.innerHeight * 0.4 // 40% of window height
const isInTriggerArea =
e.clientX > rightPosition &&
@@ -403,32 +407,31 @@ const ButtonGroup = styled.div`
display: flex;
flex-direction: column;
background: var(--bg-color);
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
backdrop-filter: blur(12px);
backdrop-filter: blur(8px);
border: 1px solid var(--color-border);
`
const NavigationButton = styled(Button)`
width: 32px;
height: 32px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0;
border: none;
color: var(--color-text);
transition: all 0.25s ease-in-out;
transition: all 0.2s ease-in-out;
&:hover {
background-color: var(--color-hover);
color: var(--color-primary);
transform: scale(1.05);
}
.anticon {
font-size: 16px;
font-size: 14px;
}
`

View File

@@ -1,4 +1,4 @@
import { SearchOutlined } from '@ant-design/icons'
import { FormOutlined, SearchOutlined } from '@ant-design/icons'
import { Navbar, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar'
import { HStack } from '@renderer/components/Layout'
import MinAppsPopover from '@renderer/components/Popups/MinAppsPopover'
@@ -64,10 +64,10 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant }) => {
<i className="iconfont icon-hide-sidebar" />
</NavbarIcon>
</Tooltip>
<Tooltip title={t('chat.assistant.search.placeholder')} mouseEnterDelay={0.8}>
<NarrowIcon onClick={() => SearchPopup.show()}>
<SearchOutlined />
</NarrowIcon>
<Tooltip title={t('settings.shortcuts.new_topic')} mouseEnterDelay={0.8}>
<NavbarIcon onClick={() => EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)}>
<FormOutlined />
</NavbarIcon>
</Tooltip>
</NavbarLeft>
)}
@@ -86,6 +86,11 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant }) => {
</HStack>
<HStack alignItems="center" gap={8}>
<UpdateAppButton />
<Tooltip title={t('chat.assistant.search.placeholder')} mouseEnterDelay={0.8}>
<NarrowIcon onClick={() => SearchPopup.show()}>
<SearchOutlined />
</NarrowIcon>
</Tooltip>
<Tooltip title={t('navbar.expand')} mouseEnterDelay={0.8}>
<NarrowIcon onClick={handleNarrowModeToggle}>
<i className="iconfont icon-icon-adaptive-width"></i>

View File

@@ -84,6 +84,9 @@ const AssistantItem: FC<AssistantItemProps> = ({ assistant, isActive, onSwitch,
const agent = omit(assistant, ['model', 'emoji'])
agent.id = uuid()
agent.type = 'agent'
if (assistant.promptVariables) {
agent.promptVariables = [...assistant.promptVariables]
}
addAgent(agent)
window.message.success({
content: t('assistants.save.success'),

View File

@@ -204,13 +204,7 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
allowClear: true
}
})
prompt !== null &&
(() => {
const updatedTopic = { ...topic, prompt: prompt.trim() }
updateTopic(updatedTopic)
topic.id === activeTopic.id && setActiveTopic(updatedTopic)
})()
prompt !== null && updateTopic({ ...topic, prompt: prompt.trim() })
}
},
{
@@ -345,18 +339,7 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
return menus
},
[
t,
assistants,
assistant,
updateTopic,
activeTopic.id,
setActiveTopic,
onPinTopic,
onClearMessages,
onMoveTopic,
onDeleteTopic
]
[assistant, assistants, onClearMessages, onDeleteTopic, onPinTopic, onMoveTopic, t, updateTopic]
)
return (

View File

@@ -1,32 +1,35 @@
import 'emoji-picker-element'
import { CloseCircleFilled } from '@ant-design/icons'
import { CloseCircleFilled, PlusOutlined } from '@ant-design/icons'
import EmojiPicker from '@renderer/components/EmojiPicker'
import { Box, HSpaceBetweenStack, HStack } from '@renderer/components/Layout'
import { Box, HStack } from '@renderer/components/Layout'
import VariableList from '@renderer/components/VariableList'
import { estimateTextTokens } from '@renderer/services/TokenService'
import { Assistant, AssistantSettings } from '@renderer/types'
import { Assistant, AssistantSettings, Variable } from '@renderer/types'
import { getLeadingEmoji } from '@renderer/utils'
import { Button, Input, Popover } from 'antd'
import { Button, Input, Popover, Tooltip, Typography } from 'antd'
import TextArea from 'antd/es/input/TextArea'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ReactMarkdown from 'react-markdown'
import styled from 'styled-components'
import { v4 as uuidv4 } from 'uuid'
interface Props {
assistant: Assistant
updateAssistant: (assistant: Assistant) => void
updateAssistantSettings?: (settings: AssistantSettings) => void
onOk?: () => void
updateAssistantSettings: (settings: AssistantSettings) => void
onOk: () => void
}
const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant }) => {
const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant, onOk }) => {
const [emoji, setEmoji] = useState(getLeadingEmoji(assistant.name) || assistant.emoji)
const [name, setName] = useState(assistant.name.replace(getLeadingEmoji(assistant.name) || '', '').trim())
const [prompt, setPrompt] = useState(assistant.prompt)
const [tokenCount, setTokenCount] = useState(0)
const [variables, setVariables] = useState<Variable[]>(assistant.promptVariables || [])
const [variableName, setVariableName] = useState('')
const [variableValue, setVariableValue] = useState('')
const { t } = useTranslation()
const [showMarkdown, setShowMarkdown] = useState(prompt.length > 0)
useEffect(() => {
const updateTokenCount = async () => {
@@ -37,19 +40,77 @@ const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant }
}, [prompt])
const onUpdate = () => {
const _assistant = { ...assistant, name: name.trim(), emoji, prompt }
const _assistant = {
...assistant,
name: name.trim(),
emoji,
prompt,
promptVariables: variables
}
updateAssistant(_assistant)
}
const handleEmojiSelect = (selectedEmoji: string) => {
setEmoji(selectedEmoji)
const _assistant = { ...assistant, name: name.trim(), emoji: selectedEmoji, prompt }
const _assistant = {
...assistant,
name: name.trim(),
emoji: selectedEmoji,
prompt,
promptVariables: variables
}
updateAssistant(_assistant)
}
const handleEmojiDelete = () => {
setEmoji('')
const _assistant = { ...assistant, name: name.trim(), prompt, emoji: '' }
const _assistant = {
...assistant,
name: name.trim(),
prompt,
emoji: '',
promptVariables: variables
}
updateAssistant(_assistant)
}
const handleUpdateVariables = (updatedVariables: Variable[]) => {
const _assistant = {
...assistant,
name: name.trim(),
emoji,
prompt,
promptVariables: updatedVariables
}
updateAssistant(_assistant)
}
const handleInsertVariable = (varName: string) => {
const insertText = `{{${varName}}}`
setPrompt((prev) => prev + insertText)
}
const addVariable = () => {
if (!variableName.trim()) return
const newVar: Variable = {
id: uuidv4(),
name: variableName.trim(),
value: variableValue.trim()
}
const updatedVariables = [...variables, newVar]
setVariables(updatedVariables)
setVariableName('')
setVariableValue('')
const _assistant = {
...assistant,
name: name.trim(),
emoji,
prompt,
promptVariables: updatedVariables
}
updateAssistant(_assistant)
}
@@ -94,39 +155,61 @@ const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant }
{t('common.prompt')}
</Box>
<TextAreaContainer>
{showMarkdown ? (
<MarkdownContainer onClick={() => setShowMarkdown(false)}>
<ReactMarkdown className="markdown">{prompt}</ReactMarkdown>
<div style={{ height: '30px' }} />
</MarkdownContainer>
) : (
<TextArea
rows={10}
placeholder={t('common.assistant') + t('common.prompt')}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onBlur={() => {
onUpdate()
}}
autoFocus={true}
spellCheck={false}
style={{ minHeight: 'calc(80vh - 200px)', maxHeight: 'calc(80vh - 200px)', paddingBottom: '30px' }}
/>
)}
</TextAreaContainer>
<HSpaceBetweenStack width="100%" justifyContent="flex-end" mt="10px">
<TextArea
rows={10}
placeholder={t('common.assistant') + t('common.prompt')}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onBlur={onUpdate}
spellCheck={false}
style={{ minHeight: 'calc(80vh - 320px)', maxHeight: 'calc(80vh - 270px)' }}
/>
<TokenCount>Tokens: {tokenCount}</TokenCount>
</TextAreaContainer>
{showMarkdown ? (
<Button type="primary" onClick={() => setShowMarkdown(false)}>
{t('common.edit')}
</Button>
) : (
<Button type="primary" onClick={() => setShowMarkdown(true)}>
{t('common.save')}
</Button>
)}
</HSpaceBetweenStack>
<Box mt={12} mb={8}>
<HStack justifyContent="space-between" alignItems="center">
<Typography.Title level={5} style={{ margin: 0 }}>
{t('common.variables')}
</Typography.Title>
<Tooltip title={t('common.variables_help')}>
<Typography.Text type="secondary" style={{ fontSize: '12px', cursor: 'help' }}>
?
</Typography.Text>
</Tooltip>
</HStack>
</Box>
<VariableList
variables={variables}
setVariables={setVariables}
onUpdate={handleUpdateVariables}
onInsertVariable={handleInsertVariable}
/>
<HStack gap={8} width="100%" mt={8} mb={8}>
<Input
placeholder={t('common.variable_name')}
value={variableName}
onChange={(e) => setVariableName(e.target.value)}
style={{ width: '30%' }}
/>
<Input
placeholder={t('common.value')}
value={variableValue}
onChange={(e) => setVariableValue(e.target.value)}
style={{ flex: 1 }}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={addVariable}>
{t('common.add')}
</Button>
</HStack>
<HStack width="100%" justifyContent="flex-end" mt="10px">
<Button type="primary" onClick={onOk}>
{t('common.close')}
</Button>
</HStack>
</Container>
)
}
@@ -154,19 +237,15 @@ const TextAreaContainer = styled.div`
`
const TokenCount = styled.div`
padding: 2px 2px;
position: absolute;
bottom: 8px;
right: 8px;
background-color: var(--color-background-soft);
padding: 2px 8px;
border-radius: 4px;
font-size: 14px;
font-size: 12px;
color: var(--color-text-2);
user-select: none;
`
const MarkdownContainer = styled.div`
min-height: calc(80vh - 200px);
max-height: calc(80vh - 200px);
padding-right: 2px;
overflow: auto;
overflow-x: hidden;
`
export default AssistantPromptSettings

View File

@@ -90,7 +90,8 @@ const AssistantSettingPopupContainer: React.FC<Props> = ({ resolve, tab, ...prop
content: {
padding: 0,
overflow: 'hidden',
background: 'var(--color-background)'
background: 'var(--color-background)',
border: `1px solid var(--color-frame-border)`
},
header: { padding: '10px 15px', borderBottom: '0.5px solid var(--color-border)', margin: 0 }
}}
@@ -99,7 +100,8 @@ const AssistantSettingPopupContainer: React.FC<Props> = ({ resolve, tab, ...prop
centered>
<HStack>
<LeftMenu>
<StyledMenu
<Menu
style={{ width: 220, padding: 5, background: 'transparent' }}
defaultSelectedKeys={[tab || 'prompt']}
mode="vertical"
items={items}
@@ -112,6 +114,7 @@ const AssistantSettingPopupContainer: React.FC<Props> = ({ resolve, tab, ...prop
assistant={assistant}
updateAssistant={updateAssistant}
updateAssistantSettings={updateAssistantSettings}
onOk={onOk}
/>
)}
{menu === 'model' && (
@@ -193,16 +196,6 @@ const StyledModal = styled(Modal)`
}
`
const StyledMenu = styled(Menu)`
width: 220px;
padding: 5px;
background: transparent;
margin-top: 2px;
.ant-menu-item {
margin-bottom: 7px;
}
`
export default class AssistantSettingsPopup {
static show(props: AssistantSettingPopupShowParams) {
return new Promise<Assistant>((resolve) => {

View File

@@ -93,17 +93,22 @@ const McpSettings: React.FC<Props> = ({ server }) => {
.join('\n')
: ''
})
}, [server, form])
useEffect(() => {
const currentServerType = form.getFieldValue('serverType')
if (currentServerType) {
setServerType(currentServerType)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [form.getFieldValue('serverType')])
}, [server])
const fetchTools = useCallback(async () => {
// Watch the serverType field to update the form layout dynamically
useEffect(() => {
const type = form.getFieldValue('serverType')
type && setServerType(type)
}, [form])
// Load tools on initial mount if server is active
useEffect(() => {
fetchTools()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const fetchTools = async () => {
if (server.isActive) {
try {
setLoadingServer(server.id)
@@ -119,15 +124,7 @@ const McpSettings: React.FC<Props> = ({ server }) => {
setLoadingServer(null)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [server.id])
useEffect(() => {
console.log('Loading tools for server:', server.id, 'Active:', server.isActive)
if (server.isActive) {
fetchTools()
}
}, [server.id, server.isActive, fetchTools])
}
// Save the form data
const onSave = async () => {
@@ -244,6 +241,10 @@ const McpSettings: React.FC<Props> = ({ server }) => {
[server, t]
)
const onFormValuesChange = () => {
setIsFormChanged(true)
}
const formatError = (error: any) => {
if (error.message.includes('32000')) {
return t('settings.mcp.errors.32000')
@@ -277,35 +278,6 @@ const McpSettings: React.FC<Props> = ({ server }) => {
}
}
// Handle toggling a tool on/off
const handleToggleTool = useCallback(
async (tool: MCPTool, enabled: boolean) => {
// Create a new disabledTools array or use the existing one
let disabledTools = [...(server.disabledTools || [])]
if (enabled) {
// Remove tool from disabledTools if it's being enabled
disabledTools = disabledTools.filter((name) => name !== tool.name)
} else {
// Add tool to disabledTools if it's being disabled
if (!disabledTools.includes(tool.name)) {
disabledTools.push(tool.name)
}
}
// Update the server with new disabledTools
const updatedServer = {
...server,
disabledTools
}
// Save the updated server configuration
// await window.api.mcp.updateServer(updatedServer)
updateMCPServer(updatedServer)
},
[server, updateMCPServer]
)
return (
<SettingContainer>
<SettingGroup style={{ marginBottom: 0 }}>
@@ -330,7 +302,7 @@ const McpSettings: React.FC<Props> = ({ server }) => {
<Form
form={form}
layout="vertical"
onValuesChange={() => setIsFormChanged(true)}
onValuesChange={onFormValuesChange}
style={{
// height: 'calc(100vh - var(--navbar-height) - 315px)',
overflowY: 'auto',
@@ -408,7 +380,7 @@ const McpSettings: React.FC<Props> = ({ server }) => {
</>
)}
</Form>
{server.isActive && <MCPToolsSection tools={tools} server={server} onToggleTool={handleToggleTool} />}
{server.isActive && <MCPToolsSection tools={tools} />}
</SettingGroup>
</SettingContainer>
)

View File

@@ -1,27 +1,11 @@
import { MCPServer, MCPTool } from '@renderer/types'
import { Badge, Collapse, Descriptions, Empty, Flex, Switch, Tag, Tooltip, Typography } from 'antd'
import { MCPTool } from '@renderer/types'
import { Badge, Collapse, Descriptions, Empty, Flex, Tag, Tooltip, Typography } from 'antd'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface MCPToolsSectionProps {
tools: MCPTool[]
server: MCPServer
onToggleTool: (tool: MCPTool, enabled: boolean) => void
}
const MCPToolsSection = ({ tools, server, onToggleTool }: MCPToolsSectionProps) => {
const MCPToolsSection = ({ tools }: { tools: MCPTool[] }) => {
const { t } = useTranslation()
// Check if a tool is enabled (not in the disabledTools array)
const isToolEnabled = (tool: MCPTool) => {
return !server.disabledTools?.includes(tool.name)
}
// Handle tool toggle
const handleToggle = (tool: MCPTool, checked: boolean) => {
onToggleTool(tool, checked)
}
// Render tool properties from the input schema
const renderToolProperties = (tool: MCPTool) => {
if (!tool.inputSchema?.properties) return null
@@ -102,21 +86,18 @@ const MCPToolsSection = ({ tools, server, onToggleTool }: MCPToolsSectionProps)
<Collapse.Panel
key={tool.id}
header={
<Flex justify="space-between" align="center" style={{ width: '100%' }}>
<Flex vertical align="flex-start">
<Flex align="center" style={{ width: '100%' }}>
<Typography.Text strong>{tool.name}</Typography.Text>
<Typography.Text type="secondary" style={{ marginLeft: 8, fontSize: '12px' }}>
{tool.id}
</Typography.Text>
</Flex>
{tool.description && (
<Typography.Text type="secondary" style={{ fontSize: '13px', marginTop: 4 }}>
{tool.description}
</Typography.Text>
)}
<Flex vertical align="flex-start" style={{ width: '100%' }}>
<Flex align="center" style={{ width: '100%' }}>
<Typography.Text strong>{tool.name}</Typography.Text>
<Typography.Text type="secondary" style={{ marginLeft: 8, fontSize: '12px' }}>
{tool.id}
</Typography.Text>
</Flex>
<Switch checked={isToolEnabled(tool)} onChange={(checked) => handleToggle(tool, checked)} />
{tool.description && (
<Typography.Text type="secondary" style={{ fontSize: '13px', marginTop: 4 }}>
{tool.description}
</Typography.Text>
)}
</Flex>
}>
<SelectableContent>{renderToolProperties(tool)}</SelectableContent>

View File

@@ -44,10 +44,7 @@ export default class AnthropicProvider extends BaseProvider {
this.sdk = new Anthropic({
apiKey: this.apiKey,
baseURL: this.getBaseURL(),
dangerouslyAllowBrowser: true,
defaultHeaders: {
'anthropic-beta': 'output-128k-2025-02-19'
}
dangerouslyAllowBrowser: true
})
}

View File

@@ -4,6 +4,7 @@ import i18n from '@renderer/i18n'
import store from '@renderer/store'
import { setGenerating } from '@renderer/store/runtime'
import { Assistant, MCPTool, Message, Model, Provider, Suggestion } from '@renderer/types'
import { processPromptVariables } from '@renderer/utils'
import { formatMessageError, isAbortError } from '@renderer/utils/error'
import { withGenerateImage } from '@renderer/utils/formats'
import { cloneDeep, findLast, isEmpty } from 'lodash'
@@ -42,6 +43,14 @@ export async function fetchChatCompletion({
let isFirstChunk = true
let query = ''
// Process variables in the prompt if they exist
if (assistant.promptVariables && assistant.promptVariables.length > 0) {
assistant = {
...assistant,
prompt: processPromptVariables(assistant.prompt, assistant.promptVariables)
}
}
// Search web
if (WebSearchService.isWebSearchEnabled() && assistant.enableWebSearch && assistant.model) {
const webSearchParams = getOpenAIWebSearchParams(assistant, assistant.model)
@@ -106,8 +115,8 @@ export async function fetchChatCompletion({
if (enabledMCPs && enabledMCPs.length > 0) {
for (const mcpServer of enabledMCPs) {
const tools = await window.api.mcp.listTools(mcpServer)
const availableTools = tools.filter((tool: any) => !mcpServer.disabledTools?.includes(tool.name))
mcpTools.push(...availableTools)
console.debug('tools', tools)
mcpTools.push(...tools)
}
}

View File

@@ -60,25 +60,14 @@ export async function reset() {
}
// 备份到 webdav
/**
* @param autoBackupProcess
* if call in auto backup process, not show any message, any error will be thrown
*/
export async function backupToWebdav({
showMessage = false,
customFileName = '',
autoBackupProcess = false
}: { showMessage?: boolean; customFileName?: string; autoBackupProcess?: boolean } = {}) {
customFileName = ''
}: { showMessage?: boolean; customFileName?: string } = {}) {
if (isManualBackupRunning) {
Logger.log('[Backup] Manual backup already in progress')
return
}
// force set showMessage to false when auto backup process
if (autoBackupProcess) {
showMessage = false
}
isManualBackupRunning = true
store.dispatch(setWebDAVSyncState({ syncing: true, lastSyncError: null }))
@@ -109,41 +98,25 @@ export async function backupToWebdav({
lastSyncError: null
})
)
if (showMessage && !autoBackupProcess) {
window.message.success({ content: i18n.t('message.backup.success'), key: 'backup' })
}
showMessage && window.message.success({ content: i18n.t('message.backup.success'), key: 'backup' })
} else {
// if auto backup process, throw error
if (autoBackupProcess) {
throw new Error(i18n.t('message.backup.failed'))
}
store.dispatch(setWebDAVSyncState({ lastSyncError: 'Backup failed' }))
showMessage && window.message.error({ content: i18n.t('message.backup.failed'), key: 'backup' })
window.message.error({ content: i18n.t('message.backup.failed'), key: 'backup' })
}
} catch (error: any) {
// if auto backup process, throw error
if (autoBackupProcess) {
throw error
}
store.dispatch(setWebDAVSyncState({ lastSyncError: error.message }))
console.error('[Backup] backupToWebdav: Error uploading file to WebDAV:', error)
showMessage &&
window.modal.error({
title: i18n.t('message.backup.failed'),
content: error.message
})
throw error
window.modal.error({
title: i18n.t('message.backup.failed'),
content: error.message
})
} finally {
if (!autoBackupProcess) {
store.dispatch(
setWebDAVSyncState({
lastSyncTime: Date.now(),
syncing: false
})
)
}
store.dispatch(
setWebDAVSyncState({
lastSyncTime: Date.now(),
syncing: false
})
)
isManualBackupRunning = false
}
}
@@ -176,7 +149,7 @@ let syncTimeout: NodeJS.Timeout | null = null
let isAutoBackupRunning = false
let isManualBackupRunning = false
export function startAutoSync(immediate = false) {
export function startAutoSync() {
if (autoSyncStarted) {
return
}
@@ -192,15 +165,9 @@ export function startAutoSync(immediate = false) {
stopAutoSync()
scheduleNextBackup(immediate ? 'immediate' : 'fromLastSyncTime')
scheduleNextBackup()
/**
* @param type 'immediate' | 'fromLastSyncTime' | 'fromNow'
* 'immediate', first backup right now
* 'fromLastSyncTime', schedule next backup from last sync time
* 'fromNow', schedule next backup from now
*/
function scheduleNextBackup(type: 'immediate' | 'fromLastSyncTime' | 'fromNow' = 'fromLastSyncTime') {
function scheduleNextBackup() {
if (syncTimeout) {
clearTimeout(syncTimeout)
syncTimeout = null
@@ -218,15 +185,10 @@ export function startAutoSync(immediate = false) {
// 用户指定的自动备份时间间隔(毫秒)
const requiredInterval = webdavSyncInterval * 60 * 1000
let timeUntilNextSync = 1000 //also immediate
switch (type) {
case 'fromLastSyncTime': // 如果存在最后一次同步WebDAV的时间以它为参考计算下一次同步的时间
timeUntilNextSync = Math.max(1000, (webdavSync?.lastSyncTime || 0) + requiredInterval - Date.now())
break
case 'fromNow':
timeUntilNextSync = requiredInterval
break
}
// 如果存在最后一次同步WebDAV的时间以它为参考计算下一次同步的时间
const timeUntilNextSync = webdavSync?.lastSyncTime
? Math.max(1000, webdavSync.lastSyncTime + requiredInterval - Date.now())
: requiredInterval
syncTimeout = setTimeout(performAutoBackup, timeUntilNextSync)
@@ -245,62 +207,14 @@ export function startAutoSync(immediate = false) {
}
isAutoBackupRunning = true
const maxRetries = 4
let retryCount = 0
while (retryCount < maxRetries) {
try {
console.log(`[AutoSync] Starting auto backup... (attempt ${retryCount + 1}/${maxRetries})`)
await backupToWebdav({ autoBackupProcess: true })
store.dispatch(
setWebDAVSyncState({
lastSyncError: null,
lastSyncTime: Date.now(),
syncing: false
})
)
isAutoBackupRunning = false
scheduleNextBackup()
break
} catch (error: any) {
retryCount++
if (retryCount === maxRetries) {
console.error('[AutoSync] Auto backup failed after all retries:', error)
store.dispatch(
setWebDAVSyncState({
lastSyncError: 'Auto backup failed',
lastSyncTime: Date.now(),
syncing: false
})
)
//only show 1 time error modal, and autoback stopped until user click ok
await window.modal.error({
title: i18n.t('message.backup.failed'),
content: `[WebDAV Auto Backup] ${new Date().toLocaleString()} ` + error.message
})
scheduleNextBackup('fromNow')
isAutoBackupRunning = false
} else {
//Exponential Backoff with Base 2 7s、17s、37s
const backoffDelay = Math.pow(2, retryCount - 1) * 10000 - 3000
console.log(`[AutoSync] Failed, retry ${retryCount}/${maxRetries} after ${backoffDelay / 1000}s`)
await new Promise((resolve) => setTimeout(resolve, backoffDelay))
//in case auto backup is stopped by user
if (!isAutoBackupRunning) {
console.log('[AutoSync] retry cancelled by user, exit')
break
}
}
}
try {
console.log('[AutoSync] Starting auto backup...')
await backupToWebdav({ showMessage: false })
} catch (error) {
console.error('[AutoSync] Auto backup failed:', error)
} finally {
isAutoBackupRunning = false
scheduleNextBackup()
}
}
}

View File

@@ -123,7 +123,7 @@ export async function backupToNutstore({
}
}
export async function restoreFromNutstore(fileName?: string) {
export async function restoreFromNutstore() {
const nutstoreToken = getNutstoreToken()
if (!nutstoreToken) {
return
@@ -137,7 +137,7 @@ export async function restoreFromNutstore(fileName?: string) {
let data = ''
try {
data = await window.api.backup.restoreFromWebdav({ ...config, fileName })
data = await window.api.backup.restoreFromWebdav(config)
} catch (error: any) {
console.error('[backup] restoreFromWebdav: Error downloading file from WebDAV:', error)
window.modal.error({

View File

@@ -42,7 +42,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 89,
version: 88,
blacklist: ['runtime', 'messages'],
migrate
},

View File

@@ -11,7 +11,6 @@ const initialState: MCPConfig = {
baseUrl: '',
command: 'npx',
args: ['-y', '@mcpmarket/mcp-auto-install', 'connect', '--json'],
registryUrl: 'https://registry.npmmirror.com',
env: {},
isActive: false
}

View File

@@ -24,13 +24,6 @@ function removeMiniAppIconsFromState(state: RootState) {
}
}
function removeMiniAppFromState(state: RootState, id: string) {
if (state.minapps) {
state.minapps.enabled = state.minapps.enabled.filter((app) => app.id !== id)
state.minapps.disabled = state.minapps.disabled.filter((app) => app.id !== id)
}
}
// add provider to state
function addProvider(state: RootState, id: string) {
if (!state.llm.providers.find((p) => p.id === id)) {
@@ -1166,18 +1159,12 @@ const migrateConfig = {
state.mcp.servers = [{ ...defaultServer, id: nanoid() }, ...state.mcp.servers]
}
}
return state
} catch (error) {
return state
}
},
'89': (state: RootState) => {
try {
removeMiniAppFromState(state, 'aistudio')
return state
} catch (error) {
console.error(error)
return state
}
return state
}
}

View File

@@ -17,6 +17,7 @@ export type Assistant = {
messages?: AssistantMessage[]
enableWebSearch?: boolean
enableGenerateImage?: boolean
promptVariables?: Variable[]
mcpServers?: MCPServer[]
}
@@ -44,6 +45,12 @@ export type AssistantSettings = {
reasoning_effort?: 'low' | 'medium' | 'high'
}
export type Variable = {
id: string
name: string
value: string
}
export type Agent = Omit<Assistant, 'model'>
export type Message = {
@@ -372,7 +379,6 @@ export interface MCPServer {
args?: string[]
env?: Record<string, string>
isActive: boolean
disabledTools?: string[] // List of tool names that are disabled for this server
}
export interface MCPToolInputSchema {

View File

@@ -500,4 +500,23 @@ export function hasObjectKey(obj: any, key: string) {
return Object.keys(obj).includes(key)
}
/**
* Process variables in a prompt string
* @param prompt The prompt string containing variables in {{var_name}} format
* @param variables Array of variables with name and value
* @returns The prompt with variables replaced
*/
export function processPromptVariables(prompt: string, variables: Array<{ name: string; value: string }> = []) {
if (!prompt || !variables || variables.length === 0) {
return prompt
}
let processedPrompt = prompt
variables.forEach((variable) => {
const pattern = new RegExp(`{{${variable.name}}}`, 'g')
processedPrompt = processedPrompt.replace(pattern, variable.value)
})
return processedPrompt
}
export { classNames }

View File

@@ -245,7 +245,6 @@ export async function callMCPTool(tool: MCPTool): Promise<any> {
command: resp.data.command,
args: resp.data.args,
env: resp.data.env,
registryUrl: '',
isActive: false
}
store.dispatch(addMCPServer(mcpServer))