diff --git a/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch b/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch new file mode 100644 index 0000000000..a1ae65f02e --- /dev/null +++ b/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch @@ -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, diff --git a/electron-builder.yml b/electron-builder.yml index 9b9a239160..05fdc8b2f6 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -125,6 +125,7 @@ afterSign: scripts/notarize.js artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | + 🚀 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 + + 🚀 新功能: + - 重构 AI 核心引擎,提供更高效稳定的内容生成 + - 新增多个 AI 模型提供商支持:CherryIN、AiOnly + - 新增 API 服务器功能,支持外部应用集成 + - 新增 PaddleOCR 文档识别,增强文档处理能力 + - 新增 Anthropic OAuth 认证支持 + - 新增数据存储空间限制提醒 + - 新增字体设置,支持全局字体和代码字体自定义 + - 新增翻译完成后自动复制功能 + - 新增键盘快捷键:重命名主题、编辑最后一条消息等 + - 新增文本附件预览,可查看消息中的文件内容 + - 新增自定义窗口控制按钮(最小化、最大化、关闭) + - 支持通义千问长文本(qwen-long)和文档分析(qwen-doc)模型,原生文件上传 + - 支持通义千问图像识别模型(Qwen-Image) + - 新增 iFlow CLI 支持 + - 知识库和网页搜索转换为工具调用方式,提升灵活性 + + 🎨 界面改进与问题修复: + - 集成 HeroUI 和 Tailwind CSS 框架 + - 优化消息通知样式,统一 toast 组件 + - 免费模型移至底部固定位置,便于访问 + - 重构快捷面板和输入栏工具,操作更流畅 + - 优化导航栏和侧边栏响应式设计 + - 改进滚动条组件,支持水平滚动 + - 修复多个翻译问题:粘贴处理、文件处理、状态管理 + - 各种界面优化和问题修复 + diff --git a/package.json b/package.json index 6a5735c785..97537f883f 100644 --- a/package.json +++ b/package.json @@ -380,7 +380,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": { diff --git a/packages/aiCore/package.json b/packages/aiCore/package.json index 75ed6ea34e..28ae7c8e25 100644 --- a/packages/aiCore/package.json +++ b/packages/aiCore/package.json @@ -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", diff --git a/packages/shared/config/constant.ts b/packages/shared/config/constant.ts index 9ce35e3a5d..3ffe88f08a 100644 --- a/packages/shared/config/constant.ts +++ b/packages/shared/config/constant.ts @@ -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'` ] }) } diff --git a/src/main/services/AppUpdater.ts b/src/main/services/AppUpdater.ts index 3cb1558b0e..66b88bce84 100644 --- a/src/main/services/AppUpdater.ts +++ b/src/main/services/AppUpdater.ts @@ -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: '', + ZH_CN_START: '', + 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: + * English contentChinese content + */ + 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 { diff --git a/src/main/services/CodeToolsService.ts b/src/main/services/CodeToolsService.ts index 74fca367fc..486e58c212 100644 --- a/src/main/services/CodeToolsService.ts +++ b/src/main/services/CodeToolsService.ts @@ -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})`) diff --git a/src/main/services/__tests__/AppUpdater.test.ts b/src/main/services/__tests__/AppUpdater.test.ts new file mode 100644 index 0000000000..bb6a7827cb --- /dev/null +++ b/src/main/services/__tests__/AppUpdater.test.ts @@ -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 = ` +🚀 New Features: +- Feature A +- Feature B + +🎨 UI Improvements: +- Improvement A + +🚀 新功能: +- 功能 A +- 功能 B + +🎨 界面改进: +- 改进 A +` + + 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 = `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('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: `English notes中文说明` + } 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 = `English中文` + + 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('') + }) + }) +}) diff --git a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts index cc2a93e511..4562dcabd1 100644 --- a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts +++ b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts @@ -185,8 +185,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 diff --git a/src/renderer/src/aiCore/plugins/reasoningTimePlugin.ts b/src/renderer/src/aiCore/plugins/reasoningTimePlugin.ts index 1fe0a177c3..b76d9ea342 100644 --- a/src/renderer/src/aiCore/plugins/reasoningTimePlugin.ts +++ b/src/renderer/src/aiCore/plugins/reasoningTimePlugin.ts @@ -7,18 +7,14 @@ export default definePlugin({ transformStream: () => () => { // === 时间跟踪状态 === let thinkingStartTime = 0 - let hasStartedThinking = false let accumulatedThinkingContent = '' - let reasoningBlockId = '' return new TransformStream, TextStreamPart>({ transform(chunk: TextStreamPart, controller: TransformStreamDefaultController>) { // === 处理 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) } diff --git a/src/renderer/src/components/Preview/utils.ts b/src/renderer/src/components/Preview/utils.ts index 42b93df156..a209a6b4c8 100644 --- a/src/renderer/src/components/Preview/utils.ts +++ b/src/renderer/src/components/Preview/utils.ts @@ -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%; diff --git a/src/renderer/src/pages/code/CodeToolsPage.tsx b/src/renderer/src/pages/code/CodeToolsPage.tsx index 44c1e323d9..a70f8397dc 100644 --- a/src/renderer/src/pages/code/CodeToolsPage.tsx +++ b/src/renderer/src/pages/code/CodeToolsPage.tsx @@ -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 { getClaudeSupportedProviders } from '@renderer/utils/provider' import { codeTools, terminalApps, TerminalConfig } from '@shared/config/constant' import { Alert, Avatar, Button, Checkbox, Input, Popover, Select, Space, Tooltip } from 'antd' @@ -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] diff --git a/src/renderer/src/pages/code/index.ts b/src/renderer/src/pages/code/index.ts index f24003a1c4..39067e1c1e 100644 --- a/src/renderer/src/pages/code/index.ts +++ b/src/renderer/src/pages/code/index.ts @@ -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 Provider[]> = { diff --git a/src/renderer/src/pages/settings/AboutSettings.tsx b/src/renderer/src/pages/settings/AboutSettings.tsx index 40b0a99ecb..d611ed458e 100644 --- a/src/renderer/src/pages/settings/AboutSettings.tsx +++ b/src/renderer/src/pages/settings/AboutSettings.tsx @@ -273,7 +273,7 @@ const AboutSettings: FC = () => { - + {typeof update.info.releaseNotes === 'string' ? update.info.releaseNotes.replace(/\n/g, '\n\n') diff --git a/src/renderer/src/services/messageStreaming/callbacks/thinkingCallbacks.ts b/src/renderer/src/services/messageStreaming/callbacks/thinkingCallbacks.ts index 80c63858c7..4d717c6c64 100644 --- a/src/renderer/src/services/messageStreaming/callbacks/thinkingCallbacks.ts +++ b/src/renderer/src/services/messageStreaming/callbacks/thinkingCallbacks.ts @@ -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 = { 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 = { 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 = { 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.` diff --git a/src/renderer/src/store/thunk/__tests__/streamCallback.integration.test.ts b/src/renderer/src/store/thunk/__tests__/streamCallback.integration.test.ts index 96aff69f7b..e8c113d62b 100644 --- a/src/renderer/src/store/thunk/__tests__/streamCallback.integration.test.ts +++ b/src/renderer/src/store/thunk/__tests__/streamCallback.integration.test.ts @@ -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 } ] diff --git a/yarn.lock b/yarn.lock index 375746207e..aaa9e58cde 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" @@ -2374,7 +2386,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"