From 2bafc53b25063eade007f93df2d76cb6f19a3b5b Mon Sep 17 00:00:00 2001 From: "Johnny.H" Date: Wed, 24 Sep 2025 23:27:07 +0800 Subject: [PATCH 01/32] Show loading icon when chat is in streaming (#10319) * support chat stream loading rendering * support chat stream loading rendering * update loading icon to dots * fix format --------- Co-authored-by: suyao --- .../home/Messages/Blocks/PlaceholderBlock.tsx | 4 ++-- .../src/pages/home/Messages/Blocks/index.tsx | 21 +++++++++++++++---- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/pages/home/Messages/Blocks/PlaceholderBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/PlaceholderBlock.tsx index bcc8a96859..7682ae2343 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/PlaceholderBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/PlaceholderBlock.tsx @@ -1,4 +1,4 @@ -import { LoadingIcon } from '@renderer/components/Icons' +import { Spinner } from '@heroui/react' import { MessageBlockStatus, MessageBlockType, type PlaceholderMessageBlock } from '@renderer/types/newMessage' import React from 'react' import styled from 'styled-components' @@ -10,7 +10,7 @@ const PlaceholderBlock: React.FC = ({ block }) => { if (block.status === MessageBlockStatus.PROCESSING && block.type === MessageBlockType.UNKNOWN) { return ( - + ) } diff --git a/src/renderer/src/pages/home/Messages/Blocks/index.tsx b/src/renderer/src/pages/home/Messages/Blocks/index.tsx index 5d6128e660..0e2d318e1e 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/index.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/index.tsx @@ -3,7 +3,7 @@ import type { RootState } from '@renderer/store' import { messageBlocksSelectors } from '@renderer/store/messageBlock' import type { ImageMessageBlock, Message, MessageBlock } from '@renderer/types/newMessage' import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage' -import { isMainTextBlock, isVideoBlock } from '@renderer/utils/messageUtils/is' +import { isMainTextBlock, isMessageProcessing, isVideoBlock } from '@renderer/utils/messageUtils/is' import { AnimatePresence, motion, type Variants } from 'motion/react' import React, { useMemo } from 'react' import { useSelector } from 'react-redux' @@ -107,6 +107,9 @@ const MessageBlockRenderer: React.FC = ({ blocks, message }) => { const renderedBlocks = blocks.map((blockId) => blockEntities[blockId]).filter(Boolean) const groupedBlocks = useMemo(() => groupSimilarBlocks(renderedBlocks), [renderedBlocks]) + // Check if message is still processing + const isProcessing = isMessageProcessing(message) + return ( {groupedBlocks.map((block) => { @@ -151,9 +154,6 @@ const MessageBlockRenderer: React.FC = ({ blocks, message }) => { switch (block.type) { case MessageBlockType.UNKNOWN: - if (block.status === MessageBlockStatus.PROCESSING) { - blockComponent = - } break case MessageBlockType.MAIN_TEXT: case MessageBlockType.CODE: { @@ -213,6 +213,19 @@ const MessageBlockRenderer: React.FC = ({ blocks, message }) => { ) })} + {isProcessing && ( + + + + )} ) } From a3a26c69c5142eed9883e65a2861150416c48a99 Mon Sep 17 00:00:00 2001 From: SuYao Date: Thu, 25 Sep 2025 10:55:31 +0800 Subject: [PATCH 02/32] fix: seed think (#10322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 添加 seedThink 标签以支持新的模型识别 * Enable reasoning for SEED-OSS models - Add SEED-OSS model ID check to reasoning exclusion logic - Include SEED-OSS models in reasoning model detection * fix: 更新 reasoning-end 事件处理以使用最终推理内容 --- src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts | 2 +- src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts | 4 +++- src/renderer/src/aiCore/utils/reasoning.ts | 2 +- src/renderer/src/config/models/reasoning.ts | 3 ++- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts index c27362eb14..8e35496ae6 100644 --- a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts +++ b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts @@ -170,7 +170,7 @@ export class AiSdkToChunkAdapter { case 'reasoning-end': this.onChunk({ type: ChunkType.THINKING_COMPLETE, - text: (chunk.providerMetadata?.metadata?.thinking_content as string) || '', + text: (chunk.providerMetadata?.metadata?.thinking_content as string) || final.reasoningContent, thinking_millsec: (chunk.providerMetadata?.metadata?.thinking_millsec as number) || 0 }) final.reasoningContent = '' diff --git a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts index 1f18e49bad..20b89cf2e5 100644 --- a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts +++ b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts @@ -143,12 +143,14 @@ export function buildAiSdkMiddlewares(config: AiSdkMiddlewareConfig): LanguageMo const tagName = { reasoning: 'reasoning', think: 'think', - thought: 'thought' + thought: 'thought', + seedThink: 'seed:think' } function getReasoningTagName(modelId: string | undefined): string { if (modelId?.includes('gpt-oss')) return tagName.reasoning if (modelId?.includes('gemini')) return tagName.thought + if (modelId?.includes('seed-oss-36b')) return tagName.seedThink return tagName.think } diff --git a/src/renderer/src/aiCore/utils/reasoning.ts b/src/renderer/src/aiCore/utils/reasoning.ts index 18f303c0a3..9328f7f0ce 100644 --- a/src/renderer/src/aiCore/utils/reasoning.ts +++ b/src/renderer/src/aiCore/utils/reasoning.ts @@ -52,7 +52,7 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin return {} } // Don't disable reasoning for models that require it - if (isGrokReasoningModel(model) || isOpenAIReasoningModel(model)) { + if (isGrokReasoningModel(model) || isOpenAIReasoningModel(model) || model.id.includes('seed-oss')) { return {} } return { reasoning: { enabled: false, exclude: true } } diff --git a/src/renderer/src/config/models/reasoning.ts b/src/renderer/src/config/models/reasoning.ts index 25f13c86e4..607df8fd95 100644 --- a/src/renderer/src/config/models/reasoning.ts +++ b/src/renderer/src/config/models/reasoning.ts @@ -391,7 +391,8 @@ export function isReasoningModel(model?: Model): boolean { isDeepSeekHybridInferenceModel(model) || modelId.includes('magistral') || modelId.includes('minimax-m1') || - modelId.includes('pangu-pro-moe') + modelId.includes('pangu-pro-moe') || + modelId.includes('seed-oss') ) { return true } From 0a149e3d9e5c495dc81e8fe791cb340dbc6ce8ab Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Thu, 25 Sep 2025 10:36:34 +0800 Subject: [PATCH 03/32] chore: release v1.6.0 --- electron-builder.yml | 35 ++++++++++++++++++++++++++--------- package.json | 2 +- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/electron-builder.yml b/electron-builder.yml index b08ecb5563..9b9a239160 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -125,13 +125,30 @@ afterSign: scripts/notarize.js artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | - 🎨 界面优化: - - 优化了多个组件的布局和间距,提升视觉体验 - - 改进了导航栏和标签栏的样式显示 - - MCP 服务器卡片宽度调整为 100%,提高响应式布局效果 - - 优化了笔记侧边栏的滚动行为 + 🚀 New Features: + - Refactored AI core engine for more efficient and stable content generation + - Added support for multiple AI model providers: CherryIN, AiOnly + - Added API server functionality for external application integration + - Added PaddleOCR document recognition for enhanced document processing + - Added Anthropic OAuth authentication support + - Added data storage space limit notifications + - Added font settings for global and code fonts customization + - Added auto-copy feature after translation completion + - Added keyboard shortcuts: rename topic, edit last message, etc. + - Added text attachment preview for viewing file contents in messages + - Added custom window control buttons (minimize, maximize, close) + - Support for Qwen long-text (qwen-long) and document analysis (qwen-doc) models with native file uploads + - Support for Qwen image recognition models (Qwen-Image) + - Added iFlow CLI support + - Converted knowledge base and web search to tool-calling approach for better flexibility + + 🎨 UI Improvements & Bug Fixes: + - Integrated HeroUI and Tailwind CSS framework + - Optimized message notification styles with unified toast component + - Moved free models to bottom with fixed position for easier access + - Refactored quick panel and input bar tools for smoother operation + - Optimized responsive design for navbar and sidebar + - Improved scrollbar component with horizontal scrolling support + - Fixed multiple translation issues: paste handling, file processing, state management + - Various UI optimizations and bug fixes - 🐛 问题修复: - - 修复了小应用打开功能无法正常工作的问题 - - 修复了助手更新时 ID 丢失导致更新失败的问题 - - 确保助手更新时 ID 字段为必填项,防止数据错误 diff --git a/package.json b/package.json index b33ebc8940..dfe28b8f27 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.6.0-rc.5", + "version": "1.6.0", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", From 2ed99c0cb841b996d54c064f849cad5415824b77 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Thu, 25 Sep 2025 13:28:51 +0800 Subject: [PATCH 04/32] 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 --- .github/workflows/pr-ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 137208bff0..4f462db95c 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -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 From 0f8cbeed110fb11f8939f8978de0a9b47d9fb111 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Thu, 25 Sep 2025 13:44:17 +0800 Subject: [PATCH 05/32] fix(translate): remove unused effect for clearing translation contenton mount (#10349) * fix(translate): remove unused effect for clearing translation content on mount * format code --- src/renderer/src/pages/translate/TranslatePage.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/renderer/src/pages/translate/TranslatePage.tsx b/src/renderer/src/pages/translate/TranslatePage.tsx index da0056b39d..e883955eb1 100644 --- a/src/renderer/src/pages/translate/TranslatePage.tsx +++ b/src/renderer/src/pages/translate/TranslatePage.tsx @@ -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]) From 067ecb5e8e098d290f6d6e39ce8f77d200854c8d Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Thu, 25 Sep 2025 16:07:27 +0800 Subject: [PATCH 06/32] style: update UpdateNotesWrapper to use markdown class for improved formatting (#10359) --- src/renderer/src/pages/settings/AboutSettings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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') From caad0bc0053631123d0c9919456b9cb44f9af566 Mon Sep 17 00:00:00 2001 From: one Date: Thu, 25 Sep 2025 18:02:06 +0800 Subject: [PATCH 07/32] fix: svg foreignobject in code blocks (#10339) * fix: svg foreignobject in code blocks * fix: set white-space explicitly --- src/renderer/src/components/Preview/utils.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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%; From 05a318225ca7a8124175a9166345c2e051b52048 Mon Sep 17 00:00:00 2001 From: MyPrototypeWhat Date: Thu, 25 Sep 2025 19:06:25 +0800 Subject: [PATCH 08/32] =?UTF-8?q?refactor(reasoning):=20simplify=20reasoni?= =?UTF-8?q?ng=20time=20tracking=20by=20removing=20unu=E2=80=A6=20(#10360)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .../src/aiCore/chunk/AiSdkToChunkAdapter.ts | 3 +-- .../src/aiCore/plugins/reasoningTimePlugin.ts | 19 ------------------- .../callbacks/thinkingCallbacks.ts | 18 ++++++++++-------- .../streamCallback.integration.test.ts | 3 ++- 4 files changed, 13 insertions(+), 30 deletions(-) diff --git a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts index 8e35496ae6..2e8ce32969 100644 --- a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts +++ b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts @@ -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 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/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 } ] From 499cb52e2822c62074ff2f5b995b81e6de1d9d4f Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Thu, 25 Sep 2025 21:26:04 +0800 Subject: [PATCH 09/32] 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. --- packages/shared/config/constant.ts | 39 +++++++++++++++++---------- src/main/services/CodeToolsService.ts | 2 +- 2 files changed, 26 insertions(+), 15 deletions(-) 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/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})`) From d12515ccb91643ed2f05c3c3d784703e21cb1fb8 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Thu, 25 Sep 2025 21:51:05 +0800 Subject: [PATCH 10/32] feat: enhance multi-language support in release notes processing (#10355) * feat: enhance multi-language support in release notes processing * fix review comments * format code --- electron-builder.yml | 29 ++ src/main/services/AppUpdater.ts | 102 +++++- .../services/__tests__/AppUpdater.test.ts | 319 ++++++++++++++++++ 3 files changed, 445 insertions(+), 5 deletions(-) create mode 100644 src/main/services/__tests__/AppUpdater.test.ts 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/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/__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('') + }) + }) +}) From 8bcd229849018b8af5c8f450c61a66d4391ea09a Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Thu, 25 Sep 2025 22:11:17 +0800 Subject: [PATCH 11/32] 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. --- src/renderer/src/pages/code/CodeToolsPage.tsx | 27 ++++++++++++++++++- src/renderer/src/pages/code/index.ts | 12 ++++++--- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/pages/code/CodeToolsPage.tsx b/src/renderer/src/pages/code/CodeToolsPage.tsx index 69d9fb728d..b64833f6d6 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 { 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] diff --git a/src/renderer/src/pages/code/index.ts b/src/renderer/src/pages/code/index.ts index f286704d39..531a7f5f01 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[]> = { From b85040f5790a8d13ff18ae266d04c03ae37a4a59 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Thu, 25 Sep 2025 22:11:08 +0800 Subject: [PATCH 12/32] 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. --- ...@ai-sdk-google-npm-2.0.14-376d8b03cc.patch | 36 +++++++++++++++++++ package.json | 5 +-- packages/aiCore/package.json | 2 +- yarn.lock | 16 +++++++-- 4 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 .yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch 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/package.json b/package.json index dfe28b8f27..6e5ab73a8c 100644 --- a/package.json +++ b/package.json @@ -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": { 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/yarn.lock b/yarn.lock index 2393eaec5b..192c8e2076 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" @@ -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" From d41e239b89aa407ebddba5df8ce795afdf993592 Mon Sep 17 00:00:00 2001 From: Zhaokun Date: Fri, 26 Sep 2025 05:07:10 +0800 Subject: [PATCH 13/32] 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 --- .../src/components/QuickPanel/view.tsx | 8 ++++++- .../RichEditor/CommandListPopover.tsx | 3 +++ .../__tests__/commandSuggestion.test.ts | 17 ++++++++++++++ .../src/components/RichEditor/command.ts | 23 ++++++++++++++++++- 4 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/components/RichEditor/__tests__/commandSuggestion.test.ts diff --git a/src/renderer/src/components/QuickPanel/view.tsx b/src/renderer/src/components/QuickPanel/view.tsx index 52c33607c7..6ad34b4557 100644 --- a/src/renderer/src/components/QuickPanel/view.tsx +++ b/src/renderer/src/components/QuickPanel/view.tsx @@ -457,7 +457,13 @@ export const QuickPanelView: React.FC = ({ 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) diff --git a/src/renderer/src/components/RichEditor/CommandListPopover.tsx b/src/renderer/src/components/RichEditor/CommandListPopover.tsx index 4f8df4d20c..1f2250a437 100644 --- a/src/renderer/src/components/RichEditor/CommandListPopover.tsx +++ b/src/renderer/src/components/RichEditor/CommandListPopover.tsx @@ -87,6 +87,9 @@ const CommandListPopover = ({ return true case 'Enter': + if (event.shiftKey) { + return false + } event.preventDefault() if (items[internalSelectedIndex]) { selectItem(internalSelectedIndex) diff --git a/src/renderer/src/components/RichEditor/__tests__/commandSuggestion.test.ts b/src/renderer/src/components/RichEditor/__tests__/commandSuggestion.test.ts new file mode 100644 index 0000000000..e352e957d0 --- /dev/null +++ b/src/renderer/src/components/RichEditor/__tests__/commandSuggestion.test.ts @@ -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') + }) +}) diff --git a/src/renderer/src/components/RichEditor/command.ts b/src/renderer/src/components/RichEditor/command.ts index a460e210d4..1371b3ebb6 100644 --- a/src/renderer/src/components/RichEditor/command.ts +++ b/src/renderer/src/components/RichEditor/command.ts @@ -628,13 +628,34 @@ export const commandSuggestion: Omit { + // 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: () => { From 3b7ab2aec8fabe2f3ee3a51a88d930b209b01eab Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Fri, 26 Sep 2025 10:36:17 +0800 Subject: [PATCH 14/32] 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. --- src/renderer/src/config/models/default.ts | 2 +- src/renderer/src/config/providers.ts | 42 +++++++++++------------ src/renderer/src/store/index.ts | 2 +- src/renderer/src/store/migrate.ts | 9 +++++ src/renderer/src/types/index.ts | 2 +- 5 files changed, 33 insertions(+), 24 deletions(-) diff --git a/src/renderer/src/config/models/default.ts b/src/renderer/src/config/models/default.ts index 02bf37af9e..9fdced6a6a 100644 --- a/src/renderer/src/config/models/default.ts +++ b/src/renderer/src/config/models/default.ts @@ -25,7 +25,7 @@ export const SYSTEM_MODELS: Record = // Default quick assistant model glm45FlashModel ], - cherryin: [], + // cherryin: [], vertexai: [], '302ai': [ { diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts index 3b8821905a..543422d212 100644 --- a/src/renderer/src/config/providers.ts +++ b/src/renderer/src/config/providers.ts @@ -78,16 +78,16 @@ export const CHERRYAI_PROVIDER: SystemProvider = { } export const SYSTEM_PROVIDERS_CONFIG: Record = { - 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 = { - 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' diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index ba532ecc65..4b74ba91a2 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -67,7 +67,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 157, + version: 158, blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'], migrate }, diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index e26a382fc9..f10fc623da 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -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 + } } } diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 2d580c8e37..33abec0853 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -269,7 +269,7 @@ export type Provider = { } export const SystemProviderIds = { - cherryin: 'cherryin', + // cherryin: 'cherryin', silicon: 'silicon', aihubmix: 'aihubmix', ocoolai: 'ocoolai', From 52a980f75125382cc76f74828943880476d287e3 Mon Sep 17 00:00:00 2001 From: MyPrototypeWhat Date: Fri, 26 Sep 2025 12:10:28 +0800 Subject: [PATCH 15/32] 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. --- src/renderer/src/aiCore/prepareParams/parameterBuilder.ts | 3 ++- src/renderer/src/aiCore/utils/websearch.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts b/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts index 0a89e73c62..1ad04230b5 100644 --- a/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts +++ b/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts @@ -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 } } diff --git a/src/renderer/src/aiCore/utils/websearch.ts b/src/renderer/src/aiCore/utils/websearch.ts index 2fda7c1b19..9e29454b79 100644 --- a/src/renderer/src/aiCore/utils/websearch.ts +++ b/src/renderer/src/aiCore/utils/websearch.ts @@ -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 From 4aa9c9f22542bfe2c345c01fe5f3524bd89a33df Mon Sep 17 00:00:00 2001 From: Zhaokun Date: Fri, 26 Sep 2025 17:49:24 +0800 Subject: [PATCH 16/32] feat: improve content protection during file operations (#10378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: improve content protection during file operations - Add validation for knowledge base configuration before saving - Enhance error handling for note content reading - Implement content backup and restoration during file rename - Add content verification after rename operations - Improve user feedback with specific error messages * fix: format check --------- Co-authored-by: 自由的世界人 <3196812536@qq.com> --- .../Popups/SaveToKnowledgePopup.tsx | 53 ++++++++++++++++--- src/renderer/src/pages/notes/NotesPage.tsx | 36 ++++++++++++- 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx b/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx index b7c02cd4ec..cea3aca7cb 100644 --- a/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx +++ b/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx @@ -253,12 +253,39 @@ const PopupContainer: React.FC = ({ source, title, resolve }) => { let savedCount = 0 try { + // Validate knowledge base configuration before proceeding + if (!selectedBaseId) { + throw new Error('No knowledge base selected') + } + + const selectedBase = bases.find((base) => base.id === selectedBaseId) + if (!selectedBase) { + throw new Error('Selected knowledge base not found') + } + + if (!selectedBase.version) { + throw new Error('Knowledge base is not properly configured. Please check the knowledge base settings.') + } + if (isNoteMode) { const note = source.data as NotesTreeNode - const content = note.externalPath - ? await window.api.file.readExternal(note.externalPath) - : await window.api.file.read(note.id + '.md') - logger.debug('Note content:', content) + if (!note.externalPath) { + throw new Error('Note external path is required for export') + } + + let content = '' + try { + content = await window.api.file.readExternal(note.externalPath) + } catch (error) { + logger.error('Failed to read note file:', error as Error) + throw new Error('Failed to read note content. Please ensure the file exists and is accessible.') + } + + if (!content || content.trim() === '') { + throw new Error('Note content is empty. Cannot export empty notes to knowledge base.') + } + + logger.debug('Note content loaded', { contentLength: content.length }) await addNote(content) savedCount = 1 } else { @@ -283,9 +310,23 @@ const PopupContainer: React.FC = ({ source, title, resolve }) => { resolve({ success: true, savedCount }) } catch (error) { logger.error('save failed:', error as Error) - window.toast.error( - t(isTopicMode ? 'chat.save.topic.knowledge.error.save_failed' : 'chat.save.knowledge.error.save_failed') + + // Provide more specific error messages + let errorMessage = t( + isTopicMode ? 'chat.save.topic.knowledge.error.save_failed' : 'chat.save.knowledge.error.save_failed' ) + + if (error instanceof Error) { + if (error.message.includes('not properly configured')) { + errorMessage = error.message + } else if (error.message.includes('empty')) { + errorMessage = error.message + } else if (error.message.includes('read note content')) { + errorMessage = error.message + } + } + + window.toast.error(errorMessage) setLoading(false) } } diff --git a/src/renderer/src/pages/notes/NotesPage.tsx b/src/renderer/src/pages/notes/NotesPage.tsx index c85793e781..bc039e5ef2 100644 --- a/src/renderer/src/pages/notes/NotesPage.tsx +++ b/src/renderer/src/pages/notes/NotesPage.tsx @@ -492,10 +492,42 @@ const NotesPage: FC = () => { if (node && node.name !== newName) { const oldExternalPath = node.externalPath + let currentContent = '' + + // Save current content before rename to prevent content loss + if (node.type === 'file' && activeFilePath === oldExternalPath) { + // Get content from editor or current cache + currentContent = editorRef.current?.getMarkdown() || lastContentRef.current || currentContent + + // Save current content to the file before renaming + if (currentContent.trim()) { + try { + await saveCurrentNote(currentContent, oldExternalPath) + } catch (error) { + logger.warn('Failed to save content before rename:', error as Error) + } + } + } + const renamedNode = await renameNode(nodeId, newName) if (renamedNode.type === 'file' && activeFilePath === oldExternalPath) { + // Restore content to the new file path if content was lost during rename + if (currentContent.trim()) { + try { + const newFileContent = await window.api.file.readExternal(renamedNode.externalPath) + if (!newFileContent || newFileContent.trim() === '') { + await window.api.file.write(renamedNode.externalPath, currentContent) + logger.info('Restored content to renamed file') + } + } catch (error) { + logger.error('Failed to restore content after rename:', error as Error) + } + } + dispatch(setActiveFilePath(renamedNode.externalPath)) + // Invalidate cache for the new path to ensure content is loaded correctly + invalidateFileContent(renamedNode.externalPath) } else if ( renamedNode.type === 'folder' && activeFilePath && @@ -504,6 +536,8 @@ const NotesPage: FC = () => { const relativePath = activeFilePath.substring(oldExternalPath.length) const newFilePath = renamedNode.externalPath + relativePath dispatch(setActiveFilePath(newFilePath)) + // Invalidate cache for the new file path after folder rename + invalidateFileContent(newFilePath) } await sortAllLevels(sortType) if (renamedNode.name !== newName) { @@ -518,7 +552,7 @@ const NotesPage: FC = () => { }, 500) } }, - [activeFilePath, dispatch, findNodeById, sortType, t] + [activeFilePath, dispatch, findNodeById, sortType, t, invalidateFileContent, saveCurrentNote] ) // 处理文件上传 From dabfb8dc0ed9fb374abb32bc837c2e206b61dccb Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Fri, 26 Sep 2025 17:50:00 +0800 Subject: [PATCH 17/32] style(settings): remove unnecessary padding from ContentContainer (#10379) --- src/renderer/src/pages/settings/SettingsPage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/renderer/src/pages/settings/SettingsPage.tsx b/src/renderer/src/pages/settings/SettingsPage.tsx index ca83e149f0..00032484b7 100644 --- a/src/renderer/src/pages/settings/SettingsPage.tsx +++ b/src/renderer/src/pages/settings/SettingsPage.tsx @@ -191,7 +191,6 @@ const ContentContainer = styled.div` flex: 1; flex-direction: row; height: calc(100vh - var(--navbar-height)); - padding: 1px 0; ` const SettingMenus = styled(Scrollbar)` From 6829a03437589b22f132522ed5f6a9460e608efb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=A2=E5=A5=8B=E7=8C=AB?= Date: Sun, 28 Sep 2025 13:01:49 +0800 Subject: [PATCH 18/32] fix: AI_APICallError for Gemini via proxy #10366 (#10429) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When sending requests to Gemini via proxy, the system returns: "模型不存在或者请求路径错误". --- src/renderer/src/aiCore/provider/providerConfig.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/aiCore/provider/providerConfig.ts b/src/renderer/src/aiCore/provider/providerConfig.ts index b91dad9cf7..4c5c181093 100644 --- a/src/renderer/src/aiCore/provider/providerConfig.ts +++ b/src/renderer/src/aiCore/provider/providerConfig.ts @@ -18,7 +18,7 @@ import { loggerService } from '@renderer/services/LoggerService' import store from '@renderer/store' import { isSystemProvider, type Model, type Provider } from '@renderer/types' import { formatApiHost } from '@renderer/utils/api' -import { cloneDeep, isEmpty } from 'lodash' +import { cloneDeep, trim } from 'lodash' import { aihubmixProviderCreator, newApiResolverCreator, vertexAnthropicProviderCreator } from './config' import { getAiSdkProviderId } from './factory' @@ -120,7 +120,7 @@ export function providerToAiSdkConfig( // 构建基础配置 const baseConfig = { - baseURL: actualProvider.apiHost, + baseURL: trim(actualProvider.apiHost), apiKey: getRotatedApiKey(actualProvider) } // 处理OpenAI模式 @@ -195,7 +195,10 @@ export function providerToAiSdkConfig( } else if (baseConfig.baseURL.endsWith('/v1')) { baseConfig.baseURL = baseConfig.baseURL.slice(0, -3) } - baseConfig.baseURL = isEmpty(baseConfig.baseURL) ? '' : baseConfig.baseURL + + if (baseConfig.baseURL && !baseConfig.baseURL.includes('publishers/google')) { + baseConfig.baseURL = `${baseConfig.baseURL}/v1/projects/${project}/locations/${location}/publishers/google` + } } // 如果AI SDK支持该provider,使用原生配置 From 228ed474ce3949d0d5e1905e5b2bf94223307094 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Sun, 28 Sep 2025 13:31:18 +0800 Subject: [PATCH 19/32] chore: update @ai-sdk/google patch and modify getModelPath function - Updated the resolution and checksum for the @ai-sdk/google patch in yarn.lock. - Removed the patch reference from package.json for @ai-sdk/google. - Modified the getModelPath function to simplify its implementation, removing the baseURL parameter. --- ...@ai-sdk-google-npm-2.0.14-376d8b03cc.patch | 35 ++++--------------- packages/aiCore/package.json | 1 - yarn.lock | 5 ++- 3 files changed, 8 insertions(+), 33 deletions(-) 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 index a1ae65f02e..49bcec27d7 100644 --- a/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch +++ b/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch @@ -1,36 +1,13 @@ diff --git a/dist/index.mjs b/dist/index.mjs -index 110f37ec18c98b1d55ae2b73cc716194e6f9094d..91d0f336b318833c6cee9599fe91370c0ff75323 100644 +index 110f37ec18c98b1d55ae2b73cc716194e6f9094d..3ea0fadd783f334db71266e45babdcce11076974 100644 --- a/dist/index.mjs +++ b/dist/index.mjs -@@ -447,7 +447,10 @@ function convertToGoogleGenerativeAIMessages(prompt, options) { - } +@@ -448,7 +448,7 @@ 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}`; + function getModelPath(modelId) { +- return modelId.includes("/") ? modelId : `models/${modelId}`; ++ return `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, + // src/google-generative-ai-options.ts diff --git a/packages/aiCore/package.json b/packages/aiCore/package.json index 28ae7c8e25..93bf7b6414 100644 --- a/packages/aiCore/package.json +++ b/packages/aiCore/package.json @@ -39,7 +39,6 @@ "@ai-sdk/anthropic": "^2.0.17", "@ai-sdk/azure": "^2.0.30", "@ai-sdk/deepseek": "^1.0.17", - "@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/yarn.lock b/yarn.lock index 192c8e2076..748d52512c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -169,13 +169,13 @@ __metadata: "@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" + 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=c6aff2" 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 + checksum: 10c0/2a0a09debab8de0603243503ff5044bd3fff87d6c5de2d76d43839fa459cc85d5412b59ec63d0dcf1a6d6cab02882eb3c69f0f155129d0fc153bcde4deecbd32 languageName: node linkType: hard @@ -2328,7 +2328,6 @@ __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": "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" From ed2e01491edc94a0bda6a66d433d3e5c13badc11 Mon Sep 17 00:00:00 2001 From: Xin Rui <71483384+Konjac-XZ@users.noreply.github.com> Date: Sun, 28 Sep 2025 13:44:27 +0800 Subject: [PATCH 20/32] =?UTF-8?q?fix:=20clear=20@=20and=20other=20input=20?= =?UTF-8?q?text=20when=20exiting=20model=20selection=20menu=20w=E2=80=A6?= =?UTF-8?q?=20(#10427)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: clear @ and other input text when exiting model selection menu with Esc --- .../src/pages/home/Inputbar/MentionModelsButton.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx b/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx index 6bb36f988a..23c8fd13f5 100644 --- a/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx @@ -250,21 +250,23 @@ const MentionModelsButton: FC = ({ // ESC关闭时的处理:删除 @ 和搜索文本 if (action === 'esc') { // 只有在输入触发且有模型选择动作时才删除@字符和搜索文本 + const triggerInfo = ctx?.triggerInfo ?? triggerInfoRef.current if ( hasModelActionRef.current && - ctx.triggerInfo?.type === 'input' && - ctx.triggerInfo?.position !== undefined + triggerInfo?.type === 'input' && + triggerInfo?.position !== undefined ) { // 基于当前光标 + 搜索词精确定位并删除,position 仅作兜底 setText((currentText) => { const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null const caret = textArea ? (textArea.selectionStart ?? currentText.length) : currentText.length - return removeAtSymbolAndText(currentText, caret, searchText || '', ctx.triggerInfo?.position!) + return removeAtSymbolAndText(currentText, caret, searchText || '', triggerInfo.position!) }) } } // Backspace删除@的情况(delete-symbol): // @ 已经被Backspace自然删除,面板关闭,不需要额外操作 + triggerInfoRef.current = undefined } }) }, From 1df6e8c73206228063ddafb31496000fac1bf21e Mon Sep 17 00:00:00 2001 From: Pleasure1234 <3196812536@qq.com> Date: Sun, 28 Sep 2025 06:50:52 +0100 Subject: [PATCH 21/32] refactor(notes): improve notes management with local state and file handling (#10395) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(notes): improve notes management with local state and file handling - Replace UUID-based IDs with SHA1 hash of file paths for better consistency - Remove database storage for notes tree, use local state management instead - Add localStorage persistence for starred and expanded states - Improve cross-platform path normalization (replace backslashes with forward slashes) - Refactor tree operations to use optimized in-memory operations - Enhance file watcher integration for better sync performance - Simplify notes service with direct file system operations - Remove database dependencies from notes tree management 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Revert "Merge remote-tracking branch 'origin/main' into refactor/note" This reverts commit 389386ace8f30c43f4383ed59b04174672c67556, reversing changes made to 4428f511b0a3c0636bf47bdf814a8ccc69542301. * fix: format error * refactor: noteservice * refactor(notes): 完成笔记状态从localStorage向Redux的迁移 - 将starred和expanded路径状态从localStorage迁移到Redux store - 添加版本159迁移逻辑,自动从localStorage迁移现有数据到Redux - 优化NotesPage组件,使用Redux状态管理替代本地localStorage操作 - 改进SaveToKnowledgePopup的错误处理和验证逻辑 - 删除NotesTreeService中已废弃的localStorage写入函数 - 增强组件性能,使用ref避免不必要的依赖更新 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * fix: ci * feat(notes): add in-place renaming for notes in HeaderNavbar - Implemented an input field for renaming the current note directly in the HeaderNavbar. - Added handlers for title change, blur, and key events to manage renaming logic. - Updated the breadcrumb display to accommodate the new title input. - Enhanced styling for the title input to ensure seamless integration with the existing UI. This feature improves user experience by allowing quick edits without navigating away from the notes list. * Update NotesEditor.tsx --------- Co-authored-by: Claude Co-authored-by: kangfenmao --- src/main/utils/file.ts | 11 +- src/renderer/src/databases/index.ts | 5 +- src/renderer/src/pages/notes/HeaderNavbar.tsx | 211 ++++- src/renderer/src/pages/notes/NotesEditor.tsx | 21 +- src/renderer/src/pages/notes/NotesPage.tsx | 615 ++++++++------ src/renderer/src/pages/notes/NotesSidebar.tsx | 450 +++++++--- .../src/pages/settings/NotesSettings.tsx | 3 - .../src/pages/settings/SettingsPage.tsx | 1 + src/renderer/src/services/NotesService.ts | 802 +++--------------- src/renderer/src/services/NotesTreeService.ts | 365 +++----- src/renderer/src/store/note.ts | 24 +- src/renderer/src/utils/export.ts | 19 +- 12 files changed, 1158 insertions(+), 1369 deletions(-) diff --git a/src/main/utils/file.ts b/src/main/utils/file.ts index 5c197e8971..20305d1c9e 100644 --- a/src/main/utils/file.ts +++ b/src/main/utils/file.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import * as fs from 'node:fs' import { readFile } from 'node:fs/promises' import os from 'node:os' @@ -264,11 +265,12 @@ export async function scanDir(dirPath: string, depth = 0, basePath?: string): Pr if (entry.isDirectory() && options.includeDirectories) { const stats = await fs.promises.stat(entryPath) + const externalDirPath = entryPath.replace(/\\/g, '/') const dirTreeNode: NotesTreeNode = { - id: uuidv4(), + id: createHash('sha1').update(externalDirPath).digest('hex'), name: entry.name, treePath: treePath, - externalPath: entryPath, + externalPath: externalDirPath, createdAt: stats.birthtime.toISOString(), updatedAt: stats.mtime.toISOString(), type: 'folder', @@ -299,11 +301,12 @@ export async function scanDir(dirPath: string, depth = 0, basePath?: string): Pr ? `/${dirRelativePath.replace(/\\/g, '/')}/${nameWithoutExt}` : `/${nameWithoutExt}` + const externalFilePath = entryPath.replace(/\\/g, '/') const fileTreeNode: NotesTreeNode = { - id: uuidv4(), + id: createHash('sha1').update(externalFilePath).digest('hex'), name: name, treePath: fileTreePath, - externalPath: entryPath, + externalPath: externalFilePath, createdAt: stats.birthtime.toISOString(), updatedAt: stats.mtime.toISOString(), type: 'file' diff --git a/src/renderer/src/databases/index.ts b/src/renderer/src/databases/index.ts index 05bda8661d..83ad6b663d 100644 --- a/src/renderer/src/databases/index.ts +++ b/src/renderer/src/databases/index.ts @@ -7,7 +7,6 @@ import { } from '@renderer/types' // Import necessary types for blocks and new message structure import type { Message as NewMessage, MessageBlock } from '@renderer/types/newMessage' -import { NotesTreeNode } from '@renderer/types/note' import { Dexie, type EntityTable } from 'dexie' import { upgradeToV5, upgradeToV7, upgradeToV8 } from './upgrades' @@ -24,7 +23,6 @@ export const db = new Dexie('CherryStudio', { quick_phrases: EntityTable message_blocks: EntityTable // Correct type for message_blocks translate_languages: EntityTable - notes_tree: EntityTable<{ id: string; tree: NotesTreeNode[] }, 'id'> } db.version(1).stores({ @@ -118,8 +116,7 @@ db.version(10).stores({ translate_history: '&id, sourceText, targetText, sourceLanguage, targetLanguage, createdAt', translate_languages: '&id, langCode', quick_phrases: 'id', - message_blocks: 'id, messageId, file.id', - notes_tree: '&id' + message_blocks: 'id, messageId, file.id' }) export default db diff --git a/src/renderer/src/pages/notes/HeaderNavbar.tsx b/src/renderer/src/pages/notes/HeaderNavbar.tsx index c9eb189302..81f5668395 100644 --- a/src/renderer/src/pages/notes/HeaderNavbar.tsx +++ b/src/renderer/src/pages/notes/HeaderNavbar.tsx @@ -5,24 +5,25 @@ import { HStack } from '@renderer/components/Layout' import { useActiveNode } from '@renderer/hooks/useNotesQuery' import { useNotesSettings } from '@renderer/hooks/useNotesSettings' import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace' -import { findNodeByPath, findNodeInTree, updateNodeInTree } from '@renderer/services/NotesTreeService' -import { NotesTreeNode } from '@types' -import { Dropdown, Tooltip } from 'antd' +import { findNode } from '@renderer/services/NotesTreeService' +import { Dropdown, Input, Tooltip } from 'antd' import { t } from 'i18next' import { MoreHorizontal, PanelLeftClose, PanelRightClose, Star } from 'lucide-react' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import styled from 'styled-components' import { menuItems } from './MenuConfig' const logger = loggerService.withContext('HeaderNavbar') -const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => { +const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpandPath, onRenameNode }) => { const { showWorkspace, toggleShowWorkspace } = useShowWorkspace() const { activeNode } = useActiveNode(notesTree) const [breadcrumbItems, setBreadcrumbItems] = useState< Array<{ key: string; title: string; treePath: string; isFolder: boolean }> >([]) + const [titleValue, setTitleValue] = useState('') + const titleInputRef = useRef(null) const { settings, updateSettings } = useNotesSettings() const canShowStarButton = activeNode?.type === 'file' && onToggleStar @@ -52,37 +53,41 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => { }, [getCurrentNoteContent]) const handleBreadcrumbClick = useCallback( - async (item: { treePath: string; isFolder: boolean }) => { - if (item.isFolder && notesTree) { - try { - // 获取从根目录到点击目录的所有路径片段 - const pathParts = item.treePath.split('/').filter(Boolean) - const expandPromises: Promise[] = [] - - // 逐级展开从根到目标路径的所有文件夹 - for (let i = 0; i < pathParts.length; i++) { - const currentPath = '/' + pathParts.slice(0, i + 1).join('/') - const folderNode = findNodeByPath(notesTree, currentPath) - - if (folderNode && folderNode.type === 'folder' && !folderNode.expanded) { - expandPromises.push(updateNodeInTree(notesTree, folderNode.id, { expanded: true })) - } - } - - // 并行执行所有展开操作 - if (expandPromises.length > 0) { - await Promise.all(expandPromises) - logger.info('Expanded folder path from breadcrumb:', { - targetPath: item.treePath, - expandedCount: expandPromises.length - }) - } - } catch (error) { - logger.error('Failed to expand folder path from breadcrumb:', error as Error) - } + (item: { treePath: string; isFolder: boolean }) => { + if (item.isFolder && onExpandPath) { + onExpandPath(item.treePath) } }, - [notesTree] + [onExpandPath] + ) + + const handleTitleChange = useCallback((e: React.ChangeEvent) => { + setTitleValue(e.target.value) + }, []) + + const handleTitleBlur = useCallback(() => { + if (activeNode && titleValue.trim() && titleValue.trim() !== activeNode.name.replace('.md', '')) { + onRenameNode?.(activeNode.id, titleValue.trim()) + } else if (activeNode) { + // 如果没有更改或为空,恢复原始值 + setTitleValue(activeNode.name.replace('.md', '')) + } + }, [activeNode, titleValue, onRenameNode]) + + const handleTitleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + titleInputRef.current?.blur() + } else if (e.key === 'Escape') { + e.preventDefault() + if (activeNode) { + setTitleValue(activeNode.name.replace('.md', '')) + } + titleInputRef.current?.blur() + } + }, + [activeNode] ) const buildMenuItem = (item: any) => { @@ -133,13 +138,20 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => { } } + // 同步标题值 + useEffect(() => { + if (activeNode?.type === 'file') { + setTitleValue(activeNode.name.replace('.md', '')) + } + }, [activeNode]) + // 构建面包屑路径 useEffect(() => { if (!activeNode || !notesTree) { setBreadcrumbItems([]) return } - const node = findNodeInTree(notesTree, activeNode.id) + const node = findNode(notesTree, activeNode.id) if (!node) return const pathParts = node.treePath.split('/').filter(Boolean) @@ -179,16 +191,41 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar }) => { - - {breadcrumbItems.map((item, index) => ( - - handleBreadcrumbClick(item)} - $clickable={item.isFolder && index < breadcrumbItems.length - 1}> - {item.title} - - - ))} + + {breadcrumbItems.map((item, index) => { + const isLastItem = index === breadcrumbItems.length - 1 + const isCurrentNote = isLastItem && !item.isFolder + + return ( + + {isCurrentNote ? ( + + + + ) : ( + handleBreadcrumbClick(item)} + $clickable={item.isFolder && !isLastItem}> + {item.title} + + )} + + ) + })} @@ -303,6 +340,30 @@ export const BreadcrumbsContainer = styled.div` align-items: center; } + /* 最后一个面包屑项(当前笔记)可以扩展 */ + & li:last-child { + flex: 1 !important; + min-width: 0 !important; + max-width: none !important; + } + + /* 覆盖 HeroUI BreadcrumbItem 的样式 */ + & li:last-child [data-slot="item"] { + flex: 1 !important; + width: 100% !important; + max-width: none !important; + } + + /* 更强的样式覆盖 */ + & li:last-child * { + max-width: none !important; + } + + & li:last-child > * { + flex: 1 !important; + width: 100% !important; + } + /* 确保分隔符不会与标题重叠 */ & li:not(:last-child)::after { flex-shrink: 0; @@ -330,4 +391,64 @@ export const BreadcrumbTitle = styled.span<{ $clickable?: boolean }>` `} ` +export const TitleInputWrapper = styled.div` + width: 100%; + flex: 1; + min-width: 0; + max-width: none; + display: flex; + align-items: center; +` + +export const TitleInput = styled(Input)` + &&& { + border: none !important; + box-shadow: none !important; + background: transparent !important; + color: inherit !important; + font-size: inherit !important; + font-weight: inherit !important; + font-family: inherit !important; + padding: 0 !important; + height: auto !important; + line-height: inherit !important; + width: 100% !important; + min-width: 0 !important; + max-width: none !important; + flex: 1 !important; + + &:focus, + &:hover { + border: none !important; + box-shadow: none !important; + background: transparent !important; + } + + &::placeholder { + color: var(--color-text-3) !important; + } + + input { + border: none !important; + box-shadow: none !important; + background: transparent !important; + color: inherit !important; + font-size: inherit !important; + font-weight: inherit !important; + font-family: inherit !important; + padding: 0 !important; + height: auto !important; + line-height: inherit !important; + width: 100% !important; + + &:focus, + &:hover { + border: none !important; + box-shadow: none !important; + background: transparent !important; + } + } + } +` + export default HeaderNavbar diff --git a/src/renderer/src/pages/notes/NotesEditor.tsx b/src/renderer/src/pages/notes/NotesEditor.tsx index a9ed8f592f..8bdd44d12c 100644 --- a/src/renderer/src/pages/notes/NotesEditor.tsx +++ b/src/renderer/src/pages/notes/NotesEditor.tsx @@ -5,7 +5,7 @@ import { RichEditorRef } from '@renderer/components/RichEditor/types' import Selector from '@renderer/components/Selector' import { useNotesSettings } from '@renderer/hooks/useNotesSettings' import { EditorView } from '@renderer/types' -import { Empty, Spin } from 'antd' +import { Empty } from 'antd' import { FC, memo, RefObject, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -14,13 +14,12 @@ interface NotesEditorProps { activeNodeId?: string currentContent: string tokenCount: number - isLoading: boolean editorRef: RefObject onMarkdownChange: (content: string) => void } const NotesEditor: FC = memo( - ({ activeNodeId, currentContent, tokenCount, isLoading, onMarkdownChange, editorRef }) => { + ({ activeNodeId, currentContent, tokenCount, onMarkdownChange, editorRef }) => { const { t } = useTranslation() const { settings } = useNotesSettings() const currentViewMode = useMemo(() => { @@ -47,14 +46,6 @@ const NotesEditor: FC = memo( ) } - if (isLoading) { - return ( - - - - ) - } - return ( <> @@ -122,14 +113,6 @@ const NotesEditor: FC = memo( NotesEditor.displayName = 'NotesEditor' -const LoadingContainer = styled.div` - display: flex; - justify-content: center; - align-items: center; - width: 100%; - height: 100%; -` - const EmptyContainer = styled.div` display: flex; justify-content: center; diff --git a/src/renderer/src/pages/notes/NotesPage.tsx b/src/renderer/src/pages/notes/NotesPage.tsx index bc039e5ef2..0bad446acf 100644 --- a/src/renderer/src/pages/notes/NotesPage.tsx +++ b/src/renderer/src/pages/notes/NotesPage.tsx @@ -5,21 +5,38 @@ import { useActiveNode, useFileContent, useFileContentSync } from '@renderer/hoo import { useNotesSettings } from '@renderer/hooks/useNotesSettings' import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace' import { - createFolder, - createNote, - deleteNode, - initWorkSpace, - moveNode, - renameNode, - sortAllLevels, - uploadFiles + addDir, + addNote, + delNode, + loadTree, + renameNode as renameEntry, + sortTree, + uploadNotes } from '@renderer/services/NotesService' -import { getNotesTree, isParentNode, updateNodeInTree } from '@renderer/services/NotesTreeService' -import { useAppDispatch, useAppSelector } from '@renderer/store' -import { selectActiveFilePath, selectSortType, setActiveFilePath, setSortType } from '@renderer/store/note' +import { + addUniquePath, + findNode, + findNodeByPath, + findParent, + normalizePathValue, + removePathEntries, + reorderTreeNodes, + replacePathEntries, + updateTreeNode +} from '@renderer/services/NotesTreeService' +import { useAppDispatch, useAppSelector, useAppStore } from '@renderer/store' +import { + selectActiveFilePath, + selectExpandedPaths, + selectSortType, + selectStarredPaths, + setActiveFilePath, + setExpandedPaths, + setSortType, + setStarredPaths +} from '@renderer/store/note' import { NotesSortType, NotesTreeNode } from '@renderer/types/note' import { FileChangeEvent } from '@shared/config/types' -import { useLiveQuery } from 'dexie-react-hooks' import { debounce } from 'lodash' import { AnimatePresence, motion } from 'motion/react' import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -37,27 +54,98 @@ const NotesPage: FC = () => { const { t } = useTranslation() const { showWorkspace } = useShowWorkspace() const dispatch = useAppDispatch() + const store = useAppStore() const activeFilePath = useAppSelector(selectActiveFilePath) const sortType = useAppSelector(selectSortType) + const starredPaths = useAppSelector(selectStarredPaths) + const expandedPaths = useAppSelector(selectExpandedPaths) const { settings, notesPath, updateNotesPath } = useNotesSettings() // 混合策略:useLiveQuery用于笔记树,React Query用于文件内容 - const notesTreeQuery = useLiveQuery(() => getNotesTree(), []) - const notesTree = useMemo(() => notesTreeQuery || [], [notesTreeQuery]) + const [notesTree, setNotesTree] = useState([]) + const starredSet = useMemo(() => new Set(starredPaths), [starredPaths]) + const expandedSet = useMemo(() => new Set(expandedPaths), [expandedPaths]) const { activeNode } = useActiveNode(notesTree) const { invalidateFileContent } = useFileContentSync() - const { data: currentContent = '', isLoading: isContentLoading } = useFileContent(activeFilePath) + const { data: currentContent = '' } = useFileContent(activeFilePath) const [tokenCount, setTokenCount] = useState(0) const [selectedFolderId, setSelectedFolderId] = useState(null) const watcherRef = useRef<(() => void) | null>(null) - const isSyncingTreeRef = useRef(false) const lastContentRef = useRef('') const lastFilePathRef = useRef(undefined) - const isInitialSortApplied = useRef(false) const isRenamingRef = useRef(false) const isCreatingNoteRef = useRef(false) + const activeFilePathRef = useRef(activeFilePath) + const currentContentRef = useRef(currentContent) + + const updateStarredPaths = useCallback( + (updater: (paths: string[]) => string[]) => { + const current = store.getState().note.starredPaths + const safeCurrent = Array.isArray(current) ? current : [] + const next = updater(safeCurrent) ?? [] + if (!Array.isArray(next)) { + return + } + if (next !== safeCurrent) { + dispatch(setStarredPaths(next)) + } + }, + [dispatch, store] + ) + + const updateExpandedPaths = useCallback( + (updater: (paths: string[]) => string[]) => { + const current = store.getState().note.expandedPaths + const safeCurrent = Array.isArray(current) ? current : [] + const next = updater(safeCurrent) ?? [] + if (!Array.isArray(next)) { + return + } + if (next !== safeCurrent) { + dispatch(setExpandedPaths(next)) + } + }, + [dispatch, store] + ) + + const mergeTreeState = useCallback( + (nodes: NotesTreeNode[]): NotesTreeNode[] => { + return nodes.map((node) => { + const normalizedPath = normalizePathValue(node.externalPath) + const merged: NotesTreeNode = { + ...node, + externalPath: normalizedPath, + isStarred: starredSet.has(normalizedPath) + } + + if (node.type === 'folder') { + merged.expanded = expandedSet.has(normalizedPath) + merged.children = node.children ? mergeTreeState(node.children) : [] + } + + return merged + }) + }, + [starredSet, expandedSet] + ) + + const refreshTree = useCallback(async () => { + if (!notesPath) { + setNotesTree([]) + return + } + + try { + const rawTree = await loadTree(notesPath) + const sortedTree = sortTree(rawTree, sortType) + setNotesTree(mergeTreeState(sortedTree)) + } catch (error) { + logger.error('Failed to refresh notes tree:', error as Error) + } + }, [mergeTreeState, notesPath, sortType]) + useEffect(() => { const updateCharCount = () => { const textContent = editorRef.current?.getContent() || currentContent @@ -67,19 +155,16 @@ const NotesPage: FC = () => { updateCharCount() }, [currentContent]) - // 查找树节点 by ID - const findNodeById = useCallback((tree: NotesTreeNode[], nodeId: string): NotesTreeNode | null => { - for (const node of tree) { - if (node.id === nodeId) { - return node - } - if (node.children) { - const found = findNodeById(node.children, nodeId) - if (found) return found - } + useEffect(() => { + refreshTree() + }, [refreshTree]) + + // Re-merge tree state when starred or expanded paths change + useEffect(() => { + if (notesTree.length > 0) { + setNotesTree((prev) => mergeTreeState(prev)) } - return null - }, []) + }, [starredPaths, expandedPaths, mergeTreeState, notesTree.length]) // 保存当前笔记内容 const saveCurrentNote = useCallback( @@ -107,6 +192,11 @@ const NotesPage: FC = () => { [saveCurrentNote] ) + const saveCurrentNoteRef = useRef(saveCurrentNote) + const debouncedSaveRef = useRef(debouncedSave) + const invalidateFileContentRef = useRef(invalidateFileContent) + const refreshTreeRef = useRef(refreshTree) + const handleMarkdownChange = useCallback( (newMarkdown: string) => { // 记录最新内容和文件路径,用于兜底保存 @@ -118,6 +208,30 @@ const NotesPage: FC = () => { [debouncedSave, activeFilePath] ) + useEffect(() => { + activeFilePathRef.current = activeFilePath + }, [activeFilePath]) + + useEffect(() => { + currentContentRef.current = currentContent + }, [currentContent]) + + useEffect(() => { + saveCurrentNoteRef.current = saveCurrentNote + }, [saveCurrentNote]) + + useEffect(() => { + debouncedSaveRef.current = debouncedSave + }, [debouncedSave]) + + useEffect(() => { + invalidateFileContentRef.current = invalidateFileContent + }, [invalidateFileContent]) + + useEffect(() => { + refreshTreeRef.current = refreshTree + }, [refreshTree]) + useEffect(() => { async function initialize() { if (!notesPath) { @@ -133,29 +247,12 @@ const NotesPage: FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [notesPath]) - // 应用初始排序 - useEffect(() => { - async function applyInitialSort() { - if (notesTree.length > 0 && !isInitialSortApplied.current) { - try { - await sortAllLevels(sortType) - isInitialSortApplied.current = true - } catch (error) { - logger.error('Failed to apply initial sorting:', error as Error) - } - } - } - - applyInitialSort() - }, [notesTree.length, sortType]) - // 处理树同步时的状态管理 useEffect(() => { if (notesTree.length === 0) return // 如果有activeFilePath但找不到对应节点,清空选择 // 但要排除正在同步树结构、重命名或创建笔记的情况,避免在这些操作中误清空 - const shouldClearPath = - activeFilePath && !activeNode && !isSyncingTreeRef.current && !isRenamingRef.current && !isCreatingNoteRef.current + const shouldClearPath = activeFilePath && !activeNode && !isRenamingRef.current && !isCreatingNoteRef.current if (shouldClearPath) { logger.warn('Clearing activeFilePath - node not found in tree', { @@ -167,7 +264,7 @@ const NotesPage: FC = () => { }, [notesTree, activeFilePath, activeNode, dispatch]) useEffect(() => { - if (!notesPath || notesTree.length === 0) return + if (!notesPath) return async function startFileWatcher() { // 清理之前的监控 @@ -181,31 +278,14 @@ const NotesPage: FC = () => { try { if (!notesPath) return const { eventType, filePath } = data + const normalizedEventPath = normalizePathValue(filePath) switch (eventType) { case 'change': { // 处理文件内容变化 - 只有内容真正改变时才触发更新 - if (activeFilePath === filePath) { - try { - // 读取文件最新内容 - // const newFileContent = await window.api.file.readExternal(filePath) - // // 获取当前编辑器/缓存中的内容 - // const currentEditorContent = editorRef.current?.getMarkdown() - // // 如果编辑器还未初始化完成,忽略FileWatcher事件 - // if (!isEditorInitialized.current) { - // return - // } - // // 比较内容是否真正发生变化 - // if (newFileContent.trim() !== currentEditorContent?.trim()) { - // invalidateFileContent(filePath) - // } - } catch (error) { - logger.error('Failed to read file for content comparison:', error as Error) - // 读取失败时,还是执行原来的逻辑 - invalidateFileContent(filePath) - } - } else { - await initWorkSpace(notesPath, sortType) + const activePath = activeFilePathRef.current + if (activePath && normalizePathValue(activePath) === normalizedEventPath) { + invalidateFileContentRef.current?.(normalizedEventPath) } break } @@ -215,20 +295,18 @@ const NotesPage: FC = () => { case 'unlink': case 'unlinkDir': { // 如果删除的是当前活动文件,清空选择 - if ((eventType === 'unlink' || eventType === 'unlinkDir') && activeFilePath === filePath) { + if ( + (eventType === 'unlink' || eventType === 'unlinkDir') && + activeFilePathRef.current && + normalizePathValue(activeFilePathRef.current) === normalizedEventPath + ) { dispatch(setActiveFilePath(undefined)) + editorRef.current?.clear() } - // 设置同步标志,避免竞态条件 - isSyncingTreeRef.current = true - - // 重新同步数据库,useLiveQuery会自动响应数据库变化 - try { - await initWorkSpace(notesPath, sortType) - } catch (error) { - logger.error('Failed to sync database:', error as Error) - } finally { - isSyncingTreeRef.current = false + const refresh = refreshTreeRef.current + if (refresh) { + await refresh() } break } @@ -261,26 +339,19 @@ const NotesPage: FC = () => { }) // 如果有未保存的内容,立即保存 - if (lastContentRef.current && lastContentRef.current !== currentContent && lastFilePathRef.current) { - saveCurrentNote(lastContentRef.current, lastFilePathRef.current).catch((error) => { - logger.error('Emergency save failed:', error as Error) - }) + if (lastContentRef.current && lastFilePathRef.current && lastContentRef.current !== currentContentRef.current) { + const saveFn = saveCurrentNoteRef.current + if (saveFn) { + saveFn(lastContentRef.current, lastFilePathRef.current).catch((error) => { + logger.error('Emergency save failed:', error as Error) + }) + } } // 清理防抖函数 - debouncedSave.cancel() + debouncedSaveRef.current?.cancel() } - }, [ - notesPath, - notesTree.length, - activeFilePath, - invalidateFileContent, - dispatch, - currentContent, - debouncedSave, - saveCurrentNote, - sortType - ]) + }, [dispatch, notesPath]) useEffect(() => { const editor = editorRef.current @@ -316,13 +387,13 @@ const NotesPage: FC = () => { // 获取目标文件夹路径(选中文件夹或根目录) const getTargetFolderPath = useCallback(() => { if (selectedFolderId) { - const selectedNode = findNodeById(notesTree, selectedFolderId) + const selectedNode = findNode(notesTree, selectedFolderId) if (selectedNode && selectedNode.type === 'folder') { return selectedNode.externalPath } } return notesPath // 默认返回根目录 - }, [selectedFolderId, notesTree, notesPath, findNodeById]) + }, [selectedFolderId, notesTree, notesPath]) // 创建文件夹 const handleCreateFolder = useCallback( @@ -332,12 +403,14 @@ const NotesPage: FC = () => { if (!targetPath) { throw new Error('No folder path selected') } - await createFolder(name, targetPath) + await addDir(name, targetPath) + updateExpandedPaths((prev) => addUniquePath(prev, normalizePathValue(targetPath))) + await refreshTree() } catch (error) { logger.error('Failed to create folder:', error as Error) } }, - [getTargetFolderPath] + [getTargetFolderPath, refreshTree, updateExpandedPaths] ) // 创建笔记 @@ -350,11 +423,13 @@ const NotesPage: FC = () => { if (!targetPath) { throw new Error('No folder path selected') } - const newNote = await createNote(name, '', targetPath) - dispatch(setActiveFilePath(newNote.externalPath)) + const { path: notePath } = await addNote(name, '', targetPath) + const normalizedParent = normalizePathValue(targetPath) + updateExpandedPaths((prev) => addUniquePath(prev, normalizedParent)) + dispatch(setActiveFilePath(notePath)) setSelectedFolderId(null) - await sortAllLevels(sortType) + await refreshTree() } catch (error) { logger.error('Failed to create note:', error as Error) } finally { @@ -364,73 +439,41 @@ const NotesPage: FC = () => { }, 500) } }, - [dispatch, getTargetFolderPath, sortType] - ) - - // 切换展开状态 - const toggleNodeExpanded = useCallback( - async (nodeId: string) => { - try { - const tree = await getNotesTree() - const node = findNodeById(tree, nodeId) - - if (node && node.type === 'folder') { - await updateNodeInTree(tree, nodeId, { - expanded: !node.expanded - }) - } - - return tree - } catch (error) { - logger.error('Failed to toggle expanded:', error as Error) - throw error - } - }, - [findNodeById] + [dispatch, getTargetFolderPath, refreshTree, updateExpandedPaths] ) const handleToggleExpanded = useCallback( - async (nodeId: string) => { - try { - await toggleNodeExpanded(nodeId) - } catch (error) { - logger.error('Failed to toggle expanded:', error as Error) + (nodeId: string) => { + const targetNode = findNode(notesTree, nodeId) + if (!targetNode || targetNode.type !== 'folder') { + return } + + const nextExpanded = !targetNode.expanded + // Update Redux state first, then let mergeTreeState handle the UI update + updateExpandedPaths((prev) => + nextExpanded + ? addUniquePath(prev, targetNode.externalPath) + : removePathEntries(prev, targetNode.externalPath, false) + ) }, - [toggleNodeExpanded] - ) - - // 切换收藏状态 - const toggleStarred = useCallback( - async (nodeId: string) => { - try { - const tree = await getNotesTree() - const node = findNodeById(tree, nodeId) - - if (node && node.type === 'file') { - await updateNodeInTree(tree, nodeId, { - isStarred: !node.isStarred - }) - } - - return tree - } catch (error) { - logger.error('Failed to toggle star:', error as Error) - throw error - } - }, - [findNodeById] + [notesTree, updateExpandedPaths] ) const handleToggleStar = useCallback( - async (nodeId: string) => { - try { - await toggleStarred(nodeId) - } catch (error) { - logger.error('Failed to toggle star:', error as Error) + (nodeId: string) => { + const node = findNode(notesTree, nodeId) + if (!node) { + return } + + const nextStarred = !node.isStarred + // Update Redux state first, then let mergeTreeState handle the UI update + updateStarredPaths((prev) => + nextStarred ? addUniquePath(prev, node.externalPath) : removePathEntries(prev, node.externalPath, false) + ) }, - [toggleStarred] + [notesTree, updateStarredPaths] ) // 选择节点 @@ -447,7 +490,7 @@ const NotesPage: FC = () => { } } else if (node.type === 'folder') { setSelectedFolderId(node.id) - await handleToggleExpanded(node.id) + handleToggleExpanded(node.id) } }, [dispatch, handleToggleExpanded, invalidateFileContent] @@ -457,28 +500,35 @@ const NotesPage: FC = () => { const handleDeleteNode = useCallback( async (nodeId: string) => { try { - const nodeToDelete = findNodeById(notesTree, nodeId) + const nodeToDelete = findNode(notesTree, nodeId) if (!nodeToDelete) return - const isActiveNodeOrParent = - activeFilePath && - (nodeToDelete.externalPath === activeFilePath || isParentNode(notesTree, nodeId, activeNode?.id || '')) + await delNode(nodeToDelete) - await deleteNode(nodeId) - await sortAllLevels(sortType) + updateStarredPaths((prev) => removePathEntries(prev, nodeToDelete.externalPath, nodeToDelete.type === 'folder')) + updateExpandedPaths((prev) => + removePathEntries(prev, nodeToDelete.externalPath, nodeToDelete.type === 'folder') + ) - // 如果删除的是当前活动节点或其父节点,清空编辑器 - if (isActiveNodeOrParent) { + const normalizedActivePath = activeFilePath ? normalizePathValue(activeFilePath) : undefined + const normalizedDeletePath = normalizePathValue(nodeToDelete.externalPath) + const isActiveNode = normalizedActivePath === normalizedDeletePath + const isActiveDescendant = + nodeToDelete.type === 'folder' && + normalizedActivePath && + normalizedActivePath.startsWith(`${normalizedDeletePath}/`) + + if (isActiveNode || isActiveDescendant) { dispatch(setActiveFilePath(undefined)) - if (editorRef.current) { - editorRef.current.clear() - } + editorRef.current?.clear() } + + await refreshTree() } catch (error) { logger.error('Failed to delete node:', error as Error) } }, - [findNodeById, notesTree, activeFilePath, activeNode?.id, sortType, dispatch] + [notesTree, activeFilePath, dispatch, refreshTree, updateStarredPaths, updateExpandedPaths] ) // 重命名节点 @@ -487,63 +537,30 @@ const NotesPage: FC = () => { try { isRenamingRef.current = true - const tree = await getNotesTree() - const node = findNodeById(tree, nodeId) - - if (node && node.name !== newName) { - const oldExternalPath = node.externalPath - let currentContent = '' - - // Save current content before rename to prevent content loss - if (node.type === 'file' && activeFilePath === oldExternalPath) { - // Get content from editor or current cache - currentContent = editorRef.current?.getMarkdown() || lastContentRef.current || currentContent - - // Save current content to the file before renaming - if (currentContent.trim()) { - try { - await saveCurrentNote(currentContent, oldExternalPath) - } catch (error) { - logger.warn('Failed to save content before rename:', error as Error) - } - } - } - - const renamedNode = await renameNode(nodeId, newName) - - if (renamedNode.type === 'file' && activeFilePath === oldExternalPath) { - // Restore content to the new file path if content was lost during rename - if (currentContent.trim()) { - try { - const newFileContent = await window.api.file.readExternal(renamedNode.externalPath) - if (!newFileContent || newFileContent.trim() === '') { - await window.api.file.write(renamedNode.externalPath, currentContent) - logger.info('Restored content to renamed file') - } - } catch (error) { - logger.error('Failed to restore content after rename:', error as Error) - } - } - - dispatch(setActiveFilePath(renamedNode.externalPath)) - // Invalidate cache for the new path to ensure content is loaded correctly - invalidateFileContent(renamedNode.externalPath) - } else if ( - renamedNode.type === 'folder' && - activeFilePath && - activeFilePath.startsWith(oldExternalPath + '/') - ) { - const relativePath = activeFilePath.substring(oldExternalPath.length) - const newFilePath = renamedNode.externalPath + relativePath - dispatch(setActiveFilePath(newFilePath)) - // Invalidate cache for the new file path after folder rename - invalidateFileContent(newFilePath) - } - await sortAllLevels(sortType) - if (renamedNode.name !== newName) { - window.toast.info(t('notes.rename_changed', { original: newName, final: renamedNode.name })) - } + const node = findNode(notesTree, nodeId) + if (!node || node.name === newName) { + return } + + const oldPath = node.externalPath + const renamed = await renameEntry(node, newName) + + if (node.type === 'file' && activeFilePath === oldPath) { + debouncedSaveRef.current?.cancel() + lastFilePathRef.current = renamed.path + dispatch(setActiveFilePath(renamed.path)) + } else if (node.type === 'folder' && activeFilePath && activeFilePath.startsWith(`${oldPath}/`)) { + const suffix = activeFilePath.slice(oldPath.length) + const nextActivePath = `${renamed.path}${suffix}` + debouncedSaveRef.current?.cancel() + lastFilePathRef.current = nextActivePath + dispatch(setActiveFilePath(nextActivePath)) + } + + updateStarredPaths((prev) => replacePathEntries(prev, oldPath, renamed.path, node.type === 'folder')) + updateExpandedPaths((prev) => replacePathEntries(prev, oldPath, renamed.path, node.type === 'folder')) + + await refreshTree() } catch (error) { logger.error('Failed to rename node:', error as Error) } finally { @@ -552,7 +569,7 @@ const NotesPage: FC = () => { }, 500) } }, - [activeFilePath, dispatch, findNodeById, sortType, t, invalidateFileContent, saveCurrentNote] + [activeFilePath, dispatch, notesTree, refreshTree, updateStarredPaths, updateExpandedPaths] ) // 处理文件上传 @@ -569,7 +586,7 @@ const NotesPage: FC = () => { throw new Error('No folder path selected') } - const result = await uploadFiles(files, targetFolderPath) + const result = await uploadNotes(files, targetFolderPath) // 检查上传结果 if (result.fileCount === 0) { @@ -578,7 +595,8 @@ const NotesPage: FC = () => { } // 排序并显示成功信息 - await sortAllLevels(sortType) + updateExpandedPaths((prev) => addUniquePath(prev, normalizePathValue(targetFolderPath))) + await refreshTree() const successMessage = t('notes.upload_success') @@ -588,37 +606,141 @@ const NotesPage: FC = () => { window.toast.error(t('notes.upload_failed')) } }, - [getTargetFolderPath, sortType, t] + [getTargetFolderPath, refreshTree, t, updateExpandedPaths] ) // 处理节点移动 const handleMoveNode = useCallback( async (sourceNodeId: string, targetNodeId: string, position: 'before' | 'after' | 'inside') => { + if (!notesPath) { + return + } + try { - const result = await moveNode(sourceNodeId, targetNodeId, position) - if (result.success && result.type !== 'manual_reorder') { - await sortAllLevels(sortType) + const sourceNode = findNode(notesTree, sourceNodeId) + const targetNode = findNode(notesTree, targetNodeId) + + if (!sourceNode || !targetNode) { + return } + + if (position === 'inside' && targetNode.type !== 'folder') { + return + } + + const rootPath = normalizePathValue(notesPath) + const sourceParentNode = findParent(notesTree, sourceNodeId) + const targetParentNode = position === 'inside' ? targetNode : findParent(notesTree, targetNodeId) + + const sourceParentPath = sourceParentNode ? sourceParentNode.externalPath : rootPath + const targetParentPath = + position === 'inside' ? targetNode.externalPath : targetParentNode ? targetParentNode.externalPath : rootPath + + const normalizedSourceParent = normalizePathValue(sourceParentPath) + const normalizedTargetParent = normalizePathValue(targetParentPath) + + const isManualReorder = position !== 'inside' && normalizedSourceParent === normalizedTargetParent + + if (isManualReorder) { + // For manual reordering within the same parent, we can optimize by only updating the affected parent + setNotesTree((prev) => + reorderTreeNodes(prev, sourceNodeId, targetNodeId, position === 'before' ? 'before' : 'after') + ) + return + } + + const { safeName } = await window.api.file.checkFileName( + normalizedTargetParent, + sourceNode.name, + sourceNode.type === 'file' + ) + + const destinationPath = + sourceNode.type === 'file' + ? `${normalizedTargetParent}/${safeName}.md` + : `${normalizedTargetParent}/${safeName}` + + if (destinationPath === sourceNode.externalPath) { + return + } + + if (sourceNode.type === 'file') { + await window.api.file.move(sourceNode.externalPath, destinationPath) + } else { + await window.api.file.moveDir(sourceNode.externalPath, destinationPath) + } + + updateStarredPaths((prev) => + replacePathEntries(prev, sourceNode.externalPath, destinationPath, sourceNode.type === 'folder') + ) + updateExpandedPaths((prev) => { + let next = replacePathEntries(prev, sourceNode.externalPath, destinationPath, sourceNode.type === 'folder') + next = addUniquePath(next, normalizedTargetParent) + return next + }) + + const normalizedActivePath = activeFilePath ? normalizePathValue(activeFilePath) : undefined + if (normalizedActivePath) { + if (normalizedActivePath === sourceNode.externalPath) { + dispatch(setActiveFilePath(destinationPath)) + } else if (sourceNode.type === 'folder' && normalizedActivePath.startsWith(`${sourceNode.externalPath}/`)) { + const suffix = normalizedActivePath.slice(sourceNode.externalPath.length) + dispatch(setActiveFilePath(`${destinationPath}${suffix}`)) + } + } + + await refreshTree() } catch (error) { logger.error('Failed to move nodes:', error as Error) } }, - [sortType] + [activeFilePath, dispatch, notesPath, notesTree, refreshTree, updateStarredPaths, updateExpandedPaths] ) // 处理节点排序 const handleSortNodes = useCallback( async (newSortType: NotesSortType) => { - try { - // 更新Redux中的排序类型 - dispatch(setSortType(newSortType)) - await sortAllLevels(newSortType) - } catch (error) { - logger.error('Failed to sort notes:', error as Error) - throw error + dispatch(setSortType(newSortType)) + setNotesTree((prev) => mergeTreeState(sortTree(prev, newSortType))) + }, + [dispatch, mergeTreeState] + ) + + const handleExpandPath = useCallback( + (treePath: string) => { + if (!treePath) { + return + } + + const segments = treePath.split('/').filter(Boolean) + if (segments.length === 0) { + return + } + + let nextTree = notesTree + const pathsToAdd: string[] = [] + + segments.forEach((_, index) => { + const currentPath = '/' + segments.slice(0, index + 1).join('/') + const node = findNodeByPath(nextTree, currentPath) + if (node && node.type === 'folder' && !node.expanded) { + pathsToAdd.push(node.externalPath) + nextTree = updateTreeNode(nextTree, node.id, (current) => ({ ...current, expanded: true })) + } + }) + + if (pathsToAdd.length > 0) { + setNotesTree(nextTree) + updateExpandedPaths((prev) => { + let updated = prev + pathsToAdd.forEach((path) => { + updated = addUniquePath(updated, path) + }) + return updated + }) } }, - [dispatch] + [notesTree, updateExpandedPaths] ) const getCurrentNoteContent = useCallback(() => { @@ -665,12 +787,13 @@ const NotesPage: FC = () => { notesTree={notesTree} getCurrentNoteContent={getCurrentNoteContent} onToggleStar={handleToggleStar} + onExpandPath={handleExpandPath} + onRenameNode={handleRenameNode} /> diff --git a/src/renderer/src/pages/notes/NotesSidebar.tsx b/src/renderer/src/pages/notes/NotesSidebar.tsx index 09a76b6153..4588c37611 100644 --- a/src/renderer/src/pages/notes/NotesSidebar.tsx +++ b/src/renderer/src/pages/notes/NotesSidebar.tsx @@ -9,6 +9,7 @@ import NotesSidebarHeader from '@renderer/pages/notes/NotesSidebarHeader' import { useAppSelector } from '@renderer/store' import { selectSortType } from '@renderer/store/note' import { NotesSortType, NotesTreeNode } from '@renderer/types/note' +import { useVirtualizer } from '@tanstack/react-virtual' import { Dropdown, Input, InputRef, MenuProps } from 'antd' import { ChevronDown, @@ -22,7 +23,7 @@ import { Star, StarOff } from 'lucide-react' -import { FC, Ref, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { FC, memo, Ref, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -43,6 +44,157 @@ interface NotesSidebarProps { const logger = loggerService.withContext('NotesSidebar') +interface TreeNodeProps { + node: NotesTreeNode + depth: number + selectedFolderId?: string | null + activeNodeId?: string + editingNodeId: string | null + draggedNodeId: string | null + dragOverNodeId: string | null + dragPosition: 'before' | 'inside' | 'after' + inPlaceEdit: any + getMenuItems: (node: NotesTreeNode) => any[] + onSelectNode: (node: NotesTreeNode) => void + onToggleExpanded: (nodeId: string) => void + onDragStart: (e: React.DragEvent, node: NotesTreeNode) => void + onDragOver: (e: React.DragEvent, node: NotesTreeNode) => void + onDragLeave: () => void + onDrop: (e: React.DragEvent, node: NotesTreeNode) => void + onDragEnd: () => void + renderChildren?: boolean // 控制是否渲染子节点 +} + +const TreeNode = memo( + ({ + node, + depth, + selectedFolderId, + activeNodeId, + editingNodeId, + draggedNodeId, + dragOverNodeId, + dragPosition, + inPlaceEdit, + getMenuItems, + onSelectNode, + onToggleExpanded, + onDragStart, + onDragOver, + onDragLeave, + onDrop, + onDragEnd, + renderChildren = true + }) => { + const { t } = useTranslation() + + const isActive = selectedFolderId + ? node.type === 'folder' && node.id === selectedFolderId + : node.id === activeNodeId + const isEditing = editingNodeId === node.id && inPlaceEdit.isEditing + const hasChildren = node.children && node.children.length > 0 + const isDragging = draggedNodeId === node.id + const isDragOver = dragOverNodeId === node.id + const isDragBefore = isDragOver && dragPosition === 'before' + const isDragInside = isDragOver && dragPosition === 'inside' + const isDragAfter = isDragOver && dragPosition === 'after' + + return ( +
+ +
+ onDragStart(e, node)} + onDragOver={(e) => onDragOver(e, node)} + onDragLeave={onDragLeave} + onDrop={(e) => onDrop(e, node)} + onDragEnd={onDragEnd}> + onSelectNode(node)}> + + + {node.type === 'folder' && ( + { + e.stopPropagation() + onToggleExpanded(node.id) + }} + title={node.expanded ? t('notes.collapse') : t('notes.expand')}> + {node.expanded ? : } + + )} + + + {node.type === 'folder' ? ( + node.expanded ? ( + + ) : ( + + ) + ) : ( + + )} + + + {isEditing ? ( + } + value={inPlaceEdit.editValue} + onChange={inPlaceEdit.handleInputChange} + onBlur={inPlaceEdit.saveEdit} + onKeyDown={inPlaceEdit.handleKeyDown} + onClick={(e) => e.stopPropagation()} + autoFocus + size="small" + /> + ) : ( + {node.name} + )} + + +
+
+ + {renderChildren && node.type === 'folder' && node.expanded && hasChildren && ( +
+ {node.children!.map((child) => ( + + ))} +
+ )} +
+ ) + } +) + const NotesSidebar: FC = ({ onCreateFolder, onCreateNote, @@ -268,9 +420,26 @@ const NotesSidebar: FC = ({ setIsShowSearch(!isShowSearch) }, [isShowSearch]) - const filteredTree = useMemo(() => { - if (!isShowStarred && !isShowSearch) return notesTree - const flattenNodes = (nodes: NotesTreeNode[]): NotesTreeNode[] => { + // Flatten tree nodes for virtualization and filtering + const flattenedNodes = useMemo(() => { + const flattenForVirtualization = ( + nodes: NotesTreeNode[], + depth: number = 0 + ): Array<{ node: NotesTreeNode; depth: number }> => { + let result: Array<{ node: NotesTreeNode; depth: number }> = [] + + for (const node of nodes) { + result.push({ node, depth }) + + // Include children only if the folder is expanded + if (node.type === 'folder' && node.expanded && node.children && node.children.length > 0) { + result = [...result, ...flattenForVirtualization(node.children, depth + 1)] + } + } + return result + } + + const flattenForFiltering = (nodes: NotesTreeNode[]): NotesTreeNode[] => { let result: NotesTreeNode[] = [] for (const node of nodes) { @@ -284,15 +453,41 @@ const NotesSidebar: FC = ({ } } if (node.children && node.children.length > 0) { - result = [...result, ...flattenNodes(node.children)] + result = [...result, ...flattenForFiltering(node.children)] } } return result } - return flattenNodes(notesTree) + if (isShowStarred || isShowSearch) { + // For filtered views, return flat list without virtualization for simplicity + const filteredNodes = flattenForFiltering(notesTree) + return filteredNodes.map((node) => ({ node, depth: 0 })) + } + + // For normal tree view, use hierarchical flattening for virtualization + return flattenForVirtualization(notesTree) }, [notesTree, isShowStarred, isShowSearch, searchKeyword]) + // Use virtualization only for normal tree view with many items + const shouldUseVirtualization = !isShowStarred && !isShowSearch && flattenedNodes.length > 100 + + const parentRef = useRef(null) + + const virtualizer = useVirtualizer({ + count: flattenedNodes.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 28, // Estimated height of each tree item + overscan: 10 + }) + + const filteredTree = useMemo(() => { + if (isShowStarred || isShowSearch) { + return flattenedNodes.map(({ node }) => node) + } + return notesTree + }, [flattenedNodes, isShowStarred, isShowSearch, notesTree]) + const getMenuItems = useCallback( (node: NotesTreeNode) => { const baseMenuItems: MenuProps['items'] = [ @@ -351,115 +546,6 @@ const NotesSidebar: FC = ({ [t, handleStartEdit, onToggleStar, handleExportKnowledge, handleDeleteNode] ) - const renderTreeNode = useCallback( - (node: NotesTreeNode, depth: number = 0) => { - const isActive = selectedFolderId - ? node.type === 'folder' && node.id === selectedFolderId - : node.id === activeNode?.id - const isEditing = editingNodeId === node.id && inPlaceEdit.isEditing - const hasChildren = node.children && node.children.length > 0 - const isDragging = draggedNodeId === node.id - const isDragOver = dragOverNodeId === node.id - const isDragBefore = isDragOver && dragPosition === 'before' - const isDragInside = isDragOver && dragPosition === 'inside' - const isDragAfter = isDragOver && dragPosition === 'after' - - return ( -
- -
- handleDragStart(e, node)} - onDragOver={(e) => handleDragOver(e, node)} - onDragLeave={handleDragLeave} - onDrop={(e) => handleDrop(e, node)} - onDragEnd={handleDragEnd}> - onSelectNode(node)}> - - - {node.type === 'folder' && ( - { - e.stopPropagation() - onToggleExpanded(node.id) - }} - title={node.expanded ? t('notes.collapse') : t('notes.expand')}> - {node.expanded ? : } - - )} - - - {node.type === 'folder' ? ( - node.expanded ? ( - - ) : ( - - ) - ) : ( - - )} - - - {isEditing ? ( - } - value={inPlaceEdit.editValue} - onChange={inPlaceEdit.handleInputChange} - onPressEnter={inPlaceEdit.saveEdit} - onBlur={inPlaceEdit.saveEdit} - onKeyDown={inPlaceEdit.handleKeyDown} - onClick={(e) => e.stopPropagation()} - autoFocus - size="small" - /> - ) : ( - {node.name} - )} - - -
-
- - {node.type === 'folder' && node.expanded && hasChildren && ( -
{node.children!.map((child) => renderTreeNode(child, depth + 1))}
- )} -
- ) - }, - [ - selectedFolderId, - activeNode?.id, - editingNodeId, - inPlaceEdit.isEditing, - inPlaceEdit.inputRef, - inPlaceEdit.editValue, - inPlaceEdit.handleInputChange, - inPlaceEdit.saveEdit, - inPlaceEdit.handleKeyDown, - draggedNodeId, - dragOverNodeId, - dragPosition, - getMenuItems, - handleDragLeave, - handleDragEnd, - t, - handleDragStart, - handleDragOver, - handleDrop, - onSelectNode, - onToggleExpanded - ] - ) - const handleDropFiles = useCallback( async (e: React.DragEvent) => { e.preventDefault() @@ -565,9 +651,54 @@ const NotesSidebar: FC = ({ /> - - - {filteredTree.map((node) => renderTreeNode(node))} + {shouldUseVirtualization ? ( + +
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const { node, depth } = flattenedNodes[virtualItem.index] + return ( +
+
+ +
+
+ ) + })} +
{!isShowStarred && !isShowSearch && ( @@ -580,8 +711,70 @@ const NotesSidebar: FC = ({ )} -
-
+ + ) : ( + + + {isShowStarred || isShowSearch + ? filteredTree.map((node) => ( + + )) + : notesTree.map((node) => ( + + ))} + {!isShowStarred && !isShowSearch && ( + + + + + + + {t('notes.drop_markdown_hint')} + + + + )} + + + )}
{isDragOverSidebar && } @@ -592,7 +785,7 @@ const NotesSidebar: FC = ({ const SidebarContainer = styled.div` width: 250px; min-width: 250px; - height: 100vh; + height: calc(100vh - var(--navbar-height)); background-color: var(--color-background); border-right: 0.5px solid var(--color-border); border-top-left-radius: 10px; @@ -606,7 +799,15 @@ const NotesTreeContainer = styled.div` overflow: hidden; display: flex; flex-direction: column; - height: calc(100vh - 45px); + height: calc(100vh - var(--navbar-height) - 45px); +` + +const VirtualizedTreeContainer = styled.div` + flex: 1; + height: 100%; + overflow: auto; + position: relative; + padding-top: 10px; ` const StyledScrollbar = styled(Scrollbar)` @@ -752,7 +953,8 @@ const DragOverIndicator = styled.div` ` const DropHintNode = styled.div` - margin-top: 8px; + margin: 8px; + margin-bottom: 20px; ${TreeNodeContainer} { background-color: transparent; @@ -773,4 +975,4 @@ const DropHintText = styled.div` font-style: italic; ` -export default NotesSidebar +export default memo(NotesSidebar) diff --git a/src/renderer/src/pages/settings/NotesSettings.tsx b/src/renderer/src/pages/settings/NotesSettings.tsx index 1a062145d0..99dc8ba1df 100644 --- a/src/renderer/src/pages/settings/NotesSettings.tsx +++ b/src/renderer/src/pages/settings/NotesSettings.tsx @@ -2,7 +2,6 @@ import { loggerService } from '@logger' import Selector from '@renderer/components/Selector' import { useTheme } from '@renderer/context/ThemeProvider' import { useNotesSettings } from '@renderer/hooks/useNotesSettings' -import { initWorkSpace } from '@renderer/services/NotesService' import { EditorView } from '@renderer/types' import { Button, Input, message, Slider, Switch } from 'antd' import { FolderOpen } from 'lucide-react' @@ -70,7 +69,6 @@ const NotesSettings: FC = () => { } updateNotesPath(tempPath) - initWorkSpace(tempPath, 'sort_a2z') window.toast.success(t('notes.settings.data.path_updated')) } catch (error) { logger.error('Failed to apply notes path:', error as Error) @@ -83,7 +81,6 @@ const NotesSettings: FC = () => { const info = await window.api.getAppInfo() setTempPath(info.notesPath) updateNotesPath(info.notesPath) - initWorkSpace(info.notesPath, 'sort_a2z') window.toast.success(t('notes.settings.data.reset_to_default')) } catch (error) { logger.error('Failed to reset to default:', error as Error) diff --git a/src/renderer/src/pages/settings/SettingsPage.tsx b/src/renderer/src/pages/settings/SettingsPage.tsx index 00032484b7..ca83e149f0 100644 --- a/src/renderer/src/pages/settings/SettingsPage.tsx +++ b/src/renderer/src/pages/settings/SettingsPage.tsx @@ -191,6 +191,7 @@ const ContentContainer = styled.div` flex: 1; flex-direction: row; height: calc(100vh - var(--navbar-height)); + padding: 1px 0; ` const SettingMenus = styled(Scrollbar)` diff --git a/src/renderer/src/services/NotesService.ts b/src/renderer/src/services/NotesService.ts index 45383344d4..a76df9d845 100644 --- a/src/renderer/src/services/NotesService.ts +++ b/src/renderer/src/services/NotesService.ts @@ -1,100 +1,10 @@ import { loggerService } from '@logger' -import db from '@renderer/databases' -import { - findNodeInTree, - findParentNode, - getNotesTree, - insertNodeIntoTree, - isParentNode, - moveNodeInTree, - removeNodeFromTree, - renameNodeFromTree -} from '@renderer/services/NotesTreeService' import { NotesSortType, NotesTreeNode } from '@renderer/types/note' import { getFileDirectory } from '@renderer/utils' -import { v4 as uuidv4 } from 'uuid' - -const MARKDOWN_EXT = '.md' -const NOTES_TREE_ID = 'notes-tree-structure' const logger = loggerService.withContext('NotesService') -export type MoveNodeResult = { success: false } | { success: true; type: 'file_system_move' | 'manual_reorder' } - -/** - * 初始化/同步笔记树结构 - */ -export async function initWorkSpace(folderPath: string, sortType: NotesSortType): Promise { - const tree = await window.api.file.getDirectoryStructure(folderPath) - await sortAllLevels(sortType, tree) -} - -/** - * 创建新文件夹 - */ -export async function createFolder(name: string, folderPath: string): Promise { - const { safeName, exists } = await window.api.file.checkFileName(folderPath, name, false) - if (exists) { - logger.warn(`Folder already exists: ${safeName}`) - } - - const tree = await getNotesTree() - const folderId = uuidv4() - - const targetPath = await window.api.file.mkdir(`${folderPath}/${safeName}`) - - // 查找父节点ID - const parentNode = tree.find((node) => node.externalPath === folderPath) || findNodeByExternalPath(tree, folderPath) - - const folder: NotesTreeNode = { - id: folderId, - name: safeName, - treePath: parentNode ? `${parentNode.treePath}/${safeName}` : `/${safeName}`, - externalPath: targetPath, - type: 'folder', - children: [], - expanded: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - } - - insertNodeIntoTree(tree, folder, parentNode?.id) - - return folder -} - -/** - * 创建新笔记文件 - */ -export async function createNote(name: string, content: string = '', folderPath: string): Promise { - const { safeName, exists } = await window.api.file.checkFileName(folderPath, name, true) - if (exists) { - logger.warn(`Note already exists: ${safeName}`) - } - - const tree = await getNotesTree() - const noteId = uuidv4() - const notePath = `${folderPath}/${safeName}${MARKDOWN_EXT}` - - await window.api.file.write(notePath, content) - - // 查找父节点ID - const parentNode = tree.find((node) => node.externalPath === folderPath) || findNodeByExternalPath(tree, folderPath) - - const note: NotesTreeNode = { - id: noteId, - name: safeName, - treePath: parentNode ? `${parentNode.treePath}/${safeName}` : `/${safeName}`, - externalPath: notePath, - type: 'file', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - } - - insertNodeIntoTree(tree, note, parentNode?.id) - - return note -} +const MARKDOWN_EXT = '.md' export interface UploadResult { uploadedNodes: NotesTreeNode[] @@ -104,641 +14,195 @@ export interface UploadResult { folderCount: number } -/** - * 上传文件或文件夹,支持单个或批量上传,保持文件夹结构 - */ -export async function uploadFiles(files: File[], targetFolderPath: string): Promise { - const tree = await getNotesTree() - const uploadedNodes: NotesTreeNode[] = [] - let skippedFiles = 0 - - const markdownFiles = filterMarkdownFiles(files) - skippedFiles = files.length - markdownFiles.length - - if (markdownFiles.length === 0) { - return createEmptyUploadResult(files.length, skippedFiles) - } - - // 处理重复的根文件夹名称 - const processedFiles = await processDuplicateRootFolders(markdownFiles, targetFolderPath) - - const { filesByPath, foldersToCreate } = groupFilesByPath(processedFiles, targetFolderPath) - - const createdFolders = await createFoldersSequentially(foldersToCreate, targetFolderPath, tree, uploadedNodes) - - await uploadAllFiles(filesByPath, targetFolderPath, tree, createdFolders, uploadedNodes) - - const fileCount = uploadedNodes.filter((node) => node.type === 'file').length - const folderCount = uploadedNodes.filter((node) => node.type === 'folder').length - - return { - uploadedNodes, - totalFiles: files.length, - skippedFiles, - fileCount, - folderCount - } +export async function loadTree(rootPath: string): Promise { + return window.api.file.getDirectoryStructure(normalizePath(rootPath)) } -/** - * 删除笔记或文件夹 - */ -export async function deleteNode(nodeId: string): Promise { - const tree = await getNotesTree() - const node = findNodeInTree(tree, nodeId) - if (!node) { - throw new Error('Node not found') - } +export function sortTree(nodes: NotesTreeNode[], sortType: NotesSortType): NotesTreeNode[] { + const cloned = nodes.map((node) => ({ + ...node, + children: node.children ? sortTree(node.children, sortType) : undefined + })) + + const sorter = getSorter(sortType) + + cloned.sort((a, b) => { + if (a.type === b.type) { + return sorter(a, b) + } + return a.type === 'folder' ? -1 : 1 + }) + + return cloned +} + +export async function addDir(name: string, parentPath: string): Promise<{ path: string; name: string }> { + const basePath = normalizePath(parentPath) + const { safeName } = await window.api.file.checkFileName(basePath, name, false) + const fullPath = `${basePath}/${safeName}` + await window.api.file.mkdir(fullPath) + return { path: fullPath, name: safeName } +} + +export async function addNote( + name: string, + content: string = '', + parentPath: string +): Promise<{ path: string; name: string }> { + const basePath = normalizePath(parentPath) + const { safeName } = await window.api.file.checkFileName(basePath, name, true) + const notePath = `${basePath}/${safeName}${MARKDOWN_EXT}` + await window.api.file.write(notePath, content) + return { path: notePath, name: safeName } +} + +export async function delNode(node: NotesTreeNode): Promise { if (node.type === 'folder') { await window.api.file.deleteExternalDir(node.externalPath) - } else if (node.type === 'file') { + } else { await window.api.file.deleteExternalFile(node.externalPath) } - - await removeNodeFromTree(tree, nodeId) } -/** - * 重命名笔记或文件夹 - */ -export async function renameNode(nodeId: string, newName: string): Promise { - const tree = await getNotesTree() - const node = findNodeInTree(tree, nodeId) - if (!node) { - throw new Error('Node not found') - } - - const dirPath = getFileDirectory(node.externalPath) - const { safeName, exists } = await window.api.file.checkFileName(dirPath, newName, node.type === 'file') +export async function renameNode(node: NotesTreeNode, newName: string): Promise<{ path: string; name: string }> { + const isFile = node.type === 'file' + const parentDir = normalizePath(getFileDirectory(node.externalPath)) + const { safeName, exists } = await window.api.file.checkFileName(parentDir, newName, isFile) if (exists) { - logger.warn(`Target name already exists: ${safeName}`) throw new Error(`Target name already exists: ${safeName}`) } - if (node.type === 'file') { + if (isFile) { await window.api.file.rename(node.externalPath, safeName) - } else if (node.type === 'folder') { - await window.api.file.renameDir(node.externalPath, safeName) + return { path: `${parentDir}/${safeName}${MARKDOWN_EXT}`, name: safeName } } - return renameNodeFromTree(tree, nodeId, safeName) + + await window.api.file.renameDir(node.externalPath, safeName) + return { path: `${parentDir}/${safeName}`, name: safeName } } -/** - * 移动节点 - */ -export async function moveNode( - sourceNodeId: string, - targetNodeId: string, - position: 'before' | 'after' | 'inside' -): Promise { - try { - const tree = await getNotesTree() +export async function uploadNotes(files: File[], targetPath: string): Promise { + const basePath = normalizePath(targetPath) + const markdownFiles = filterMarkdown(files) + const skippedFiles = files.length - markdownFiles.length - // 找到源节点和目标节点 - const sourceNode = findNodeInTree(tree, sourceNodeId) - const targetNode = findNodeInTree(tree, targetNodeId) - - if (!sourceNode || !targetNode) { - logger.error(`Move nodes failed: node not found (source: ${sourceNodeId}, target: ${targetNodeId})`) - return { success: false } + if (markdownFiles.length === 0) { + return { + uploadedNodes: [], + totalFiles: files.length, + skippedFiles, + fileCount: 0, + folderCount: 0 } + } - // 不允许文件夹被放入文件中 - if (position === 'inside' && targetNode.type === 'file' && sourceNode.type === 'folder') { - logger.error('Move nodes failed: cannot move a folder inside a file') - return { success: false } + const folders = collectFolders(markdownFiles, basePath) + await createFolders(folders) + + let fileCount = 0 + + for (const file of markdownFiles) { + const { dir, name } = resolveFileTarget(file, basePath) + const { safeName } = await window.api.file.checkFileName(dir, name, true) + const finalPath = `${dir}/${safeName}${MARKDOWN_EXT}` + + try { + const content = await file.text() + await window.api.file.write(finalPath, content) + fileCount += 1 + } catch (error) { + logger.error('Failed to write uploaded file:', error as Error) } + } - // 不允许将节点移动到自身内部 - if (position === 'inside' && isParentNode(tree, sourceNodeId, targetNodeId)) { - logger.error('Move nodes failed: cannot move a node inside itself or its descendants') - return { success: false } - } - - let targetPath: string = '' - - if (position === 'inside') { - // 目标是文件夹内部 - if (targetNode.type === 'folder') { - targetPath = targetNode.externalPath - } else { - logger.error('Cannot move node inside a file node') - return { success: false } - } - } else { - const targetParent = findParentNode(tree, targetNodeId) - if (targetParent) { - targetPath = targetParent.externalPath - } else { - targetPath = getFileDirectory(targetNode.externalPath!) - } - } - - // 检查是否为同级拖动排序 - const sourceParent = findParentNode(tree, sourceNodeId) - const sourceDir = sourceParent ? sourceParent.externalPath : getFileDirectory(sourceNode.externalPath!) - - const isSameLevelReorder = position !== 'inside' && sourceDir === targetPath - - if (isSameLevelReorder) { - // 同级拖动排序:跳过文件系统操作,只更新树结构 - logger.debug(`Same level reorder detected, skipping file system operations`) - const success = await moveNodeInTree(tree, sourceNodeId, targetNodeId, position) - // 返回一个特殊标识,告诉调用方这是手动排序,不需要重新自动排序 - return success ? { success: true, type: 'manual_reorder' } : { success: false } - } - - // 构建新的文件路径 - const sourceName = sourceNode.externalPath!.split('/').pop()! - const sourceNameWithoutExt = sourceName.replace(sourceNode.type === 'file' ? MARKDOWN_EXT : '', '') - - const { safeName } = await window.api.file.checkFileName( - targetPath, - sourceNameWithoutExt, - sourceNode.type === 'file' - ) - - const baseName = safeName + (sourceNode.type === 'file' ? MARKDOWN_EXT : '') - const newPath = `${targetPath}/${baseName}` - - if (sourceNode.externalPath !== newPath) { - try { - if (sourceNode.type === 'folder') { - await window.api.file.moveDir(sourceNode.externalPath, newPath) - } else { - await window.api.file.move(sourceNode.externalPath, newPath) - } - sourceNode.externalPath = newPath - logger.debug(`Moved external ${sourceNode.type} to: ${newPath}`) - } catch (error) { - logger.error(`Failed to move external ${sourceNode.type}:`, error as Error) - return { success: false } - } - } - - const success = await moveNodeInTree(tree, sourceNodeId, targetNodeId, position) - return success ? { success: true, type: 'file_system_move' } : { success: false } - } catch (error) { - logger.error('Move nodes failed:', error as Error) - return { success: false } + return { + uploadedNodes: [], + totalFiles: files.length, + skippedFiles, + fileCount, + folderCount: folders.size } } -/** - * 对节点数组进行排序 - */ -function sortNodesArray(nodes: NotesTreeNode[], sortType: NotesSortType): void { - // 首先分离文件夹和文件 - const folders: NotesTreeNode[] = nodes.filter((node) => node.type === 'folder') - const files: NotesTreeNode[] = nodes.filter((node) => node.type === 'file') - - // 根据排序类型对文件夹和文件分别进行排序 - const sortFunction = getSortFunction(sortType) - folders.sort(sortFunction) - files.sort(sortFunction) - - // 清空原数组并重新填入排序后的节点 - nodes.length = 0 - nodes.push(...folders, ...files) -} - -/** - * 根据排序类型获取相应的排序函数 - */ -function getSortFunction(sortType: NotesSortType): (a: NotesTreeNode, b: NotesTreeNode) => number { +function getSorter(sortType: NotesSortType): (a: NotesTreeNode, b: NotesTreeNode) => number { switch (sortType) { case 'sort_a2z': return (a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'accent' }) - case 'sort_z2a': return (a, b) => b.name.localeCompare(a.name, undefined, { sensitivity: 'accent' }) - case 'sort_updated_desc': - return (a, b) => { - const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0 - const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0 - return timeB - timeA - } - + return (a, b) => getTime(b.updatedAt) - getTime(a.updatedAt) case 'sort_updated_asc': - return (a, b) => { - const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0 - const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0 - return timeA - timeB - } - + return (a, b) => getTime(a.updatedAt) - getTime(b.updatedAt) case 'sort_created_desc': - return (a, b) => { - const timeA = a.createdAt ? new Date(a.createdAt).getTime() : 0 - const timeB = b.createdAt ? new Date(b.createdAt).getTime() : 0 - return timeB - timeA - } - + return (a, b) => getTime(b.createdAt) - getTime(a.createdAt) case 'sort_created_asc': - return (a, b) => { - const timeA = a.createdAt ? new Date(a.createdAt).getTime() : 0 - const timeB = b.createdAt ? new Date(b.createdAt).getTime() : 0 - return timeA - timeB - } - + return (a, b) => getTime(a.createdAt) - getTime(b.createdAt) default: return (a, b) => a.name.localeCompare(b.name) } } -/** - * 递归排序笔记树中的所有层级 - */ -export async function sortAllLevels(sortType: NotesSortType, tree?: NotesTreeNode[]): Promise { - try { - if (!tree) { - tree = await getNotesTree() - } - sortNodesArray(tree, sortType) - recursiveSortNodes(tree, sortType) - await db.notes_tree.put({ id: NOTES_TREE_ID, tree }) - logger.info(`Sorted all levels of notes successfully: ${sortType}`) - } catch (error) { - logger.error('Failed to sort all levels of notes:', error as Error) - throw error - } +function getTime(value?: string): number { + return value ? new Date(value).getTime() : 0 } -/** - * 递归对节点中的子节点进行排序 - */ -function recursiveSortNodes(nodes: NotesTreeNode[], sortType: NotesSortType): void { - for (const node of nodes) { - if (node.type === 'folder' && node.children && node.children.length > 0) { - sortNodesArray(node.children, sortType) - recursiveSortNodes(node.children, sortType) - } - } +function normalizePath(value: string): string { + return value.replace(/\\/g, '/') } -/** - * 根据外部路径查找节点(递归查找) - */ -function findNodeByExternalPath(nodes: NotesTreeNode[], externalPath: string): NotesTreeNode | null { - for (const node of nodes) { - if (node.externalPath === externalPath) { - return node - } - if (node.children && node.children.length > 0) { - const found = findNodeByExternalPath(node.children, externalPath) - if (found) { - return found - } - } - } - return null +function filterMarkdown(files: File[]): File[] { + return files.filter((file) => file.name.toLowerCase().endsWith(MARKDOWN_EXT)) } -/** - * 过滤出 Markdown 文件 - */ -function filterMarkdownFiles(files: File[]): File[] { - return Array.from(files).filter((file) => { - if (file.name.toLowerCase().endsWith(MARKDOWN_EXT)) { - return true +function collectFolders(files: File[], basePath: string): Set { + const folders = new Set() + + files.forEach((file) => { + const relativePath = file.webkitRelativePath || '' + if (!relativePath.includes('/')) { + return + } + + const parts = relativePath.split('/') + parts.pop() + + let current = basePath + for (const part of parts) { + current = `${current}/${part}` + folders.add(current) } - logger.warn(`Skipping non-markdown file: ${file.name}`) - return false }) + + return folders } -/** - * 创建空的上传结果 - */ -function createEmptyUploadResult(totalFiles: number, skippedFiles: number): UploadResult { - return { - uploadedNodes: [], - totalFiles, - skippedFiles, - fileCount: 0, - folderCount: 0 - } -} - -/** - * 处理重复的根文件夹名称,为重复的文件夹重写 webkitRelativePath - */ -async function processDuplicateRootFolders(markdownFiles: File[], targetFolderPath: string): Promise { - // 按根文件夹名称分组文件 - const filesByRootFolder = new Map() - const processedFiles: File[] = [] - - for (const file of markdownFiles) { - const filePath = file.webkitRelativePath || file.name - - if (filePath.includes('/')) { - const rootFolderName = filePath.substring(0, filePath.indexOf('/')) - if (!filesByRootFolder.has(rootFolderName)) { - filesByRootFolder.set(rootFolderName, []) - } - filesByRootFolder.get(rootFolderName)!.push(file) - } else { - // 单个文件,直接添加 - processedFiles.push(file) - } - } - - // 为每个根文件夹组生成唯一的文件夹名称 - for (const [rootFolderName, files] of filesByRootFolder.entries()) { - const { safeName } = await window.api.file.checkFileName(targetFolderPath, rootFolderName, false) - - for (const file of files) { - // 创建一个新的 File 对象,并修改 webkitRelativePath - const originalPath = file.webkitRelativePath || file.name - const relativePath = originalPath.substring(originalPath.indexOf('/') + 1) - const newPath = `${safeName}/${relativePath}` - - const newFile = new File([file], file.name, { - type: file.type, - lastModified: file.lastModified - }) - - Object.defineProperty(newFile, 'webkitRelativePath', { - value: newPath, - writable: false - }) - - processedFiles.push(newFile) - } - } - - return processedFiles -} - -/** - * 按路径分组文件并收集需要创建的文件夹 - */ -function groupFilesByPath( - markdownFiles: File[], - targetFolderPath: string -): { filesByPath: Map; foldersToCreate: Set } { - const filesByPath = new Map() - const foldersToCreate = new Set() - - for (const file of markdownFiles) { - const filePath = file.webkitRelativePath || file.name - const relativeDirPath = filePath.includes('/') ? filePath.substring(0, filePath.lastIndexOf('/')) : '' - const fullDirPath = relativeDirPath ? `${targetFolderPath}/${relativeDirPath}` : targetFolderPath - - if (relativeDirPath) { - const pathParts = relativeDirPath.split('/') - - let currentPath = targetFolderPath - for (const part of pathParts) { - currentPath = `${currentPath}/${part}` - foldersToCreate.add(currentPath) - } - } - - if (!filesByPath.has(fullDirPath)) { - filesByPath.set(fullDirPath, []) - } - filesByPath.get(fullDirPath)!.push(file) - } - - return { filesByPath, foldersToCreate } -} - -/** - * 顺序创建文件夹(避免竞争条件) - */ -async function createFoldersSequentially( - foldersToCreate: Set, - targetFolderPath: string, - tree: NotesTreeNode[], - uploadedNodes: NotesTreeNode[] -): Promise> { - const createdFolders = new Map() - const sortedFolders = Array.from(foldersToCreate).sort() - const folderCreationLock = new Set() - - for (const folderPath of sortedFolders) { - if (folderCreationLock.has(folderPath)) { - continue - } - folderCreationLock.add(folderPath) +async function createFolders(folders: Set): Promise { + const ordered = Array.from(folders).sort((a, b) => a.length - b.length) + for (const folder of ordered) { try { - const result = await createSingleFolder(folderPath, targetFolderPath, tree, createdFolders) - if (result) { - createdFolders.set(folderPath, result) - if (result.externalPath !== folderPath) { - createdFolders.set(result.externalPath, result) - } - uploadedNodes.push(result) - logger.debug(`Created folder: ${folderPath} -> ${result.externalPath}`) - } + await window.api.file.mkdir(folder) } catch (error) { - logger.error(`Failed to create folder ${folderPath}:`, error as Error) - } finally { - folderCreationLock.delete(folderPath) + logger.debug('Skip existing folder while uploading notes', { + folder, + error: (error as Error).message + }) } } - - return createdFolders -} - -/** - * 创建单个文件夹 - */ -async function createSingleFolder( - folderPath: string, - targetFolderPath: string, - tree: NotesTreeNode[], - createdFolders: Map -): Promise { - const existingNode = findNodeByExternalPath(tree, folderPath) - if (existingNode) { - return existingNode - } - - const relativePath = folderPath.replace(targetFolderPath + '/', '') - const originalFolderName = relativePath.split('/').pop()! - const parentFolderPath = folderPath.substring(0, folderPath.lastIndexOf('/')) - - const { safeName: safeFolderName, exists } = await window.api.file.checkFileName( - parentFolderPath, - originalFolderName, - false - ) - - const actualFolderPath = `${parentFolderPath}/${safeFolderName}` - - if (exists) { - logger.warn(`Folder already exists, creating with new name: ${originalFolderName} -> ${safeFolderName}`) - } - - try { - await window.api.file.mkdir(actualFolderPath) - } catch (error) { - logger.debug(`Error creating folder: ${actualFolderPath}`, error as Error) - } - - let parentNode: NotesTreeNode | null - if (parentFolderPath === targetFolderPath) { - parentNode = - tree.find((node) => node.externalPath === targetFolderPath) || findNodeByExternalPath(tree, targetFolderPath) - } else { - parentNode = createdFolders.get(parentFolderPath) || null - if (!parentNode) { - parentNode = tree.find((node) => node.externalPath === parentFolderPath) || null - if (!parentNode) { - parentNode = findNodeByExternalPath(tree, parentFolderPath) - } - } - } - - const folderId = uuidv4() - const folder: NotesTreeNode = { - id: folderId, - name: safeFolderName, - treePath: parentNode ? `${parentNode.treePath}/${safeFolderName}` : `/${safeFolderName}`, - externalPath: actualFolderPath, - type: 'folder', - children: [], - expanded: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - } - - await insertNodeIntoTree(tree, folder, parentNode?.id) - return folder -} - -/** - * 读取文件内容(支持大文件处理) - */ -async function readFileContent(file: File): Promise { - const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB - - if (file.size > MAX_FILE_SIZE) { - logger.warn( - `Large file detected (${Math.round(file.size / 1024 / 1024)}MB): ${file.name}. Consider using streaming for better performance.` - ) - } - - try { - return await file.text() - } catch (error) { - logger.error(`Failed to read file content for ${file.name}:`, error as Error) - throw new Error(`Failed to read file content: ${file.name}`) - } } -/** - * 上传所有文件 - */ -async function uploadAllFiles( - filesByPath: Map, - targetFolderPath: string, - tree: NotesTreeNode[], - createdFolders: Map, - uploadedNodes: NotesTreeNode[] -): Promise { - const uploadPromises: Promise[] = [] - - for (const [dirPath, dirFiles] of filesByPath.entries()) { - for (const file of dirFiles) { - const uploadPromise = uploadSingleFile(file, dirPath, targetFolderPath, tree, createdFolders) - .then((result) => { - if (result) { - logger.debug(`Uploaded file: ${result.externalPath}`) - } - return result - }) - .catch((error) => { - logger.error(`Failed to upload file ${file.name}:`, error as Error) - return null - }) - - uploadPromises.push(uploadPromise) - } +function resolveFileTarget(file: File, basePath: string): { dir: string; name: string } { + if (!file.webkitRelativePath || !file.webkitRelativePath.includes('/')) { + const nameWithoutExt = file.name.endsWith(MARKDOWN_EXT) ? file.name.slice(0, -MARKDOWN_EXT.length) : file.name + return { dir: basePath, name: nameWithoutExt } } - const results = await Promise.all(uploadPromises) + const parts = file.webkitRelativePath.split('/') + const fileName = parts.pop() || file.name + const dirPath = `${basePath}/${parts.join('/')}` + const nameWithoutExt = fileName.endsWith(MARKDOWN_EXT) ? fileName.slice(0, -MARKDOWN_EXT.length) : fileName - results.forEach((result) => { - if (result) { - uploadedNodes.push(result) - } - }) -} - -/** - * 上传单个文件,需要根据实际创建的文件夹路径来找到正确的父节点 - */ -async function uploadSingleFile( - file: File, - originalDirPath: string, - targetFolderPath: string, - tree: NotesTreeNode[], - createdFolders: Map -): Promise { - const fileName = (file.webkitRelativePath || file.name).split('/').pop()! - const nameWithoutExt = fileName.replace(MARKDOWN_EXT, '') - - let actualDirPath = originalDirPath - let parentNode: NotesTreeNode | null = null - - if (originalDirPath === targetFolderPath) { - parentNode = - tree.find((node) => node.externalPath === targetFolderPath) || findNodeByExternalPath(tree, targetFolderPath) - - if (!parentNode) { - logger.debug(`Uploading file ${fileName} to root directory: ${targetFolderPath}`) - } - } else { - parentNode = createdFolders.get(originalDirPath) || null - if (!parentNode) { - parentNode = tree.find((node) => node.externalPath === originalDirPath) || null - if (!parentNode) { - parentNode = findNodeByExternalPath(tree, originalDirPath) - } - } - - if (!parentNode) { - for (const [originalPath, createdNode] of createdFolders.entries()) { - if (originalPath === originalDirPath) { - parentNode = createdNode - actualDirPath = createdNode.externalPath - break - } - } - } - - if (!parentNode) { - logger.error(`Cannot upload file ${fileName}: parent node not found for path ${originalDirPath}`) - return null - } - } - - const { safeName, exists } = await window.api.file.checkFileName(actualDirPath, nameWithoutExt, true) - if (exists) { - logger.warn(`Note already exists, will be overwritten: ${safeName}`) - } - - const notePath = `${actualDirPath}/${safeName}${MARKDOWN_EXT}` - - const noteId = uuidv4() - const note: NotesTreeNode = { - id: noteId, - name: safeName, - treePath: parentNode ? `${parentNode.treePath}/${safeName}` : `/${safeName}`, - externalPath: notePath, - type: 'file', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - } - - const content = await readFileContent(file) - await window.api.file.write(notePath, content) - await insertNodeIntoTree(tree, note, parentNode?.id) - - return note + return { dir: dirPath, name: nameWithoutExt } } diff --git a/src/renderer/src/services/NotesTreeService.ts b/src/renderer/src/services/NotesTreeService.ts index 4159948323..676b4996aa 100644 --- a/src/renderer/src/services/NotesTreeService.ts +++ b/src/renderer/src/services/NotesTreeService.ts @@ -1,217 +1,47 @@ -import { loggerService } from '@logger' -import db from '@renderer/databases' import { NotesTreeNode } from '@renderer/types/note' -const MARKDOWN_EXT = '.md' -const NOTES_TREE_ID = 'notes-tree-structure' - -const logger = loggerService.withContext('NotesTreeService') - -/** - * 获取树结构 - */ -export const getNotesTree = async (): Promise => { - const record = await db.notes_tree.get(NOTES_TREE_ID) - return record?.tree || [] +export function normalizePathValue(path: string): string { + return path.replace(/\\/g, '/') } -/** - * 在树中插入节点 - */ -export async function insertNodeIntoTree( - tree: NotesTreeNode[], - node: NotesTreeNode, - parentId?: string -): Promise { - try { - if (!parentId) { - tree.push(node) - } else { - const parent = findNodeInTree(tree, parentId) - if (parent && parent.type === 'folder') { - if (!parent.children) { - parent.children = [] - } - parent.children.push(node) - } - } - - await db.notes_tree.put({ id: NOTES_TREE_ID, tree }) - return tree - } catch (error) { - logger.error('Failed to insert node into tree:', error as Error) - throw error - } +export function addUniquePath(list: string[], path: string): string[] { + const normalized = normalizePathValue(path) + return list.includes(normalized) ? list : [...list, normalized] } -/** - * 从树中删除节点 - */ -export async function removeNodeFromTree(tree: NotesTreeNode[], nodeId: string): Promise { - const removed = removeNodeFromTreeInMemory(tree, nodeId) - if (removed) { - await db.notes_tree.put({ id: NOTES_TREE_ID, tree }) - } - return removed -} - -/** - * 从树中删除节点(仅在内存中操作,不保存数据库) - */ -function removeNodeFromTreeInMemory(tree: NotesTreeNode[], nodeId: string): boolean { - for (let i = 0; i < tree.length; i++) { - if (tree[i].id === nodeId) { - tree.splice(i, 1) - return true - } - if (tree[i].children) { - const removed = removeNodeFromTreeInMemory(tree[i].children!, nodeId) - if (removed) { - return true - } - } - } - return false -} - -export async function moveNodeInTree( - tree: NotesTreeNode[], - sourceNodeId: string, - targetNodeId: string, - position: 'before' | 'after' | 'inside' -): Promise { - try { - const sourceNode = findNodeInTree(tree, sourceNodeId) - const targetNode = findNodeInTree(tree, targetNodeId) - - if (!sourceNode || !targetNode) { - logger.error(`Move nodes in tree failed: node not found (source: ${sourceNodeId}, target: ${targetNodeId})`) +export function removePathEntries(list: string[], path: string, deep: boolean): string[] { + const normalized = normalizePathValue(path) + const prefix = `${normalized}/` + return list.filter((item) => { + if (item === normalized) { return false } + return !(deep && item.startsWith(prefix)) + }) +} - // 在移除节点之前先获取源节点的父节点信息,用于后续判断是否为同级排序 - const sourceParent = findParentNode(tree, sourceNodeId) - const targetParent = findParentNode(tree, targetNodeId) - - // 从原位置移除节点(不保存数据库,只在内存中操作) - const removed = removeNodeFromTreeInMemory(tree, sourceNodeId) - if (!removed) { - logger.error('Move nodes in tree failed: could not remove source node') - return false +export function replacePathEntries(list: string[], oldPath: string, newPath: string, deep: boolean): string[] { + const oldNormalized = normalizePathValue(oldPath) + const newNormalized = normalizePathValue(newPath) + const prefix = `${oldNormalized}/` + return list.map((item) => { + if (item === oldNormalized) { + return newNormalized } - - try { - // 根据位置进行放置 - if (position === 'inside' && targetNode.type === 'folder') { - if (!targetNode.children) { - targetNode.children = [] - } - targetNode.children.push(sourceNode) - targetNode.expanded = true - - sourceNode.treePath = `${targetNode.treePath}/${sourceNode.name}` - } else { - const targetList = targetParent ? targetParent.children! : tree - const targetIndex = targetList.findIndex((node) => node.id === targetNodeId) - - if (targetIndex === -1) { - logger.error('Move nodes in tree failed: target position not found') - return false - } - - // 根据position确定插入位置 - const insertIndex = position === 'before' ? targetIndex : targetIndex + 1 - targetList.splice(insertIndex, 0, sourceNode) - - // 检查是否为同级排序,如果是则保持原有的 treePath - const isSameLevelReorder = sourceParent === targetParent - - // 只有在跨级移动时才更新节点路径 - if (!isSameLevelReorder) { - if (targetParent) { - sourceNode.treePath = `${targetParent.treePath}/${sourceNode.name}` - } else { - sourceNode.treePath = `/${sourceNode.name}` - } - } - } - - // 更新修改时间 - sourceNode.updatedAt = new Date().toISOString() - - // 只有在所有操作成功后才保存到数据库 - await db.notes_tree.put({ id: NOTES_TREE_ID, tree }) - - return true - } catch (error) { - logger.error('Move nodes in tree failed during placement, attempting to restore:', error as Error) - // 如果放置失败,尝试恢复原始节点到原位置 - // 这里需要重新实现恢复逻辑,暂时返回false - return false + if (deep && item.startsWith(prefix)) { + return `${newNormalized}${item.slice(oldNormalized.length)}` } - } catch (error) { - logger.error('Move nodes in tree failed:', error as Error) - return false - } + return item + }) } -/** - * 重命名节点 - */ -export async function renameNodeFromTree( - tree: NotesTreeNode[], - nodeId: string, - newName: string -): Promise { - const node = findNodeInTree(tree, nodeId) - - if (!node) { - throw new Error('Node not found') - } - - node.name = newName - - const dirPath = node.treePath.substring(0, node.treePath.lastIndexOf('/') + 1) - node.treePath = dirPath + newName - - const externalDirPath = node.externalPath.substring(0, node.externalPath.lastIndexOf('/') + 1) - node.externalPath = node.type === 'file' ? externalDirPath + newName + MARKDOWN_EXT : externalDirPath + newName - - node.updatedAt = new Date().toISOString() - await db.notes_tree.put({ id: NOTES_TREE_ID, tree }) - return node -} - -/** - * 修改节点键值 - */ -export async function updateNodeInTree( - tree: NotesTreeNode[], - nodeId: string, - updates: Partial -): Promise { - const node = findNodeInTree(tree, nodeId) - if (!node) { - throw new Error('Node not found') - } - - Object.assign(node, updates) - node.updatedAt = new Date().toISOString() - await db.notes_tree.put({ id: NOTES_TREE_ID, tree }) - - return node -} - -/** - * 在树中查找节点 - */ -export function findNodeInTree(tree: NotesTreeNode[], nodeId: string): NotesTreeNode | null { +export function findNode(tree: NotesTreeNode[], nodeId: string): NotesTreeNode | null { for (const node of tree) { if (node.id === nodeId) { return node } if (node.children) { - const found = findNodeInTree(node.children, nodeId) + const found = findNode(node.children, nodeId) if (found) { return found } @@ -220,16 +50,13 @@ export function findNodeInTree(tree: NotesTreeNode[], nodeId: string): NotesTree return null } -/** - * 根据路径查找节点 - */ -export function findNodeByPath(tree: NotesTreeNode[], path: string): NotesTreeNode | null { +export function findNodeByPath(tree: NotesTreeNode[], targetPath: string): NotesTreeNode | null { for (const node of tree) { - if (node.treePath === path) { + if (node.treePath === targetPath || node.externalPath === targetPath) { return node } if (node.children) { - const found = findNodeByPath(node.children, path) + const found = findNodeByPath(node.children, targetPath) if (found) { return found } @@ -238,53 +65,113 @@ export function findNodeByPath(tree: NotesTreeNode[], path: string): NotesTreeNo return null } -// --- -// 辅助函数 -// --- +export function updateTreeNode( + nodes: NotesTreeNode[], + nodeId: string, + updater: (node: NotesTreeNode) => NotesTreeNode +): NotesTreeNode[] { + let changed = false -/** - * 查找节点的父节点 - */ -export function findParentNode(tree: NotesTreeNode[], targetNodeId: string): NotesTreeNode | null { + const nextNodes = nodes.map((node) => { + if (node.id === nodeId) { + changed = true + const updated = updater(node) + if (updated.type === 'folder' && !updated.children) { + return { ...updated, children: [] } + } + return updated + } + + if (node.children && node.children.length > 0) { + const updatedChildren = updateTreeNode(node.children, nodeId, updater) + if (updatedChildren !== node.children) { + changed = true + return { ...node, children: updatedChildren } + } + } + + return node + }) + + return changed ? nextNodes : nodes +} + +export function findParent(tree: NotesTreeNode[], nodeId: string): NotesTreeNode | null { for (const node of tree) { - if (node.children) { - const isDirectChild = node.children.some((child) => child.id === targetNodeId) - if (isDirectChild) { - return node - } - - const parent = findParentNode(node.children, targetNodeId) - if (parent) { - return parent - } + if (!node.children) { + continue + } + if (node.children.some((child) => child.id === nodeId)) { + return node + } + const found = findParent(node.children, nodeId) + if (found) { + return found } } return null } -/** - * 判断节点是否为另一个节点的父节点 - */ -export function isParentNode(tree: NotesTreeNode[], parentId: string, childId: string): boolean { - const childNode = findNodeInTree(tree, childId) - if (!childNode) { - return false +export function reorderTreeNodes( + nodes: NotesTreeNode[], + sourceId: string, + targetId: string, + position: 'before' | 'after' +): NotesTreeNode[] { + const [updatedNodes, moved] = reorderSiblings(nodes, sourceId, targetId, position) + if (moved) { + return updatedNodes } - const parentNode = findNodeInTree(tree, parentId) - if (!parentNode || parentNode.type !== 'folder' || !parentNode.children) { - return false - } - - if (parentNode.children.some((child) => child.id === childId)) { - return true - } - - for (const child of parentNode.children) { - if (isParentNode(tree, child.id, childId)) { - return true + let changed = false + const nextNodes = nodes.map((node) => { + if (!node.children || node.children.length === 0) { + return node } + + const reorderedChildren = reorderTreeNodes(node.children, sourceId, targetId, position) + if (reorderedChildren !== node.children) { + changed = true + return { ...node, children: reorderedChildren } + } + + return node + }) + + return changed ? nextNodes : nodes +} + +function reorderSiblings( + nodes: NotesTreeNode[], + sourceId: string, + targetId: string, + position: 'before' | 'after' +): [NotesTreeNode[], boolean] { + const sourceIndex = nodes.findIndex((node) => node.id === sourceId) + const targetIndex = nodes.findIndex((node) => node.id === targetId) + + if (sourceIndex === -1 || targetIndex === -1) { + return [nodes, false] } - return false + const updated = [...nodes] + const [sourceNode] = updated.splice(sourceIndex, 1) + + let insertIndex = targetIndex + if (sourceIndex < targetIndex) { + insertIndex -= 1 + } + if (position === 'after') { + insertIndex += 1 + } + + if (insertIndex < 0) { + insertIndex = 0 + } + if (insertIndex > updated.length) { + insertIndex = updated.length + } + + updated.splice(insertIndex, 0, sourceNode) + return [updated, true] } diff --git a/src/renderer/src/store/note.ts b/src/renderer/src/store/note.ts index df4478c07a..38f331c76e 100644 --- a/src/renderer/src/store/note.ts +++ b/src/renderer/src/store/note.ts @@ -20,6 +20,8 @@ export interface NoteState { settings: NotesSettings notesPath: string sortType: NotesSortType + starredPaths: string[] + expandedPaths: string[] } export const initialState: NoteState = { @@ -36,7 +38,9 @@ export const initialState: NoteState = { showWorkspace: true }, notesPath: '', - sortType: 'sort_a2z' + sortType: 'sort_a2z', + starredPaths: [], + expandedPaths: [] } const noteSlice = createSlice({ @@ -57,16 +61,32 @@ const noteSlice = createSlice({ }, setSortType: (state, action: PayloadAction) => { state.sortType = action.payload + }, + setStarredPaths: (state, action: PayloadAction) => { + state.starredPaths = action.payload ?? [] + }, + setExpandedPaths: (state, action: PayloadAction) => { + state.expandedPaths = action.payload ?? [] } } }) -export const { setActiveNodeId, setActiveFilePath, updateNotesSettings, setNotesPath, setSortType } = noteSlice.actions +export const { + setActiveNodeId, + setActiveFilePath, + updateNotesSettings, + setNotesPath, + setSortType, + setStarredPaths, + setExpandedPaths +} = noteSlice.actions export const selectActiveNodeId = (state: RootState) => state.note.activeNodeId export const selectActiveFilePath = (state: RootState) => state.note.activeFilePath export const selectNotesSettings = (state: RootState) => state.note.settings export const selectNotesPath = (state: RootState) => state.note.notesPath export const selectSortType = (state: RootState) => state.note.sortType +export const selectStarredPaths = (state: RootState) => state.note.starredPaths ?? [] +export const selectExpandedPaths = (state: RootState) => state.note.expandedPaths ?? [] export default noteSlice.reducer diff --git a/src/renderer/src/utils/export.ts b/src/renderer/src/utils/export.ts index d4fed4d029..f3ce321d63 100644 --- a/src/renderer/src/utils/export.ts +++ b/src/renderer/src/utils/export.ts @@ -3,12 +3,11 @@ import { Client } from '@notionhq/client' import i18n from '@renderer/i18n' import { getProviderLabel } from '@renderer/i18n/label' import { getMessageTitle } from '@renderer/services/MessagesService' -import { createNote } from '@renderer/services/NotesService' +import { addNote } from '@renderer/services/NotesService' import store from '@renderer/store' import { setExportState } from '@renderer/store/runtime' import type { Topic } from '@renderer/types' import type { Message } from '@renderer/types/newMessage' -import { NotesTreeNode } from '@renderer/types/note' import { removeSpecialCharactersForFileName } from '@renderer/utils/file' import { convertMathFormula, markdownToPlainText } from '@renderer/utils/markdown' import { getCitationContent, getMainTextContent, getThinkingContent } from '@renderer/utils/messageUtils/find' @@ -1052,18 +1051,12 @@ async function createSiyuanDoc( * @param content * @param folderPath */ -export const exportMessageToNotes = async ( - title: string, - content: string, - folderPath: string -): Promise => { +export const exportMessageToNotes = async (title: string, content: string, folderPath: string): Promise => { try { const cleanedContent = content.replace(/^## 🤖 Assistant(\n|$)/m, '') - const note = await createNote(title, cleanedContent, folderPath) + await addNote(title, cleanedContent, folderPath) window.toast.success(i18n.t('message.success.notes.export')) - - return note } catch (error) { logger.error('导出到笔记失败:', error as Error) window.toast.error(i18n.t('message.error.notes.export')) @@ -1077,14 +1070,12 @@ export const exportMessageToNotes = async ( * @param folderPath * @returns 创建的笔记节点 */ -export const exportTopicToNotes = async (topic: Topic, folderPath: string): Promise => { +export const exportTopicToNotes = async (topic: Topic, folderPath: string): Promise => { try { const content = await topicToMarkdown(topic) - const note = await createNote(topic.name, content, folderPath) + await addNote(topic.name, content, folderPath) window.toast.success(i18n.t('message.success.notes.export')) - - return note } catch (error) { logger.error('导出到笔记失败:', error as Error) window.toast.error(i18n.t('message.error.notes.export')) From cd3031479c73ca57b20d5b8f3db9d1161f92a68d Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Sun, 28 Sep 2025 14:07:30 +0800 Subject: [PATCH 22/32] fix(reasoning): correct regex pattern for deepseek model detection (#10407) --- src/renderer/src/config/models/reasoning.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/config/models/reasoning.ts b/src/renderer/src/config/models/reasoning.ts index 607df8fd95..f309811a9d 100644 --- a/src/renderer/src/config/models/reasoning.ts +++ b/src/renderer/src/config/models/reasoning.ts @@ -335,7 +335,7 @@ export const isDeepSeekHybridInferenceModel = (model: Model) => { const modelId = getLowerBaseModelName(model.id) // deepseek官方使用chat和reasoner做推理控制,其他provider需要单独判断,id可能会有所差别 // openrouter: deepseek/deepseek-chat-v3.1 不知道会不会有其他provider仿照ds官方分出一个同id的作为非思考模式的模型,这里有风险 - return /deepseek-v3(?:\.1|-1-\d+)?/.test(modelId) || modelId.includes('deepseek-chat-v3.1') + return /deepseek-v3(?:\.1|-1-\d+)/.test(modelId) || modelId.includes('deepseek-chat-v3.1') } export const isSupportedThinkingTokenDeepSeekModel = isDeepSeekHybridInferenceModel From 5524571c8091bd09304679029d03836bfc4522be Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Sun, 28 Sep 2025 14:09:11 +0800 Subject: [PATCH 23/32] fix(ErrorBlock): prevent event propagation when removing block (#10368) This PR correctly addresses an event propagation issue where clicking the close button on an error alert was unintentionally triggering the parent click handler (which opens the detail modal). --- src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx index 298d3c274a..2cf85a3d63 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx @@ -103,7 +103,8 @@ const MessageErrorInfo: React.FC<{ block: ErrorMessageBlock; message: Message }> const [showDetailModal, setShowDetailModal] = useState(false) const { t } = useTranslation() - const onRemoveBlock = () => { + const onRemoveBlock = (e: React.MouseEvent) => { + e.stopPropagation() setTimeoutTimer('onRemoveBlock', () => dispatch(removeBlocksThunk(message.topicId, message.id, [block.id])), 350) } From 20f527168290b772bed279e843d4e15653d468a3 Mon Sep 17 00:00:00 2001 From: one Date: Sun, 28 Sep 2025 14:15:56 +0800 Subject: [PATCH 24/32] fix: quick assistant avatar and search (#10281) --- .../pages/settings/QuickAssistantSettings.tsx | 59 ++++++++++++------- 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/src/renderer/src/pages/settings/QuickAssistantSettings.tsx b/src/renderer/src/pages/settings/QuickAssistantSettings.tsx index b7279fce6f..a547a5450b 100644 --- a/src/renderer/src/pages/settings/QuickAssistantSettings.tsx +++ b/src/renderer/src/pages/settings/QuickAssistantSettings.tsx @@ -11,9 +11,10 @@ import { setEnableQuickAssistant, setReadClipboardAtStartup } from '@renderer/store/settings' +import { matchKeywordsInString } from '@renderer/utils' import HomeWindow from '@renderer/windows/mini/home/HomeWindow' import { Button, Select, Switch, Tooltip } from 'antd' -import { FC } from 'react' +import { FC, useMemo } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -26,9 +27,15 @@ const QuickAssistantSettings: FC = () => { const dispatch = useAppDispatch() const { assistants } = useAssistants() const { quickAssistantId } = useAppSelector((state) => state.llm) - const { defaultAssistant } = useDefaultAssistant() + const { defaultAssistant: _defaultAssistant } = useDefaultAssistant() const { defaultModel } = useDefaultModel() + // Take the "default assistant" from the assistant list first. + const defaultAssistant = useMemo( + () => assistants.find((a) => a.id === _defaultAssistant.id) || _defaultAssistant, + [assistants, _defaultAssistant] + ) + const handleEnableQuickAssistant = async (enable: boolean) => { dispatch(setEnableQuickAssistant(enable)) await window.api.config.set('enableQuickAssistant', enable, true) @@ -110,27 +117,39 @@ const QuickAssistantSettings: FC = () => { value={quickAssistantId || defaultAssistant.id} style={{ width: 300, height: 34 }} onChange={(value) => dispatch(setQuickAssistantId(value))} - placeholder={t('settings.models.quick_assistant_selection')}> - - - - {defaultAssistant.name} - - {t('settings.models.quick_assistant_default_tag')} - - - {assistants - .filter((a) => a.id !== defaultAssistant.id) - .map((a) => ( - + placeholder={t('settings.models.quick_assistant_selection')} + showSearch + options={[ + { + key: defaultAssistant.id, + value: defaultAssistant.id, + title: defaultAssistant.name, + label: ( - - {a.name} + + {defaultAssistant.name} + {t('settings.models.quick_assistant_default_tag')} - - ))} - + ) + }, + ...assistants + .filter((a) => a.id !== defaultAssistant.id) + .map((a) => ({ + key: a.id, + value: a.id, + title: a.name, + label: ( + + + {a.name} + + + ) + })) + ]} + filterOption={(input, option) => matchKeywordsInString(input, option?.title || '')} + /> )} From e195ad4a8f3010e7c512955456bf45182ab5b49a Mon Sep 17 00:00:00 2001 From: MyPrototypeWhat Date: Sun, 28 Sep 2025 14:56:04 +0800 Subject: [PATCH 25/32] refactor(tools): enhance descriptions for knowledge and web search tools (#10433) * refactor(tools): enhance descriptions for knowledge and web search tools - Updated the descriptions for the knowledgeSearchTool and webSearchTool to provide clearer context on their functionality. - Improved the formatting of prepared queries and relevant links in the descriptions to enhance user understanding. - Added information on how to use the tools with additional context for refined searches. * fix:format lint --- src/renderer/src/aiCore/tools/KnowledgeSearchTool.ts | 9 +++++---- src/renderer/src/aiCore/tools/WebSearchTool.ts | 11 ++++++----- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/aiCore/tools/KnowledgeSearchTool.ts b/src/renderer/src/aiCore/tools/KnowledgeSearchTool.ts index 7764fd81e7..314eb9ba01 100644 --- a/src/renderer/src/aiCore/tools/KnowledgeSearchTool.ts +++ b/src/renderer/src/aiCore/tools/KnowledgeSearchTool.ts @@ -18,12 +18,13 @@ export const knowledgeSearchTool = ( ) => { return tool({ name: 'builtin_knowledge_search', - description: `Search the knowledge base for relevant information using pre-analyzed search intent. + description: `Knowledge base search tool for retrieving information from user's private knowledge base. This searches your local collection of documents, web content, notes, and other materials you have stored. -Pre-extracted search queries: "${extractedKeywords.question.join(', ')}" -Rewritten query: "${extractedKeywords.rewrite}" +This tool has been configured with search parameters based on the conversation context: +- Prepared queries: ${extractedKeywords.question.map((q) => `"${q}"`).join(', ')} +- Query rewrite: "${extractedKeywords.rewrite}" -Call this tool to execute the search. You can optionally provide additional context to refine the search.`, +You can use this tool as-is, or provide additionalContext to refine the search focus within the knowledge base.`, inputSchema: z.object({ additionalContext: z diff --git a/src/renderer/src/aiCore/tools/WebSearchTool.ts b/src/renderer/src/aiCore/tools/WebSearchTool.ts index 2b0b7fb13b..2d6e318306 100644 --- a/src/renderer/src/aiCore/tools/WebSearchTool.ts +++ b/src/renderer/src/aiCore/tools/WebSearchTool.ts @@ -21,16 +21,17 @@ export const webSearchToolWithPreExtractedKeywords = ( return tool({ name: 'builtin_web_search', - description: `Search the web and return citable sources using pre-analyzed search intent. + description: `Web search tool for finding current information, news, and real-time data from the internet. -Pre-extracted search keywords: "${extractedKeywords.question.join(', ')}"${ - extractedKeywords.links +This tool has been configured with search parameters based on the conversation context: +- Prepared queries: ${extractedKeywords.question.map((q) => `"${q}"`).join(', ')}${ + extractedKeywords.links?.length ? ` -Relevant links: ${extractedKeywords.links.join(', ')}` +- Relevant URLs: ${extractedKeywords.links.join(', ')}` : '' } -Call this tool to execute the search. You can optionally provide additional context to refine the search.`, +You can use this tool as-is to search with the prepared queries, or provide additionalContext to refine or replace the search terms.`, inputSchema: z.object({ additionalContext: z From e401685449c60e735472b358c081f68146560205 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Sun, 28 Sep 2025 14:57:01 +0800 Subject: [PATCH 26/32] lint: fix code format --- .../src/pages/home/Inputbar/MentionModelsButton.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx b/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx index 23c8fd13f5..64d4212698 100644 --- a/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx @@ -251,11 +251,7 @@ const MentionModelsButton: FC = ({ if (action === 'esc') { // 只有在输入触发且有模型选择动作时才删除@字符和搜索文本 const triggerInfo = ctx?.triggerInfo ?? triggerInfoRef.current - if ( - hasModelActionRef.current && - triggerInfo?.type === 'input' && - triggerInfo?.position !== undefined - ) { + if (hasModelActionRef.current && triggerInfo?.type === 'input' && triggerInfo?.position !== undefined) { // 基于当前光标 + 搜索词精确定位并删除,position 仅作兜底 setText((currentText) => { const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null From 5365fddec945d126e6c96d8b05055b0a44406ba7 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Sun, 28 Sep 2025 15:07:21 +0800 Subject: [PATCH 27/32] chore: bump version to 1.6.2 - Updated release notes to reflect recent optimizations and bug fixes, including improvements to the note-taking feature and resolution of issues with CherryAI and VertexAI. - Bumped version number from 1.6.1 to 1.6.2 in package.json. --- electron-builder.yml | 60 +++----------------------------------------- package.json | 2 +- 2 files changed, 5 insertions(+), 57 deletions(-) diff --git a/electron-builder.yml b/electron-builder.yml index 05fdc8b2f6..56dba2795c 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -125,59 +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 - - Added API server functionality for external application integration - - Added PaddleOCR document recognition for enhanced document processing - - Added Anthropic OAuth authentication support - - Added data storage space limit notifications - - Added font settings for global and code fonts customization - - Added auto-copy feature after translation completion - - Added keyboard shortcuts: rename topic, edit last message, etc. - - Added text attachment preview for viewing file contents in messages - - Added custom window control buttons (minimize, maximize, close) - - Support for Qwen long-text (qwen-long) and document analysis (qwen-doc) models with native file uploads - - Support for Qwen image recognition models (Qwen-Image) - - Added iFlow CLI support - - Converted knowledge base and web search to tool-calling approach for better flexibility - - 🎨 UI Improvements & Bug Fixes: - - Integrated HeroUI and Tailwind CSS framework - - Optimized message notification styles with unified toast component - - Moved free models to bottom with fixed position for easier access - - Refactored quick panel and input bar tools for smoother operation - - Optimized responsive design for navbar and sidebar - - 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 组件 - - 免费模型移至底部固定位置,便于访问 - - 重构快捷面板和输入栏工具,操作更流畅 - - 优化导航栏和侧边栏响应式设计 - - 改进滚动条组件,支持水平滚动 - - 修复多个翻译问题:粘贴处理、文件处理、状态管理 - - 各种界面优化和问题修复 - - + Optimized note-taking feature, now able to quickly rename by modifying the title + Fixed issue where CherryAI free model could not be used + Fixed issue where VertexAI proxy address could not be called normally + Fixed issue where built-in tools from service providers could not be called normally diff --git a/package.json b/package.json index 6e5ab73a8c..2e6b6251e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.6.1", + "version": "1.6.2", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", From 4975c2d9e853dbab8fa9b1ce0fbb3108ab24f252 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Sun, 28 Sep 2025 16:07:09 +0800 Subject: [PATCH 28/32] chore: update build configurations to use secrets for sensitive environment variables - Modified GitHub Actions workflows to replace environment variable references with secrets for MAIN_VITE_MINERU_API_KEY, RENDERER_VITE_AIHUBMIX_SECRET, and RENDERER_VITE_PPIO_APP_SECRET. - Added onwarn handler in electron.vite.config.ts to suppress specific warnings related to CommonJS variables in ESM. --- .github/workflows/auto-i18n.yml | 4 ++-- .github/workflows/nightly-build.yml | 24 ++++++++++++------------ .github/workflows/release.yml | 24 ++++++++++++------------ electron.vite.config.ts | 8 ++++++++ 4 files changed, 34 insertions(+), 26 deletions(-) diff --git a/.github/workflows/auto-i18n.yml b/.github/workflows/auto-i18n.yml index 4cdd1481cf..140d6208fc 100644 --- a/.github/workflows/auto-i18n.yml +++ b/.github/workflows/auto-i18n.yml @@ -2,8 +2,8 @@ name: Auto I18N env: API_KEY: ${{ secrets.TRANSLATE_API_KEY }} - MODEL: ${{ vars.MODEL || 'deepseek/deepseek-v3.1'}} - BASE_URL: ${{ vars.BASE_URL || 'https://api.ppinfra.com/openai'}} + MODEL: ${{ vars.AUTO_I18N_MODEL || 'deepseek/deepseek-v3.1'}} + BASE_URL: ${{ vars.AUTO_I18N_BASE_URL || 'https://api.ppinfra.com/openai'}} on: pull_request: diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 7f7100dc54..42d0d66150 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -99,9 +99,9 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_OPTIONS: --max-old-space-size=8192 MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }} - MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }} - RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }} - RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }} + MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }} + RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }} + RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }} - name: Build Mac if: matrix.os == 'macos-latest' @@ -110,15 +110,15 @@ jobs: env: CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} - APPLE_ID: ${{ vars.APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ vars.APPLE_APP_SPECIFIC_PASSWORD }} - APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_OPTIONS: --max-old-space-size=8192 MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }} - MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }} - RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }} - RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }} + MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }} + RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }} + RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }} - name: Build Windows if: matrix.os == 'windows-latest' @@ -128,9 +128,9 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_OPTIONS: --max-old-space-size=8192 MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }} - MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }} - RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }} - RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }} + MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }} + RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }} + RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }} - name: Rename artifacts with nightly format shell: bash diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c4a772ad6b..0ca1eb0146 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -86,9 +86,9 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_OPTIONS: --max-old-space-size=8192 MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }} - MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }} - RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }} - RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }} + MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }} + RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }} + RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }} - name: Build Mac if: matrix.os == 'macos-latest' @@ -98,15 +98,15 @@ jobs: env: CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} - APPLE_ID: ${{ vars.APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ vars.APPLE_APP_SPECIFIC_PASSWORD }} - APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_OPTIONS: --max-old-space-size=8192 MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }} - MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }} - RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }} - RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }} + MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }} + RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }} + RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }} - name: Build Windows if: matrix.os == 'windows-latest' @@ -116,9 +116,9 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_OPTIONS: --max-old-space-size=8192 MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }} - MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }} - RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }} - RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }} + MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }} + RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }} + RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }} - name: Release uses: ncipollo/release-action@v1 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index b7c55793d3..aa2bc13de6 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -34,6 +34,10 @@ export default defineConfig({ output: { manualChunks: undefined, // 彻底禁用代码分割 - 返回 null 强制单文件打包 inlineDynamicImports: true // 内联所有动态导入,这是关键配置 + }, + onwarn(warning, warn) { + if (warning.code === 'COMMONJS_VARIABLE_IN_ESM') return + warn(warning) } }, sourcemap: isDev @@ -111,6 +115,10 @@ export default defineConfig({ selectionToolbar: resolve(__dirname, 'src/renderer/selectionToolbar.html'), selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html'), traceWindow: resolve(__dirname, 'src/renderer/traceWindow.html') + }, + onwarn(warning, warn) { + if (warning.code === 'COMMONJS_VARIABLE_IN_ESM') return + warn(warning) } } }, From 483b4e090e767801971b1da1dab943f5bd0ba088 Mon Sep 17 00:00:00 2001 From: MyPrototypeWhat Date: Sun, 28 Sep 2025 16:27:26 +0800 Subject: [PATCH 29/32] feat(toolUsePlugin): separate provider-defined tools from prompt tool (#10428) * feat(toolUsePlugin): separate provider-defined tools from prompt tools in context - Enhanced the `createPromptToolUsePlugin` function to distinguish between provider-defined tools and other tools, ensuring only non-provider-defined tools are saved in the context. - Updated the handling of tools in the transformed parameters to retain provider-defined tools while removing others. - Improved error handling in `ToolExecutor` by logging tool and tool use details for better debugging. - Refactored various components to use `NormalToolResponse` instead of `MCPToolResponse`, aligning with the new response structure across multiple message components. * refactor(toolUsePlugin): streamline tool handling in createPromptToolUsePlugin - Updated the `createPromptToolUsePlugin` function to improve type handling for tools, ensuring proper type inference and reducing the use of type assertions. - Enhanced clarity in the separation of provider-defined tools and prompt tools, maintaining functionality while improving code readability. * refactor(ToolExecutor): remove debug logging for tool and tool use - Removed console logging for tool and tool use details in the ToolExecutor class to clean up the code and improve performance. This change enhances the clarity of the code without affecting functionality. --- .../toolUsePlugin/promptToolUsePlugin.ts | 30 +++++++++++++++---- .../built-in/webSearchPlugin/helper.ts | 28 ++++++++++------- .../Messages/Tools/MessageKnowledgeSearch.tsx | 6 ++-- .../home/Messages/Tools/MessageMcpTool.tsx | 3 +- .../Messages/Tools/MessageMemorySearch.tsx | 4 +-- .../pages/home/Messages/Tools/MessageTool.tsx | 17 ++++++----- .../home/Messages/Tools/MessageWebSearch.tsx | 4 +-- src/renderer/src/types/newMessage.ts | 3 +- 8 files changed, 63 insertions(+), 32 deletions(-) diff --git a/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts b/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts index fce028f5cd..a2cc7d9aff 100644 --- a/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts +++ b/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts @@ -261,22 +261,39 @@ export const createPromptToolUsePlugin = (config: PromptToolUseConfig = {}) => { return params } - context.mcpTools = params.tools + // 分离 provider-defined 和其他类型的工具 + const providerDefinedTools: ToolSet = {} + const promptTools: ToolSet = {} - // 构建系统提示符 + for (const [toolName, tool] of Object.entries(params.tools as ToolSet)) { + if (tool.type === 'provider-defined') { + // provider-defined 类型的工具保留在 tools 参数中 + providerDefinedTools[toolName] = tool + } else { + // 其他工具转换为 prompt 模式 + promptTools[toolName] = tool + } + } + + // 只有当有非 provider-defined 工具时才保存到 context + if (Object.keys(promptTools).length > 0) { + context.mcpTools = promptTools + } + + // 构建系统提示符(只包含非 provider-defined 工具) const userSystemPrompt = typeof params.system === 'string' ? params.system : '' - const systemPrompt = buildSystemPrompt(userSystemPrompt, params.tools) + const systemPrompt = buildSystemPrompt(userSystemPrompt, promptTools) let systemMessage: string | null = systemPrompt if (config.createSystemMessage) { // 🎯 如果用户提供了自定义处理函数,使用它 systemMessage = config.createSystemMessage(systemPrompt, params, context) } - // 移除 tools,改为 prompt 模式 + // 保留 provider-defined tools,移除其他 tools const transformedParams = { ...params, ...(systemMessage ? { system: systemMessage } : {}), - tools: undefined + tools: Object.keys(providerDefinedTools).length > 0 ? providerDefinedTools : undefined } context.originalParams = transformedParams return transformedParams @@ -285,8 +302,9 @@ export const createPromptToolUsePlugin = (config: PromptToolUseConfig = {}) => { let textBuffer = '' // let stepId = '' + // 如果没有需要 prompt 模式处理的工具,直接返回原始流 if (!context.mcpTools) { - throw new Error('No tools available') + return new TransformStream() } // 从 context 中获取或初始化 usage 累加器 diff --git a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts index 4845ce4ace..95c2cdda2c 100644 --- a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts +++ b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts @@ -1,6 +1,7 @@ import { anthropic } from '@ai-sdk/anthropic' import { google } from '@ai-sdk/google' import { openai } from '@ai-sdk/openai' +import { InferToolInput, InferToolOutput } from 'ai' import { ProviderOptionsMap } from '../../../options/types' import { OpenRouterSearchConfig } from './openrouter' @@ -58,24 +59,31 @@ export const DEFAULT_WEB_SEARCH_CONFIG: WebSearchPluginConfig = { export type WebSearchToolOutputSchema = { // Anthropic 工具 - 手动定义 - anthropicWebSearch: Array<{ - url: string - title: string - pageAge: string | null - encryptedContent: string - type: string - }> + anthropic: InferToolOutput> // OpenAI 工具 - 基于实际输出 - openaiWebSearch: { + // TODO: 上游定义不规范,是unknown + // openai: InferToolOutput> + openai: { + status: 'completed' | 'failed' + } + 'openai-chat': { status: 'completed' | 'failed' } - // Google 工具 - googleSearch: { + // TODO: 上游定义不规范,是unknown + // google: InferToolOutput> + google: { webSearchQueries?: string[] groundingChunks?: Array<{ web?: { uri: string; title: string } }> } } + +export type WebSearchToolInputSchema = { + anthropic: InferToolInput> + openai: InferToolInput> + google: InferToolInput> + 'openai-chat': InferToolInput> +} diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageKnowledgeSearch.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageKnowledgeSearch.tsx index 72a3f6e36c..19c3a135d7 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageKnowledgeSearch.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageKnowledgeSearch.tsx @@ -1,13 +1,13 @@ import { KnowledgeSearchToolInput, KnowledgeSearchToolOutput } from '@renderer/aiCore/tools/KnowledgeSearchTool' import Spinner from '@renderer/components/Spinner' import i18n from '@renderer/i18n' -import { MCPToolResponse } from '@renderer/types' +import { NormalToolResponse } from '@renderer/types' import { Typography } from 'antd' import { FileSearch } from 'lucide-react' import styled from 'styled-components' const { Text } = Typography -export function MessageKnowledgeSearchToolTitle({ toolResponse }: { toolResponse: MCPToolResponse }) { +export function MessageKnowledgeSearchToolTitle({ toolResponse }: { toolResponse: NormalToolResponse }) { const toolInput = toolResponse.arguments as KnowledgeSearchToolInput const toolOutput = toolResponse.response as KnowledgeSearchToolOutput @@ -28,7 +28,7 @@ export function MessageKnowledgeSearchToolTitle({ toolResponse }: { toolResponse ) } -export function MessageKnowledgeSearchToolBody({ toolResponse }: { toolResponse: MCPToolResponse }) { +export function MessageKnowledgeSearchToolBody({ toolResponse }: { toolResponse: NormalToolResponse }) { const toolOutput = toolResponse.response as KnowledgeSearchToolOutput return toolResponse.status === 'done' ? ( diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageMcpTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageMcpTool.tsx index be5b21104a..11f29221d6 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageMcpTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageMcpTool.tsx @@ -4,6 +4,7 @@ import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useMCPServers } from '@renderer/hooks/useMCPServers' import { useSettings } from '@renderer/hooks/useSettings' import { useTimer } from '@renderer/hooks/useTimer' +import { MCPToolResponse } from '@renderer/types' import type { ToolMessageBlock } from '@renderer/types/newMessage' import { isToolAutoApproved } from '@renderer/utils/mcp-tools' import { cancelToolAction, confirmToolAction } from '@renderer/utils/userConfirmation' @@ -57,7 +58,7 @@ const MessageMcpTool: FC = ({ block }) => { const [progress, setProgress] = useState(0) const { setTimeoutTimer } = useTimer() - const toolResponse = block.metadata?.rawMcpToolResponse + const toolResponse = block.metadata?.rawMcpToolResponse as MCPToolResponse const { id, tool, status, response } = toolResponse! const isPending = status === 'pending' diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageMemorySearch.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageMemorySearch.tsx index cb86d8a259..2d49144633 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageMemorySearch.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageMemorySearch.tsx @@ -1,6 +1,6 @@ import { MemorySearchToolInput, MemorySearchToolOutput } from '@renderer/aiCore/tools/MemorySearchTool' import Spinner from '@renderer/components/Spinner' -import { MCPToolResponse } from '@renderer/types' +import { NormalToolResponse } from '@renderer/types' import { Typography } from 'antd' import { ChevronRight } from 'lucide-react' import { useTranslation } from 'react-i18next' @@ -8,7 +8,7 @@ import styled from 'styled-components' const { Text } = Typography -export const MessageMemorySearchToolTitle = ({ toolResponse }: { toolResponse: MCPToolResponse }) => { +export const MessageMemorySearchToolTitle = ({ toolResponse }: { toolResponse: NormalToolResponse }) => { const { t } = useTranslation() const toolInput = toolResponse.arguments as MemorySearchToolInput const toolOutput = toolResponse.response as MemorySearchToolOutput diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageTool.tsx index 38ae73e95e..704fdafd0d 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageTool.tsx @@ -1,4 +1,4 @@ -import { MCPToolResponse } from '@renderer/types' +import { NormalToolResponse } from '@renderer/types' import type { ToolMessageBlock } from '@renderer/types/newMessage' import { Collapse } from 'antd' @@ -11,8 +11,9 @@ interface Props { } const prefix = 'builtin_' -const ChooseTool = (toolResponse: MCPToolResponse): { label: React.ReactNode; body: React.ReactNode } | null => { +const ChooseTool = (toolResponse: NormalToolResponse): { label: React.ReactNode; body: React.ReactNode } | null => { let toolName = toolResponse.tool.name + const toolType = toolResponse.tool.type if (toolName.startsWith(prefix)) { toolName = toolName.slice(prefix.length) } @@ -20,10 +21,12 @@ const ChooseTool = (toolResponse: MCPToolResponse): { label: React.ReactNode; bo switch (toolName) { case 'web_search': case 'web_search_preview': - return { - label: , - body: null - } + return toolType === 'provider' + ? null + : { + label: , + body: null + } case 'knowledge_search': return { label: , @@ -41,7 +44,7 @@ const ChooseTool = (toolResponse: MCPToolResponse): { label: React.ReactNode; bo export default function MessageTool({ block }: Props) { // FIXME: 语义错误,这里已经不是 MCP tool 了,更改rawMcpToolResponse需要改用户数据, 所以暂时保留 - const toolResponse = block.metadata?.rawMcpToolResponse + const toolResponse = block.metadata?.rawMcpToolResponse as NormalToolResponse if (!toolResponse) return null diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageWebSearch.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageWebSearch.tsx index cd04de3a24..5fe71bbae8 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageWebSearch.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageWebSearch.tsx @@ -1,6 +1,6 @@ import { WebSearchToolInput, WebSearchToolOutput } from '@renderer/aiCore/tools/WebSearchTool' import Spinner from '@renderer/components/Spinner' -import { MCPToolResponse } from '@renderer/types' +import { NormalToolResponse } from '@renderer/types' import { Typography } from 'antd' import { Search } from 'lucide-react' import { useTranslation } from 'react-i18next' @@ -8,7 +8,7 @@ import styled from 'styled-components' const { Text } = Typography -export const MessageWebSearchToolTitle = ({ toolResponse }: { toolResponse: MCPToolResponse }) => { +export const MessageWebSearchToolTitle = ({ toolResponse }: { toolResponse: NormalToolResponse }) => { const { t } = useTranslation() const toolInput = toolResponse.arguments as WebSearchToolInput const toolOutput = toolResponse.response as WebSearchToolOutput diff --git a/src/renderer/src/types/newMessage.ts b/src/renderer/src/types/newMessage.ts index 74e2b8266a..7ac6ab5bcb 100644 --- a/src/renderer/src/types/newMessage.ts +++ b/src/renderer/src/types/newMessage.ts @@ -10,6 +10,7 @@ import type { MemoryItem, Metrics, Model, + NormalToolResponse, Topic, Usage, WebSearchResponse, @@ -113,7 +114,7 @@ export interface ToolMessageBlock extends BaseMessageBlock { arguments?: Record content?: string | object metadata?: BaseMessageBlock['metadata'] & { - rawMcpToolResponse?: MCPToolResponse + rawMcpToolResponse?: MCPToolResponse | NormalToolResponse } } From bb0ec0a3eca2f5ceef11cfbcb95d573ab8de7438 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Sun, 28 Sep 2025 16:32:42 +0800 Subject: [PATCH 30/32] chore: update @ai-sdk/google patch and refine getModelPath function - Updated the resolution and checksum for the @ai-sdk/google patch in yarn.lock. - Enhanced the getModelPath function to check for "models/" in the modelId before returning the path, improving its robustness. --- .yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch | 4 ++-- yarn.lock | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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 index 49bcec27d7..f8868aa916 100644 --- a/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch +++ b/.yarn/patches/@ai-sdk-google-npm-2.0.14-376d8b03cc.patch @@ -1,5 +1,5 @@ diff --git a/dist/index.mjs b/dist/index.mjs -index 110f37ec18c98b1d55ae2b73cc716194e6f9094d..3ea0fadd783f334db71266e45babdcce11076974 100644 +index 110f37ec18c98b1d55ae2b73cc716194e6f9094d..17e109b7778cbebb904f1919e768d21a2833d965 100644 --- a/dist/index.mjs +++ b/dist/index.mjs @@ -448,7 +448,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) { @@ -7,7 +7,7 @@ index 110f37ec18c98b1d55ae2b73cc716194e6f9094d..3ea0fadd783f334db71266e45babdcce // src/get-model-path.ts function getModelPath(modelId) { - return modelId.includes("/") ? modelId : `models/${modelId}`; -+ return `models/${modelId}`; ++ return modelId?.includes("models/") ? modelId : `models/${modelId}`; } // src/google-generative-ai-options.ts diff --git a/yarn.lock b/yarn.lock index 748d52512c..9252c911de 100644 --- a/yarn.lock +++ b/yarn.lock @@ -169,13 +169,13 @@ __metadata: "@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=c6aff2" + 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=351f1a" dependencies: "@ai-sdk/provider": "npm:2.0.0" "@ai-sdk/provider-utils": "npm:3.0.9" peerDependencies: zod: ^3.25.76 || ^4 - checksum: 10c0/2a0a09debab8de0603243503ff5044bd3fff87d6c5de2d76d43839fa459cc85d5412b59ec63d0dcf1a6d6cab02882eb3c69f0f155129d0fc153bcde4deecbd32 + checksum: 10c0/1ed5a0732a82b981d51f63c6241ed8ee94d5c29a842764db770305cfc2f49ab6e528cac438b5357fc7b02194104c7b76d4390a1dc1d019ace9c174b0849e0da6 languageName: node linkType: hard From 06ab2822be98258d2df6dbf6e2d37c179505024c Mon Sep 17 00:00:00 2001 From: MyPrototypeWhat Date: Sun, 28 Sep 2025 19:38:44 +0800 Subject: [PATCH 31/32] Refactor/reasoning time (#10393) --- .../src/aiCore/chunk/AiSdkToChunkAdapter.ts | 5 +- .../src/aiCore/plugins/PluginBuilder.ts | 7 ++- .../home/Messages/Blocks/ThinkingBlock.tsx | 51 +++++++++++-------- .../Blocks/__tests__/ThinkingBlock.test.tsx | 3 +- .../callbacks/thinkingCallbacks.ts | 19 +++---- .../streamCallback.integration.test.ts | 5 +- 6 files changed, 49 insertions(+), 41 deletions(-) diff --git a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts index 2e8ce32969..fb68cedb23 100644 --- a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts +++ b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts @@ -163,14 +163,13 @@ export class AiSdkToChunkAdapter { final.reasoningContent += chunk.text || '' this.onChunk({ type: ChunkType.THINKING_DELTA, - text: final.reasoningContent || '', - thinking_millsec: (chunk.providerMetadata?.metadata?.thinking_millsec as number) || 0 + text: final.reasoningContent || '' }) break case 'reasoning-end': this.onChunk({ type: ChunkType.THINKING_COMPLETE, - text: (chunk.providerMetadata?.metadata?.thinking_content as string) || final.reasoningContent + text: final.reasoningContent || '' }) final.reasoningContent = '' break diff --git a/src/renderer/src/aiCore/plugins/PluginBuilder.ts b/src/renderer/src/aiCore/plugins/PluginBuilder.ts index 7c5478eb77..7767564bd9 100644 --- a/src/renderer/src/aiCore/plugins/PluginBuilder.ts +++ b/src/renderer/src/aiCore/plugins/PluginBuilder.ts @@ -5,7 +5,6 @@ import { getEnableDeveloperMode } from '@renderer/hooks/useSettings' import { Assistant } from '@renderer/types' import { AiSdkMiddlewareConfig } from '../middleware/AiSdkMiddlewareBuilder' -import reasoningTimePlugin from './reasoningTimePlugin' import { searchOrchestrationPlugin } from './searchOrchestrationPlugin' import { createTelemetryPlugin } from './telemetryPlugin' @@ -39,9 +38,9 @@ export function buildPlugins( } // 3. 推理模型时添加推理插件 - if (middlewareConfig.enableReasoning) { - plugins.push(reasoningTimePlugin) - } + // if (middlewareConfig.enableReasoning) { + // plugins.push(reasoningTimePlugin) + // } // 4. 启用Prompt工具调用时添加工具插件 if (middlewareConfig.isPromptToolUse) { diff --git a/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx index 98cad2a8ca..109562f7d5 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx @@ -5,7 +5,7 @@ import { useSettings } from '@renderer/hooks/useSettings' import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue' import { MessageBlockStatus, type ThinkingMessageBlock } from '@renderer/types/newMessage' import { Collapse, message as antdMessage, Tooltip } from 'antd' -import { memo, useCallback, useEffect, useMemo, useState } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -105,30 +105,37 @@ const ThinkingBlock: React.FC = ({ block }) => { const ThinkingTimeSeconds = memo( ({ blockThinkingTime, isThinking }: { blockThinkingTime: number; isThinking: boolean }) => { const { t } = useTranslation() - // const [thinkingTime, setThinkingTime] = useState(blockThinkingTime || 0) + const [displayTime, setDisplayTime] = useState(blockThinkingTime) - // FIXME: 这里统计的和请求处统计的有一定误差 - // useEffect(() => { - // let timer: NodeJS.Timeout | null = null - // if (isThinking) { - // timer = setInterval(() => { - // setThinkingTime((prev) => prev + 100) - // }, 100) - // } else if (timer) { - // // 立即清除计时器 - // clearInterval(timer) - // timer = null - // } + const timer = useRef(null) - // return () => { - // if (timer) { - // clearInterval(timer) - // timer = null - // } - // } - // }, [isThinking]) + useEffect(() => { + if (isThinking) { + if (!timer.current) { + timer.current = setInterval(() => { + setDisplayTime((prev) => prev + 100) + }, 100) + } + } else { + if (timer.current) { + clearInterval(timer.current) + timer.current = null + } + setDisplayTime(blockThinkingTime) + } - const thinkingTimeSeconds = useMemo(() => (blockThinkingTime / 1000).toFixed(1), [blockThinkingTime]) + return () => { + if (timer.current) { + clearInterval(timer.current) + timer.current = null + } + } + }, [isThinking, blockThinkingTime]) + + const thinkingTimeSeconds = useMemo( + () => ((displayTime < 1000 ? 100 : displayTime) / 1000).toFixed(1), + [displayTime] + ) return isThinking ? t('chat.thinking', { diff --git a/src/renderer/src/pages/home/Messages/Blocks/__tests__/ThinkingBlock.test.tsx b/src/renderer/src/pages/home/Messages/Blocks/__tests__/ThinkingBlock.test.tsx index 8db122d948..d573408225 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/__tests__/ThinkingBlock.test.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/__tests__/ThinkingBlock.test.tsx @@ -235,13 +235,12 @@ describe('ThinkingBlock', () => { renderThinkingBlock(thinkingBlock) const activeTimeText = getThinkingTimeText() - expect(activeTimeText).toHaveTextContent('1.0s') expect(activeTimeText).toHaveTextContent('Thinking...') }) it('should handle extreme thinking times correctly', () => { const testCases = [ - { thinking_millsec: 0, expectedTime: '0.0s' }, + { thinking_millsec: 0, expectedTime: '0.1s' }, // New logic: values < 1000ms display as 0.1s { thinking_millsec: 86400000, expectedTime: '86400.0s' }, // 1 day { thinking_millsec: 259200000, expectedTime: '259200.0s' } // 3 days ] diff --git a/src/renderer/src/services/messageStreaming/callbacks/thinkingCallbacks.ts b/src/renderer/src/services/messageStreaming/callbacks/thinkingCallbacks.ts index 4d717c6c64..605259b646 100644 --- a/src/renderer/src/services/messageStreaming/callbacks/thinkingCallbacks.ts +++ b/src/renderer/src/services/messageStreaming/callbacks/thinkingCallbacks.ts @@ -15,7 +15,7 @@ export const createThinkingCallbacks = (deps: ThinkingCallbacksDependencies) => // 内部维护的状态 let thinkingBlockId: string | null = null - let _thinking_millsec = 0 + let thinking_millsec_now: number = 0 return { onThinkingStart: async () => { @@ -24,27 +24,27 @@ export const createThinkingCallbacks = (deps: ThinkingCallbacksDependencies) => type: MessageBlockType.THINKING, content: '', status: MessageBlockStatus.STREAMING, - thinking_millsec: _thinking_millsec + thinking_millsec: 0 } thinkingBlockId = blockManager.initialPlaceholderBlockId! blockManager.smartBlockUpdate(thinkingBlockId, changes, MessageBlockType.THINKING, true) } else if (!thinkingBlockId) { const newBlock = createThinkingBlock(assistantMsgId, '', { status: MessageBlockStatus.STREAMING, - thinking_millsec: _thinking_millsec + thinking_millsec: 0 }) thinkingBlockId = newBlock.id await blockManager.handleBlockTransition(newBlock, MessageBlockType.THINKING) } + thinking_millsec_now = performance.now() }, - onThinkingChunk: async (text: string, thinking_millsec?: number) => { - _thinking_millsec = thinking_millsec || 0 + onThinkingChunk: async (text: string) => { if (thinkingBlockId) { const blockChanges: Partial = { content: text, - status: MessageBlockStatus.STREAMING, - thinking_millsec: _thinking_millsec + status: MessageBlockStatus.STREAMING + // thinking_millsec: performance.now() - thinking_millsec_now } blockManager.smartBlockUpdate(thinkingBlockId, blockChanges, MessageBlockType.THINKING) } @@ -52,14 +52,15 @@ export const createThinkingCallbacks = (deps: ThinkingCallbacksDependencies) => onThinkingComplete: (finalText: string) => { if (thinkingBlockId) { + const now = performance.now() const changes: Partial = { content: finalText, status: MessageBlockStatus.SUCCESS, - thinking_millsec: _thinking_millsec + thinking_millsec: now - thinking_millsec_now } blockManager.smartBlockUpdate(thinkingBlockId, changes, MessageBlockType.THINKING, true) thinkingBlockId = null - _thinking_millsec = 0 + thinking_millsec_now = 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 e8c113d62b..49c71aea56 100644 --- a/src/renderer/src/store/thunk/__tests__/streamCallback.integration.test.ts +++ b/src/renderer/src/store/thunk/__tests__/streamCallback.integration.test.ts @@ -425,7 +425,10 @@ describe('streamCallback Integration Tests', () => { expect(thinkingBlock).toBeDefined() expect(thinkingBlock?.content).toBe('Final thoughts') expect(thinkingBlock?.status).toBe(MessageBlockStatus.SUCCESS) - expect((thinkingBlock as any)?.thinking_millsec).toBe(3000) + // thinking_millsec 现在是本地计算的,只验证它存在且是一个合理的数字 + expect((thinkingBlock as any)?.thinking_millsec).toBeDefined() + expect(typeof (thinkingBlock as any)?.thinking_millsec).toBe('number') + expect((thinkingBlock as any)?.thinking_millsec).toBeGreaterThanOrEqual(0) }) it('should handle tool call flow', async () => { From c7d2588f1a174f21eaa6a846dd60c092bec126ec Mon Sep 17 00:00:00 2001 From: LeaderOnePro Date: Sun, 28 Sep 2025 20:54:42 +0800 Subject: [PATCH 32/32] feat: add LongCat provider support (#10365) * feat: add LongCat provider support - Add LongCat to SystemProviderIds enum - Add LongCat provider logo and configuration - Configure API endpoints and URLs based on official docs - Add two models: LongCat-Flash-Chat and LongCat-Flash-Thinking - Update provider mappings for proper integration The LongCat provider uses OpenAI-compatible API format and supports up to 8K tokens output with daily free quota of 500K tokens. Signed-off-by: LeaderOnePro * feat: add migration for LongCat provider - Add migration version 158 for LongCat provider - Ensure existing users get LongCat provider on app update - Follow standard migration pattern for simple provider additions Signed-off-by: LeaderOnePro --------- Signed-off-by: LeaderOnePro --- .../src/assets/images/providers/longcat.png | Bin 0 -> 1658 bytes src/renderer/src/config/models/default.ts | 14 ++++++++++ src/renderer/src/config/providers.ts | 25 +++++++++++++++++- src/renderer/src/store/migrate.ts | 1 + src/renderer/src/types/index.ts | 3 ++- 5 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/assets/images/providers/longcat.png diff --git a/src/renderer/src/assets/images/providers/longcat.png b/src/renderer/src/assets/images/providers/longcat.png new file mode 100644 index 0000000000000000000000000000000000000000..146cf3ea7a9a1ae05038cf180f5e6d278fb11619 GIT binary patch literal 1658 zcmV-=28H>FP)Px#1am@3R0s$N2z&@+hyVZvBuPX;RCt{2o560|Mi7SoSt@|llXB?U)J^uK4tfm` z`UyfO%DM6rBz}TePY~w`K49wotRdCiy+9tMZp_R0wx(d#anOgE7 zQ0y|6bqKeTI3!g|gw0M^kKb=9tivR3)K%>*41`;RHTEx?{h`g64j!l?i$-rq1)o~U zi;n%c`AEDjhsbu^nr%Z0g6CkiX&I8D-tOm@&(^W!S@t@+fFi;(+wtu^kU6q$3xJtA zuHNYAb$1XV+l9{RD=Xf z*4({;9&7;(pw=OU*Vu$`i=*msQ@W^$J&+J=!29lv5|UEAx~hF4RZC_$?8dVhQjf%8 zEEh0X@BH_mAo+e!ETCcfHs^#j_Q7PGtXsnvLh}KEPtDuNw`QU_a8}tkGc>;*ZTBDF zw!LZ}Gcx6*O`&l#YW7d&f9_nhzrb(`kZxy09>_|tV<((1{&}(8KPF9208S-i%HWhW z_7}yQUS7VJ5+ccnvJjT+y>f2)qWR&R^<*<4%@}^wlP9^}L*@cX(>j@eqWO=~ysMYZ zK{^UQ91zeIja`Js(J1Wir_I52-p33!_dsD+yl9ZPnahd;-G-WlI(BKnevktv(a+spY~^2k>*|r;Mz9 zT{>vm99$EzQALPAg{yI4CSqEB))V^G#4d*)0sNJ@R#xQA+HV8+EFO8H-a~Q6z_Npa zv~HybPSZCE%lqfl_FxWx$K@uQ>EojL;T&dR+{k;(I;S9nRb+A_L{3@9I-az-^l@rp zRFu}O6wow%;|V^R*sY*44+I4HaY0(Q+5-)rP%%he8M&ic57cyJWJlOrRi(}qaF)Ut z-k4L~Td4;M?!B>vTJCC`E1;zM?3njfD&UQvxUfo-{1D#A;h|hA4PnzF?u;n!;ynSw zByLEazB!)2uhavD_ntcs-V;!gz5&3Nn-^8;fj{6;D6!8=>+T6CKRi?u-9&~;Jy3(C z;z!vF_XHFk9s(3^3vXhPrLzlq?{V z*4+x|07*qoM6N<$ Ef?2mPg#Z8m literal 0 HcmV?d00001 diff --git a/src/renderer/src/config/models/default.ts b/src/renderer/src/config/models/default.ts index 9fdced6a6a..1858675ed8 100644 --- a/src/renderer/src/config/models/default.ts +++ b/src/renderer/src/config/models/default.ts @@ -1804,5 +1804,19 @@ export const SYSTEM_MODELS: Record = provider: 'aionly', group: 'gemini' } + ], + longcat: [ + { + id: 'LongCat-Flash-Chat', + name: 'LongCat Flash Chat', + provider: 'longcat', + group: 'LongCat' + }, + { + id: 'LongCat-Flash-Thinking', + name: 'LongCat Flash Thinking', + provider: 'longcat', + group: 'LongCat' + } ] } diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts index 543422d212..64e78e847a 100644 --- a/src/renderer/src/config/providers.ts +++ b/src/renderer/src/config/providers.ts @@ -27,6 +27,7 @@ import InfiniProviderLogo from '@renderer/assets/images/providers/infini.png' import JinaProviderLogo from '@renderer/assets/images/providers/jina.png' import LanyunProviderLogo from '@renderer/assets/images/providers/lanyun.png' import LMStudioProviderLogo from '@renderer/assets/images/providers/lmstudio.png' +import LongCatProviderLogo from '@renderer/assets/images/providers/longcat.png' import MinimaxProviderLogo from '@renderer/assets/images/providers/minimax.png' import MistralProviderLogo from '@renderer/assets/images/providers/mistral.png' import ModelScopeProviderLogo from '@renderer/assets/images/providers/modelscope.png' @@ -622,6 +623,16 @@ export const SYSTEM_PROVIDERS_CONFIG: Record = models: SYSTEM_MODELS['poe'], isSystem: true, enabled: false + }, + longcat: { + id: 'longcat', + name: 'LongCat', + type: 'openai', + apiKey: '', + apiHost: 'https://api.longcat.chat/openai', + models: SYSTEM_MODELS.longcat, + isSystem: true, + enabled: false } } as const @@ -684,7 +695,8 @@ export const PROVIDER_LOGO_MAP: AtLeast = { 'new-api': NewAPIProviderLogo, 'aws-bedrock': AwsProviderLogo, poe: 'poe', // use svg icon component - aionly: AiOnlyProviderLogo + aionly: AiOnlyProviderLogo, + longcat: LongCatProviderLogo } as const export function getProviderLogo(providerId: string) { @@ -1290,6 +1302,17 @@ export const PROVIDER_URLS: Record = { docs: 'https://www.aiionly.com/document', models: 'https://www.aiionly.com' } + }, + longcat: { + api: { + url: 'https://api.longcat.chat/openai' + }, + websites: { + official: 'https://longcat.chat', + apiKey: 'https://longcat.chat/platform/api_keys', + docs: 'https://longcat.chat/platform/docs/zh/', + models: 'https://longcat.chat/platform/docs/zh/APIDocs.html' + } } } diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index f10fc623da..1c954ba27e 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -2543,6 +2543,7 @@ const migrateConfig = { '158': (state: RootState) => { try { state.llm.providers = state.llm.providers.filter((provider) => provider.id !== 'cherryin') + addProvider(state, 'longcat') return state } catch (error) { logger.error('migrate 158 error', error as Error) diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 33abec0853..2b9271d548 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -322,7 +322,8 @@ export const SystemProviderIds = { voyageai: 'voyageai', 'aws-bedrock': 'aws-bedrock', poe: 'poe', - aionly: 'aionly' + aionly: 'aionly', + longcat: 'longcat' } as const export type SystemProviderId = keyof typeof SystemProviderIds