Merge branch 'main' of github.com:CherryHQ/cherry-studio into v2

This commit is contained in:
fullex
2025-11-10 18:44:02 +08:00
43 changed files with 500 additions and 658 deletions
+7 -6
View File
@@ -10,6 +10,7 @@ import { getBinaryName } from '@main/utils/process'
import type { TerminalConfig, TerminalConfigWithCommand } from '@shared/config/constant'
import {
codeTools,
HOME_CHERRY_DIR,
MACOS_TERMINALS,
MACOS_TERMINALS_WITH_COMMANDS,
terminalApps,
@@ -66,7 +67,7 @@ class CodeToolsService {
}
public async getBunPath() {
const dir = path.join(os.homedir(), '.cherrystudio', 'bin')
const dir = path.join(os.homedir(), HOME_CHERRY_DIR, 'bin')
const bunName = await getBinaryName('bun')
const bunPath = path.join(dir, bunName)
return bunPath
@@ -362,7 +363,7 @@ class CodeToolsService {
private async isPackageInstalled(cliTool: string): Promise<boolean> {
const executableName = await this.getCliExecutableName(cliTool)
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
const binDir = path.join(os.homedir(), HOME_CHERRY_DIR, 'bin')
const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : ''))
// Ensure bin directory exists
@@ -389,7 +390,7 @@ class CodeToolsService {
logger.info(`${cliTool} is installed, getting current version`)
try {
const executableName = await this.getCliExecutableName(cliTool)
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
const binDir = path.join(os.homedir(), HOME_CHERRY_DIR, 'bin')
const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : ''))
const { stdout } = await execAsync(`"${executablePath}" --version`, {
@@ -500,7 +501,7 @@ class CodeToolsService {
try {
const packageName = await this.getPackageName(cliTool)
const bunPath = await this.getBunPath()
const bunInstallPath = path.join(os.homedir(), '.cherrystudio')
const bunInstallPath = path.join(os.homedir(), HOME_CHERRY_DIR)
const registryUrl = await this.getNpmRegistryUrl()
const installEnvPrefix = isWin
@@ -550,7 +551,7 @@ class CodeToolsService {
const packageName = await this.getPackageName(cliTool)
const bunPath = await this.getBunPath()
const executableName = await this.getCliExecutableName(cliTool)
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
const binDir = path.join(os.homedir(), HOME_CHERRY_DIR, 'bin')
const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : ''))
logger.debug(`Package name: ${packageName}`)
@@ -652,7 +653,7 @@ class CodeToolsService {
baseCommand = `${baseCommand} ${configParams}`
}
const bunInstallPath = path.join(os.homedir(), '.cherrystudio')
const bunInstallPath = path.join(os.homedir(), HOME_CHERRY_DIR)
if (isInstalled) {
// If already installed, run executable directly (with optional update message)
+2 -1
View File
@@ -31,6 +31,7 @@ import {
ToolListChangedNotificationSchema
} from '@modelcontextprotocol/sdk/types.js'
import { nanoid } from '@reduxjs/toolkit'
import { HOME_CHERRY_DIR } from '@shared/config/constant'
import type { MCPProgressEvent } from '@shared/config/types'
import { IpcChannel } from '@shared/IpcChannel'
import { defaultAppHeaders } from '@shared/utils'
@@ -715,7 +716,7 @@ class McpService {
}
public async getInstallInfo() {
const dir = path.join(os.homedir(), '.cherrystudio', 'bin')
const dir = path.join(os.homedir(), HOME_CHERRY_DIR, 'bin')
const uvName = await getBinaryName('uv')
const bunName = await getBinaryName('bun')
const uvPath = path.join(dir, uvName)
+9 -8
View File
@@ -3,6 +3,7 @@ import { homedir } from 'node:os'
import { promisify } from 'node:util'
import { loggerService } from '@logger'
import { HOME_CHERRY_DIR } from '@shared/config/constant'
import * as fs from 'fs-extra'
import * as path from 'path'
@@ -145,7 +146,7 @@ class OvmsManager {
*/
public async runOvms(): Promise<{ success: boolean; message?: string }> {
const homeDir = homedir()
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
const ovmsDir = path.join(homeDir, HOME_CHERRY_DIR, 'ovms', 'ovms')
const configPath = path.join(ovmsDir, 'models', 'config.json')
const runBatPath = path.join(ovmsDir, 'run.bat')
@@ -195,7 +196,7 @@ class OvmsManager {
*/
public async getOvmsStatus(): Promise<'not-installed' | 'not-running' | 'running'> {
const homeDir = homedir()
const ovmsPath = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms', 'ovms.exe')
const ovmsPath = path.join(homeDir, HOME_CHERRY_DIR, 'ovms', 'ovms', 'ovms.exe')
try {
// Check if OVMS executable exists
@@ -273,7 +274,7 @@ class OvmsManager {
}
const homeDir = homedir()
const configPath = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms', 'models', 'config.json')
const configPath = path.join(homeDir, HOME_CHERRY_DIR, 'ovms', 'ovms', 'models', 'config.json')
try {
if (!(await fs.pathExists(configPath))) {
logger.warn(`Config file does not exist: ${configPath}`)
@@ -304,7 +305,7 @@ class OvmsManager {
private async applyModelPath(modelDirPath: string): Promise<boolean> {
const homeDir = homedir()
const patchDir = path.join(homeDir, '.cherrystudio', 'ovms', 'patch')
const patchDir = path.join(homeDir, HOME_CHERRY_DIR, 'ovms', 'patch')
if (!(await fs.pathExists(patchDir))) {
return true
}
@@ -355,7 +356,7 @@ class OvmsManager {
logger.info(`Adding model: ${modelName} with ID: ${modelId}, Source: ${modelSource}, Task: ${task}`)
const homeDir = homedir()
const ovdndDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
const ovdndDir = path.join(homeDir, HOME_CHERRY_DIR, 'ovms', 'ovms')
const pathModel = path.join(ovdndDir, 'models', modelId)
try {
@@ -468,7 +469,7 @@ class OvmsManager {
*/
public async checkModelExists(modelId: string): Promise<boolean> {
const homeDir = homedir()
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
const ovmsDir = path.join(homeDir, HOME_CHERRY_DIR, 'ovms', 'ovms')
const configPath = path.join(ovmsDir, 'models', 'config.json')
try {
@@ -495,7 +496,7 @@ class OvmsManager {
*/
public async updateModelConfig(modelName: string, modelId: string): Promise<boolean> {
const homeDir = homedir()
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
const ovmsDir = path.join(homeDir, HOME_CHERRY_DIR, 'ovms', 'ovms')
const configPath = path.join(ovmsDir, 'models', 'config.json')
try {
@@ -548,7 +549,7 @@ class OvmsManager {
*/
public async getModels(): Promise<ModelConfig[]> {
const homeDir = homedir()
const ovmsDir = path.join(homeDir, '.cherrystudio', 'ovms', 'ovms')
const ovmsDir = path.join(homeDir, HOME_CHERRY_DIR, 'ovms', 'ovms')
const configPath = path.join(ovmsDir, 'models', 'config.json')
try {
+2 -1
View File
@@ -4,6 +4,7 @@ import type { Attributes, SpanEntity, TokenUsage, TraceCache } from '@mcp-trace/
import { convertSpanToSpanEntity } from '@mcp-trace/trace-core'
import { SpanStatusCode } from '@opentelemetry/api'
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'
import { HOME_CHERRY_DIR } from '@shared/config/constant'
import fs from 'fs/promises'
import * as os from 'os'
import * as path from 'path'
@@ -17,7 +18,7 @@ class SpanCacheService implements TraceCache {
pri
constructor() {
this.fileDir = path.join(os.homedir(), '.cherrystudio', 'trace')
this.fileDir = path.join(os.homedir(), HOME_CHERRY_DIR, 'trace')
}
createSpan: (span: ReadableSpan) => void = (span: ReadableSpan) => {
+81
View File
@@ -1,4 +1,6 @@
import { loggerService } from '@logger'
import { configManager } from '@main/services/ConfigManager'
import { locales } from '@main/utils/locales'
import type EventEmitter from 'events'
import http from 'http'
import { URL } from 'url'
@@ -7,6 +9,36 @@ import type { OAuthCallbackServerOptions } from './types'
const logger = loggerService.withContext('MCP:OAuthCallbackServer')
function getTranslation(key: string): string {
const language = configManager.getLanguage()
const localeData = locales[language]
if (!localeData) {
logger.warn(`No locale data found for language: ${language}`)
return key
}
const translations = localeData.translation as any
if (!translations) {
logger.warn(`No translations found for language: ${language}`)
return key
}
const keys = key.split('.')
let value = translations
for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
value = value[k]
} else {
logger.warn(`Translation key not found: ${key} (failed at: ${k})`)
return key // fallback to key if translation not found
}
}
return typeof value === 'string' ? value : key
}
export class CallBackServer {
private server: Promise<http.Server>
private events: EventEmitter
@@ -28,6 +60,55 @@ export class CallBackServer {
if (code) {
// Emit the code event
this.events.emit('auth-code-received', code)
// Send success response to browser
const title = getTranslation('settings.mcp.oauth.callback.title')
const message = getTranslation('settings.mcp.oauth.callback.message')
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>${title}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: #ffffff;
}
.container {
text-align: center;
padding: 2rem;
}
h1 {
color: #2d3748;
margin: 0 0 0.5rem 0;
font-size: 24px;
font-weight: 600;
}
p {
color: #718096;
margin: 0;
font-size: 14px;
}
</style>
</head>
<body>
<div class="container">
<h1>${title}</h1>
<p>${message}</p>
</div>
</body>
</html>
`)
} else {
res.writeHead(400, { 'Content-Type': 'text/plain' })
res.end('Missing authorization code')
}
} catch (error) {
logger.error('Error processing OAuth callback:', error as Error)
@@ -1,5 +1,6 @@
import { loggerService } from '@logger'
import { isWin } from '@main/constant'
import { HOME_CHERRY_DIR } from '@shared/config/constant'
import type { OcrOvConfig, OcrResult, SupportedOcrFile } from '@types'
import { isImageFileMetadata } from '@types'
import { exec } from 'child_process'
@@ -13,7 +14,7 @@ import { OcrBaseService } from './OcrBaseService'
const logger = loggerService.withContext('OvOcrService')
const execAsync = promisify(exec)
const PATH_BAT_FILE = path.join(os.homedir(), '.cherrystudio', 'ovms', 'ovocr', 'run.npu.bat')
const PATH_BAT_FILE = path.join(os.homedir(), HOME_CHERRY_DIR, 'ovms', 'ovocr', 'run.npu.bat')
export class OvOcrService extends OcrBaseService {
constructor() {
@@ -30,7 +31,7 @@ export class OvOcrService extends OcrBaseService {
}
private getOvOcrPath(): string {
return path.join(os.homedir(), '.cherrystudio', 'ovms', 'ovocr')
return path.join(os.homedir(), HOME_CHERRY_DIR, 'ovms', 'ovocr')
}
private getImgDir(): string {
+3 -3
View File
@@ -5,7 +5,7 @@ import os from 'node:os'
import path from 'node:path'
import { loggerService } from '@logger'
import { audioExts, documentExts, imageExts, MB, textExts, videoExts } from '@shared/config/constant'
import { audioExts, documentExts, HOME_CHERRY_DIR, imageExts, MB, textExts, videoExts } from '@shared/config/constant'
import type { FileMetadata, NotesTreeNode } from '@types'
import { FileTypes } from '@types'
import chardet from 'chardet'
@@ -160,7 +160,7 @@ export function getNotesDir() {
}
export function getConfigDir() {
return path.join(os.homedir(), '.cherrystudio', 'config')
return path.join(os.homedir(), HOME_CHERRY_DIR, 'config')
}
export function getCacheDir() {
@@ -172,7 +172,7 @@ export function getAppConfigDir(name: string) {
}
export function getMcpDir() {
return path.join(os.homedir(), '.cherrystudio', 'mcp')
return path.join(os.homedir(), HOME_CHERRY_DIR, 'mcp')
}
/**
+2 -1
View File
@@ -3,6 +3,7 @@ import os from 'node:os'
import path from 'node:path'
import { isLinux, isPortable, isWin } from '@main/constant'
import { HOME_CHERRY_DIR } from '@shared/config/constant'
import { app } from 'electron'
// Please don't import any other modules which is not node/electron built-in modules
@@ -17,7 +18,7 @@ function hasWritePermission(path: string) {
}
function getConfigDir() {
return path.join(os.homedir(), '.cherrystudio', 'config')
return path.join(os.homedir(), HOME_CHERRY_DIR, 'config')
}
export function initAppDataDir() {
+3 -2
View File
@@ -1,4 +1,5 @@
import { loggerService } from '@logger'
import { HOME_CHERRY_DIR } from '@shared/config/constant'
import { spawn } from 'child_process'
import fs from 'fs'
import os from 'os'
@@ -46,11 +47,11 @@ export async function getBinaryName(name: string): Promise<string> {
export async function getBinaryPath(name?: string): Promise<string> {
if (!name) {
return path.join(os.homedir(), '.cherrystudio', 'bin')
return path.join(os.homedir(), HOME_CHERRY_DIR, 'bin')
}
const binaryName = await getBinaryName(name)
const binariesDir = path.join(os.homedir(), '.cherrystudio', 'bin')
const binariesDir = path.join(os.homedir(), HOME_CHERRY_DIR, 'bin')
const binariesDirExists = fs.existsSync(binariesDir)
return binariesDirExists ? path.join(binariesDir, binaryName) : binaryName
}
+7 -5
View File
@@ -418,6 +418,8 @@ export function getAnthropicReasoningParams(assistant: Assistant, model: Model):
/**
* 获取 Gemini 推理参数
* 从 GeminiAPIClient 中提取的逻辑
* 注意:Gemini/GCP 端点所使用的 thinkingBudget 等参数应该按照驼峰命名法传递
* 而在 Google 官方提供的 OpenAI 兼容端点中则使用蛇形命名法 thinking_budget
*/
export function getGeminiReasoningParams(assistant: Assistant, model: Model): Record<string, any> {
if (!isReasoningModel(model)) {
@@ -431,8 +433,8 @@ export function getGeminiReasoningParams(assistant: Assistant, model: Model): Re
if (reasoningEffort === undefined) {
return {
thinkingConfig: {
include_thoughts: false,
...(GEMINI_FLASH_MODEL_REGEX.test(model.id) ? { thinking_budget: 0 } : {})
includeThoughts: false,
...(GEMINI_FLASH_MODEL_REGEX.test(model.id) ? { thinkingBudget: 0 } : {})
}
}
}
@@ -442,7 +444,7 @@ export function getGeminiReasoningParams(assistant: Assistant, model: Model): Re
if (effortRatio > 1) {
return {
thinkingConfig: {
include_thoughts: true
includeThoughts: true
}
}
}
@@ -452,8 +454,8 @@ export function getGeminiReasoningParams(assistant: Assistant, model: Model): Re
return {
thinkingConfig: {
...(budget > 0 ? { thinking_budget: budget } : {}),
include_thoughts: true
...(budget > 0 ? { thinkingBudget: budget } : {}),
includeThoughts: true
}
}
}
@@ -20,11 +20,11 @@ import {
updateMessageAndBlocksThunk,
updateTranslationBlockThunk
} from '@renderer/store/thunk/messageThunk'
import type { Assistant, Model, Topic, TranslateLanguageCode } from '@renderer/types'
import { type Assistant, type Model, objectKeys, type Topic, type TranslateLanguageCode } from '@renderer/types'
import type { Message, MessageBlock } from '@renderer/types/newMessage'
import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
import { abortCompletion } from '@renderer/utils/abortController'
import { throttle } from 'lodash'
import { difference, throttle } from 'lodash'
import { useCallback } from 'react'
const logger = loggerService.withContext('UseMessageOperations')
@@ -82,10 +82,12 @@ export function useMessageOperations(topic: Topic) {
logger.error('[editMessage] Topic prop is not valid.')
return
}
const uiStates = ['multiModelMessageStyle', 'foldSelected'] as const satisfies (keyof Message)[]
const extraUpdate = difference(objectKeys(updates), uiStates)
const isUiUpdateOnly = extraUpdate.length === 0
const messageUpdates: Partial<Message> & Pick<Message, 'id'> = {
id: messageId,
updatedAt: new Date().toISOString(),
updatedAt: isUiUpdateOnly ? undefined : new Date().toISOString(),
...updates
}
+6
View File
@@ -3863,6 +3863,12 @@
"usage": "Usage",
"version": "Version"
},
"oauth": {
"callback": {
"message": "You can close this page and return to Cherry Studio",
"title": "Authentication Successful"
}
},
"prompts": {
"arguments": "Arguments",
"availablePrompts": "Available Prompts",
+6
View File
@@ -3863,6 +3863,12 @@
"usage": "用法",
"version": "版本"
},
"oauth": {
"callback": {
"message": "您可以关闭此页面并返回 Cherry Studio",
"title": "认证成功"
}
},
"prompts": {
"arguments": "参数",
"availablePrompts": "可用提示",
+6
View File
@@ -3863,6 +3863,12 @@
"usage": "用法",
"version": "版本"
},
"oauth": {
"callback": {
"message": "您可以關閉此頁面並返回 Cherry Studio",
"title": "認證成功"
}
},
"prompts": {
"arguments": "參數",
"availablePrompts": "可用提示",
@@ -3863,6 +3863,12 @@
"usage": "Verwendung",
"version": "Version"
},
"oauth": {
"callback": {
"message": "Sie können diese Seite schließen und zu Cherry Studio zurückkehren",
"title": "Authentifizierung erfolgreich"
}
},
"prompts": {
"arguments": "Parameter",
"availablePrompts": "Verfügbare Prompts",
@@ -3863,6 +3863,12 @@
"usage": "Χρήση",
"version": "Έκδοση"
},
"oauth": {
"callback": {
"message": "Μπορείτε να κλείσετε αυτήν τη σελίδα και να επιστρέψετε στο Cherry Studio",
"title": "Επιτυχής Ταυτοποίηση"
}
},
"prompts": {
"arguments": "Ορίσματα",
"availablePrompts": "Διαθέσιμες Υποδείξεις",
@@ -3863,6 +3863,12 @@
"usage": "Uso",
"version": "Versión"
},
"oauth": {
"callback": {
"message": "Puede cerrar esta página y volver a Cherry Studio",
"title": "Autenticación Exitosa"
}
},
"prompts": {
"arguments": "Argumentos",
"availablePrompts": "Indicaciones disponibles",
@@ -3863,6 +3863,12 @@
"usage": "Utilisation",
"version": "Version"
},
"oauth": {
"callback": {
"message": "Vous pouvez fermer cette page et retourner à Cherry Studio",
"title": "Authentification Réussie"
}
},
"prompts": {
"arguments": "Arguments",
"availablePrompts": "Invites disponibles",
@@ -3863,6 +3863,12 @@
"usage": "使用法",
"version": "バージョン"
},
"oauth": {
"callback": {
"message": "このページを閉じてCherry Studioに戻ることができます",
"title": "認証成功"
}
},
"prompts": {
"arguments": "引数",
"availablePrompts": "利用可能なプロンプト",
@@ -3863,6 +3863,12 @@
"usage": "Uso",
"version": "Versão"
},
"oauth": {
"callback": {
"message": "Você pode fechar esta página e retornar ao Cherry Studio",
"title": "Autenticação Bem-Sucedida"
}
},
"prompts": {
"arguments": "Argumentos",
"availablePrompts": "Dicas disponíveis",
@@ -3863,6 +3863,12 @@
"usage": "Использование",
"version": "Версия"
},
"oauth": {
"callback": {
"message": "Вы можете закрыть эту страницу и вернуться в Cherry Studio",
"title": "Аутентификация Успешна"
}
},
"prompts": {
"arguments": "Аргументы",
"availablePrompts": "Доступные подсказки",
+1 -1
View File
@@ -84,7 +84,7 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
</Tooltip>
)}
{isTopNavbar && !showAssistants && (
<Tooltip placement="bottom" content={t('navbar.show_sidebar')} delay={800}>
<Tooltip placement="bottom" content={t('navbar.show_sidebar')} delay={800} placement="right">
<NavbarIcon onClick={() => toggleShowAssistants()} style={{ marginRight: 8 }}>
<PanelRightClose size={18} />
</NavbarIcon>
@@ -5,11 +5,13 @@ import { useTopicMessages } from '@renderer/hooks/useMessageOperations'
import { getGroupedMessages } from '@renderer/services/MessagesService'
import { type Topic, TopicType } from '@renderer/types'
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
import { Spin } from 'antd'
import { memo, useMemo } from 'react'
import styled from 'styled-components'
import MessageGroup from './MessageGroup'
import NarrowLayout from './NarrowLayout'
import PermissionModeDisplay from './PermissionModeDisplay'
import { MessagesContainer, ScrollContainer } from './shared'
const logger = loggerService.withContext('AgentSessionMessages')
@@ -67,8 +69,12 @@ const AgentSessionMessages: React.FC<Props> = ({ agentId, sessionId }) => {
groupedMessages.map(([key, groupMessages]) => (
<MessageGroup key={key} messages={groupMessages} topic={derivedTopic} />
))
) : session ? (
<PermissionModeDisplay session={session} agentId={agentId} />
) : (
<EmptyState>{session ? 'No messages yet.' : 'Loading session...'}</EmptyState>
<LoadingState>
<Spin size="small" />
</LoadingState>
)}
</ScrollContainer>
</ContextMenu>
@@ -77,10 +83,10 @@ const AgentSessionMessages: React.FC<Props> = ({ agentId, sessionId }) => {
)
}
const EmptyState = styled.div`
color: var(--color-text-3);
font-size: 12px;
text-align: center;
const LoadingState = styled.div`
display: flex;
justify-content: center;
align-items: center;
padding: 20px 0;
`
@@ -301,7 +301,7 @@ const BuiltinError = ({ error }: { error: SerializedError }) => {
)
}
// 作为 base,渲染公共字段,应当在 ErrorDetailList 中渲染
// Base component to render common fields, should be rendered inside ErrorDetailList
const AiSdkErrorBase = ({ error }: { error: SerializedAiSdkError }) => {
const { t } = useTranslation()
const { highlightCode } = useCodeStyle()
@@ -366,6 +366,13 @@ const AiSdkError = ({ error }: { error: SerializedAiSdkErrorUnion }) => {
{isSerializedAiSdkAPICallError(error) && (
<>
{error.responseBody && (
<ErrorDetailItem>
<ErrorDetailLabel>{t('error.responseBody')}:</ErrorDetailLabel>
<CodeViewer value={error.responseBody} className="source-view" language="json" expanded />
</ErrorDetailItem>
)}
{error.requestBodyValues && (
<ErrorDetailItem>
<ErrorDetailLabel>{t('error.requestBodyValues')}:</ErrorDetailLabel>
@@ -390,13 +397,6 @@ const AiSdkError = ({ error }: { error: SerializedAiSdkErrorUnion }) => {
</ErrorDetailItem>
)}
{error.responseBody && (
<ErrorDetailItem>
<ErrorDetailLabel>{t('error.responseBody')}:</ErrorDetailLabel>
<CodeViewer value={error.responseBody} className="source-view" language="json" expanded />
</ErrorDetailItem>
)}
{error.data && (
<ErrorDetailItem>
<ErrorDetailLabel>{t('error.data')}:</ErrorDetailLabel>
@@ -0,0 +1,82 @@
import { permissionModeCards } from '@renderer/config/agent'
import SessionSettingsPopup from '@renderer/pages/settings/AgentSettings/SessionSettingsPopup'
import type { GetAgentSessionResponse, PermissionMode } from '@renderer/types'
import { FileEdit, Lightbulb, Shield, ShieldOff } from 'lucide-react'
import type { FC } from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
interface Props {
session: GetAgentSessionResponse
agentId: string
}
const getPermissionModeConfig = (mode: PermissionMode) => {
switch (mode) {
case 'default':
return {
icon: <Shield size={18} color="var(--color-primary)" />
}
case 'plan':
return {
icon: <Lightbulb size={18} color="#faad14" />
}
case 'acceptEdits':
return {
icon: <FileEdit size={18} color="#52c41a" />
}
case 'bypassPermissions':
return {
icon: <ShieldOff size={18} color="var(--color-error)" />
}
default:
return {
icon: <Shield size={18} color="var(--color-primary)" />
}
}
}
const PermissionModeDisplay: FC<Props> = ({ session, agentId }) => {
const { t } = useTranslation()
const permissionMode = session?.configuration?.permission_mode ?? 'default'
const modeCard = useMemo(() => {
return permissionModeCards.find((card) => card.mode === permissionMode)
}, [permissionMode])
const modeConfig = useMemo(() => getPermissionModeConfig(permissionMode), [permissionMode])
const handleClick = () => {
SessionSettingsPopup.show({
agentId,
sessionId: session.id,
tab: 'tooling'
})
}
if (!modeCard) {
return null
}
return (
<div
onClick={handleClick}
className="mx-2 cursor-pointer rounded-lg border-[0.5px] border-[var(--color-border)] px-3 py-2">
<div className="flex items-center gap-2.5">
<div className="flex shrink-0 items-center justify-center">{modeConfig.icon}</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="overflow-hidden text-ellipsis whitespace-nowrap font-semibold text-[var(--color-text-1)] text-xs">
{t(modeCard.titleKey, modeCard.titleFallback)}
</div>
<div className="overflow-hidden text-ellipsis whitespace-nowrap text-[11px] text-[var(--color-text-2)] leading-[1.4]">
{t(modeCard.descriptionKey, modeCard.descriptionFallback)}{' '}
{t(modeCard.behaviorKey, modeCard.behaviorFallback)}
</div>
</div>
</div>
</div>
)
}
export default PermissionModeDisplay
@@ -1,10 +1,12 @@
import type { CollapseProps } from 'antd'
import { Tag } from 'antd'
import { Popover, Tag } from 'antd'
import { Terminal } from 'lucide-react'
import { ToolTitle } from './GenericTools'
import type { BashToolInput as BashToolInputType, BashToolOutput as BashToolOutputType } from './types'
const MAX_TAG_LENGTH = 100
export function BashTool({
input,
output
@@ -15,6 +17,13 @@ export function BashTool({
// 如果有输出,计算输出行数
const outputLines = output ? output.split('\n').length : 0
// 处理命令字符串的截断
const command = input.command
const needsTruncate = command.length > MAX_TAG_LENGTH
const displayCommand = needsTruncate ? `${command.slice(0, MAX_TAG_LENGTH)}...` : command
const tagContent = <Tag className="whitespace-pre-wrap break-all font-mono">{displayCommand}</Tag>
return {
key: 'tool',
label: (
@@ -26,7 +35,15 @@ export function BashTool({
stats={output ? `${outputLines} ${outputLines === 1 ? 'line' : 'lines'}` : undefined}
/>
<div className="mt-1">
<Tag className="whitespace-pre-wrap break-all font-mono">{input.command}</Tag>
{needsTruncate ? (
<Popover
content={<div className="max-w-xl whitespace-pre-wrap break-all font-mono">{command}</div>}
trigger="hover">
{tagContent}
</Popover>
) : (
tagContent
)}
</div>
</>
),
+1 -1
View File
@@ -95,7 +95,7 @@ const HeaderNavbar: FC<Props> = ({
paddingRight: 0,
minWidth: 'auto'
}}>
<Tooltip placement="bottom" content={t('navbar.show_sidebar')} delay={800}>
<Tooltip placement="bottom" content={t('navbar.show_sidebar')} delay={800} placement="right">
<NavbarIcon onClick={() => toggleShowAssistants()}>
<PanelRightClose size={18} />
</NavbarIcon>
@@ -181,7 +181,7 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpand
</Tooltip>
)}
{!showWorkspace && (
<Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={0.8}>
<Tooltip title={t('navbar.show_sidebar')} mouseEnterDelay={0.8} placement="right">
<NavbarIcon onClick={handleToggleShowWorkspace}>
<PanelRightClose size={18} />
</NavbarIcon>
@@ -459,11 +459,22 @@ export const ToolingSettings: FC<AgentToolingSettingsProps> = ({ agentBase, upda
key={server.id}
className="border border-default-200"
title={
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 flex-col">
<span className="truncate font-medium text-sm">{server.name}</span>
<div className="flex items-center justify-between gap-2 py-3">
<div className="flex min-w-0 flex-col gap-1">
<div className="flex items-center gap-2">
{server.logoUrl && (
<img
src={server.logoUrl}
alt={`${server.name} logo`}
className="h-5 w-5 rounded object-cover"
/>
)}
<span className="truncate font-medium text-sm">{server.name}</span>
</div>
{server.description ? (
<span className="line-clamp-2 text-foreground-500 text-xs">{server.description}</span>
<span className="line-clamp-2 whitespace-pre-wrap break-all text-foreground-500 text-xs">
{server.description}
</span>
) : null}
</div>
<Switch
@@ -263,6 +263,7 @@ export const PluginBrowser: FC<PluginBrowserProps> = ({
items={pluginTypeTabItems}
className="w-full"
size="small"
centered
/>
</div>
+1 -1
View File
@@ -71,7 +71,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 171,
version: 172,
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'],
migrate
},
+92 -126
View File
@@ -2628,132 +2628,6 @@ const migrateConfig = {
return state
}
},
'162': (state: RootState) => {
try {
// @ts-ignore
if (state?.agents?.agents) {
// @ts-ignore
state.assistants.presets = [...state.agents.agents]
// @ts-ignore
delete state.agents.agents
}
if (state.settings.sidebarIcons) {
state.settings.sidebarIcons.visible = state.settings.sidebarIcons.visible.map((icon) => {
// @ts-ignore
return icon === 'agents' ? 'store' : icon
})
state.settings.sidebarIcons.disabled = state.settings.sidebarIcons.disabled.map((icon) => {
// @ts-ignore
return icon === 'agents' ? 'store' : icon
})
}
state.llm.providers.forEach((provider) => {
if (provider.anthropicApiHost) {
return
}
switch (provider.id) {
case 'deepseek':
provider.anthropicApiHost = 'https://api.deepseek.com/anthropic'
break
case 'moonshot':
provider.anthropicApiHost = 'https://api.moonshot.cn/anthropic'
break
case 'zhipu':
provider.anthropicApiHost = 'https://open.bigmodel.cn/api/anthropic'
break
case 'dashscope':
provider.anthropicApiHost = 'https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy'
break
case 'modelscope':
provider.anthropicApiHost = 'https://api-inference.modelscope.cn'
break
case 'aihubmix':
provider.anthropicApiHost = 'https://aihubmix.com'
break
case 'new-api':
provider.anthropicApiHost = 'http://localhost:3000'
break
case 'grok':
provider.anthropicApiHost = 'https://api.x.ai'
}
})
return state
} catch (error) {
logger.error('migrate 162 error', error as Error)
return state
}
},
'163': (state: RootState) => {
try {
addOcrProvider(state, BUILTIN_OCR_PROVIDERS_MAP.ovocr)
state.llm.providers.forEach((provider) => {
if (provider.id === 'cherryin') {
provider.anthropicApiHost = 'https://open.cherryin.net'
}
})
state.paintings.ovms_paintings = []
return state
} catch (error) {
logger.error('migrate 163 error', error as Error)
return state
}
},
'164': (state: RootState) => {
try {
addMiniApp(state, 'ling')
return state
} catch (error) {
logger.error('migrate 164 error', error as Error)
return state
}
},
'165': (state: RootState) => {
try {
addMiniApp(state, 'huggingchat')
return state
} catch (error) {
logger.error('migrate 165 error', error as Error)
return state
}
},
'166': (state: RootState) => {
try {
if (state.assistants.presets === undefined) {
state.assistants.presets = []
}
state.assistants.presets.forEach((preset) => {
if (!preset.settings) {
preset.settings = DEFAULT_ASSISTANT_SETTINGS
} else if (!preset.settings.toolUseMode) {
preset.settings.toolUseMode = DEFAULT_ASSISTANT_SETTINGS.toolUseMode
}
})
// 更新阿里云百炼的 Anthropic API 地址
const dashscopeProvider = state.llm.providers.find((provider) => provider.id === 'dashscope')
if (dashscopeProvider) {
dashscopeProvider.anthropicApiHost = 'https://dashscope.aliyuncs.com/apps/anthropic'
}
state.llm.providers.forEach((provider) => {
if (provider.id === SystemProviderIds['new-api'] && provider.type !== 'new-api') {
provider.type = 'new-api'
}
if (provider.id === SystemProviderIds.longcat) {
// https://longcat.chat/platform/docs/zh/#anthropic-api-%E6%A0%BC%E5%BC%8F
if (!provider.anthropicApiHost) {
provider.anthropicApiHost = 'https://api.longcat.chat/anthropic'
}
}
})
return state
} catch (error) {
logger.error('migrate 166 error', error as Error)
return state
}
},
'167': (state: RootState) => {
try {
addProvider(state, 'huggingface')
@@ -2822,6 +2696,98 @@ const migrateConfig = {
logger.error('migrate 171 error', error as Error)
return state
}
},
'172': (state: RootState) => {
try {
// Add ling and huggingchat mini apps
addMiniApp(state, 'ling')
addMiniApp(state, 'huggingchat')
// Add ovocr provider and clear ovms paintings
addOcrProvider(state, BUILTIN_OCR_PROVIDERS_MAP.ovocr)
if (isEmpty(state.paintings.ovms_paintings)) {
state.paintings.ovms_paintings = []
}
// Migrate agents to assistants presets
// @ts-ignore
if (state?.agents?.agents) {
// @ts-ignore
state.assistants.presets = [...state.agents.agents]
// @ts-ignore
delete state.agents.agents
}
// Initialize assistants presets
if (state.assistants.presets === undefined) {
state.assistants.presets = []
}
// Migrate assistants presets
state.assistants.presets.forEach((preset) => {
if (!preset.settings) {
preset.settings = DEFAULT_ASSISTANT_SETTINGS
} else if (!preset.settings.toolUseMode) {
preset.settings.toolUseMode = DEFAULT_ASSISTANT_SETTINGS.toolUseMode
}
})
// Migrate sidebar icons
if (state.settings.sidebarIcons) {
state.settings.sidebarIcons.visible = state.settings.sidebarIcons.visible.map((icon) => {
// @ts-ignore
return icon === 'agents' ? 'store' : icon
})
state.settings.sidebarIcons.disabled = state.settings.sidebarIcons.disabled.map((icon) => {
// @ts-ignore
return icon === 'agents' ? 'store' : icon
})
}
// Migrate llm providers
state.llm.providers.forEach((provider) => {
if (provider.id === SystemProviderIds['new-api'] && provider.type !== 'new-api') {
provider.type = 'new-api'
}
switch (provider.id) {
case 'deepseek':
provider.anthropicApiHost = 'https://api.deepseek.com/anthropic'
break
case 'moonshot':
provider.anthropicApiHost = 'https://api.moonshot.cn/anthropic'
break
case 'zhipu':
provider.anthropicApiHost = 'https://open.bigmodel.cn/api/anthropic'
break
case 'dashscope':
provider.anthropicApiHost = 'https://dashscope.aliyuncs.com/apps/anthropic'
break
case 'modelscope':
provider.anthropicApiHost = 'https://api-inference.modelscope.cn'
break
case 'aihubmix':
provider.anthropicApiHost = 'https://aihubmix.com'
break
case 'new-api':
provider.anthropicApiHost = 'http://localhost:3000'
break
case 'grok':
provider.anthropicApiHost = 'https://api.x.ai'
break
case 'cherryin':
provider.anthropicApiHost = 'https://open.cherryin.net'
break
case 'longcat':
provider.anthropicApiHost = 'https://api.longcat.chat/anthropic'
break
}
})
return state
} catch (error) {
logger.error('migrate 172 error', error as Error)
return state
}
}
}
-207
View File
@@ -1,207 +0,0 @@
'use client'
import * as React from 'react'
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
import { cn } from '@renderer/utils'
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
}
function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
}
function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
}
function ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[inset]:pl-8 data-[state=open]:text-accent-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in',
className
)}
{...props}
/>
)
}
function ContextMenuContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in',
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"data-[variant=destructive]:*:[svg]:!text-destructive relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[disabled]:opacity-50 data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
checked={checked}
{...props}>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn('px-2 py-1.5 font-medium text-foreground text-sm data-[inset]:pl-8', className)}
{...props}
/>
)
}
function ContextMenuSeparator({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
)
}
function ContextMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="context-menu-shortcut"
className={cn('ml-auto text-muted-foreground text-xs tracking-widest', className)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup
}