Compare commits

...

15 Commits

Author SHA1 Message Date
MyPrototypeWhat
d7e79353fc feat: add fadeInWithBlur animation to Tailwind CSS and update Markdown component
- Introduced a new `fadeInWithBlur` keyframe animation in Tailwind CSS for enhanced visual effects.
- Removed inline fade animation styles from the `Markdown` component to streamline rendering.
- Updated the `SmoothFade` function to utilize the new animation, improving the user experience during content transitions.
2025-09-26 19:34:49 +08:00
MyPrototypeWhat
93e972a5da chore: remove @radix-ui/react-slot dependency and update utility functions
- Removed `@radix-ui/react-slot` dependency from package.json and corresponding entries in yarn.lock to streamline dependencies.
- Adjusted the `PlaceholderBlock` component's margin styling for improved layout.
- Refactored utility functions by exporting `cn` from `@heroui/react`, enhancing class name management.
2025-09-26 19:17:19 +08:00
MyPrototypeWhat
12d08e4748 feat: add Radix UI slot component and enhance Markdown rendering
- Added `@radix-ui/react-slot` dependency to package.json for improved component composition.
- Introduced a new `Loader` component with various loading styles to enhance user experience during asynchronous operations.
- Updated `PlaceholderBlock` to utilize the new `Loader` component, improving loading state representation.
- Enhanced `Markdown` component to support smooth fade animations based on streaming status, improving visual feedback during content updates.
- Refactored utility functions to include a new `cn` function for class name merging, streamlining component styling.
2025-09-26 17:46:51 +08:00
MyPrototypeWhat
52a980f751 fix(websearch): handle blocked domains conditionally in web search (#10374)
fix(websearch): handle blocked domains conditionally in web search configurations

- Updated the handling of blocked domains in both Google Vertex and Anthropic web search configurations to only include them if they are present, improving robustness and preventing unnecessary parameters from being passed.
2025-09-26 12:10:28 +08:00
kangfenmao
3b7ab2aec8 chore: remove cherryin provider references and update versioning
- Commented out all references to the 'cherryin' provider in configuration files.
- Updated the version in the persisted reducer from 157 to 158.
- Added migration logic to remove 'cherryin' from the state during version 158 migration.
2025-09-26 10:36:17 +08:00
Zhaokun
d41e239b89 Fix slash newline (#10305)
* Fix slash menu Shift+Enter newline

* fix: enable Shift+Enter newline in rich editor with slash commands

Fixed an issue where users couldn't create new lines using Shift+Enter when
slash command menu (/foo) was active. The problem was caused by globa
keyboard event handlers intercepting all Enter key variants.

Changes:
 - Allow Shift+Enter to pass through QuickPanel event handling
 - Add Shift+Enter detection in CommandListPopover to return false
 - Implement fallback Shift+Enter handling in command suggestion render
 - Remove unused import in AppUpdater.ts
 - Convert Chinese comments to English in QuickPanel
- Add test coverage for command suggestion functionality

---------

Co-authored-by: Zhaokun Zhang <zhaokunzhang@Zhaokuns-Air.lan>
2025-09-25 22:07:10 +01:00
kangfenmao
b85040f579 chore: update dependencies and versioning
- Bump version to 1.6.1 in package.json.
- Add patch for @ai-sdk/google@2.0.14 to address specific issues.
- Update yarn.lock to reflect the new dependency resolution for @ai-sdk/google.
- Modify getModelPath function to accept baseURL parameter for improved flexibility.
2025-09-25 22:11:29 +08:00
kangfenmao
8bcd229849 feat: enhance model filtering based on supported endpoint types
- Updated CodeToolsPage to include checks for supported endpoint types for various CLI tools.
- Added 'cherryin' to GEMINI_SUPPORTED_PROVIDERS and updated CLAUDE_SUPPORTED_PROVIDERS to include it.
- Improved logic for determining model compatibility with selected CLI tools, enhancing overall functionality.
2025-09-25 22:11:29 +08:00
beyondkmp
d12515ccb9 feat: enhance multi-language support in release notes processing (#10355)
* feat: enhance multi-language support in release notes processing

* fix review comments

* format code
2025-09-25 21:51:05 +08:00
beyondkmp
499cb52e28 feat: enhance terminal command handling for macOS (#10362)
- Introduced a helper function to escape strings for AppleScript to ensure proper command execution.
- Updated terminal command definitions to utilize the new escape function, improving compatibility with special characters.
- Adjusted command parameters to use double quotes for directory paths, enhancing consistency and reliability.
2025-09-25 21:26:04 +08:00
MyPrototypeWhat
05a318225c refactor(reasoning): simplify reasoning time tracking by removing unu… (#10360)
* refactor(reasoning): simplify reasoning time tracking by removing unused variables and logic

- Removed hasStartedThinking and reasoningBlockId variables as they are no longer needed.
- Updated onThinkingComplete callback to eliminate final_thinking_millsec parameter, streamlining the function.

* refactor(thinking): streamline thinking millisecond tracking and update event handling

- Removed unused thinking_millsec parameter from onThinkingComplete and adjusted related logic.
- Updated AiSdkToChunkAdapter to simplify reasoning-end event handling by removing unnecessary properties.
- Modified integration tests to reflect changes in thinking event structure.
2025-09-25 19:06:25 +08:00
one
caad0bc005 fix: svg foreignobject in code blocks (#10339)
* fix: svg foreignobject in code blocks

* fix: set white-space explicitly
2025-09-25 18:02:06 +08:00
beyondkmp
067ecb5e8e style: update UpdateNotesWrapper to use markdown class for improved formatting (#10359) 2025-09-25 09:07:27 +01:00
beyondkmp
0f8cbeed11 fix(translate): remove unused effect for clearing translation contenton mount (#10349)
* fix(translate): remove unused effect for clearing translation content on mount

* format code
2025-09-25 13:44:17 +08:00
Phantom
2ed99c0cb8 ci(workflow): only trigger PR CI on non-draft PRs (#10338)
ci(workflow): only trigger PR CI on non-draft PRs and specific events

Add trigger conditions for PR CI workflow to run on non-draft PRs and specific event types
2025-09-25 13:28:51 +08:00
36 changed files with 1088 additions and 127 deletions

View File

@@ -10,12 +10,14 @@ on:
- main
- develop
- v2
types: [ready_for_review, synchronize, opened]
jobs:
build:
runs-on: ubuntu-latest
env:
PRCI: true
if: github.event.pull_request.draft == false
steps:
- name: Check out Git repository

View File

@@ -0,0 +1,36 @@
diff --git a/dist/index.mjs b/dist/index.mjs
index 110f37ec18c98b1d55ae2b73cc716194e6f9094d..91d0f336b318833c6cee9599fe91370c0ff75323 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -447,7 +447,10 @@ function convertToGoogleGenerativeAIMessages(prompt, options) {
}
// src/get-model-path.ts
-function getModelPath(modelId) {
+function getModelPath(modelId, baseURL) {
+ if (baseURL?.includes('cherryin')) {
+ return `models/${modelId}`;
+ }
return modelId.includes("/") ? modelId : `models/${modelId}`;
}
@@ -856,7 +859,8 @@ var GoogleGenerativeAILanguageModel = class {
rawValue: rawResponse
} = await postJsonToApi2({
url: `${this.config.baseURL}/${getModelPath(
- this.modelId
+ this.modelId,
+ this.config.baseURL
)}:generateContent`,
headers: mergedHeaders,
body: args,
@@ -962,7 +966,8 @@ var GoogleGenerativeAILanguageModel = class {
);
const { responseHeaders, value: response } = await postJsonToApi2({
url: `${this.config.baseURL}/${getModelPath(
- this.modelId
+ this.modelId,
+ this.config.baseURL
)}:streamGenerateContent?alt=sse`,
headers,
body: args,

View File

@@ -125,6 +125,7 @@ afterSign: scripts/notarize.js
artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
<!--LANG:en-->
🚀 New Features:
- Refactored AI core engine for more efficient and stable content generation
- Added support for multiple AI model providers: CherryIN, AiOnly
@@ -151,4 +152,32 @@ releaseInfo:
- Improved scrollbar component with horizontal scrolling support
- Fixed multiple translation issues: paste handling, file processing, state management
- Various UI optimizations and bug fixes
<!--LANG:zh-CN-->
🚀 新功能:
- 重构 AI 核心引擎,提供更高效稳定的内容生成
- 新增多个 AI 模型提供商支持CherryIN、AiOnly
- 新增 API 服务器功能,支持外部应用集成
- 新增 PaddleOCR 文档识别,增强文档处理能力
- 新增 Anthropic OAuth 认证支持
- 新增数据存储空间限制提醒
- 新增字体设置,支持全局字体和代码字体自定义
- 新增翻译完成后自动复制功能
- 新增键盘快捷键:重命名主题、编辑最后一条消息等
- 新增文本附件预览,可查看消息中的文件内容
- 新增自定义窗口控制按钮(最小化、最大化、关闭)
- 支持通义千问长文本qwen-long和文档分析qwen-doc模型原生文件上传
- 支持通义千问图像识别模型Qwen-Image
- 新增 iFlow CLI 支持
- 知识库和网页搜索转换为工具调用方式,提升灵活性
🎨 界面改进与问题修复:
- 集成 HeroUI 和 Tailwind CSS 框架
- 优化消息通知样式,统一 toast 组件
- 免费模型移至底部固定位置,便于访问
- 重构快捷面板和输入栏工具,操作更流畅
- 优化导航栏和侧边栏响应式设计
- 改进滚动条组件,支持水平滚动
- 修复多个翻译问题:粘贴处理、文件处理、状态管理
- 各种界面优化和问题修复
<!--LANG:END-->

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.6.0",
"version": "1.6.1",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -368,7 +368,8 @@
"pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch",
"undici": "6.21.2",
"vite": "npm:rolldown-vite@latest",
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch"
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
"@ai-sdk/google@npm:2.0.14": "patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch"
},
"packageManager": "yarn@4.9.1",
"lint-staged": {

View File

@@ -39,7 +39,7 @@
"@ai-sdk/anthropic": "^2.0.17",
"@ai-sdk/azure": "^2.0.30",
"@ai-sdk/deepseek": "^1.0.17",
"@ai-sdk/google": "^2.0.14",
"@ai-sdk/google": "patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch",
"@ai-sdk/openai": "^2.0.30",
"@ai-sdk/openai-compatible": "^1.0.17",
"@ai-sdk/provider": "^2.0.0",

View File

@@ -368,16 +368,27 @@ export const WINDOWS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
}
]
// Helper function to escape strings for AppleScript
const escapeForAppleScript = (str: string): string => {
// In AppleScript strings, backslashes and double quotes need to be escaped
// When passed through osascript -e with single quotes, we need:
// 1. Backslash: \ -> \\
// 2. Double quote: " -> \"
return str
.replace(/\\/g, '\\\\') // Escape backslashes first
.replace(/"/g, '\\"') // Then escape double quotes
}
export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
{
id: terminalApps.systemDefault,
name: 'Terminal',
bundleId: 'com.apple.Terminal',
command: (directory: string, fullCommand: string) => ({
command: (_directory: string, fullCommand: string) => ({
command: 'sh',
args: [
'-c',
`open -na Terminal && sleep 0.5 && osascript -e 'tell application "Terminal" to activate' -e 'tell application "Terminal" to do script "cd '${directory.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}' && clear && ${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}" in front window'`
`open -na Terminal && sleep 0.5 && osascript -e 'tell application "Terminal" to activate' -e 'tell application "Terminal" to do script "${escapeForAppleScript(fullCommand)}" in front window'`
]
})
},
@@ -385,11 +396,11 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
id: terminalApps.iterm2,
name: 'iTerm2',
bundleId: 'com.googlecode.iterm2',
command: (directory: string, fullCommand: string) => ({
command: (_directory: string, fullCommand: string) => ({
command: 'sh',
args: [
'-c',
`open -na iTerm && sleep 0.8 && osascript -e 'on waitUntilRunning()\n repeat 50 times\n tell application "System Events"\n if (exists process "iTerm2") then exit repeat\n end tell\n delay 0.1\n end repeat\nend waitUntilRunning\n\nwaitUntilRunning()\n\ntell application "iTerm2"\n if (count of windows) = 0 then\n create window with default profile\n delay 0.3\n else\n tell current window\n create tab with default profile\n end tell\n delay 0.3\n end if\n tell current session of current window to write text "cd '${directory.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}' && clear && ${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"\n activate\nend tell'`
`open -na iTerm && sleep 0.8 && osascript -e 'on waitUntilRunning()\n repeat 50 times\n tell application "System Events"\n if (exists process "iTerm2") then exit repeat\n end tell\n delay 0.1\n end repeat\nend waitUntilRunning\n\nwaitUntilRunning()\n\ntell application "iTerm2"\n if (count of windows) = 0 then\n create window with default profile\n delay 0.3\n else\n tell current window\n create tab with default profile\n end tell\n delay 0.3\n end if\n tell current session of current window to write text "${escapeForAppleScript(fullCommand)}"\n activate\nend tell'`
]
})
},
@@ -397,11 +408,11 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
id: terminalApps.kitty,
name: 'kitty',
bundleId: 'net.kovidgoyal.kitty',
command: (directory: string, fullCommand: string) => ({
command: (_directory: string, fullCommand: string) => ({
command: 'sh',
args: [
'-c',
`cd "${directory}" && open -na kitty --args --directory="${directory}" sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "kitty" to activate'`
`cd "${_directory}" && open -na kitty --args --directory="${_directory}" sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "kitty" to activate'`
]
})
},
@@ -409,11 +420,11 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
id: terminalApps.alacritty,
name: 'Alacritty',
bundleId: 'org.alacritty',
command: (directory: string, fullCommand: string) => ({
command: (_directory: string, fullCommand: string) => ({
command: 'sh',
args: [
'-c',
`open -na Alacritty --args --working-directory "${directory}" -e sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "Alacritty" to activate'`
`open -na Alacritty --args --working-directory "${_directory}" -e sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "Alacritty" to activate'`
]
})
},
@@ -421,11 +432,11 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
id: terminalApps.wezterm,
name: 'WezTerm',
bundleId: 'com.github.wez.wezterm',
command: (directory: string, fullCommand: string) => ({
command: (_directory: string, fullCommand: string) => ({
command: 'sh',
args: [
'-c',
`open -na WezTerm --args start --new-tab --cwd "${directory}" -- sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "WezTerm" to activate'`
`open -na WezTerm --args start --new-tab --cwd "${_directory}" -- sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "WezTerm" to activate'`
]
})
},
@@ -433,11 +444,11 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
id: terminalApps.ghostty,
name: 'Ghostty',
bundleId: 'com.mitchellh.ghostty',
command: (directory: string, fullCommand: string) => ({
command: (_directory: string, fullCommand: string) => ({
command: 'sh',
args: [
'-c',
`cd "${directory}" && open -na Ghostty --args --working-directory="${directory}" -e sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "Ghostty" to activate'`
`cd "${_directory}" && open -na Ghostty --args --working-directory="${_directory}" -e sh -c "${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}; exec \\$SHELL" && sleep 0.5 && osascript -e 'tell application "Ghostty" to activate'`
]
})
},
@@ -445,7 +456,7 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
id: terminalApps.tabby,
name: 'Tabby',
bundleId: 'org.tabby',
command: (directory: string, fullCommand: string) => ({
command: (_directory: string, fullCommand: string) => ({
command: 'sh',
args: [
'-c',
@@ -453,7 +464,7 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [
open -na Tabby --args open && sleep 0.3
else
open -na Tabby --args open && sleep 2
fi && osascript -e 'tell application "Tabby" to activate' -e 'set the clipboard to "cd \\"${directory.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}\\" && clear && ${fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"' -e 'tell application "System Events" to tell process "Tabby" to keystroke "v" using {command down}' -e 'tell application "System Events" to key code 36'`
fi && osascript -e 'tell application "Tabby" to activate' -e 'set the clipboard to "${escapeForAppleScript(fullCommand)}"' -e 'tell application "System Events" to tell process "Tabby" to keystroke "v" using {command down}' -e 'tell application "System Events" to key code 36'`
]
})
}

View File

@@ -17,6 +17,13 @@ import { windowService } from './WindowService'
const logger = loggerService.withContext('AppUpdater')
// Language markers constants for multi-language release notes
const LANG_MARKERS = {
EN_START: '<!--LANG:en-->',
ZH_CN_START: '<!--LANG:zh-CN-->',
END: '<!--LANG:END-->'
} as const
export default class AppUpdater {
autoUpdater: _AppUpdater = autoUpdater
private releaseInfo: UpdateInfo | undefined
@@ -41,7 +48,8 @@ export default class AppUpdater {
autoUpdater.on('update-available', (releaseInfo: UpdateInfo) => {
logger.info('update available', releaseInfo)
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateAvailable, releaseInfo)
const processedReleaseInfo = this.processReleaseInfo(releaseInfo)
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateAvailable, processedReleaseInfo)
})
// 检测到不需要更新时
@@ -56,9 +64,10 @@ export default class AppUpdater {
// 当需要更新的内容下载完成后
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, releaseInfo)
this.releaseInfo = releaseInfo
logger.info('update downloaded', releaseInfo)
const processedReleaseInfo = this.processReleaseInfo(releaseInfo)
windowService.getMainWindow()?.webContents.send(IpcChannel.UpdateDownloaded, processedReleaseInfo)
this.releaseInfo = processedReleaseInfo
logger.info('update downloaded', processedReleaseInfo)
})
if (isWin) {
@@ -271,16 +280,99 @@ export default class AppUpdater {
})
}
/**
* Check if release notes contain multi-language markers
*/
private hasMultiLanguageMarkers(releaseNotes: string): boolean {
return releaseNotes.includes(LANG_MARKERS.EN_START)
}
/**
* Parse multi-language release notes and return the appropriate language version
* @param releaseNotes - Release notes string with language markers
* @returns Parsed release notes for the user's language
*
* Expected format:
* <!--LANG:en-->English content<!--LANG:zh-CN-->Chinese content<!--LANG:END-->
*/
private parseMultiLangReleaseNotes(releaseNotes: string): string {
try {
const language = configManager.getLanguage()
const isChineseUser = language === 'zh-CN' || language === 'zh-TW'
// Create regex patterns using constants
const enPattern = new RegExp(
`${LANG_MARKERS.EN_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([\\s\\S]*?)${LANG_MARKERS.ZH_CN_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`
)
const zhPattern = new RegExp(
`${LANG_MARKERS.ZH_CN_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([\\s\\S]*?)${LANG_MARKERS.END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`
)
// Extract language sections
const enMatch = releaseNotes.match(enPattern)
const zhMatch = releaseNotes.match(zhPattern)
// Return appropriate language version with proper fallback
if (isChineseUser && zhMatch) {
return zhMatch[1].trim()
} else if (enMatch) {
return enMatch[1].trim()
} else {
// Clean fallback: remove all language markers
logger.warn('Failed to extract language-specific release notes, using cleaned fallback')
return releaseNotes
.replace(new RegExp(`${LANG_MARKERS.EN_START}|${LANG_MARKERS.ZH_CN_START}|${LANG_MARKERS.END}`, 'g'), '')
.trim()
}
} catch (error) {
logger.error('Failed to parse multi-language release notes', error as Error)
// Return original notes as safe fallback
return releaseNotes
}
}
/**
* Process release info to handle multi-language release notes
* @param releaseInfo - Original release info from updater
* @returns Processed release info with localized release notes
*/
private processReleaseInfo(releaseInfo: UpdateInfo): UpdateInfo {
const processedInfo = { ...releaseInfo }
// Handle multi-language release notes in string format
if (releaseInfo.releaseNotes && typeof releaseInfo.releaseNotes === 'string') {
// Check if it contains multi-language markers
if (this.hasMultiLanguageMarkers(releaseInfo.releaseNotes)) {
processedInfo.releaseNotes = this.parseMultiLangReleaseNotes(releaseInfo.releaseNotes)
}
}
return processedInfo
}
/**
* Format release notes for display
* @param releaseNotes - Release notes in various formats
* @returns Formatted string for display
*/
private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string {
if (!releaseNotes) {
return ''
}
if (typeof releaseNotes === 'string') {
// Check if it contains multi-language markers
if (this.hasMultiLanguageMarkers(releaseNotes)) {
return this.parseMultiLangReleaseNotes(releaseNotes)
}
return releaseNotes
}
return releaseNotes.map((note) => note.note).join('\n')
if (Array.isArray(releaseNotes)) {
return releaseNotes.map((note) => note.note).join('\n')
}
return ''
}
}
interface GithubReleaseInfo {

View File

@@ -666,7 +666,7 @@ class CodeToolsService {
const command = envPrefix ? `${envPrefix} && ${baseCommand}` : baseCommand
// Combine directory change with the main command to ensure they execute in the same shell session
const fullCommand = `cd '${directory.replace(/'/g, "\\'")}' && clear && ${command}`
const fullCommand = `cd "${directory.replace(/"/g, '\\"')}" && clear && ${command}`
const terminalConfig = await this.getTerminalConfig(options.terminal)
logger.info(`Using terminal: ${terminalConfig.name} (${terminalConfig.id})`)

View File

@@ -0,0 +1,319 @@
import { UpdateInfo } from 'builder-util-runtime'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Mock dependencies
vi.mock('@logger', () => ({
loggerService: {
withContext: () => ({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn()
})
}
}))
vi.mock('../ConfigManager', () => ({
configManager: {
getLanguage: vi.fn(),
getAutoUpdate: vi.fn(() => false),
getTestPlan: vi.fn(() => false),
getTestChannel: vi.fn(),
getClientId: vi.fn(() => 'test-client-id')
}
}))
vi.mock('../WindowService', () => ({
windowService: {
getMainWindow: vi.fn()
}
}))
vi.mock('@main/constant', () => ({
isWin: false
}))
vi.mock('@main/utils/ipService', () => ({
getIpCountry: vi.fn(() => 'US')
}))
vi.mock('@main/utils/locales', () => ({
locales: {
en: { translation: { update: {} } },
'zh-CN': { translation: { update: {} } }
}
}))
vi.mock('@main/utils/systemInfo', () => ({
generateUserAgent: vi.fn(() => 'test-user-agent')
}))
vi.mock('electron', () => ({
app: {
isPackaged: true,
getVersion: vi.fn(() => '1.0.0'),
getPath: vi.fn(() => '/test/path')
},
dialog: {
showMessageBox: vi.fn()
},
BrowserWindow: vi.fn(),
net: {
fetch: vi.fn()
}
}))
vi.mock('electron-updater', () => ({
autoUpdater: {
logger: null,
forceDevUpdateConfig: false,
autoDownload: false,
autoInstallOnAppQuit: false,
requestHeaders: {},
on: vi.fn(),
setFeedURL: vi.fn(),
checkForUpdates: vi.fn(),
downloadUpdate: vi.fn(),
quitAndInstall: vi.fn(),
channel: '',
allowDowngrade: false,
disableDifferentialDownload: false,
currentVersion: '1.0.0'
},
Logger: vi.fn(),
NsisUpdater: vi.fn(),
AppUpdater: vi.fn()
}))
// Import after mocks
import AppUpdater from '../AppUpdater'
import { configManager } from '../ConfigManager'
describe('AppUpdater', () => {
let appUpdater: AppUpdater
beforeEach(() => {
vi.clearAllMocks()
appUpdater = new AppUpdater()
})
describe('parseMultiLangReleaseNotes', () => {
const sampleReleaseNotes = `<!--LANG:en-->
🚀 New Features:
- Feature A
- Feature B
🎨 UI Improvements:
- Improvement A
<!--LANG:zh-CN-->
🚀 新功能:
- 功能 A
- 功能 B
🎨 界面改进:
- 改进 A
<!--LANG:END-->`
it('should return Chinese notes for zh-CN users', () => {
vi.mocked(configManager.getLanguage).mockReturnValue('zh-CN')
const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes)
expect(result).toContain('新功能')
expect(result).toContain('功能 A')
expect(result).not.toContain('New Features')
})
it('should return Chinese notes for zh-TW users', () => {
vi.mocked(configManager.getLanguage).mockReturnValue('zh-TW')
const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes)
expect(result).toContain('新功能')
expect(result).toContain('功能 A')
expect(result).not.toContain('New Features')
})
it('should return English notes for non-Chinese users', () => {
vi.mocked(configManager.getLanguage).mockReturnValue('en-US')
const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes)
expect(result).toContain('New Features')
expect(result).toContain('Feature A')
expect(result).not.toContain('新功能')
})
it('should return English notes for other language users', () => {
vi.mocked(configManager.getLanguage).mockReturnValue('ru-RU')
const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes)
expect(result).toContain('New Features')
expect(result).not.toContain('新功能')
})
it('should handle missing language sections gracefully', () => {
const malformedNotes = 'Simple release notes without markers'
const result = (appUpdater as any).parseMultiLangReleaseNotes(malformedNotes)
expect(result).toBe('Simple release notes without markers')
})
it('should handle malformed markers', () => {
const malformedNotes = `<!--LANG:en-->English only`
vi.mocked(configManager.getLanguage).mockReturnValue('zh-CN')
const result = (appUpdater as any).parseMultiLangReleaseNotes(malformedNotes)
// Should clean up markers and return cleaned content
expect(result).toContain('English only')
expect(result).not.toContain('<!--LANG:')
})
it('should handle empty release notes', () => {
const result = (appUpdater as any).parseMultiLangReleaseNotes('')
expect(result).toBe('')
})
it('should handle errors gracefully', () => {
// Force an error by mocking configManager to throw
vi.mocked(configManager.getLanguage).mockImplementation(() => {
throw new Error('Test error')
})
const result = (appUpdater as any).parseMultiLangReleaseNotes(sampleReleaseNotes)
// Should return original notes as fallback
expect(result).toBe(sampleReleaseNotes)
})
})
describe('hasMultiLanguageMarkers', () => {
it('should return true when markers are present', () => {
const notes = '<!--LANG:en-->Test'
const result = (appUpdater as any).hasMultiLanguageMarkers(notes)
expect(result).toBe(true)
})
it('should return false when no markers are present', () => {
const notes = 'Simple text without markers'
const result = (appUpdater as any).hasMultiLanguageMarkers(notes)
expect(result).toBe(false)
})
})
describe('processReleaseInfo', () => {
it('should process multi-language release notes in string format', () => {
vi.mocked(configManager.getLanguage).mockReturnValue('zh-CN')
const releaseInfo = {
version: '1.0.0',
files: [],
path: '',
sha512: '',
releaseDate: new Date().toISOString(),
releaseNotes: `<!--LANG:en-->English notes<!--LANG:zh-CN-->中文说明<!--LANG:END-->`
} as UpdateInfo
const result = (appUpdater as any).processReleaseInfo(releaseInfo)
expect(result.releaseNotes).toBe('中文说明')
})
it('should not process release notes without markers', () => {
const releaseInfo = {
version: '1.0.0',
files: [],
path: '',
sha512: '',
releaseDate: new Date().toISOString(),
releaseNotes: 'Simple release notes'
} as UpdateInfo
const result = (appUpdater as any).processReleaseInfo(releaseInfo)
expect(result.releaseNotes).toBe('Simple release notes')
})
it('should handle array format release notes', () => {
const releaseInfo = {
version: '1.0.0',
files: [],
path: '',
sha512: '',
releaseDate: new Date().toISOString(),
releaseNotes: [
{ version: '1.0.0', note: 'Note 1' },
{ version: '1.0.1', note: 'Note 2' }
]
} as UpdateInfo
const result = (appUpdater as any).processReleaseInfo(releaseInfo)
expect(result.releaseNotes).toEqual(releaseInfo.releaseNotes)
})
it('should handle null release notes', () => {
const releaseInfo = {
version: '1.0.0',
files: [],
path: '',
sha512: '',
releaseDate: new Date().toISOString(),
releaseNotes: null
} as UpdateInfo
const result = (appUpdater as any).processReleaseInfo(releaseInfo)
expect(result.releaseNotes).toBeNull()
})
})
describe('formatReleaseNotes', () => {
it('should format string release notes with markers', () => {
vi.mocked(configManager.getLanguage).mockReturnValue('en-US')
const notes = `<!--LANG:en-->English<!--LANG:zh-CN-->中文<!--LANG:END-->`
const result = (appUpdater as any).formatReleaseNotes(notes)
expect(result).toBe('English')
})
it('should format string release notes without markers', () => {
const notes = 'Simple notes'
const result = (appUpdater as any).formatReleaseNotes(notes)
expect(result).toBe('Simple notes')
})
it('should format array release notes', () => {
const notes = [
{ version: '1.0.0', note: 'Note 1' },
{ version: '1.0.1', note: 'Note 2' }
]
const result = (appUpdater as any).formatReleaseNotes(notes)
expect(result).toBe('Note 1\nNote 2')
})
it('should handle null release notes', () => {
const result = (appUpdater as any).formatReleaseNotes(null)
expect(result).toBe('')
})
it('should handle undefined release notes', () => {
const result = (appUpdater as any).formatReleaseNotes(undefined)
expect(result).toBe('')
})
})
})

View File

@@ -170,8 +170,7 @@ export class AiSdkToChunkAdapter {
case 'reasoning-end':
this.onChunk({
type: ChunkType.THINKING_COMPLETE,
text: (chunk.providerMetadata?.metadata?.thinking_content as string) || final.reasoningContent,
thinking_millsec: (chunk.providerMetadata?.metadata?.thinking_millsec as number) || 0
text: (chunk.providerMetadata?.metadata?.thinking_content as string) || final.reasoningContent
})
final.reasoningContent = ''
break

View File

@@ -7,18 +7,14 @@ export default definePlugin({
transformStream: () => () => {
// === 时间跟踪状态 ===
let thinkingStartTime = 0
let hasStartedThinking = false
let accumulatedThinkingContent = ''
let reasoningBlockId = ''
return new TransformStream<TextStreamPart<ToolSet>, TextStreamPart<ToolSet>>({
transform(chunk: TextStreamPart<ToolSet>, controller: TransformStreamDefaultController<TextStreamPart<ToolSet>>) {
// === 处理 reasoning 类型 ===
if (chunk.type === 'reasoning-start') {
controller.enqueue(chunk)
hasStartedThinking = true
thinkingStartTime = performance.now()
reasoningBlockId = chunk.id
} else if (chunk.type === 'reasoning-delta') {
accumulatedThinkingContent += chunk.text
controller.enqueue({
@@ -32,21 +28,6 @@ export default definePlugin({
}
}
})
} else if (chunk.type === 'reasoning-end' && hasStartedThinking) {
controller.enqueue({
type: 'reasoning-end',
id: reasoningBlockId,
providerMetadata: {
metadata: {
thinking_millsec: performance.now() - thinkingStartTime,
thinking_content: accumulatedThinkingContent
}
}
})
accumulatedThinkingContent = ''
hasStartedThinking = false
thinkingStartTime = 0
reasoningBlockId = ''
} else {
controller.enqueue(chunk)
}

View File

@@ -134,9 +134,10 @@ export async function buildStreamTextParams(
if (aiSdkProviderId === 'google-vertex') {
tools.google_search = vertex.tools.googleSearch({}) as ProviderDefinedTool
} else if (aiSdkProviderId === 'google-vertex-anthropic') {
const blockedDomains = mapRegexToPatterns(webSearchConfig.excludeDomains)
tools.web_search = vertexAnthropic.tools.webSearch_20250305({
maxUses: webSearchConfig.maxResults,
blockedDomains: mapRegexToPatterns(webSearchConfig.excludeDomains)
blockedDomains: blockedDomains.length > 0 ? blockedDomains : undefined
}) as ProviderDefinedTool
}
}

View File

@@ -61,9 +61,10 @@ export function buildProviderBuiltinWebSearchConfig(
}
}
case 'anthropic': {
const blockedDomains = mapRegexToPatterns(webSearchConfig.excludeDomains)
const anthropicSearchOptions: AnthropicSearchConfig = {
maxUses: webSearchConfig.maxResults,
blockedDomains: mapRegexToPatterns(webSearchConfig.excludeDomains)
blockedDomains: blockedDomains.length > 0 ? blockedDomains : undefined
}
return {
anthropic: anthropicSearchOptions

View File

@@ -163,6 +163,13 @@
}
}
@layer components {
@keyframes fadeInWithBlur {
from { opacity: 0; filter: blur(2px); }
to { opacity: 1; filter: blur(0px); }
}
}
:root {
background-color: unset;
}

View File

@@ -18,7 +18,8 @@ export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLEleme
// Sanitize the SVG content
const sanitizedContent = DOMPurify.sanitize(svgContent, {
ADD_TAGS: ['animate', 'foreignObject', 'use'],
ADD_ATTR: ['from', 'to']
ADD_ATTR: ['from', 'to'],
HTML_INTEGRATION_POINTS: { foreignobject: true }
})
const shadowRoot = hostElement.shadowRoot || hostElement.attachShadow({ mode: 'open' })
@@ -36,6 +37,7 @@ export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLEleme
border-radius: var(--shadow-host-border-radius);
padding: 1em;
overflow: hidden; /* Prevent scrollbars, as scaling is now handled */
white-space: normal;
display: block;
position: relative;
width: 100%;

View File

@@ -457,7 +457,13 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
// 面板可见且未折叠时:拦截所有 Enter 变体;
// 纯 Enter 选择项,带修饰键仅拦截不处理
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
if (e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
// Don't prevent default or stop propagation - let it create a newline
setIsMouseOver(false)
break
}
if (e.ctrlKey || e.metaKey || e.altKey) {
e.preventDefault()
e.stopPropagation()
setIsMouseOver(false)

View File

@@ -87,6 +87,9 @@ const CommandListPopover = ({
return true
case 'Enter':
if (event.shiftKey) {
return false
}
event.preventDefault()
if (items[internalSelectedIndex]) {
selectItem(internalSelectedIndex)

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { commandSuggestion } from '../command'
describe('commandSuggestion render', () => {
it('has render function', () => {
expect(commandSuggestion.render).toBeDefined()
expect(typeof commandSuggestion.render).toBe('function')
})
it('render function returns object with onKeyDown', () => {
const renderResult = commandSuggestion.render?.()
expect(renderResult).toBeDefined()
expect(renderResult?.onKeyDown).toBeDefined()
expect(typeof renderResult?.onKeyDown).toBe('function')
})
})

View File

@@ -628,13 +628,34 @@ export const commandSuggestion: Omit<SuggestionOptions<Command, MentionNodeAttrs
},
onKeyDown: (props) => {
// Let CommandListPopover handle events first
const popoverHandled = component.ref?.onKeyDown?.(props.event)
if (popoverHandled) {
return true
}
// Handle Shift+Enter for newline when popover doesn't handle it
if (props.event.key === 'Enter' && props.event.shiftKey) {
props.event.preventDefault()
// Close the suggestion menu
if (cleanup) cleanup()
component.destroy()
// Use the view from SuggestionKeyDownProps to insert newline
const { view } = props
const { state, dispatch } = view
const { tr } = state
tr.insertText('\n')
dispatch(tr)
return true
}
if (props.event.key === 'Escape') {
if (cleanup) cleanup()
component.destroy()
return true
}
return component.ref?.onKeyDown(props.event)
return false
},
onExit: () => {

View File

@@ -25,7 +25,7 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
// Default quick assistant model
glm45FlashModel
],
cherryin: [],
// cherryin: [],
vertexai: [],
'302ai': [
{

View File

@@ -78,16 +78,16 @@ export const CHERRYAI_PROVIDER: SystemProvider = {
}
export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> = {
cherryin: {
id: 'cherryin',
name: 'CherryIN',
type: 'openai',
apiKey: '',
apiHost: 'https://open.cherryin.ai',
models: [],
isSystem: true,
enabled: true
},
// cherryin: {
// id: 'cherryin',
// name: 'CherryIN',
// type: 'openai',
// apiKey: '',
// apiHost: 'https://open.cherryin.ai',
// models: [],
// isSystem: true,
// enabled: true
// },
silicon: {
id: 'silicon',
name: 'Silicon',
@@ -708,17 +708,17 @@ type ProviderUrls = {
}
export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
cherryin: {
api: {
url: 'https://open.cherryin.ai'
},
websites: {
official: 'https://open.cherryin.ai',
apiKey: 'https://open.cherryin.ai/console/token',
docs: 'https://open.cherryin.ai',
models: 'https://open.cherryin.ai/pricing'
}
},
// cherryin: {
// api: {
// url: 'https://open.cherryin.ai'
// },
// websites: {
// official: 'https://open.cherryin.ai',
// apiKey: 'https://open.cherryin.ai/console/token',
// docs: 'https://open.cherryin.ai',
// models: 'https://open.cherryin.ai/pricing'
// }
// },
ph8: {
api: {
url: 'https://ph8.co'

View File

@@ -13,7 +13,7 @@ import { loggerService } from '@renderer/services/LoggerService'
import { getModelUniqId } from '@renderer/services/ModelService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setIsBunInstalled } from '@renderer/store/mcp'
import { Model } from '@renderer/types'
import { EndpointType, Model } from '@renderer/types'
import { codeTools, terminalApps, TerminalConfig } from '@shared/config/constant'
import { Alert, Avatar, Button, Checkbox, Input, Popover, Select, Space, Tooltip } from 'antd'
import { ArrowUpRight, Download, FolderOpen, HelpCircle, Terminal, X } from 'lucide-react'
@@ -70,18 +70,43 @@ const CodeToolsPage: FC = () => {
if (isEmbeddingModel(m) || isRerankModel(m) || isTextToImageModel(m)) {
return false
}
if (m.provider === 'cherryai') {
return false
}
if (selectedCliTool === codeTools.claudeCode) {
if (m.supported_endpoint_types) {
return m.supported_endpoint_types.includes('anthropic')
}
return m.id.includes('claude') || CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS.includes(m.provider)
}
if (selectedCliTool === codeTools.geminiCli) {
if (m.supported_endpoint_types) {
return m.supported_endpoint_types.includes('gemini')
}
return m.id.includes('gemini')
}
if (selectedCliTool === codeTools.openaiCodex) {
if (m.supported_endpoint_types) {
return ['openai', 'openai-response'].some((type) =>
m.supported_endpoint_types?.includes(type as EndpointType)
)
}
return m.id.includes('openai') || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(m.provider)
}
if (selectedCliTool === codeTools.qwenCode || selectedCliTool === codeTools.iFlowCli) {
if (m.supported_endpoint_types) {
return ['openai', 'openai-response'].some((type) =>
m.supported_endpoint_types?.includes(type as EndpointType)
)
}
return true
}
return true
},
[selectedCliTool]

View File

@@ -23,10 +23,16 @@ export const CLI_TOOLS = [
{ value: codeTools.iFlowCli, label: 'iFlow CLI' }
]
export const GEMINI_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api']
export const GEMINI_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api', 'cherryin']
export const CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS = ['deepseek', 'moonshot', 'zhipu', 'dashscope', 'modelscope']
export const CLAUDE_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api', ...CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS]
export const OPENAI_CODEX_SUPPORTED_PROVIDERS = ['openai', 'openrouter', 'aihubmix', 'new-api']
export const CLAUDE_SUPPORTED_PROVIDERS = [
'aihubmix',
'dmxapi',
'new-api',
'cherryin',
...CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS
]
export const OPENAI_CODEX_SUPPORTED_PROVIDERS = ['openai', 'openrouter', 'aihubmix', 'new-api', 'cherryin']
// Provider 过滤映射
export const CLI_TOOL_PROVIDER_MAP: Record<string, (providers: Provider[]) => Provider[]> = {

View File

@@ -12,6 +12,7 @@ import { removeSvgEmptyLines } from '@renderer/utils/formats'
import { processLatexBrackets } from '@renderer/utils/markdown'
import { isEmpty } from 'lodash'
import { type FC, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown'
import rehypeKatex from 'rehype-katex'
@@ -64,6 +65,8 @@ const Markdown: FC<Props> = ({ block, postProcess }) => {
initialText: block.content
})
const isStreaming = block.status === 'streaming'
useEffect(() => {
const newContent = block.content || ''
const oldContent = prevContentRef.current || ''
@@ -85,9 +88,8 @@ const Markdown: FC<Props> = ({ block, postProcess }) => {
prevBlockIdRef.current = block.id
// 更新 stream 状态
const isStreaming = block.status === 'streaming'
setIsStreamDone(!isStreaming)
}, [block.content, block.id, block.status, addChunk, reset])
}, [block.content, block.id, block.status, addChunk, reset, isStreaming])
const remarkPlugins = useMemo(() => {
const plugins = [
@@ -130,14 +132,16 @@ const Markdown: FC<Props> = ({ block, postProcess }) => {
table: (props: any) => <Table {...props} blockId={block.id} />,
img: (props: any) => <ImageViewer style={{ maxWidth: 500, maxHeight: 500 }} {...props} />,
pre: (props: any) => <pre style={{ overflow: 'visible' }} {...props} />,
p: (props) => {
p: SmoothFade((props) => {
const hasImage = props?.node?.children?.some((child: any) => child.tagName === 'img')
if (hasImage) return <div {...props} />
return <p {...props} />
},
svg: MarkdownSvgRenderer
}, isStreaming),
svg: MarkdownSvgRenderer,
li: SmoothFade((props) => <li {...props} />, isStreaming),
span: SmoothFade((props) => <span {...props} />, isStreaming)
} as Partial<Components>
}, [block.id])
}, [block.id, isStreaming])
if (/<style\b[^>]*>/i.test(messageContent)) {
components.style = MarkdownShadowDOMRenderer as any
@@ -168,3 +172,23 @@ const Markdown: FC<Props> = ({ block, postProcess }) => {
}
export default memo(Markdown)
const SmoothFade = (Comp: React.ElementType, isStreaming: boolean) => {
const handleAnimationEnd = (e: React.AnimationEvent) => {
// 动画结束后移除类名
if (e.animationName === 'fadeInWithBlur') {
e.currentTarget.classList.remove('animate-[fadeInWithBlur_500ms_ease-out_forwards]')
e.currentTarget.classList.remove('opacity-0')
}
}
return ({ children, ...rest }) => {
return (
<Comp
{...rest}
className={isStreaming ? 'animate-[fadeInWithBlur_500ms_ease-out_forwards] opacity-0' : ''}
onAnimationEnd={handleAnimationEnd}>
{children}
</Comp>
)
}
}

View File

@@ -1,27 +1,20 @@
import { Spinner } from '@heroui/react'
import { MessageBlockStatus, MessageBlockType, type PlaceholderMessageBlock } from '@renderer/types/newMessage'
import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
import { Loader } from '@renderer/ui/loader'
import React from 'react'
import styled from 'styled-components'
interface PlaceholderBlockProps {
block: PlaceholderMessageBlock
status: MessageBlockStatus
type: MessageBlockType
}
const PlaceholderBlock: React.FC<PlaceholderBlockProps> = ({ block }) => {
if (block.status === MessageBlockStatus.PROCESSING && block.type === MessageBlockType.UNKNOWN) {
const PlaceholderBlock: React.FC<PlaceholderBlockProps> = ({ status, type }) => {
if (status === MessageBlockStatus.PROCESSING && type === MessageBlockType.UNKNOWN) {
return (
<MessageContentLoading>
<Spinner color="current" variant="dots" />
</MessageContentLoading>
<div className="-mt-2">
<Loader variant="terminal" />
</div>
)
}
return null
}
const MessageContentLoading = styled.div`
display: flex;
flex-direction: row;
align-items: center;
height: 32px;
margin-top: -5px;
margin-bottom: 5px;
`
export default React.memo(PlaceholderBlock)

View File

@@ -215,15 +215,7 @@ const MessageBlockRenderer: React.FC<Props> = ({ blocks, message }) => {
})}
{isProcessing && (
<AnimatedBlockWrapper key="message-loading-placeholder" enableAnimation={true}>
<PlaceholderBlock
block={{
id: `loading-${message.id}`,
messageId: message.id,
type: MessageBlockType.UNKNOWN,
status: MessageBlockStatus.PROCESSING,
createdAt: new Date().toISOString()
}}
/>
<PlaceholderBlock type={MessageBlockType.UNKNOWN} status={MessageBlockStatus.PROCESSING} />
</AnimatedBlockWrapper>
)}
</AnimatePresence>

View File

@@ -273,7 +273,7 @@ const AboutSettings: FC = () => {
<IndicatorLight color="green" />
</SettingRowTitle>
</SettingRow>
<UpdateNotesWrapper>
<UpdateNotesWrapper className="markdown">
<Markdown>
{typeof update.info.releaseNotes === 'string'
? update.info.releaseNotes.replace(/\n/g, '\n\n')

View File

@@ -335,12 +335,6 @@ const TranslatePage: FC = () => {
setTargetLanguage(source)
}, [couldExchangeAuto, detectedLanguage, sourceLanguage, t, targetLanguage])
// Clear translation content when component mounts
useEffect(() => {
setText('')
setTranslatedContent('')
}, [])
useEffect(() => {
isEmpty(text) && setTranslatedContent('')
}, [setTranslatedContent, text])

View File

@@ -15,22 +15,23 @@ export const createThinkingCallbacks = (deps: ThinkingCallbacksDependencies) =>
// 内部维护的状态
let thinkingBlockId: string | null = null
let _thinking_millsec = 0
return {
onThinkingStart: async () => {
if (blockManager.hasInitialPlaceholder) {
const changes = {
const changes: Partial<MessageBlock> = {
type: MessageBlockType.THINKING,
content: '',
status: MessageBlockStatus.STREAMING,
thinking_millsec: 0
thinking_millsec: _thinking_millsec
}
thinkingBlockId = blockManager.initialPlaceholderBlockId!
blockManager.smartBlockUpdate(thinkingBlockId, changes, MessageBlockType.THINKING, true)
} else if (!thinkingBlockId) {
const newBlock = createThinkingBlock(assistantMsgId, '', {
status: MessageBlockStatus.STREAMING,
thinking_millsec: 0
thinking_millsec: _thinking_millsec
})
thinkingBlockId = newBlock.id
await blockManager.handleBlockTransition(newBlock, MessageBlockType.THINKING)
@@ -38,26 +39,27 @@ export const createThinkingCallbacks = (deps: ThinkingCallbacksDependencies) =>
},
onThinkingChunk: async (text: string, thinking_millsec?: number) => {
_thinking_millsec = thinking_millsec || 0
if (thinkingBlockId) {
const blockChanges: Partial<MessageBlock> = {
content: text,
status: MessageBlockStatus.STREAMING,
thinking_millsec: thinking_millsec || 0
thinking_millsec: _thinking_millsec
}
blockManager.smartBlockUpdate(thinkingBlockId, blockChanges, MessageBlockType.THINKING)
}
},
onThinkingComplete: (finalText: string, final_thinking_millsec?: number) => {
onThinkingComplete: (finalText: string) => {
if (thinkingBlockId) {
const changes = {
type: MessageBlockType.THINKING,
const changes: Partial<MessageBlock> = {
content: finalText,
status: MessageBlockStatus.SUCCESS,
thinking_millsec: final_thinking_millsec || 0
thinking_millsec: _thinking_millsec
}
blockManager.smartBlockUpdate(thinkingBlockId, changes, MessageBlockType.THINKING, true)
thinkingBlockId = null
_thinking_millsec = 0
} else {
logger.warn(
`[onThinkingComplete] Received thinking.complete but last block was not THINKING (was ${blockManager.lastBlockType}) or lastBlockId is null.`

View File

@@ -67,7 +67,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 157,
version: 158,
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'],
migrate
},

View File

@@ -2539,6 +2539,15 @@ const migrateConfig = {
logger.error('migrate 157 error', error as Error)
return state
}
},
'158': (state: RootState) => {
try {
state.llm.providers = state.llm.providers.filter((provider) => provider.id !== 'cherryin')
return state
} catch (error) {
logger.error('migrate 158 error', error as Error)
return state
}
}
}

View File

@@ -410,7 +410,8 @@ describe('streamCallback Integration Tests', () => {
{ type: ChunkType.THINKING_START },
{ type: ChunkType.THINKING_DELTA, text: 'Let me think...', thinking_millsec: 1000 },
{ type: ChunkType.THINKING_DELTA, text: 'I need to consider...', thinking_millsec: 2000 },
{ type: ChunkType.THINKING_COMPLETE, text: 'Final thoughts', thinking_millsec: 3000 },
{ type: ChunkType.THINKING_DELTA, text: 'Final thoughts', thinking_millsec: 3000 },
{ type: ChunkType.THINKING_COMPLETE, text: 'Final thoughts' },
{ type: ChunkType.BLOCK_COMPLETE }
]

View File

@@ -269,7 +269,7 @@ export type Provider = {
}
export const SystemProviderIds = {
cherryin: 'cherryin',
// cherryin: 'cherryin',
silicon: 'silicon',
aihubmix: 'aihubmix',
ocoolai: 'ocoolai',

View File

@@ -0,0 +1,374 @@
import { cn } from '@renderer/utils/index'
export interface LoaderProps {
variant?:
| 'circular'
| 'classic'
| 'pulse'
| 'pulse-dot'
| 'dots'
| 'typing'
| 'wave'
| 'bars'
| 'terminal'
| 'text-blink'
| 'text-shimmer'
| 'loading-dots'
size?: 'sm' | 'md' | 'lg'
text?: string
className?: string
}
export function CircularLoader({ className, size = 'md' }: { className?: string; size?: 'sm' | 'md' | 'lg' }) {
const sizeClasses = {
sm: 'size-4',
md: 'size-5',
lg: 'size-6'
}
return (
<div
className={cn(
'animate-spin rounded-full border-2 border-primary border-t-transparent',
sizeClasses[size],
className
)}>
<span className="sr-only">Loading</span>
</div>
)
}
export function ClassicLoader({ className, size = 'md' }: { className?: string; size?: 'sm' | 'md' | 'lg' }) {
const sizeClasses = {
sm: 'size-4',
md: 'size-5',
lg: 'size-6'
}
const barSizes = {
sm: { height: '6px', width: '1.5px' },
md: { height: '8px', width: '2px' },
lg: { height: '10px', width: '2.5px' }
}
return (
<div className={cn('relative', sizeClasses[size], className)}>
<div className="absolute h-full w-full">
{[...Array(12)].map((_, i) => (
<div
key={i}
className="absolute animate-[spinner-fade_1.2s_linear_infinite] rounded-full bg-primary"
style={{
top: '0',
left: '50%',
marginLeft: size === 'sm' ? '-0.75px' : size === 'lg' ? '-1.25px' : '-1px',
transformOrigin: `${size === 'sm' ? '0.75px' : size === 'lg' ? '1.25px' : '1px'} ${size === 'sm' ? '10px' : size === 'lg' ? '14px' : '12px'}`,
transform: `rotate(${i * 30}deg)`,
opacity: 0,
animationDelay: `${i * 0.1}s`,
height: barSizes[size].height,
width: barSizes[size].width
}}
/>
))}
</div>
<span className="sr-only">Loading</span>
</div>
)
}
export function PulseLoader({ className, size = 'md' }: { className?: string; size?: 'sm' | 'md' | 'lg' }) {
const sizeClasses = {
sm: 'size-4',
md: 'size-5',
lg: 'size-6'
}
return (
<div className={cn('relative', sizeClasses[size], className)}>
<div className="absolute inset-0 animate-[thin-pulse_1.5s_ease-in-out_infinite] rounded-full border-2 border-primary" />
<span className="sr-only">Loading</span>
</div>
)
}
export function PulseDotLoader({ className, size = 'md' }: { className?: string; size?: 'sm' | 'md' | 'lg' }) {
const sizeClasses = {
sm: 'size-1',
md: 'size-2',
lg: 'size-3'
}
return (
<div
className={cn(
'animate-[pulse-dot_1.2s_ease-in-out_infinite] rounded-full bg-primary',
sizeClasses[size],
className
)}>
<span className="sr-only">Loading</span>
</div>
)
}
export function DotsLoader({ className, size = 'md' }: { className?: string; size?: 'sm' | 'md' | 'lg' }) {
const dotSizes = {
sm: 'h-1.5 w-1.5',
md: 'h-2 w-2',
lg: 'h-2.5 w-2.5'
}
const containerSizes = {
sm: 'h-4',
md: 'h-5',
lg: 'h-6'
}
return (
<div className={cn('flex items-center space-x-1', containerSizes[size], className)}>
{[...Array(3)].map((_, i) => (
<div
key={i}
className={cn('animate-[bounce-dots_1.4s_ease-in-out_infinite] rounded-full bg-primary', dotSizes[size])}
style={{
animationDelay: `${i * 160}ms`
}}
/>
))}
<span className="sr-only">Loading</span>
</div>
)
}
export function TypingLoader({ className, size = 'md' }: { className?: string; size?: 'sm' | 'md' | 'lg' }) {
const dotSizes = {
sm: 'h-1 w-1',
md: 'h-1.5 w-1.5',
lg: 'h-2 w-2'
}
const containerSizes = {
sm: 'h-4',
md: 'h-5',
lg: 'h-6'
}
return (
<div className={cn('flex items-center space-x-1', containerSizes[size], className)}>
{[...Array(3)].map((_, i) => (
<div
key={i}
className={cn('animate-[typing_1s_infinite] rounded-full bg-primary', dotSizes[size])}
style={{
animationDelay: `${i * 250}ms`
}}
/>
))}
<span className="sr-only">Loading</span>
</div>
)
}
export function WaveLoader({ className, size = 'md' }: { className?: string; size?: 'sm' | 'md' | 'lg' }) {
const barWidths = {
sm: 'w-0.5',
md: 'w-0.5',
lg: 'w-1'
}
const containerSizes = {
sm: 'h-4',
md: 'h-5',
lg: 'h-6'
}
const heights = {
sm: ['6px', '9px', '12px', '9px', '6px'],
md: ['8px', '12px', '16px', '12px', '8px'],
lg: ['10px', '15px', '20px', '15px', '10px']
}
return (
<div className={cn('flex items-center gap-0.5', containerSizes[size], className)}>
{[...Array(5)].map((_, i) => (
<div
key={i}
className={cn('animate-[wave_1s_ease-in-out_infinite] rounded-full bg-primary', barWidths[size])}
style={{
animationDelay: `${i * 100}ms`,
height: heights[size][i]
}}
/>
))}
<span className="sr-only">Loading</span>
</div>
)
}
export function BarsLoader({ className, size = 'md' }: { className?: string; size?: 'sm' | 'md' | 'lg' }) {
const barWidths = {
sm: 'w-1',
md: 'w-1.5',
lg: 'w-2'
}
const containerSizes = {
sm: 'h-4 gap-1',
md: 'h-5 gap-1.5',
lg: 'h-6 gap-2'
}
return (
<div className={cn('flex', containerSizes[size], className)}>
{[...Array(3)].map((_, i) => (
<div
key={i}
className={cn('h-full animate-[wave-bars_1.2s_ease-in-out_infinite] bg-primary', barWidths[size])}
style={{
animationDelay: `${i * 0.2}s`
}}
/>
))}
<span className="sr-only">Loading</span>
</div>
)
}
export function TerminalLoader({ className, size = 'md' }: { className?: string; size?: 'sm' | 'md' | 'lg' }) {
const cursorSizes = {
sm: 'h-3 w-1.5',
md: 'h-4 w-2',
lg: 'h-5 w-2.5'
}
const textSizes = {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base'
}
const containerSizes = {
sm: 'h-4',
md: 'h-5',
lg: 'h-6'
}
return (
<div className={cn('flex items-center space-x-1', containerSizes[size], className)}>
<span className={cn('font-mono text-primary', textSizes[size])}>{'>'}</span>
<div className={cn('animate-[blink_1s_step-end_infinite] bg-primary', cursorSizes[size])} />
<span className="sr-only">Loading</span>
</div>
)
}
export function TextBlinkLoader({
text = 'Thinking',
className,
size = 'md'
}: {
text?: string
className?: string
size?: 'sm' | 'md' | 'lg'
}) {
const textSizes = {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base'
}
return (
<div className={cn('animate-[text-blink_2s_ease-in-out_infinite] font-medium', textSizes[size], className)}>
{text}
</div>
)
}
export function TextShimmerLoader({
text = 'Thinking',
className,
size = 'md'
}: {
text?: string
className?: string
size?: 'sm' | 'md' | 'lg'
}) {
const textSizes = {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base'
}
return (
<div
className={cn(
'bg-[linear-gradient(to_right,var(--muted-foreground)_40%,var(--foreground)_60%,var(--muted-foreground)_80%)]',
'bg-[200%_auto] bg-clip-text font-medium text-transparent',
'animate-[shimmer_4s_infinite_linear]',
textSizes[size],
className
)}>
{text}
</div>
)
}
export function TextDotsLoader({
className,
text = 'Thinking',
size = 'md'
}: {
className?: string
text?: string
size?: 'sm' | 'md' | 'lg'
}) {
const textSizes = {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base'
}
return (
<div className={cn('inline-flex items-center', className)}>
<span className={cn('font-medium text-primary', textSizes[size])}>{text}</span>
<span className="inline-flex">
<span className="animate-[loading-dots_1.4s_infinite_0.2s] text-primary">.</span>
<span className="animate-[loading-dots_1.4s_infinite_0.4s] text-primary">.</span>
<span className="animate-[loading-dots_1.4s_infinite_0.6s] text-primary">.</span>
</span>
</div>
)
}
function Loader({ variant = 'circular', size = 'md', text, className }: LoaderProps) {
switch (variant) {
case 'circular':
return <CircularLoader size={size} className={className} />
case 'classic':
return <ClassicLoader size={size} className={className} />
case 'pulse':
return <PulseLoader size={size} className={className} />
case 'pulse-dot':
return <PulseDotLoader size={size} className={className} />
case 'dots':
return <DotsLoader size={size} className={className} />
case 'typing':
return <TypingLoader size={size} className={className} />
case 'wave':
return <WaveLoader size={size} className={className} />
case 'bars':
return <BarsLoader size={size} className={className} />
case 'terminal':
return <TerminalLoader size={size} className={className} />
case 'text-blink':
return <TextBlinkLoader text={text} size={size} className={className} />
case 'text-shimmer':
return <TextShimmerLoader text={text} size={size} className={className} />
case 'loading-dots':
return <TextDotsLoader text={text} size={size} className={className} />
default:
return <CircularLoader size={size} className={className} />
}
}
export { Loader }

View File

@@ -234,3 +234,4 @@ export * from './match'
export * from './naming'
export * from './sort'
export * from './style'
export { cn } from '@heroui/react'

View File

@@ -155,7 +155,7 @@ __metadata:
languageName: node
linkType: hard
"@ai-sdk/google@npm:2.0.14, @ai-sdk/google@npm:^2.0.14":
"@ai-sdk/google@npm:2.0.14":
version: 2.0.14
resolution: "@ai-sdk/google@npm:2.0.14"
dependencies:
@@ -167,6 +167,18 @@ __metadata:
languageName: node
linkType: hard
"@ai-sdk/google@patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch":
version: 2.0.14
resolution: "@ai-sdk/google@patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch::version=2.0.14&hash=a91bb2"
dependencies:
"@ai-sdk/provider": "npm:2.0.0"
"@ai-sdk/provider-utils": "npm:3.0.9"
peerDependencies:
zod: ^3.25.76 || ^4
checksum: 10c0/5ec33dc9898457b1f48ed14cb767817345032c539dd21b7e21985ed47bc21b0820922b581bf349bb3898136790b12da3a0a7c9903c333a28ead0c3c2cd5230f2
languageName: node
linkType: hard
"@ai-sdk/mistral@npm:^2.0.14":
version: 2.0.14
resolution: "@ai-sdk/mistral@npm:2.0.14"
@@ -2316,7 +2328,7 @@ __metadata:
"@ai-sdk/anthropic": "npm:^2.0.17"
"@ai-sdk/azure": "npm:^2.0.30"
"@ai-sdk/deepseek": "npm:^1.0.17"
"@ai-sdk/google": "npm:^2.0.14"
"@ai-sdk/google": "patch:@ai-sdk/google@npm%3A2.0.14#~/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch"
"@ai-sdk/openai": "npm:^2.0.30"
"@ai-sdk/openai-compatible": "npm:^1.0.17"
"@ai-sdk/provider": "npm:^2.0.0"