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
commit f32fa08c41
43 changed files with 500 additions and 658 deletions

View File

@ -22,7 +22,6 @@
"eslint.config.mjs"
],
"overrides": [
// set different env
{
"env": {
"node": true
@ -37,8 +36,7 @@
"src/renderer/**/*.{ts,tsx}",
"packages/aiCore/**",
"packages/extension-table-plus/**",
"packages/ui/**",
"resources/js/**"
"packages/ui/**"
]
},
{
@ -56,74 +54,16 @@
"files": ["src/preload/**"]
}
],
// We don't use the React plugin here because its behavior differs slightly from that of ESLint's React plugin.
"plugins": ["unicorn", "typescript", "oxc", "import"],
"rules": {
"constructor-super": "error",
"for-direction": "error",
"getter-return": "error",
"no-array-constructor": "off",
// "import/no-cycle": "error", // tons of error, bro
"no-async-promise-executor": "error",
"no-caller": "warn",
"no-case-declarations": "error",
"no-class-assign": "error",
"no-compare-neg-zero": "error",
"no-cond-assign": "error",
"no-const-assign": "error",
"no-constant-binary-expression": "error",
"no-constant-condition": "error",
"no-control-regex": "error",
"no-debugger": "error",
"no-delete-var": "error",
"no-dupe-args": "error",
"no-dupe-class-members": "error",
"no-dupe-else-if": "error",
"no-dupe-keys": "error",
"no-duplicate-case": "error",
"no-empty": "error",
"no-empty-character-class": "error",
"no-empty-pattern": "error",
"no-empty-static-block": "error",
"no-eval": "warn",
"no-ex-assign": "error",
"no-extra-boolean-cast": "error",
"no-fallthrough": "warn",
"no-func-assign": "error",
"no-global-assign": "error",
"no-import-assign": "error",
"no-invalid-regexp": "error",
"no-irregular-whitespace": "error",
"no-loss-of-precision": "error",
"no-misleading-character-class": "error",
"no-new-native-nonconstructor": "error",
"no-nonoctal-decimal-escape": "error",
"no-obj-calls": "error",
"no-octal": "error",
"no-prototype-builtins": "error",
"no-redeclare": "error",
"no-regex-spaces": "error",
"no-self-assign": "error",
"no-setter-return": "error",
"no-shadow-restricted-names": "error",
"no-sparse-arrays": "error",
"no-this-before-super": "error",
"no-unassigned-vars": "warn",
"no-undef": "error",
"no-unexpected-multiline": "error",
"no-unreachable": "error",
"no-unsafe-finally": "error",
"no-unsafe-negation": "error",
"no-unsafe-optional-chaining": "error",
"no-unused-expressions": "off", // this rule disallow us to use expression to call function, like `condition && fn()`
"no-unused-labels": "error",
"no-unused-private-class-members": "error",
"no-unused-expressions": "off",
"no-unused-vars": ["warn", { "caughtErrors": "none" }],
"no-useless-backreference": "error",
"no-useless-catch": "error",
"no-useless-escape": "error",
"no-useless-rename": "warn",
"no-with": "error",
"oxc/bad-array-method-on-arguments": "warn",
"oxc/bad-char-at-comparison": "warn",
"oxc/bad-comparison-sequence": "warn",
@ -135,19 +75,17 @@
"oxc/erasing-op": "warn",
"oxc/missing-throw": "warn",
"oxc/number-arg-out-of-range": "warn",
"oxc/only-used-in-recursion": "off", // manually off bacause of existing warning. may turn it on in the future
"oxc/only-used-in-recursion": "off",
"oxc/uninvoked-array-callback": "warn",
"require-yield": "error",
"typescript/await-thenable": "warn",
// "typescript/ban-ts-comment": "error",
"typescript/no-array-constructor": "error",
"typescript/consistent-type-imports": "error",
"typescript/no-array-constructor": "error",
"typescript/no-array-delete": "warn",
"typescript/no-base-to-string": "warn",
"typescript/no-duplicate-enum-values": "error",
"typescript/no-duplicate-type-constituents": "warn",
"typescript/no-empty-object-type": "off",
"typescript/no-explicit-any": "off", // not safe but too many errors
"typescript/no-explicit-any": "off",
"typescript/no-extra-non-null-assertion": "error",
"typescript/no-floating-promises": "warn",
"typescript/no-for-in-array": "warn",
@ -156,7 +94,7 @@
"typescript/no-misused-new": "error",
"typescript/no-misused-spread": "warn",
"typescript/no-namespace": "error",
"typescript/no-non-null-asserted-optional-chain": "off", // it's off now. but may turn it on.
"typescript/no-non-null-asserted-optional-chain": "off",
"typescript/no-redundant-type-constituents": "warn",
"typescript/no-require-imports": "off",
"typescript/no-this-alias": "error",
@ -174,20 +112,18 @@
"typescript/triple-slash-reference": "error",
"typescript/unbound-method": "warn",
"unicorn/no-await-in-promise-methods": "warn",
"unicorn/no-empty-file": "off", // manually off bacause of existing warning. may turn it on in the future
"unicorn/no-empty-file": "off",
"unicorn/no-invalid-fetch-options": "warn",
"unicorn/no-invalid-remove-event-listener": "warn",
"unicorn/no-new-array": "off", // manually off bacause of existing warning. may turn it on in the future
"unicorn/no-new-array": "off",
"unicorn/no-single-promise-in-promise-methods": "warn",
"unicorn/no-thenable": "off", // manually off bacause of existing warning. may turn it on in the future
"unicorn/no-thenable": "off",
"unicorn/no-unnecessary-await": "warn",
"unicorn/no-useless-fallback-in-spread": "warn",
"unicorn/no-useless-length-check": "warn",
"unicorn/no-useless-spread": "off", // manually off bacause of existing warning. may turn it on in the future
"unicorn/no-useless-spread": "off",
"unicorn/prefer-set-size": "warn",
"unicorn/prefer-string-starts-ends-with": "warn",
"use-isnan": "error",
"valid-typeof": "error"
"unicorn/prefer-string-starts-ends-with": "warn"
},
"settings": {
"jsdoc": {

View File

@ -11,8 +11,7 @@ This file provides guidance to AI coding assistants when working with code in th
- **Log centrally**: Route all logging through `loggerService` with the right context—no `console.log`.
- **Research via subagent**: Lean on `subagent` for external docs, APIs, news, and references.
- **Always propose before executing**: Before making any changes, clearly explain your planned approach and wait for explicit user approval to ensure alignment and prevent unwanted modifications.
- **Write conventional commits with emoji**: Commit small, focused changes using emoji-prefixed Conventional Commit messages (e.g., `✨ feat:`, `🐛 fix:`, `♻️ refactor:`, `
📝 docs:`).
- **Write conventional commits**: Commit small, focused changes using Conventional Commit messages (e.g., `feat:`, `fix:`, `refactor:`, `docs:`).
## Development Commands

View File

@ -82,7 +82,7 @@ Cherry Studio is a desktop client that supports multiple LLM providers, availabl
1. **Diverse LLM Provider Support**:
- ☁️ Major LLM Cloud Services: OpenAI, Gemini, Anthropic, and more
- 🔗 AI Web Service Integration: Claude, Perplexity, Poe, and others
- 🔗 AI Web Service Integration: Claude, Perplexity, [Poe](https://poe.com/), and others
- 💻 Local Model Support with Ollama, LM Studio
2. **AI Assistants & Conversations**:
@ -238,10 +238,6 @@ The Enterprise Edition addresses core challenges in team collaboration by centra
## ✨ Online Demo
> 🚧 **Public Beta Notice**
>
> The Enterprise Edition is currently in its early public beta stage, and we are actively iterating and optimizing its features. We are aware that it may not be perfectly stable yet. If you encounter any issues or have valuable suggestions during your trial, we would be very grateful if you could contact us via email to provide feedback.
**🔗 [Cherry Studio Enterprise](https://www.cherry-ai.com/enterprise)**
## Version Comparison
@ -249,7 +245,7 @@ The Enterprise Edition addresses core challenges in team collaboration by centra
| Feature | Community Edition | Enterprise Edition |
| :---------------- | :----------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------- |
| **Open Source** | ✅ Yes | ⭕️ Partially released to customers |
| **Cost** | Free for Personal Use / Commercial License | Buyout / Subscription Fee |
| **Cost** | [AGPL-3.0 License](https://github.com/CherryHQ/cherry-studio?tab=AGPL-3.0-1-ov-file) | Buyout / Subscription Fee |
| **Admin Backend** | — | ● Centralized **Model** Access<br>**Employee** Management<br>● Shared **Knowledge Base**<br>**Access** Control<br>**Data** Backup |
| **Server** | — | ✅ Dedicated Private Deployment |
@ -262,8 +258,12 @@ We believe the Enterprise Edition will become your team's AI productivity engine
# 🔗 Related Projects
- [new-api](https://github.com/QuantumNous/new-api): The next-generation LLM gateway and AI asset management system supports multiple languages.
- [one-api](https://github.com/songquanpeng/one-api): LLM API management and distribution system supporting mainstream models like OpenAI, Azure, and Anthropic. Features a unified API interface, suitable for key management and secondary distribution.
- [Poe](https://poe.com/): Poe gives you access to the best AI, all in one place. Explore GPT-5, Claude Opus 4.1, DeepSeek-R1, Veo 3, ElevenLabs, and millions of others.
- [ublacklist](https://github.com/iorate/ublacklist): Blocks specific sites from appearing in Google search results
# 🚀 Contributors

View File

@ -1,21 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"aliases": {
"components": "@renderer/ui/third-party",
"hooks": "@renderer/hooks",
"lib": "@renderer/lib",
"ui": "@renderer/ui",
"utils": "@renderer/utils"
},
"iconLibrary": "lucide",
"rsc": false,
"style": "new-york",
"tailwind": {
"baseColor": "zinc",
"config": "",
"css": "src/renderer/src/assets/styles/tailwind.css",
"cssVariables": true,
"prefix": ""
},
"tsx": true
}

View File

@ -470,3 +470,6 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
})
}
]
// resources/scripts should be maintained manually
export const HOME_CHERRY_DIR = '.cherrystudio'

View File

@ -1,36 +0,0 @@
;(() => {
let messageId = 0
const pendingCalls = new Map()
function api(method, ...args) {
const id = messageId++
return new Promise((resolve, reject) => {
pendingCalls.set(id, { resolve, reject })
window.parent.postMessage({ id, type: 'api-call', method, args }, '*')
})
}
window.addEventListener('message', (event) => {
if (event.data.type === 'api-response') {
const { id, result, error } = event.data
const pendingCall = pendingCalls.get(id)
if (pendingCall) {
if (error) {
pendingCall.reject(new Error(error))
} else {
pendingCall.resolve(result)
}
pendingCalls.delete(id)
}
}
})
window.api = new Proxy(
{},
{
get: (target, prop) => {
return (...args) => api(prop, ...args)
}
}
)
})()

View File

@ -1,5 +0,0 @@
export function getQueryParam(paramName) {
const url = new URL(window.location.href)
const params = new URLSearchParams(url.search)
return params.get(paramName)
}

View File

@ -7,7 +7,7 @@ const { downloadWithRedirects } = require('./download')
// Base URL for downloading bun binaries
const BUN_RELEASE_BASE_URL = 'https://gitcode.com/CherryHQ/bun/releases/download'
const DEFAULT_BUN_VERSION = '1.2.17' // Default fallback version
const DEFAULT_BUN_VERSION = '1.3.1' // Default fallback version
// Mapping of platform+arch to binary package name
const BUN_PACKAGES = {

View File

@ -7,28 +7,29 @@ const { downloadWithRedirects } = require('./download')
// Base URL for downloading uv binaries
const UV_RELEASE_BASE_URL = 'https://gitcode.com/CherryHQ/uv/releases/download'
const DEFAULT_UV_VERSION = '0.7.13'
const DEFAULT_UV_VERSION = '0.9.5'
// Mapping of platform+arch to binary package name
const UV_PACKAGES = {
'darwin-arm64': 'uv-aarch64-apple-darwin.zip',
'darwin-x64': 'uv-x86_64-apple-darwin.zip',
'darwin-arm64': 'uv-aarch64-apple-darwin.tar.gz',
'darwin-x64': 'uv-x86_64-apple-darwin.tar.gz',
'win32-arm64': 'uv-aarch64-pc-windows-msvc.zip',
'win32-ia32': 'uv-i686-pc-windows-msvc.zip',
'win32-x64': 'uv-x86_64-pc-windows-msvc.zip',
'linux-arm64': 'uv-aarch64-unknown-linux-gnu.zip',
'linux-ia32': 'uv-i686-unknown-linux-gnu.zip',
'linux-ppc64': 'uv-powerpc64-unknown-linux-gnu.zip',
'linux-ppc64le': 'uv-powerpc64le-unknown-linux-gnu.zip',
'linux-s390x': 'uv-s390x-unknown-linux-gnu.zip',
'linux-x64': 'uv-x86_64-unknown-linux-gnu.zip',
'linux-armv7l': 'uv-armv7-unknown-linux-gnueabihf.zip',
'linux-arm64': 'uv-aarch64-unknown-linux-gnu.tar.gz',
'linux-ia32': 'uv-i686-unknown-linux-gnu.tar.gz',
'linux-ppc64': 'uv-powerpc64-unknown-linux-gnu.tar.gz',
'linux-ppc64le': 'uv-powerpc64le-unknown-linux-gnu.tar.gz',
'linux-riscv64': 'uv-riscv64gc-unknown-linux-gnu.tar.gz',
'linux-s390x': 'uv-s390x-unknown-linux-gnu.tar.gz',
'linux-x64': 'uv-x86_64-unknown-linux-gnu.tar.gz',
'linux-armv7l': 'uv-armv7-unknown-linux-gnueabihf.tar.gz',
// MUSL variants
'linux-musl-arm64': 'uv-aarch64-unknown-linux-musl.zip',
'linux-musl-ia32': 'uv-i686-unknown-linux-musl.zip',
'linux-musl-x64': 'uv-x86_64-unknown-linux-musl.zip',
'linux-musl-armv6l': 'uv-arm-unknown-linux-musleabihf.zip',
'linux-musl-armv7l': 'uv-armv7-unknown-linux-musleabihf.zip'
'linux-musl-arm64': 'uv-aarch64-unknown-linux-musl.tar.gz',
'linux-musl-ia32': 'uv-i686-unknown-linux-musl.tar.gz',
'linux-musl-x64': 'uv-x86_64-unknown-linux-musl.tar.gz',
'linux-musl-armv6l': 'uv-arm-unknown-linux-musleabihf.tar.gz',
'linux-musl-armv7l': 'uv-armv7-unknown-linux-musleabihf.tar.gz'
}
/**
@ -56,6 +57,7 @@ async function downloadUvBinary(platform, arch, version = DEFAULT_UV_VERSION, is
const downloadUrl = `${UV_RELEASE_BASE_URL}/${version}/${packageName}`
const tempdir = os.tmpdir()
const tempFilename = path.join(tempdir, packageName)
const isTarGz = packageName.endsWith('.tar.gz')
try {
console.log(`Downloading uv ${version} for ${platformKey}...`)
@ -65,34 +67,58 @@ async function downloadUvBinary(platform, arch, version = DEFAULT_UV_VERSION, is
console.log(`Extracting ${packageName} to ${binDir}...`)
const zip = new StreamZip.async({ file: tempFilename })
if (isTarGz) {
// Use tar command to extract tar.gz files (macOS and Linux)
const tempExtractDir = path.join(tempdir, `uv-extract-${Date.now()}`)
fs.mkdirSync(tempExtractDir, { recursive: true })
// Get all entries in the zip file
const entries = await zip.entries()
execSync(`tar -xzf "${tempFilename}" -C "${tempExtractDir}"`, { stdio: 'inherit' })
// Extract files directly to binDir, flattening the directory structure
for (const entry of Object.values(entries)) {
if (!entry.isDirectory) {
// Get just the filename without path
const filename = path.basename(entry.name)
const outputPath = path.join(binDir, filename)
console.log(`Extracting ${entry.name} -> ${filename}`)
await zip.extract(entry.name, outputPath)
// Make executable files executable on Unix-like systems
if (platform !== 'win32') {
try {
// Find all files in the extracted directory and move them to binDir
const findAndMoveFiles = (dir) => {
const entries = fs.readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
findAndMoveFiles(fullPath)
} else {
const filename = path.basename(entry.name)
const outputPath = path.join(binDir, filename)
fs.copyFileSync(fullPath, outputPath)
console.log(`Extracted ${entry.name} -> ${outputPath}`)
// Make executable on Unix-like systems
fs.chmodSync(outputPath, 0o755)
} catch (chmodError) {
console.error(`Warning: Failed to set executable permissions on ${filename}`)
return 102
}
}
console.log(`Extracted ${entry.name} -> ${outputPath}`)
}
findAndMoveFiles(tempExtractDir)
// Clean up temporary extraction directory
fs.rmSync(tempExtractDir, { recursive: true })
} else {
// Use StreamZip for zip files (Windows)
const zip = new StreamZip.async({ file: tempFilename })
// Get all entries in the zip file
const entries = await zip.entries()
// Extract files directly to binDir, flattening the directory structure
for (const entry of Object.values(entries)) {
if (!entry.isDirectory) {
// Get just the filename without path
const filename = path.basename(entry.name)
const outputPath = path.join(binDir, filename)
console.log(`Extracting ${entry.name} -> ${filename}`)
await zip.extract(entry.name, outputPath)
console.log(`Extracted ${entry.name} -> ${outputPath}`)
}
}
await zip.close()
}
await zip.close()
fs.unlinkSync(tempFilename)
console.log(`Successfully installed uv ${version} for ${platform}-${arch}`)
return 0

View File

@ -1,88 +0,0 @@
const https = require('https')
const { loggerService } = require('@logger')
const logger = loggerService.withContext('IpService')
/**
* 获取用户的IP地址所在国家
* @returns {Promise<string>} 返回国家代码默认为'CN'
*/
async function getIpCountry() {
return new Promise((resolve) => {
// 添加超时控制
const timeout = setTimeout(() => {
logger.info('IP Address Check Timeout, default to China Mirror')
resolve('CN')
}, 5000)
const options = {
hostname: 'ipinfo.io',
path: '/json',
method: 'GET',
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Accept-Language': 'en-US,en;q=0.9'
}
}
const req = https.request(options, (res) => {
clearTimeout(timeout)
let data = ''
res.on('data', (chunk) => {
data += chunk
})
res.on('end', () => {
try {
const parsed = JSON.parse(data)
const country = parsed.country || 'CN'
logger.info(`Detected user IP address country: ${country}`)
resolve(country)
} catch (error) {
logger.error('Failed to parse IP address information:', error.message)
resolve('CN')
}
})
})
req.on('error', (error) => {
clearTimeout(timeout)
logger.error('Failed to get IP address information:', error.message)
resolve('CN')
})
req.end()
})
}
/**
* 检查用户是否在中国
* @returns {Promise<boolean>} 如果用户在中国返回true否则返回false
*/
async function isUserInChina() {
const country = await getIpCountry()
return country.toLowerCase() === 'cn'
}
/**
* 根据用户位置获取适合的npm镜像URL
* @returns {Promise<string>} 返回npm镜像URL
*/
async function getNpmRegistryUrl() {
const inChina = await isUserInChina()
if (inChina) {
logger.info('User in China, using Taobao npm mirror')
return 'https://registry.npmmirror.com'
} else {
logger.info('User not in China, using default npm mirror')
return 'https://registry.npmjs.org'
}
}
module.exports = {
getIpCountry,
isUserInChina,
getNpmRegistryUrl
}

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)

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)

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 {

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

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)

View File

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

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')
}
/**

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

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
}

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

View File

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

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

View File

@ -3863,6 +3863,12 @@
"usage": "用法",
"version": "版本"
},
"oauth": {
"callback": {
"message": "您可以关闭此页面并返回 Cherry Studio",
"title": "认证成功"
}
},
"prompts": {
"arguments": "参数",
"availablePrompts": "可用提示",

View File

@ -3863,6 +3863,12 @@
"usage": "用法",
"version": "版本"
},
"oauth": {
"callback": {
"message": "您可以關閉此頁面並返回 Cherry Studio",
"title": "認證成功"
}
},
"prompts": {
"arguments": "參數",
"availablePrompts": "可用提示",

View File

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

View File

@ -3863,6 +3863,12 @@
"usage": "Χρήση",
"version": "Έκδοση"
},
"oauth": {
"callback": {
"message": "Μπορείτε να κλείσετε αυτήν τη σελίδα και να επιστρέψετε στο Cherry Studio",
"title": "Επιτυχής Ταυτοποίηση"
}
},
"prompts": {
"arguments": "Ορίσματα",
"availablePrompts": "Διαθέσιμες Υποδείξεις",

View File

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

View File

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

View File

@ -3863,6 +3863,12 @@
"usage": "使用法",
"version": "バージョン"
},
"oauth": {
"callback": {
"message": "このページを閉じてCherry Studioに戻ることができます",
"title": "認証成功"
}
},
"prompts": {
"arguments": "引数",
"availablePrompts": "利用可能なプロンプト",

View File

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

View File

@ -3863,6 +3863,12 @@
"usage": "Использование",
"version": "Версия"
},
"oauth": {
"callback": {
"message": "Вы можете закрыть эту страницу и вернуться в Cherry Studio",
"title": "Аутентификация Успешна"
}
},
"prompts": {
"arguments": "Аргументы",
"availablePrompts": "Доступные подсказки",

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>

View File

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

View File

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

View File

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

View File

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

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>

View File

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

View File

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

View File

@ -263,6 +263,7 @@ export const PluginBrowser: FC<PluginBrowserProps> = ({
items={pluginTypeTabItems}
className="w-full"
size="small"
centered
/>
</div>

View File

@ -71,7 +71,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 171,
version: 172,
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'],
migrate
},

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

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
}