From 276269e583b85bde6207d7ca7b51836fed097b42 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Fri, 12 Sep 2025 16:00:39 +0800 Subject: [PATCH 01/99] style(MinApp): responsive layout for MinAppPage (#10125) style(MinApp): adjust container styles for better layout - Add min-height to MinApp container - Remove absolute positioning from search bar - Improve flex and overflow handling in containers - Adjust margins and widths for better spacing --- src/renderer/src/components/MinApp/MinApp.tsx | 1 + src/renderer/src/pages/minapps/MinAppsPage.tsx | 12 +++++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/MinApp/MinApp.tsx b/src/renderer/src/components/MinApp/MinApp.tsx index 853d37d109..5833ed9b1a 100644 --- a/src/renderer/src/components/MinApp/MinApp.tsx +++ b/src/renderer/src/components/MinApp/MinApp.tsx @@ -135,6 +135,7 @@ const Container = styled.div` align-items: center; cursor: pointer; overflow: hidden; + min-height: 85px; ` const IconContainer = styled.div` diff --git a/src/renderer/src/pages/minapps/MinAppsPage.tsx b/src/renderer/src/pages/minapps/MinAppsPage.tsx index db82bf43a3..5aadc22020 100644 --- a/src/renderer/src/pages/minapps/MinAppsPage.tsx +++ b/src/renderer/src/pages/minapps/MinAppsPage.tsx @@ -46,10 +46,7 @@ const AppsPage: FC = () => { style={{ width: '30%', height: 28, - borderRadius: 15, - position: 'absolute', - left: '50vw', - transform: 'translateX(-50%)' + borderRadius: 15 }} size="small" variant="filled" @@ -107,6 +104,7 @@ const Container = styled.div` flex: 1; flex-direction: column; height: 100%; + overflow: hidden; ` const ContentContainer = styled.div` @@ -132,11 +130,13 @@ const MainContainer = styled.div` flex: 1; flex-direction: row; height: calc(100vh - var(--navbar-height)); + width: 100%; ` const RightContainer = styled(Scrollbar)` display: flex; - flex: 1; + flex: 1 1 0%; + min-width: 0; flex-direction: column; height: 100%; align-items: center; @@ -150,6 +150,7 @@ const AppsContainerWrapper = styled(Scrollbar)` justify-content: center; padding: 50px 0; width: 100%; + margin-bottom: 20px; [navbar-position='top'] & { padding: 20px 0; } @@ -159,6 +160,7 @@ const AppsContainer = styled.div` display: grid; min-width: 0; max-width: 930px; + margin: 0 20px; width: 100%; grid-template-columns: repeat(auto-fill, 90px); gap: 25px; From f5d8974d042f1172f8dfd4d25ccf26ebfe5e96db Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Fri, 12 Sep 2025 20:57:32 +0800 Subject: [PATCH 02/99] fix(provider): wrong new api provider id (#10136) refactor(provider): wrap system provider checks in isSystemProvider Centralize system provider checks to improve maintainability and reduce code duplication --- .../src/aiCore/provider/providerConfig.ts | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/aiCore/provider/providerConfig.ts b/src/renderer/src/aiCore/provider/providerConfig.ts index ddbb178f3f..654b06114c 100644 --- a/src/renderer/src/aiCore/provider/providerConfig.ts +++ b/src/renderer/src/aiCore/provider/providerConfig.ts @@ -15,7 +15,7 @@ import { createVertexProvider, isVertexAIConfigured } from '@renderer/hooks/useV import { getProviderByModel } from '@renderer/services/AssistantService' import { loggerService } from '@renderer/services/LoggerService' import store from '@renderer/store' -import type { Model, Provider } from '@renderer/types' +import { isSystemProvider, type Model, type Provider } from '@renderer/types' import { formatApiHost } from '@renderer/utils/api' import { cloneDeep, isEmpty } from 'lodash' @@ -61,14 +61,16 @@ function handleSpecialProviders(model: Model, provider: Provider): Provider { // return createVertexProvider(provider) // } - if (provider.id === 'aihubmix') { - return aihubmixProviderCreator(model, provider) - } - if (provider.id === 'newapi') { - return newApiResolverCreator(model, provider) - } - if (provider.id === 'vertexai') { - return vertexAnthropicProviderCreator(model, provider) + if (isSystemProvider(provider)) { + if (provider.id === 'aihubmix') { + return aihubmixProviderCreator(model, provider) + } + if (provider.id === 'new-api') { + return newApiResolverCreator(model, provider) + } + if (provider.id === 'vertexai') { + return vertexAnthropicProviderCreator(model, provider) + } } return provider } From 80afb3a86e91d5fd210ee05ab701f490748c76e7 Mon Sep 17 00:00:00 2001 From: SuYao Date: Sat, 13 Sep 2025 00:06:18 +0800 Subject: [PATCH 03/99] feat: add Perplexity SDK integration (#10137) * feat: add Perplexity SDK integration * feat: enhance AiSdkToChunkAdapter with web search capabilities - Added support for web search in AiSdkToChunkAdapter, allowing for dynamic link conversion based on provider type. - Updated constructor to accept provider type and web search enablement flag. - Improved link handling logic to buffer and process incomplete links. - Enhanced message block handling in the store to accommodate new message structure. - Updated middleware configuration to include web search option. * fix * fix * chore: remove unuseful code * fix: ci * chore: log --- package.json | 1 + .../src/aiCore/chunk/AiSdkToChunkAdapter.ts | 79 +++-- src/renderer/src/aiCore/index_new.ts | 2 +- .../middleware/core/WebSearchMiddleware.ts | 12 +- .../middleware/AiSdkMiddlewareBuilder.ts | 1 + .../aiCore/provider/providerInitialization.ts | 8 + src/renderer/src/store/messageBlock.ts | 32 +- src/renderer/src/store/thunk/messageThunk.ts | 6 +- src/renderer/src/types/index.ts | 4 + .../src/utils/__tests__/linkConverter.test.ts | 140 ++------ src/renderer/src/utils/linkConverter.ts | 305 +----------------- yarn.lock | 13 + 12 files changed, 143 insertions(+), 460 deletions(-) diff --git a/package.json b/package.json index 153be63374..b620dd51d4 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "@ai-sdk/amazon-bedrock": "^3.0.0", "@ai-sdk/google-vertex": "^3.0.25", "@ai-sdk/mistral": "^2.0.0", + "@ai-sdk/perplexity": "^2.0.8", "@ant-design/v5-patch-for-react-19": "^1.0.3", "@anthropic-ai/sdk": "^0.41.0", "@anthropic-ai/vertex-sdk": "patch:@anthropic-ai/vertex-sdk@npm%3A0.11.4#~/.yarn/patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch", diff --git a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts index a28000d9dd..8af4388d5f 100644 --- a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts +++ b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts @@ -4,8 +4,9 @@ */ import { loggerService } from '@logger' -import { MCPTool, WebSearchResults, WebSearchSource } from '@renderer/types' +import { AISDKWebSearchResult, MCPTool, WebSearchResults, WebSearchSource } from '@renderer/types' import { Chunk, ChunkType } from '@renderer/types/chunk' +import { convertLinks, flushLinkConverterBuffer } from '@renderer/utils/linkConverter' import type { TextStreamPart, ToolSet } from 'ai' import { ToolCallChunkHandler } from './handleToolCallChunk' @@ -29,13 +30,18 @@ export interface CherryStudioChunk { export class AiSdkToChunkAdapter { toolCallHandler: ToolCallChunkHandler private accumulate: boolean | undefined + private isFirstChunk = true + private enableWebSearch: boolean = false + constructor( private onChunk: (chunk: Chunk) => void, mcpTools: MCPTool[] = [], - accumulate?: boolean + accumulate?: boolean, + enableWebSearch?: boolean ) { this.toolCallHandler = new ToolCallChunkHandler(onChunk, mcpTools) this.accumulate = accumulate + this.enableWebSearch = enableWebSearch || false } /** @@ -65,11 +71,24 @@ export class AiSdkToChunkAdapter { webSearchResults: [], reasoningId: '' } + // Reset link converter state at the start of stream + this.isFirstChunk = true + try { while (true) { const { done, value } = await reader.read() if (done) { + // Flush any remaining content from link converter buffer if web search is enabled + if (this.enableWebSearch) { + const remainingText = flushLinkConverterBuffer() + if (remainingText) { + this.onChunk({ + type: ChunkType.TEXT_DELTA, + text: remainingText + }) + } + } break } @@ -87,7 +106,7 @@ export class AiSdkToChunkAdapter { */ private convertAndEmitChunk( chunk: TextStreamPart, - final: { text: string; reasoningContent: string; webSearchResults: any[]; reasoningId: string } + final: { text: string; reasoningContent: string; webSearchResults: AISDKWebSearchResult[]; reasoningId: string } ) { logger.silly(`AI SDK chunk type: ${chunk.type}`, chunk) switch (chunk.type) { @@ -97,17 +116,44 @@ export class AiSdkToChunkAdapter { type: ChunkType.TEXT_START }) break - case 'text-delta': - if (this.accumulate) { - final.text += chunk.text || '' + case 'text-delta': { + const processedText = chunk.text || '' + let finalText: string + + // Only apply link conversion if web search is enabled + if (this.enableWebSearch) { + const result = convertLinks(processedText, this.isFirstChunk) + + if (this.isFirstChunk) { + this.isFirstChunk = false + } + + // Handle buffered content + if (result.hasBufferedContent) { + finalText = result.text + } else { + finalText = result.text || processedText + } } else { - final.text = chunk.text || '' + // Without web search, just use the original text + finalText = processedText + } + + if (this.accumulate) { + final.text += finalText + } else { + final.text = finalText + } + + // Only emit chunk if there's text to send + if (finalText) { + this.onChunk({ + type: ChunkType.TEXT_DELTA, + text: this.accumulate ? final.text : finalText + }) } - this.onChunk({ - type: ChunkType.TEXT_DELTA, - text: final.text || '' - }) break + } case 'text-end': this.onChunk({ type: ChunkType.TEXT_COMPLETE, @@ -200,7 +246,7 @@ export class AiSdkToChunkAdapter { [WebSearchSource.ANTHROPIC]: WebSearchSource.ANTHROPIC, [WebSearchSource.OPENROUTER]: WebSearchSource.OPENROUTER, [WebSearchSource.GEMINI]: WebSearchSource.GEMINI, - [WebSearchSource.PERPLEXITY]: WebSearchSource.PERPLEXITY, + // [WebSearchSource.PERPLEXITY]: WebSearchSource.PERPLEXITY, [WebSearchSource.QWEN]: WebSearchSource.QWEN, [WebSearchSource.HUNYUAN]: WebSearchSource.HUNYUAN, [WebSearchSource.ZHIPU]: WebSearchSource.ZHIPU, @@ -268,18 +314,9 @@ export class AiSdkToChunkAdapter { // === 源和文件相关事件 === case 'source': if (chunk.sourceType === 'url') { - // if (final.webSearchResults.length === 0) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { sourceType: _, ...rest } = chunk final.webSearchResults.push(rest) - // } - // this.onChunk({ - // type: ChunkType.LLM_WEB_SEARCH_COMPLETE, - // llm_web_search: { - // source: WebSearchSource.AISDK, - // results: final.webSearchResults - // } - // }) } break case 'file': diff --git a/src/renderer/src/aiCore/index_new.ts b/src/renderer/src/aiCore/index_new.ts index 8da8e8c7c1..0225d15d26 100644 --- a/src/renderer/src/aiCore/index_new.ts +++ b/src/renderer/src/aiCore/index_new.ts @@ -284,7 +284,7 @@ export default class ModernAiProvider { // 创建带有中间件的执行器 if (config.onChunk) { const accumulate = this.model!.supported_text_delta !== false // true and undefined - const adapter = new AiSdkToChunkAdapter(config.onChunk, config.mcpTools, accumulate) + const adapter = new AiSdkToChunkAdapter(config.onChunk, config.mcpTools, accumulate, config.enableWebSearch) const streamResult = await executor.streamText({ ...params, diff --git a/src/renderer/src/aiCore/legacy/middleware/core/WebSearchMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/core/WebSearchMiddleware.ts index 4c72e877a9..ae346af836 100644 --- a/src/renderer/src/aiCore/legacy/middleware/core/WebSearchMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/core/WebSearchMiddleware.ts @@ -1,6 +1,6 @@ import { loggerService } from '@logger' import { ChunkType } from '@renderer/types/chunk' -import { flushLinkConverterBuffer, smartLinkConverter } from '@renderer/utils/linkConverter' +import { convertLinks, flushLinkConverterBuffer } from '@renderer/utils/linkConverter' import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas' import { CompletionsContext, CompletionsMiddleware } from '../types' @@ -28,8 +28,6 @@ export const WebSearchMiddleware: CompletionsMiddleware = } // 调用下游中间件 const result = await next(ctx, params) - - const model = params.assistant?.model! let isFirstChunk = true // 响应后处理:记录Web搜索事件 @@ -42,15 +40,9 @@ export const WebSearchMiddleware: CompletionsMiddleware = new TransformStream({ transform(chunk: GenericChunk, controller) { if (chunk.type === ChunkType.TEXT_DELTA) { - const providerType = model.provider || 'openai' // 使用当前可用的Web搜索结果进行链接转换 const text = chunk.text - const result = smartLinkConverter( - text, - providerType, - isFirstChunk, - ctx._internal.webSearchState!.results - ) + const result = convertLinks(text, isFirstChunk) if (isFirstChunk) { isFirstChunk = false } diff --git a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts index 04b5efd1b5..f0d3b2eb59 100644 --- a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts +++ b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts @@ -20,6 +20,7 @@ export interface AiSdkMiddlewareConfig { isSupportedToolUse: boolean // image generation endpoint isImageGenerationEndpoint: boolean + // 是否开启内置搜索 enableWebSearch: boolean enableGenerateImage: boolean enableUrlContext: boolean diff --git a/src/renderer/src/aiCore/provider/providerInitialization.ts b/src/renderer/src/aiCore/provider/providerInitialization.ts index cf3366d70a..3c188313b9 100644 --- a/src/renderer/src/aiCore/provider/providerInitialization.ts +++ b/src/renderer/src/aiCore/provider/providerInitialization.ts @@ -39,6 +39,14 @@ export const NEW_PROVIDER_CONFIGS: ProviderConfig[] = [ creatorFunctionName: 'createAmazonBedrock', supportsImageGeneration: true, aliases: ['aws-bedrock'] + }, + { + id: 'perplexity', + name: 'Perplexity', + import: () => import('@ai-sdk/perplexity'), + creatorFunctionName: 'createPerplexity', + supportsImageGeneration: false, + aliases: ['perplexity'] } ] as const diff --git a/src/renderer/src/store/messageBlock.ts b/src/renderer/src/store/messageBlock.ts index cd77c05430..368696a4d0 100644 --- a/src/renderer/src/store/messageBlock.ts +++ b/src/renderer/src/store/messageBlock.ts @@ -1,16 +1,19 @@ import { WebSearchResultBlock } from '@anthropic-ai/sdk/resources' import type { GroundingMetadata } from '@google/genai' import { createEntityAdapter, createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit' -import { Citation, WebSearchProviderResponse, WebSearchSource } from '@renderer/types' +import { AISDKWebSearchResult, Citation, WebSearchProviderResponse, WebSearchSource } from '@renderer/types' import type { CitationMessageBlock, MessageBlock } from '@renderer/types/newMessage' import { MessageBlockType } from '@renderer/types/newMessage' import type OpenAI from 'openai' import type { RootState } from './index' // 确认 RootState 从 store/index.ts 导出 +// Create a simplified type for the entity adapter to avoid circular type issues +type MessageBlockEntity = MessageBlock + // 1. 创建实体适配器 (Entity Adapter) // 我们使用块的 `id` 作为唯一标识符。 -const messageBlocksAdapter = createEntityAdapter() +const messageBlocksAdapter = createEntityAdapter() // 2. 使用适配器定义初始状态 (Initial State) // 如果需要,可以在规范化实体的旁边添加其他状态属性。 @@ -20,6 +23,7 @@ const initialState = messageBlocksAdapter.getInitialState({ }) // 3. 创建 Slice +// @ts-ignore ignore export const messageBlocksSlice = createSlice({ name: 'messageBlocks', initialState, @@ -76,8 +80,13 @@ export const messageBlocksSelectors = messageBlocksAdapter.getSelectors - blockId ? messageBlocksSelectors.selectById(state, blockId) : undefined // Use adapter selector +const selectBlockEntityById = (state: RootState, blockId: string | undefined): MessageBlock | undefined => { + const entity = blockId ? messageBlocksSelectors.selectById(state, blockId) : undefined + if (!entity) return undefined + + // Convert back to full MessageBlock type + return entity +} // --- Centralized Citation Formatting Logic --- export const formatCitationsFromBlock = (block: CitationMessageBlock | undefined): Citation[] => { @@ -173,13 +182,16 @@ export const formatCitationsFromBlock = (block: CitationMessageBlock | undefined case WebSearchSource.GROK: case WebSearchSource.OPENROUTER: formattedCitations = - (block.response.results as any[])?.map((url, index) => { + (block.response.results as AISDKWebSearchResult[])?.map((result, index) => { + const url = result.url try { - const hostname = new URL(url).hostname + const hostname = new URL(result.url).hostname + const content = result.providerMetadata && result.providerMetadata['openrouter']?.content return { number: index + 1, url, - hostname, + title: result.title || hostname, + content: content as string, showFavicon: true, type: 'websearch' } @@ -218,10 +230,12 @@ export const formatCitationsFromBlock = (block: CitationMessageBlock | undefined break case WebSearchSource.AISDK: formattedCitations = - (block.response.results as any[])?.map((result, index) => ({ + (block.response.results && (block.response.results as AISDKWebSearchResult[]))?.map((result, index) => ({ number: index + 1, url: result.url, - title: result.title, + title: result.title || new URL(result.url).hostname, + showFavicon: true, + type: 'websearch', providerMetadata: result?.providerMetadata })) || [] break diff --git a/src/renderer/src/store/thunk/messageThunk.ts b/src/renderer/src/store/thunk/messageThunk.ts index 45143b3ccc..42195eb8bb 100644 --- a/src/renderer/src/store/thunk/messageThunk.ts +++ b/src/renderer/src/store/thunk/messageThunk.ts @@ -19,6 +19,7 @@ import { resetAssistantMessage } from '@renderer/utils/messageUtils/create' import { getTopicQueue, waitForTopicQueue } from '@renderer/utils/queue' +import { defaultAppHeaders } from '@shared/utils' import { t } from 'i18next' import { isEmpty, throttle } from 'lodash' import { LRUCache } from 'lru-cache' @@ -369,7 +370,8 @@ const fetchAndProcessAssistantResponseImpl = async ( topicId, options: { signal: abortController.signal, - timeout: 30000 + timeout: 30000, + headers: defaultAppHeaders() } }, streamProcessorCallbacks @@ -1073,7 +1075,7 @@ export const cloneMessagesToNewTopicThunk = const oldBlock = state.messageBlocks.entities[oldBlockId] if (oldBlock) { const newBlockId = uuid() - const newBlock: MessageBlock = { + const newBlock = { ...oldBlock, id: newBlockId, messageId: newMsgId // Link block to the NEW message ID diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index a5fba1040b..9518f1b38a 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -1,3 +1,4 @@ +import type { LanguageModelV2Source } from '@ai-sdk/provider' import type { WebSearchResultBlock } from '@anthropic-ai/sdk/resources' import type { GenerateImagesConfig, GroundingMetadata, PersonGeneration } from '@google/genai' import type OpenAI from 'openai' @@ -726,12 +727,15 @@ export type WebSearchProviderResponse = { results: WebSearchProviderResult[] } +export type AISDKWebSearchResult = Omit, 'sourceType'> + export type WebSearchResults = | WebSearchProviderResponse | GroundingMetadata | OpenAI.Chat.Completions.ChatCompletionMessage.Annotation.URLCitation[] | OpenAI.Responses.ResponseOutputText.URLCitation[] | WebSearchResultBlock[] + | AISDKWebSearchResult[] | any[] export enum WebSearchSource { diff --git a/src/renderer/src/utils/__tests__/linkConverter.test.ts b/src/renderer/src/utils/__tests__/linkConverter.test.ts index 16d2e8aefb..1b813f1a54 100644 --- a/src/renderer/src/utils/__tests__/linkConverter.test.ts +++ b/src/renderer/src/utils/__tests__/linkConverter.test.ts @@ -3,91 +3,12 @@ import { describe, expect, it } from 'vitest' import { cleanLinkCommas, completeLinks, - completionPerplexityLinks, convertLinks, - convertLinksToHunyuan, - convertLinksToOpenRouter, - convertLinksToZhipu, extractUrlsFromMarkdown, flushLinkConverterBuffer } from '../linkConverter' describe('linkConverter', () => { - describe('convertLinksToZhipu', () => { - it('should correctly convert complete [ref_N] format', () => { - const input = '这里有一个参考文献 [ref_1] 和另一个 [ref_2]' - const result = convertLinksToZhipu(input, true) - expect(result).toBe('这里有一个参考文献 [1]() 和另一个 [2]()') - }) - - it('should handle chunked input and preserve incomplete link patterns', () => { - // 第一个块包含未完成的模式 - const chunk1 = '这是第一部分 [ref' - const result1 = convertLinksToZhipu(chunk1, true) - expect(result1).toBe('这是第一部分 ') - - // 第二个块完成该模式 - const chunk2 = '_1] 这是剩下的部分' - const result2 = convertLinksToZhipu(chunk2, false) - expect(result2).toBe('[1]() 这是剩下的部分') - }) - - it('should clear buffer when resetting counter', () => { - // 先进行一次转换不重置 - const input1 = '第一次输入 [ref_1]' - convertLinksToZhipu(input1, false) - - // 然后重置并进行新的转换 - const input2 = '新的输入 [ref_2]' - const result = convertLinksToZhipu(input2, true) - expect(result).toBe('新的输入 [2]()') - }) - }) - - describe('convertLinksToHunyuan', () => { - it('should correctly convert [N](@ref) format to links with URLs', () => { - const webSearch = [{ url: 'https://example.com/1' }, { url: 'https://example.com/2' }] - const input = '这里有单个引用 [1](@ref) 和多个引用 [2](@ref)' - const result = convertLinksToHunyuan(input, webSearch, true) - expect(result).toBe( - '这里有单个引用 [1](https://example.com/1) 和多个引用 [2](https://example.com/2)' - ) - }) - - it('should correctly handle comma-separated multiple references', () => { - const webSearch = [ - { url: 'https://example.com/1' }, - { url: 'https://example.com/2' }, - { url: 'https://example.com/3' } - ] - const input = '这里有多个引用 [1, 2, 3](@ref)' - const result = convertLinksToHunyuan(input, webSearch, true) - expect(result).toBe( - '这里有多个引用 [1](https://example.com/1)[2](https://example.com/2)[3](https://example.com/3)' - ) - }) - - it('should handle non-existent reference indices', () => { - const webSearch = [{ url: 'https://example.com/1' }] - const input = '这里有一个超出范围的引用 [2](@ref)' - const result = convertLinksToHunyuan(input, webSearch, true) - expect(result).toBe('这里有一个超出范围的引用 [2](@ref)') - }) - - it('should handle incomplete reference formats in chunked input', () => { - const webSearch = [{ url: 'https://example.com/1' }] - // 第一个块包含未完成的模式 - const chunk1 = '这是第一部分 [' - const result1 = convertLinksToHunyuan(chunk1, webSearch, true) - expect(result1).toBe('这是第一部分 ') - - // 第二个块完成该模式 - const chunk2 = '1](@ref) 这是剩下的部分' - const result2 = convertLinksToHunyuan(chunk2, webSearch, false) - expect(result2).toBe('[1](https://example.com/1) 这是剩下的部分') - }) - }) - describe('convertLinks', () => { it('should convert number links to numbered links', () => { const input = '参考 [1](https://example.com/1) 和 [2](https://example.com/2)' @@ -226,8 +147,10 @@ describe('linkConverter', () => { it('should handle real links split across small chunks with proper buffering', () => { // 模拟真实链接被分割成小chunks的情况 - 更现实的分割方式 const chunks = [ - 'Please visit [example.com](', // 不完整链接 - 'https://example.com) for details' // 完成链接 + 'Please visit [example.', + 'com](', // 不完整链接' + 'https://exa', + 'mple.com) for details' // 完成链接' ] let accumulatedText = '' @@ -235,14 +158,24 @@ describe('linkConverter', () => { // 第一个chunk:包含不完整链接 [text]( const result1 = convertLinks(chunks[0], true) expect(result1.text).toBe('Please visit ') // 只返回安全部分 - expect(result1.hasBufferedContent).toBe(true) // [example.com]( 被缓冲 + expect(result1.hasBufferedContent).toBe(true) // accumulatedText += result1.text - // 第二个chunk:完成链接 + // 第二个chunk const result2 = convertLinks(chunks[1], false) - expect(result2.text).toBe('[1](https://example.com) for details') // 完整链接 + 剩余文本 - expect(result2.hasBufferedContent).toBe(false) - accumulatedText += result2.text + expect(result2.text).toBe('') + expect(result2.hasBufferedContent).toBe(true) + // 第三个chunk + const result3 = convertLinks(chunks[2], false) + expect(result3.text).toBe('') + expect(result3.hasBufferedContent).toBe(true) + accumulatedText += result3.text + + // 第四个chunk + const result4 = convertLinks(chunks[3], false) + expect(result4.text).toBe('[1](https://example.com) for details') + expect(result4.hasBufferedContent).toBe(false) + accumulatedText += result4.text // 验证最终结果 expect(accumulatedText).toBe('Please visit [1](https://example.com) for details') @@ -293,32 +226,6 @@ describe('linkConverter', () => { }) }) - describe('convertLinksToOpenRouter', () => { - it('should only convert links with domain-like text', () => { - const input = '网站 [example.com](https://example.com) 和 [点击这里](https://other.com)' - const result = convertLinksToOpenRouter(input, true) - expect(result).toBe('网站 [1](https://example.com) 和 [点击这里](https://other.com)') - }) - - it('should use the same counter for duplicate URLs', () => { - const input = '两个相同的链接 [example.com](https://example.com) 和 [example.org](https://example.com)' - const result = convertLinksToOpenRouter(input, true) - expect(result).toBe('两个相同的链接 [1](https://example.com) 和 [1](https://example.com)') - }) - - it('should handle incomplete links in chunked input', () => { - // 第一个块包含未完成的链接 - const chunk1 = '这是域名链接 [' - const result1 = convertLinksToOpenRouter(chunk1, true) - expect(result1).toBe('这是域名链接 ') - - // 第二个块完成链接 - const chunk2 = 'example.com](https://example.com)' - const result2 = convertLinksToOpenRouter(chunk2, false) - expect(result2).toBe('[1](https://example.com)') - }) - }) - describe('completeLinks', () => { it('should complete empty links with webSearch data', () => { const webSearch = [{ link: 'https://example.com/1' }, { link: 'https://example.com/2' }] @@ -383,13 +290,4 @@ describe('linkConverter', () => { expect(result).toBe('[链接1](https://example.com)[链接2](https://other.com)') }) }) - - describe('completionPerplexityLinks', () => { - it('should complete links with webSearch data', () => { - const webSearch = [{ url: 'https://example.com/1' }, { url: 'https://example.com/2' }] - const input = '参考 [1] 和 [2]' - const result = completionPerplexityLinks(input, webSearch) - expect(result).toBe('参考 [1](https://example.com/1) 和 [2](https://example.com/2)') - }) - }) }) diff --git a/src/renderer/src/utils/linkConverter.ts b/src/renderer/src/utils/linkConverter.ts index 220333f027..ccb6ec5004 100644 --- a/src/renderer/src/utils/linkConverter.ts +++ b/src/renderer/src/utils/linkConverter.ts @@ -1,5 +1,3 @@ -import { WebSearchResponse, WebSearchSource } from '@renderer/types' - // Counter for numbering links let linkCounter = 1 // Buffer to hold incomplete link fragments across chunks @@ -17,109 +15,6 @@ function isHost(text: string): boolean { return /^(https?:\/\/)?[\w.-]+\.[a-z]{2,}(\/.*)?$/i.test(text) || /^[\w.-]+\.[a-z]{2,}(\/.*)?$/i.test(text) } -/** - * Converts Markdown links in the text to numbered links based on the rules:s - * [ref_N] -> [N] - * @param {string} text The current chunk of text to process - * @param {boolean} resetCounter Whether to reset the counter and buffer - * @returns {string} Processed text with complete links converted - */ -export function convertLinksToZhipu(text: string, resetCounter: boolean = false): string { - if (resetCounter) { - linkCounter = 1 - buffer = '' - } - - // Append the new text to the buffer - buffer += text - let safePoint = buffer.length - - // Check from the end for potentially incomplete [ref_N] patterns - for (let i = buffer.length - 1; i >= 0; i--) { - if (buffer[i] === '[') { - const substring = buffer.substring(i) - // Check if it's a complete [ref_N] pattern - const match = /^\[ref_\d+\]/.exec(substring) - - if (!match) { - // Potentially incomplete [ref_N] pattern - safePoint = i - break - } - } - } - - // Process the safe part of the buffer - const safeBuffer = buffer.substring(0, safePoint) - buffer = buffer.substring(safePoint) - - // Replace all complete [ref_N] patterns - return safeBuffer.replace(/\[ref_(\d+)\]/g, (_, num) => { - return `[${num}]()` - }) -} - -/** - * Converts Markdown links in the text to numbered links based on the rules: - * [N](@ref) -> [N]() - * [N,M,...](@ref) -> [N]() [M]() ... - * @param {string} text The current chunk of text to process - * @param {any[]} webSearch webSearch results - * @param {boolean} resetCounter Whether to reset the counter and buffer - * @returns {string} Processed text with complete links converted - */ -export function convertLinksToHunyuan(text: string, webSearch: any[], resetCounter: boolean = false): string { - if (resetCounter) { - linkCounter = 1 - buffer = '' - } - - buffer += text - let safePoint = buffer.length - - // Check from the end for potentially incomplete patterns - for (let i = buffer.length - 1; i >= 0; i--) { - if (buffer[i] === '[') { - const substring = buffer.substring(i) - // Check if it's a complete pattern - handles both [N](@ref) and [N,M,...](@ref) - const match = /^\[[\d,\s]+\]\(@ref\)/.exec(substring) - - if (!match) { - // Potentially incomplete pattern - safePoint = i - break - } - } - } - - // Process the safe part of the buffer - const safeBuffer = buffer.substring(0, safePoint) - buffer = buffer.substring(safePoint) - - // Replace all complete patterns - return safeBuffer.replace(/\[([\d,\s]+)\]\(@ref\)/g, (_, numbers) => { - // Split the numbers string into individual numbers - const numArray = numbers - .split(',') - .map((num) => parseInt(num.trim())) - .filter((num) => !isNaN(num)) - - // Generate separate superscript links for each number - const links = numArray.map((num) => { - const index = num - 1 - // Check if the index is valid in webSearch array - if (index >= 0 && index < webSearch.length && webSearch[index]?.url) { - return `[${num}](${webSearch[index].url})` - } - // If no matching URL found, keep the original reference format for this number - return `[${num}](@ref)` - }) - - // Join the separate links with spaces - return links.join('') - }) -} - /** * Converts Markdown links in the text to numbered links based on the rules: * 1. ([host](url)) -> [cnt](url) @@ -171,13 +66,21 @@ export function convertLinks( break } - // 检查是否是完整的链接但需要验证 + // 检查是否是完整的链接 const completeLink = /^\[([^\]]+)\]\(([^)]+)\)/.test(substring) if (completeLink) { // 如果是完整链接,继续处理,不设置safePoint continue } + // 检查是否是不完整的 [ 开始但还没有闭合的 ] + // 例如 [example. 这种情况 + const incompleteBracket = /^\[[^\]]*$/.test(substring) + if (incompleteBracket) { + safePoint = i + break + } + // 如果不是潜在的链接格式,继续检查 } } @@ -263,65 +166,6 @@ export function convertLinks( } } -/** - * Converts Markdown links in the text to numbered links based on the rules: - * 1. [host](url) -> [cnt](url) - * - * @param {string} text The current chunk of text to process - * @param {boolean} resetCounter Whether to reset the counter and buffer - * @returns {string} Processed text with complete links converted - */ -export function convertLinksToOpenRouter(text: string, resetCounter = false): string { - if (resetCounter) { - linkCounter = 1 - buffer = '' - urlToCounterMap = new Map() - } - - // Append the new text to the buffer - buffer += text - - // Find a safe point to process - let safePoint = buffer.length - - // Check for potentially incomplete link patterns from the end - for (let i = buffer.length - 1; i >= 0; i--) { - if (buffer[i] === '[') { - const substring = buffer.substring(i) - const match = /^\[([^\]]+)\]\(([^)]+)\)/.exec(substring) - - if (!match) { - safePoint = i - break - } - } - } - - // Extract the part of the buffer that we can safely process - const safeBuffer = buffer.substring(0, safePoint) - buffer = buffer.substring(safePoint) - - // Process the safe buffer to handle complete links - const result = safeBuffer.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => { - // Only convert link if the text looks like a host/URL - if (isHost(text)) { - // Check if this URL has been seen before - let counter: number - if (urlToCounterMap.has(url)) { - counter = urlToCounterMap.get(url)! - } else { - counter = linkCounter++ - urlToCounterMap.set(url, counter) - } - return `[${counter}](${url})` - } - // Keep original link format if the text doesn't look like a host - return match - }) - - return result -} - /** * 根据webSearch结果补全链接,将[num]()转换为[num](webSearch[num-1].url) * @param {string} text 原始文本 @@ -341,25 +185,6 @@ export function completeLinks(text: string, webSearch: any[]): string { }) } -/** - * 根据webSearch结果补全链接,将[num]转换为[num](webSearch[num-1].url) - * @param {string} text 原始文本 - * @param {any[]} webSearch webSearch结果 - * @returns {string} 补全后的文本 - */ -export function completionPerplexityLinks(text: string, webSearch: any[]): string { - return text.replace(/\[(\d+)\]/g, (match, numStr) => { - const num = parseInt(numStr) - const index = num - 1 - // 检查 webSearch 数组中是否存在对应的 URL - if (index >= 0 && index < webSearch.length && webSearch[index].url) { - return `[${num}](${webSearch[index].url})` - } - // 如果没有找到对应的 URL,保持原样 - return match - }) -} - /** * 从Markdown文本中提取所有URL * 支持以下格式: @@ -412,118 +237,6 @@ export function cleanLinkCommas(text: string): string { return text.replace(/\]\(([^)]+)\)\s*,\s*\[/g, ']($1)[') } -/** - * 从文本中识别各种格式的Web搜索引用占位符 - * 支持的格式包括:[1], [ref_1], [1](@ref), [1,2,3](@ref) 等 - * @param {string} text 要分析的文本 - * @returns {Array} 识别到的引用信息数组 - */ -export function extractWebSearchReferences(text: string): Array<{ - match: string - placeholder: string - numbers: number[] - startIndex: number - endIndex: number -}> { - const references: Array<{ - match: string - placeholder: string - numbers: number[] - startIndex: number - endIndex: number - }> = [] - - // 匹配各种引用格式的正则表达式 - const patterns = [ - // [1], [2], [3] - 简单数字引用 - { regex: /\[(\d+)\]/g, type: 'simple' }, - // [ref_1], [ref_2] - Zhipu格式 - { regex: /\[ref_(\d+)\]/g, type: 'zhipu' }, - // [1](@ref), [2](@ref) - Hunyuan单个引用格式 - { regex: /\[(\d+)\]\(@ref\)/g, type: 'hunyuan_single' }, - // [1,2,3](@ref) - Hunyuan多个引用格式 - { regex: /\[([\d,\s]+)\]\(@ref\)/g, type: 'hunyuan_multiple' } - ] - - patterns.forEach(({ regex, type }) => { - let match - while ((match = regex.exec(text)) !== null) { - let numbers: number[] = [] - - if (type === 'hunyuan_multiple') { - // 解析逗号分隔的数字 - numbers = match[1] - .split(',') - .map((num) => parseInt(num.trim())) - .filter((num) => !isNaN(num)) - } else { - // 单个数字 - numbers = [parseInt(match[1])] - } - - references.push({ - match: match[0], - placeholder: match[0], - numbers: numbers, - startIndex: match.index!, - endIndex: match.index! + match[0].length - }) - } - }) - - // 按位置排序 - return references.sort((a, b) => a.startIndex - b.startIndex) -} - -/** - * 智能链接转换器 - 根据文本中的引用模式和Web搜索结果自动选择合适的转换策略 - * @param {string} text 当前文本块 - * @param {any[]} webSearchResults Web搜索结果数组 - * @param {string} providerType Provider类型 ('openai', 'zhipu', 'hunyuan', 'openrouter', etc.) - * @param {boolean} resetCounter 是否重置计数器 - * @returns {{text: string, hasBufferedContent: boolean}} 转换后的文本和是否有内容被缓冲 - */ -export function smartLinkConverter( - text: string, - providerType: string = 'openai', - resetCounter: boolean = false, - webSearchResults?: WebSearchResponse -): { text: string; hasBufferedContent: boolean } { - if (webSearchResults) { - const webSearch = webSearchResults.results - switch (webSearchResults.source) { - case WebSearchSource.PERPLEXITY: { - text = completionPerplexityLinks(text, webSearch as any[]) - break - } - } - } - // 检测文本中的引用模式 - const references = extractWebSearchReferences(text) - - if (references.length === 0) { - // 如果没有特定的引用模式,使用通用转换 - return convertLinks(text, resetCounter) - } - - // 根据检测到的引用模式选择合适的转换器 - const hasZhipuPattern = references.some((ref) => ref.placeholder.includes('ref_')) - - if (hasZhipuPattern) { - return { - text: convertLinksToZhipu(text, resetCounter), - hasBufferedContent: false - } - } else if (providerType === 'openrouter') { - return { - text: convertLinksToOpenRouter(text, resetCounter), - hasBufferedContent: false - } - } else { - return convertLinks(text, resetCounter) - } -} - /** * 强制返回buffer中的所有内容,用于流结束时清空缓冲区 * @returns {string} buffer中剩余的所有内容 diff --git a/yarn.lock b/yarn.lock index eb0cde8a17..342c305c3d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -239,6 +239,18 @@ __metadata: languageName: node linkType: hard +"@ai-sdk/perplexity@npm:^2.0.8": + version: 2.0.8 + resolution: "@ai-sdk/perplexity@npm:2.0.8" + dependencies: + "@ai-sdk/provider": "npm:2.0.0" + "@ai-sdk/provider-utils": "npm:3.0.8" + peerDependencies: + zod: ^3.25.76 || ^4 + checksum: 10c0/acfd6c09c4c0ef5af7eeec6e8bc20b90b24d1d3fc2bc8ee9de4e40770fc0c17ca2c8db8f0248ff07264b71e5aa65f64d37a165db2f43fee84c1b3513cb97983c + languageName: node + linkType: hard + "@ai-sdk/provider-utils@npm:3.0.3": version: 3.0.3 resolution: "@ai-sdk/provider-utils@npm:3.0.3" @@ -13023,6 +13035,7 @@ __metadata: "@ai-sdk/amazon-bedrock": "npm:^3.0.0" "@ai-sdk/google-vertex": "npm:^3.0.25" "@ai-sdk/mistral": "npm:^2.0.0" + "@ai-sdk/perplexity": "npm:^2.0.8" "@ant-design/v5-patch-for-react-19": "npm:^1.0.3" "@anthropic-ai/sdk": "npm:^0.41.0" "@anthropic-ai/vertex-sdk": "patch:@anthropic-ai/vertex-sdk@npm%3A0.11.4#~/.yarn/patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch" From 993d497aad685a1189fc99b2ba20b2d8b2bcb909 Mon Sep 17 00:00:00 2001 From: Licardo <1014660822@qq.com> Date: Sat, 13 Sep 2025 20:36:13 +0800 Subject: [PATCH 04/99] feature: add option to change font (#10133) * feature: add option to change font 1. set app global font 2. set code block font Signed-off-by: Albert Abdilim * formatted code with Prettier * fix ci errors 1.add migration in `migrate.ts` 2.add to-be-translated strings by running `yarn sync:i18n` * chore: update yarn.lock to include font-list package version 2.0.0 * fix migration issue --------- Signed-off-by: Albert Abdilim Co-authored-by: suyao --- package.json | 1 + packages/shared/IpcChannel.ts | 1 + src/main/ipc.ts | 12 ++ src/preload/index.ts | 1 + src/renderer/src/assets/styles/font.css | 17 +-- src/renderer/src/hooks/useUserTheme.ts | 4 + src/renderer/src/i18n/locales/en-us.json | 7 ++ src/renderer/src/i18n/locales/zh-cn.json | 7 ++ src/renderer/src/i18n/locales/zh-tw.json | 7 ++ src/renderer/src/i18n/translate/el-gr.json | 7 ++ src/renderer/src/i18n/translate/es-es.json | 7 ++ src/renderer/src/i18n/translate/fr-fr.json | 7 ++ src/renderer/src/i18n/translate/ja-jp.json | 7 ++ src/renderer/src/i18n/translate/pt-pt.json | 7 ++ src/renderer/src/i18n/translate/ru-ru.json | 7 ++ .../DisplaySettings/DisplaySettings.tsx | 105 +++++++++++++++++- src/renderer/src/store/migrate.ts | 12 ++ src/renderer/src/store/settings.ts | 6 +- yarn.lock | 8 ++ 19 files changed, 220 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index b620dd51d4..23c460a6f7 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "@strongtz/win32-arm64-msvc": "^0.4.7", "express": "^5.1.0", "faiss-node": "^0.5.1", + "font-list": "^2.0.0", "graceful-fs": "^4.2.11", "jsdom": "26.1.0", "node-stream-zip": "^1.15.0", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index fa02991682..a2ef66284c 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -38,6 +38,7 @@ export enum IpcChannel { App_GetDiskInfo = 'app:get-disk-info', App_SetFullScreen = 'app:set-full-screen', App_IsFullScreen = 'app:is-full-screen', + App_GetSystemFonts = 'app:get-system-fonts', App_MacIsProcessTrusted = 'app:mac-is-process-trusted', App_MacRequestProcessTrust = 'app:mac-request-process-trust', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 6b36a96a35..1926145e86 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -14,6 +14,7 @@ import { IpcChannel } from '@shared/IpcChannel' import { FileMetadata, Provider, Shortcut, ThemeMode } from '@types' import checkDiskSpace from 'check-disk-space' import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPreferences, webContents } from 'electron' +import fontList from 'font-list' import { Notification } from 'src/renderer/src/types/notification' import { apiServerService } from './services/ApiServerService' @@ -219,6 +220,17 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { return mainWindow.isFullScreen() }) + // Get System Fonts + ipcMain.handle(IpcChannel.App_GetSystemFonts, async () => { + try { + const fonts = await fontList.getFonts() + return fonts.map((font: string) => font.replace(/^"(.*)"$/, '$1')).filter((font: string) => font.length > 0) + } catch (error) { + logger.error('Failed to get system fonts:', error as Error) + return [] + } + }) + ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any, isNotify: boolean = false) => { configManager.set(key, value, isNotify) }) diff --git a/src/preload/index.ts b/src/preload/index.ts index d8c55dd256..12cab36118 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -84,6 +84,7 @@ const api = { ipcRenderer.invoke(IpcChannel.App_LogToMain, source, level, message, data), setFullScreen: (value: boolean): Promise => ipcRenderer.invoke(IpcChannel.App_SetFullScreen, value), isFullScreen: (): Promise => ipcRenderer.invoke(IpcChannel.App_IsFullScreen), + getSystemFonts: (): Promise => ipcRenderer.invoke(IpcChannel.App_GetSystemFonts), mac: { isProcessTrusted: (): Promise => ipcRenderer.invoke(IpcChannel.App_MacIsProcessTrusted), requestProcessTrust: (): Promise => ipcRenderer.invoke(IpcChannel.App_MacRequestProcessTrust) diff --git a/src/renderer/src/assets/styles/font.css b/src/renderer/src/assets/styles/font.css index be71cef964..8e122fe887 100644 --- a/src/renderer/src/assets/styles/font.css +++ b/src/renderer/src/assets/styles/font.css @@ -1,23 +1,24 @@ :root { --font-family: - Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen, Cantarell, 'Open Sans', - 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', - 'Noto Color Emoji'; + var(--user-font-family), Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen, + Cantarell, 'Open Sans', 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', + 'Segoe UI Symbol', 'Noto Color Emoji'; --font-family-serif: serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Ubuntu, Roboto, Oxygen, Cantarell, 'Open Sans', 'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; - --code-font-family: 'Cascadia Code', 'Fira Code', 'Consolas', Menlo, Courier, monospace; + --code-font-family: var(--user-code-font-family), 'Cascadia Code', 'Fira Code', 'Consolas', Menlo, Courier, monospace; } /* Windows系统专用字体配置 */ body[os='windows'] { --font-family: - 'Twemoji Country Flags', Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen, - Cantarell, 'Open Sans', 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', - 'Segoe UI Symbol', 'Noto Color Emoji'; + var(--user-font-family), 'Twemoji Country Flags', Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, + Roboto, Oxygen, Cantarell, 'Open Sans', 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', + 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; --code-font-family: - 'Cascadia Code', 'Fira Code', 'Consolas', 'Sarasa Mono SC', 'Microsoft YaHei UI', Courier, monospace; + var(--user-code-font-family), 'Cascadia Code', 'Fira Code', 'Consolas', 'Sarasa Mono SC', 'Microsoft YaHei UI', + Courier, monospace; } diff --git a/src/renderer/src/hooks/useUserTheme.ts b/src/renderer/src/hooks/useUserTheme.ts index 0b1bc5fb24..03191f5984 100644 --- a/src/renderer/src/hooks/useUserTheme.ts +++ b/src/renderer/src/hooks/useUserTheme.ts @@ -15,6 +15,10 @@ export default function useUserTheme() { document.body.style.setProperty('--primary', colorPrimary.toString()) document.body.style.setProperty('--color-primary-soft', colorPrimary.alpha(0.6).toString()) document.body.style.setProperty('--color-primary-mute', colorPrimary.alpha(0.3).toString()) + + // Set font family CSS variables + document.documentElement.style.setProperty('--user-font-family', `'${theme.userFontFamily}'`) + document.documentElement.style.setProperty('--user-code-font-family', `'${theme.userCodeFontFamily}'`) } return { diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 066478b2f1..24f8be0eb4 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -3116,6 +3116,13 @@ "placeholder": "/* Put custom CSS here */" } }, + "font": { + "code": "Code Font", + "default": "Default", + "global": "Global Font", + "select": "Select Font", + "title": "Font Settings" + }, "navbar": { "position": { "label": "Navbar Position", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 002284335b..ba0f14bbd4 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -3117,6 +3117,13 @@ "placeholder": "/* 这里写自定义 CSS */" } }, + "font": { + "code": "代码字体", + "default": "默认", + "global": "全局字体", + "select": "选择字体", + "title": "字体设置" + }, "navbar": { "position": { "label": "导航栏位置", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 753981be90..5a225cbd94 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -3116,6 +3116,13 @@ "placeholder": "/* 這裡寫自訂 CSS */" } }, + "font": { + "code": "[to be translated]:代码字体", + "default": "[to be translated]:默认", + "global": "[to be translated]:全局字体", + "select": "[to be translated]:选择字体", + "title": "[to be translated]:字体设置" + }, "navbar": { "position": { "label": "導航欄位置", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 8f1c235ece..59c775f230 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -3116,6 +3116,13 @@ "placeholder": "/* Γράψτε εδώ την προσαρμοστική CSS */" } }, + "font": { + "code": "[to be translated]:代码字体", + "default": "[to be translated]:默认", + "global": "[to be translated]:全局字体", + "select": "[to be translated]:选择字体", + "title": "[to be translated]:字体设置" + }, "navbar": { "position": { "label": "Θέση Γραμμής Πλοήγησης", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 905e720b8e..47f0423ba4 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -3116,6 +3116,13 @@ "placeholder": "/* Escribe tu CSS personalizado aquí */" } }, + "font": { + "code": "[to be translated]:代码字体", + "default": "[to be translated]:默认", + "global": "[to be translated]:全局字体", + "select": "[to be translated]:选择字体", + "title": "[to be translated]:字体设置" + }, "navbar": { "position": { "label": "Posición de la barra de navegación", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 5c2a0d2a5d..8d52cd8948 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -3116,6 +3116,13 @@ "placeholder": "/* Écrire votre CSS personnalisé ici */" } }, + "font": { + "code": "[to be translated]:代码字体", + "default": "[to be translated]:默认", + "global": "[to be translated]:全局字体", + "select": "[to be translated]:选择字体", + "title": "[to be translated]:字体设置" + }, "navbar": { "position": { "label": "Position de la barre de navigation", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 391cbec4e3..5ab90ecb60 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -3116,6 +3116,13 @@ "placeholder": "/* ここにカスタムCSSを入力 */" } }, + "font": { + "code": "[to be translated]:代码字体", + "default": "[to be translated]:默认", + "global": "[to be translated]:全局字体", + "select": "[to be translated]:选择字体", + "title": "[to be translated]:字体设置" + }, "navbar": { "position": { "label": "ナビゲーションバー位置", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 4d1edba05d..926d88ae90 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -3116,6 +3116,13 @@ "placeholder": "/* Escreva seu CSS personalizado aqui */" } }, + "font": { + "code": "[to be translated]:代码字体", + "default": "[to be translated]:默认", + "global": "[to be translated]:全局字体", + "select": "[to be translated]:选择字体", + "title": "[to be translated]:字体设置" + }, "navbar": { "position": { "label": "Posição da Barra de Navegação", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 493a565508..cb3baa9a65 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -3116,6 +3116,13 @@ "placeholder": "/* Здесь введите пользовательский CSS */" } }, + "font": { + "code": "[to be translated]:代码字体", + "default": "[to be translated]:默认", + "global": "[to be translated]:全局字体", + "select": "[to be translated]:选择字体", + "title": "[to be translated]:字体设置" + }, "navbar": { "position": { "label": "Положение навигации", diff --git a/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx b/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx index 2bedfbbd8b..b1d670792c 100644 --- a/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx +++ b/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx @@ -18,7 +18,7 @@ import { setSidebarIcons } from '@renderer/store/settings' import { ThemeMode } from '@renderer/types' -import { Button, ColorPicker, Segmented, Switch } from 'antd' +import { Button, ColorPicker, Segmented, Select, Switch } from 'antd' import { Minus, Monitor, Moon, Plus, Sun } from 'lucide-react' import { FC, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -78,6 +78,7 @@ const DisplaySettings: FC = () => { const [visibleIcons, setVisibleIcons] = useState(sidebarIcons?.visible || DEFAULT_SIDEBAR_ICONS) const [disabledIcons, setDisabledIcons] = useState(sidebarIcons?.disabled || []) + const [fontList, setFontList] = useState([]) const handleWindowStyleChange = useCallback( (checked: boolean) => { @@ -136,6 +137,11 @@ const DisplaySettings: FC = () => { ) useEffect(() => { + // 初始化获取所有系统字体 + window.api.getSystemFonts().then((fonts: string[]) => { + setFontList(fonts) + }) + // 初始化获取当前缩放值 window.api.handleZoomFactor(0).then((factor) => { setCurrentZoom(factor) @@ -160,6 +166,26 @@ const DisplaySettings: FC = () => { setCurrentZoom(zoomFactor) } + const handleUserFontChange = useCallback( + (value: string) => { + setUserTheme({ + ...userTheme, + userFontFamily: value + }) + }, + [setUserTheme, userTheme] + ) + + const handleUserCodeFontChange = useCallback( + (value: string) => { + setUserTheme({ + ...userTheme, + userCodeFontFamily: value + }) + }, + [setUserTheme, userTheme] + ) + const assistantIconTypeOptions = useMemo( () => [ { value: 'model', label: t('settings.assistant.icon.type.model') }, @@ -194,6 +220,7 @@ const DisplaySettings: FC = () => { ))} handleColorPrimaryChange(color.toHexString())} @@ -255,6 +282,75 @@ const DisplaySettings: FC = () => { + + + {t('settings.display.font.title')} + + + + {t('settings.display.font.global')} + + + {t('settings.display.font.default')} + + ), + value: '' + }, + ...fontList.map((font) => ({ label: {font}, value: font })) + ]} + value={userTheme.userCodeFontFamily || ''} + onChange={(font) => handleUserCodeFontChange(font)} + showSearch + getPopupContainer={(triggerNode) => triggerNode.parentElement || document.body} + /> + - ))} - - ), Slider: ({ value, onChange, min, max, step, marks, style }: any) => { // Determine test ID based on slider characteristics const isWeightSlider = min === 0 && max === 1 && step === 0.1 @@ -193,14 +175,10 @@ function createKnowledgeBase(overrides: Partial = {}): KnowledgeB id: 'test-base-id', name: 'Test Knowledge Base', model: defaultModel, - retriever: { - mode: 'hybrid' - }, items: [], created_at: Date.now(), updated_at: Date.now(), version: 1, - framework: 'langchain', ...overrides } } @@ -319,42 +297,6 @@ describe('GeneralSettingsPanel', () => { expect(mockSetNewBase).toHaveBeenCalledWith(expect.any(Function)) }) - it('should handle hybrid weight change', async () => { - renderComponent() - - const weightSlider = screen.getByTestId('weight-slider') - fireEvent.change(weightSlider, { target: { value: '0.7' } }) - - expect(mockSetNewBase).toHaveBeenCalledWith({ - ...mockBase, - retriever: { - ...mockBase.retriever, - mode: 'hybrid', - weight: 0.7 - } - }) - }) - - it('should handle retriever selection change', async () => { - renderComponent() - - // Test clicking on hybrid retriever option - const hybridOption = screen.getByTestId('segmented-option-hybrid') - await user.click(hybridOption) - - expect(mockSetNewBase).toHaveBeenCalledWith({ - ...mockBase, - retriever: { mode: 'hybrid' } - }) - }) - - it('should not render retriever segmented when framework is embedjs', () => { - const baseWithEmbedjs = createKnowledgeBase({ framework: 'embedjs' }) - renderComponent({ newBase: baseWithEmbedjs }) - - expect(screen.queryByTestId('retriever-segmented')).not.toBeInTheDocument() - }) - it('should disable dimension input when no model is selected', () => { const baseWithoutModel = createKnowledgeBase({ model: undefined as any }) renderComponent({ newBase: baseWithoutModel }) diff --git a/src/renderer/src/pages/knowledge/__tests__/__snapshots__/GeneralSettingsPanel.test.tsx.snap b/src/renderer/src/pages/knowledge/__tests__/__snapshots__/GeneralSettingsPanel.test.tsx.snap index dddbeebbbe..ad9c3da240 100644 --- a/src/renderer/src/pages/knowledge/__tests__/__snapshots__/GeneralSettingsPanel.test.tsx.snap +++ b/src/renderer/src/pages/knowledge/__tests__/__snapshots__/GeneralSettingsPanel.test.tsx.snap @@ -170,69 +170,6 @@ exports[`GeneralSettingsPanel > basic rendering > should match snapshot 1`] = ` -
-
- knowledge.retriever - - ℹ️ - -
-
- - - -
-
-
-
- knowledge.retriever_hybrid_weight.title -
- -
diff --git a/src/renderer/src/pages/knowledge/__tests__/__snapshots__/KnowledgeBaseFormModal.test.tsx.snap b/src/renderer/src/pages/knowledge/__tests__/__snapshots__/KnowledgeBaseFormModal.test.tsx.snap index e7aeaffd38..3e6ec8336a 100644 --- a/src/renderer/src/pages/knowledge/__tests__/__snapshots__/KnowledgeBaseFormModal.test.tsx.snap +++ b/src/renderer/src/pages/knowledge/__tests__/__snapshots__/KnowledgeBaseFormModal.test.tsx.snap @@ -65,7 +65,7 @@ exports[`KnowledgeBaseFormModal > basic rendering > should match snapshot 1`] = data-title="Knowledge Base Settings" styles="[object Object]" transitionname="animation-move-down" - width="min(900px, 75vw)" + width="min(900px, 65vw)" >
= ({ title, resolve }) => { const _newBase: KnowledgeBase = { ...newBase, created_at: Date.now(), - updated_at: Date.now(), - framework: 'langchain' + updated_at: Date.now() } await window.api.knowledgeBase.create(getKnowledgeBaseParams(_newBase)) diff --git a/src/renderer/src/pages/knowledge/components/EditKnowledgeBasePopup.tsx b/src/renderer/src/pages/knowledge/components/EditKnowledgeBasePopup.tsx index 0f1490238c..82dfc02a9c 100644 --- a/src/renderer/src/pages/knowledge/components/EditKnowledgeBasePopup.tsx +++ b/src/renderer/src/pages/knowledge/components/EditKnowledgeBasePopup.tsx @@ -4,7 +4,7 @@ import { TopView } from '@renderer/components/TopView' import { useKnowledge } from '@renderer/hooks/useKnowledge' import { useKnowledgeBaseForm } from '@renderer/hooks/useKnowledgeBaseForm' import { getModelUniqId } from '@renderer/services/ModelService' -import { KnowledgeBase, MigrationModeEnum } from '@renderer/types' +import { KnowledgeBase } from '@renderer/types' import { formatErrorMessage } from '@renderer/utils/error' import { Flex } from 'antd' import { useCallback, useMemo, useState } from 'react' @@ -48,7 +48,7 @@ const PopupContainer: React.FC = ({ base: _base, resolve }) const handleEmbeddingModelChangeMigration = useCallback(async () => { const migratedBase = { ...newBase, id: nanoid() } try { - await migrateBase(migratedBase, MigrationModeEnum.EmbeddingModelChange) + await migrateBase(migratedBase) setOpen(false) resolve(migratedBase) } catch (error) { diff --git a/src/renderer/src/pages/knowledge/components/KnowledgeSettings/GeneralSettingsPanel.tsx b/src/renderer/src/pages/knowledge/components/KnowledgeSettings/GeneralSettingsPanel.tsx index 5b01da00ed..c5125c4aff 100644 --- a/src/renderer/src/pages/knowledge/components/KnowledgeSettings/GeneralSettingsPanel.tsx +++ b/src/renderer/src/pages/knowledge/components/KnowledgeSettings/GeneralSettingsPanel.tsx @@ -6,7 +6,7 @@ import { isEmbeddingModel, isRerankModel } from '@renderer/config/models' import { useProviders } from '@renderer/hooks/useProvider' import { getModelUniqId } from '@renderer/services/ModelService' import { KnowledgeBase, PreprocessProvider } from '@renderer/types' -import { Input, Segmented, Select, SelectProps, Slider } from 'antd' +import { Input, Select, SelectProps, Slider } from 'antd' import { useTranslation } from 'react-i18next' import { SettingsItem, SettingsPanel } from './styles' @@ -106,55 +106,6 @@ const GeneralSettingsPanel: React.FC = ({ /> - {newBase.framework !== 'embedjs' && ( - <> - -
- {t('knowledge.retriever')} - -
- - setNewBase({ ...newBase, retriever: { mode: value as 'vector' | 'bm25' | 'hybrid' } }) - } - options={[ - { label: t('knowledge.retriever_hybrid'), value: 'hybrid' }, - { label: t('knowledge.retriever_vector'), value: 'vector' }, - { label: t('knowledge.retriever_bm25'), value: 'bm25' } - ]} - /> -
- {newBase.retriever?.mode === 'hybrid' && ( - -
{t('knowledge.retriever_hybrid_weight.title')}
- - setNewBase({ - ...newBase, - retriever: { - ...newBase.retriever, - mode: 'hybrid', - weight: value - } - }) - } - /> -
- )} - - )} -
{t('knowledge.document_count')} diff --git a/src/renderer/src/pages/knowledge/components/KnowledgeSettings/KnowledgeBaseFormModal.tsx b/src/renderer/src/pages/knowledge/components/KnowledgeSettings/KnowledgeBaseFormModal.tsx index 73c55fb06b..25ee7cbe8c 100644 --- a/src/renderer/src/pages/knowledge/components/KnowledgeSettings/KnowledgeBaseFormModal.tsx +++ b/src/renderer/src/pages/knowledge/components/KnowledgeSettings/KnowledgeBaseFormModal.tsx @@ -25,9 +25,9 @@ const KnowledgeBaseFormModal: React.FC = ({ panels, maskClosable={false} centered transitionName="animation-move-down" - width="min(900px, 75vw)" + width="min(900px, 65vw)" styles={{ - body: { padding: 0, height: 700 }, + body: { padding: 0, height: 550 }, header: { padding: '10px 15px', borderBottom: '0.5px solid var(--color-border)', diff --git a/src/renderer/src/pages/knowledge/components/MigrationInfoTag.tsx b/src/renderer/src/pages/knowledge/components/MigrationInfoTag.tsx deleted file mode 100644 index 7af67f2f30..0000000000 --- a/src/renderer/src/pages/knowledge/components/MigrationInfoTag.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { loggerService } from '@logger' -import { nanoid } from '@reduxjs/toolkit' -import { useKnowledge } from '@renderer/hooks/useKnowledge' -import { useKnowledgeBaseForm } from '@renderer/hooks/useKnowledgeBaseForm' -import { KnowledgeBase, MigrationModeEnum } from '@renderer/types' -import { formatErrorMessage } from '@renderer/utils/error' -import { Flex, Tag } from 'antd' -import { FC, useCallback } from 'react' -import { useTranslation } from 'react-i18next' - -const logger = loggerService.withContext('MigrationInfoTag') - -const MigrationInfoTag: FC<{ base: KnowledgeBase }> = ({ base: _base }) => { - const { t } = useTranslation() - const { migrateBase } = useKnowledge(_base.id) - const { newBase } = useKnowledgeBaseForm(_base) - - // 处理嵌入模型更改迁移 - const handleMigration = useCallback(async () => { - const migratedBase = { ...newBase, id: nanoid() } - try { - await migrateBase(migratedBase, MigrationModeEnum.MigrationToLangChain) - } catch (error) { - logger.error('KnowledgeBase migration failed:', error as Error) - window.toast.error(t('knowledge.migrate.error.failed') + ': ' + formatErrorMessage(error)) - } - }, [newBase, migrateBase, t]) - - const onClick = async () => { - window.modal.confirm({ - title: t('knowledge.migrate.confirm.title'), - content: ( - - {t('knowledge.migrate.migrate_to_langchain.content')} - - ), - okText: t('knowledge.migrate.confirm.ok'), - centered: true, - onOk: handleMigration - }) - } - - return ( - - {t('knowledge.migrate.migrate_to_langchain.info')} - - ) -} - -export default MigrationInfoTag diff --git a/src/renderer/src/pages/knowledge/components/QuotaTag.tsx b/src/renderer/src/pages/knowledge/components/QuotaTag.tsx index 66bcfb0412..b6f29ce5d1 100644 --- a/src/renderer/src/pages/knowledge/components/QuotaTag.tsx +++ b/src/renderer/src/pages/knowledge/components/QuotaTag.tsx @@ -31,18 +31,10 @@ const QuotaTag: FC<{ base: KnowledgeBase; providerId: PreprocessProviderId; quot const userId = getStoreSetting('userId') const baseParams = getKnowledgeBaseParams(base) try { - let response: number - if (base.framework === 'langchain') { - response = await window.api.knowledgeBase.checkQuota({ - base: baseParams, - userId: userId as string - }) - } else { - response = await window.api.knowledgeBase.checkQuota({ - base: baseParams, - userId: userId as string - }) - } + const response = await window.api.knowledgeBase.checkQuota({ + base: baseParams, + userId: userId as string + }) setQuota(response) } catch (error) { logger.error('[KnowledgeContent] Error checking quota:', error as Error) diff --git a/src/renderer/src/services/KnowledgeService.ts b/src/renderer/src/services/KnowledgeService.ts index a36a63ac9f..bc64a92015 100644 --- a/src/renderer/src/services/KnowledgeService.ts +++ b/src/renderer/src/services/KnowledgeService.ts @@ -75,9 +75,7 @@ export const getKnowledgeBaseParams = (base: KnowledgeBase): KnowledgeBaseParams baseURL: rerankHost }, documentCount: base.documentCount, - preprocessProvider: updatedPreprocessProvider, - framework: base.framework, - retriever: base.retriever || { mode: 'hybrid' } + preprocessProvider: updatedPreprocessProvider } } diff --git a/src/renderer/src/services/WebSearchService.ts b/src/renderer/src/services/WebSearchService.ts index a7feca7267..d7d30c1dbc 100644 --- a/src/renderer/src/services/WebSearchService.ts +++ b/src/renderer/src/services/WebSearchService.ts @@ -244,8 +244,7 @@ class WebSearchService { items: [], created_at: Date.now(), updated_at: Date.now(), - version: 1, - framework: 'langchain' + version: 1 } // 更新LRU cache diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index ea45e2c6ae..dc599d0658 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: 154, + version: 155, blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'], migrate }, diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 5212fa37a2..13fb71f544 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -2376,8 +2376,8 @@ const migrateConfig = { '147': (state: RootState) => { try { state.knowledge.bases.forEach((base) => { - if (!base.framework) { - base.framework = 'embedjs' + if ((base as any).framework) { + delete (base as any).framework } }) return state @@ -2398,8 +2398,8 @@ const migrateConfig = { '149': (state: RootState) => { try { state.knowledge.bases.forEach((base) => { - if (!base.framework) { - base.framework = 'embedjs' + if ((base as any).framework) { + delete (base as any).framework } }) return state @@ -2463,6 +2463,19 @@ const migrateConfig = { logger.error('migrate 154 error', error as Error) return state } + }, + '155': (state: RootState) => { + try { + state.knowledge.bases.forEach((base) => { + if ((base as any).framework) { + delete (base as any).framework + } + }) + return state + } catch (error) { + logger.error('migrate 155 error', error as Error) + return state + } } } diff --git a/src/renderer/src/types/knowledge.ts b/src/renderer/src/types/knowledge.ts index 720e60d4d1..c32e6dd003 100644 --- a/src/renderer/src/types/knowledge.ts +++ b/src/renderer/src/types/knowledge.ts @@ -100,12 +100,6 @@ export interface KnowledgeBase { type: 'preprocess' provider: PreprocessProvider } - framework: 'embedjs' | 'langchain' - // default is hybrid - retriever?: { - mode: 'vector' | 'bm25' | 'hybrid' - weight?: number - } } export type ProcessingStatus = 'pending' | 'processing' | 'completed' | 'failed' @@ -145,11 +139,6 @@ export type KnowledgeBaseParams = { type: 'preprocess' provider: PreprocessProvider } - framework: 'embedjs' | 'langchain' - retriever?: { - mode: 'vector' | 'bm25' | 'hybrid' - weight?: number - } } export type KnowledgeReference = { @@ -166,8 +155,3 @@ export interface KnowledgeSearchResult { score: number metadata: Record } - -export enum MigrationModeEnum { - EmbeddingModelChange = 'EmbeddingModelChange', - MigrationToLangChain = 'MigrationToLangChain' -} diff --git a/yarn.lock b/yarn.lock index b62fce71cc..faa763cc43 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6186,26 +6186,6 @@ __metadata: languageName: node linkType: hard -"@langchain/core@npm:^0.3.68": - version: 0.3.73 - resolution: "@langchain/core@npm:0.3.73" - dependencies: - "@cfworker/json-schema": "npm:^4.0.2" - ansi-styles: "npm:^5.0.0" - camelcase: "npm:6" - decamelize: "npm:1.2.0" - js-tiktoken: "npm:^1.0.12" - langsmith: "npm:^0.3.46" - mustache: "npm:^4.2.0" - p-queue: "npm:^6.6.2" - p-retry: "npm:4" - uuid: "npm:^10.0.0" - zod: "npm:^3.25.32" - zod-to-json-schema: "npm:^3.22.3" - checksum: 10c0/c10be034bd6698b276f040a749c9792008b43cfb3d41e9c58d6a11ff09fa1b8cf68ddc1836ca858a4b53a45163b57ff22be033cf415bc976f761b9e81c453c2b - languageName: node - linkType: hard - "@langchain/core@patch:@langchain/core@npm%3A0.3.44#~/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch": version: 0.3.44 resolution: "@langchain/core@patch:@langchain/core@npm%3A0.3.44#~/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch::version=0.3.44&hash=41dd7b" @@ -6240,20 +6220,6 @@ __metadata: languageName: node linkType: hard -"@langchain/ollama@npm:^0.2.1": - version: 0.2.1 - resolution: "@langchain/ollama@npm:0.2.1" - dependencies: - ollama: "npm:^0.5.12" - uuid: "npm:^10.0.0" - zod: "npm:^3.24.1" - zod-to-json-schema: "npm:^3.24.1" - peerDependencies: - "@langchain/core": ">=0.2.21 <0.4.0" - checksum: 10c0/be3083a15e879f2c19d0a51aafb88a3ba4ea69f9fdd90e6069b84edd92649c00a29a34cb885746e099f5cab0791b55df3776cd582cba7de299b8bd574d32b8c1 - languageName: node - linkType: hard - "@langchain/openai@npm:0.3.16": version: 0.3.16 resolution: "@langchain/openai@npm:0.3.16" @@ -6282,7 +6248,7 @@ __metadata: languageName: node linkType: hard -"@langchain/openai@npm:>=0.2.0 <0.7.0, @langchain/openai@npm:^0.6.7": +"@langchain/openai@npm:>=0.2.0 <0.7.0": version: 0.6.11 resolution: "@langchain/openai@npm:0.6.11" dependencies: @@ -13074,9 +13040,6 @@ __metadata: "@heroui/react": "npm:^2.8.3" "@kangfenmao/keyv-storage": "npm:^0.1.0" "@langchain/community": "npm:^0.3.50" - "@langchain/core": "npm:^0.3.68" - "@langchain/ollama": "npm:^0.2.1" - "@langchain/openai": "npm:^0.6.7" "@libsql/client": "npm:0.14.0" "@libsql/win32-x64-msvc": "npm:^0.4.7" "@mistralai/mistralai": "npm:^1.7.5" @@ -13194,7 +13157,6 @@ __metadata: eslint-plugin-simple-import-sort: "npm:^12.1.1" eslint-plugin-unused-imports: "npm:^4.1.4" express: "npm:^5.1.0" - faiss-node: "npm:^0.5.1" fast-diff: "npm:^1.3.0" fast-xml-parser: "npm:^5.2.0" fetch-socks: "npm:1.3.2" @@ -14062,15 +14024,6 @@ __metadata: languageName: node linkType: hard -"bindings@npm:^1.5.0": - version: 1.5.0 - resolution: "bindings@npm:1.5.0" - dependencies: - file-uri-to-path: "npm:1.0.0" - checksum: 10c0/3dab2491b4bb24124252a91e656803eac24292473e56554e35bbfe3cc1875332cfa77600c3bac7564049dc95075bf6fcc63a4609920ff2d64d0fe405fcf0d4ba - languageName: node - linkType: hard - "birecord@npm:^0.1.1": version: 0.1.1 resolution: "birecord@npm:0.1.1" @@ -16239,13 +16192,6 @@ __metadata: languageName: node linkType: hard -"detect-libc@npm:^2.0.0, detect-libc@npm:^2.0.3, detect-libc@npm:^2.0.4": - version: 2.0.4 - resolution: "detect-libc@npm:2.0.4" - checksum: 10c0/c15541f836eba4b1f521e4eecc28eefefdbc10a94d3b8cb4c507689f332cc111babb95deda66f2de050b22122113189986d5190be97d51b5a2b23b938415e67c - languageName: node - linkType: hard - "detect-libc@npm:^2.0.1": version: 2.0.3 resolution: "detect-libc@npm:2.0.3" @@ -16253,6 +16199,13 @@ __metadata: languageName: node linkType: hard +"detect-libc@npm:^2.0.3, detect-libc@npm:^2.0.4": + version: 2.0.4 + resolution: "detect-libc@npm:2.0.4" + checksum: 10c0/c15541f836eba4b1f521e4eecc28eefefdbc10a94d3b8cb4c507689f332cc111babb95deda66f2de050b22122113189986d5190be97d51b5a2b23b938415e67c + languageName: node + linkType: hard + "detect-node@npm:^2.0.4": version: 2.1.0 resolution: "detect-node@npm:2.1.0" @@ -17625,18 +17578,6 @@ __metadata: languageName: node linkType: hard -"faiss-node@npm:^0.5.1": - version: 0.5.1 - resolution: "faiss-node@npm:0.5.1" - dependencies: - bindings: "npm:^1.5.0" - node-addon-api: "npm:^6.0.0" - node-gyp: "npm:latest" - prebuild-install: "npm:^7.1.1" - checksum: 10c0/2045c4db87b39637d56c8228b26a30f4bcf49f5e0f99a7717b89030683aa8ed4d37c2f58d3b7b630fe56128fa7b8b55a0ad375ea651008bff17cb3618abc30be - languageName: node - linkType: hard - "fast-deep-equal@npm:3.1.3, fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": version: 3.1.3 resolution: "fast-deep-equal@npm:3.1.3" @@ -17931,13 +17872,6 @@ __metadata: languageName: node linkType: hard -"file-uri-to-path@npm:1.0.0": - version: 1.0.0 - resolution: "file-uri-to-path@npm:1.0.0" - checksum: 10c0/3b545e3a341d322d368e880e1c204ef55f1d45cdea65f7efc6c6ce9e0c4d22d802d5629320eb779d006fe59624ac17b0e848d83cc5af7cd101f206cb704f5519 - languageName: node - linkType: hard - "filelist@npm:^1.0.4": version: 1.0.4 resolution: "filelist@npm:1.0.4" @@ -22431,13 +22365,6 @@ __metadata: languageName: node linkType: hard -"napi-build-utils@npm:^2.0.0": - version: 2.0.0 - resolution: "napi-build-utils@npm:2.0.0" - checksum: 10c0/5833aaeb5cc5c173da47a102efa4680a95842c13e0d9cc70428bd3ee8d96bb2172f8860d2811799b5daa5cbeda779933601492a2028a6a5351c6d0fcf6de83db - languageName: node - linkType: hard - "native-promise-only@npm:0.8.1": version: 0.8.1 resolution: "native-promise-only@npm:0.8.1" @@ -22537,15 +22464,6 @@ __metadata: languageName: node linkType: hard -"node-addon-api@npm:^6.0.0": - version: 6.1.0 - resolution: "node-addon-api@npm:6.1.0" - dependencies: - node-gyp: "npm:latest" - checksum: 10c0/d2699c4ad15740fd31482a3b6fca789af7723ab9d393adc6ac45250faaee72edad8f0b10b2b9d087df0de93f1bdc16d97afdd179b26b9ebc9ed68b569faa4bac - languageName: node - linkType: hard - "node-addon-api@npm:^8.4.0": version: 8.4.0 resolution: "node-addon-api@npm:8.4.0" @@ -23683,28 +23601,6 @@ __metadata: languageName: node linkType: hard -"prebuild-install@npm:^7.1.1": - version: 7.1.3 - resolution: "prebuild-install@npm:7.1.3" - dependencies: - detect-libc: "npm:^2.0.0" - expand-template: "npm:^2.0.3" - github-from-package: "npm:0.0.0" - minimist: "npm:^1.2.3" - mkdirp-classic: "npm:^0.5.3" - napi-build-utils: "npm:^2.0.0" - node-abi: "npm:^3.3.0" - pump: "npm:^3.0.0" - rc: "npm:^1.2.7" - simple-get: "npm:^4.0.0" - tar-fs: "npm:^2.0.0" - tunnel-agent: "npm:^0.6.0" - bin: - prebuild-install: bin.js - checksum: 10c0/25919a42b52734606a4036ab492d37cfe8b601273d8dfb1fa3c84e141a0a475e7bad3ab848c741d2f810cef892fcf6059b8c7fe5b29f98d30e0c29ad009bedff - languageName: node - linkType: hard - "prelude-ls@npm:^1.2.1": version: 1.2.1 resolution: "prelude-ls@npm:1.2.1" @@ -26316,17 +26212,6 @@ __metadata: languageName: node linkType: hard -"simple-get@npm:^4.0.0": - version: 4.0.1 - resolution: "simple-get@npm:4.0.1" - dependencies: - decompress-response: "npm:^6.0.0" - once: "npm:^1.3.1" - simple-concat: "npm:^1.0.0" - checksum: 10c0/b0649a581dbca741babb960423248899203165769747142033479a7dc5e77d7b0fced0253c731cd57cf21e31e4d77c9157c3069f4448d558ebc96cf9e1eebcf0 - languageName: node - linkType: hard - "simple-swizzle@npm:^0.2.2": version: 0.2.2 resolution: "simple-swizzle@npm:0.2.2" From e5ccf68476815ff71c81095ec9f2986e58d4799b Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Mon, 15 Sep 2025 19:21:15 +0800 Subject: [PATCH 16/99] feat: use biome to format files (#10170) * build: add @biomejs/biome as a dependency * chore: add biome extension to vscode recommendations * chore: migrate from prettier to biome for code formatting Update VSCode settings to use Biome as the default formatter for multiple languages Add Biome to code actions on save and reorder search exclude patterns * build: add biome.json configuration file for code formatting * build: migrate from prettier to biome for formatting Update package.json scripts and biome.json configuration to use biome instead of prettier for code formatting. Adjust biome formatter includes/excludes patterns for better file matching. * refactor(eslint): remove unused prettier config and imports * chore: update biome.json configuration - Enable linter and set custom rules - Change jsxQuoteStyle to single quotes - Add json parser configuration - Set formatWithErrors to true * chore: migrate biome config from json to jsonc format The new jsonc format allows for comments in the configuration file, making it more maintainable and easier to document configuration choices. * style(biome): update ignore patterns and jsx quote style Update file ignore patterns from `/*` to `/**` for consistency Change jsxQuoteStyle from single to double quotes for alignment with project standards * refactor: simplify error type annotations from Error | any to any The change standardizes error handling by using 'any' type instead of union types with Error | any, making the code more consistent and reducing unnecessary type complexity. * chore: exclude tailwind.css from biome formatting * style: standardize quote usage and fix JSX formatting - Replace single quotes with double quotes in CSS imports and selectors - Fix JSX element closing bracket alignment and formatting - Standardize JSON formatting in package.json files * Revert "style: standardize quote usage and fix JSX formatting" This reverts commit 0947f8505db31000dda44a110ebcc3db8b95dd08. * fix: remove json import assertion for biome compatibility The import assertion syntax is not supported by biome, so it was replaced with a standard import statement. * style: change quote styles in biome.jsonc to use single quotes for JSX and double quotes for JS * style: change quote style from double to single in biome config * style: change JSX quote style from single to double * chore: update biome.jsonc to use single quotes for CSS formatting * chore: update biome config and format commands - Exclude tailwind.css from linter includes - Add biome lint to format commands * style: format JSX closing brackets for better readability * style: set bracketSameLine to true in biome config The change aligns with common JSX formatting preferences where brackets on the same line improve readability for many developers * Revert "style: format JSX closing brackets for better readability" This reverts commit d442c934eef26960f1e7ae3b0f60e833dea2d706. * style: format code and clean up whitespace - Remove unnecessary whitespace in CSS and TS files - Format package.json files to consistent style - Reorder tsconfig.json properties alphabetically - Improve code formatting in React components * style(biome): update biome.jsonc config with clearer comment Add explanation for keeping bracketSameLine as true to minimize changes in current PR while noting false would be better for future * chore: remove prettier dependency and format package.json files - Remove prettier from dependencies as it's no longer needed - Reformat package.json files for better readability * chore: replace prettier with biome for code formatting Remove all prettier-related configuration, dependencies, and references Update formatting scripts and documentation to use biome instead Adjust electron-builder config to exclude biome.jsonc * build: replace prettier with biome for formatting Use biome as the default formatter instead of prettier for better performance and modern tooling support * ci(i18n): replace prettier with biome for i18n formatting Update the auto-i18n workflow to use Biome instead of Prettier for formatting translated files. This change simplifies the dependencies by removing multiple Prettier plugins and using a single tool for formatting. * fix(i18n): Auto update translations for PR #10170 * style: format package.json files by consolidating array formatting Consolidate multi-line array formatting into single-line format for better readability and consistency across package.json files * Revert "fix(i18n): Auto update translations for PR #10170" This reverts commit a7edd32efda396ce5dd8685ec8b50761aa2b58f8. * ci(workflows): specify biome config path in auto-i18n workflow * chore: update biome.jsonc to use lexicographic sort order for json keys * ci(workflows): update biome format command to use --config-path flag * chore: exclude package.json from biome formatting * ci: update biome.jsonc linter configuration Update linter includes to target specific files and modify useSortedClasses rule * chore: reorder search exclude patterns in vscode settings * style(OGCard): reorder tailwind classes for consistent styling * fix(biome): update tailwind classes sorting to safe and warn level * docs(dev): update ide setup instructions in dev docs Replace Prettier with Biome as the recommended formatter and clarify editor options * build(extension-table-plus): replace prettier with biome for formatting - Add biome.json configuration file - Update package.json to use biome instead of prettier - Remove prettier from dependencies - Update lint script to use biome format * chore: replace biome.json with biome.jsonc for extended configuration Update biome configuration file to use JSONC format for comments and more detailed settings * chore: remove unused biome.jsonc configuration file --------- Co-authored-by: GitHub Action --- .github/workflows/auto-i18n.yml | 4 +- .prettierignore | 14 - .prettierrc | 13 - .vscode/extensions.json | 4 +- .vscode/settings.json | 15 +- CLAUDE.md | 2 +- biome.jsonc | 97 +++++++ docs/dev.md | 4 +- electron-builder.yml | 92 +++---- electron.vite.config.ts | 4 +- eslint.config.mjs | 3 - package.json | 13 +- packages/aiCore/package.json | 14 +- .../built-in/googleToolsPlugin/index.ts | 1 - packages/aiCore/tsconfig.json | 35 ++- packages/extension-table-plus/package.json | 4 +- scripts/update-languages.ts | 16 +- src/main/services/MCPService.ts | 6 +- .../src/assets/styles/CommandListPopover.css | 1 - src/renderer/src/components/OGCard.tsx | 2 +- .../pages/home/Inputbar/MCPToolsButton.tsx | 6 +- .../pages/home/Messages/MessageContent.tsx | 4 +- src/renderer/src/services/BackupService.ts | 12 +- yarn.lock | 254 +++++++----------- 24 files changed, 309 insertions(+), 311 deletions(-) delete mode 100644 .prettierignore delete mode 100644 .prettierrc create mode 100644 biome.jsonc diff --git a/.github/workflows/auto-i18n.yml b/.github/workflows/auto-i18n.yml index 29cfd1fda0..4cdd1481cf 100644 --- a/.github/workflows/auto-i18n.yml +++ b/.github/workflows/auto-i18n.yml @@ -35,7 +35,7 @@ jobs: # 在临时目录安装依赖 mkdir -p /tmp/translation-deps cd /tmp/translation-deps - echo '{"dependencies": {"openai": "^5.12.2", "cli-progress": "^3.12.0", "tsx": "^4.20.3", "prettier": "^3.5.3", "prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-tailwindcss": "^0.6.14"}}' > package.json + echo '{"dependencies": {"openai": "^5.12.2", "cli-progress": "^3.12.0", "tsx": "^4.20.3", "@biomejs/biome": "2.2.4"}}' > package.json npm install --no-package-lock # 设置 NODE_PATH 让项目能找到这些依赖 @@ -45,7 +45,7 @@ jobs: run: npx tsx scripts/auto-translate-i18n.ts - name: 🔍 Format - run: cd /tmp/translation-deps && npx prettier --write --config /home/runner/work/cherry-studio/cherry-studio/.prettierrc /home/runner/work/cherry-studio/cherry-studio/src/renderer/src/i18n/ + run: cd /tmp/translation-deps && npx biome format --config-path /home/runner/work/cherry-studio/cherry-studio/biome.jsonc --write /home/runner/work/cherry-studio/cherry-studio/src/renderer/src/i18n/ - name: 🔄 Commit changes run: | diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 163c9ac20e..0000000000 --- a/.prettierignore +++ /dev/null @@ -1,14 +0,0 @@ -out/ -dist/ -build/ -.yarn/ -.github/ -.husky/ -.vscode/ -*.yaml -*.yml -*.mjs -*.cjs -*.md -*.json -src/main/integration/ \ No newline at end of file diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 7a06761104..0000000000 --- a/.prettierrc +++ /dev/null @@ -1,13 +0,0 @@ -{ - "bracketSameLine": true, - "endOfLine": "lf", - "jsonRecursiveSort": true, - "jsonSortOrder": "{\"*\": \"lexical\"}", - "plugins": ["prettier-plugin-sort-json", "prettier-plugin-tailwindcss"], - "printWidth": 120, - "semi": false, - "singleQuote": true, - "tailwindFunctions": ["clsx"], - "tailwindStylesheet": "./src/renderer/src/assets/styles/tailwind.css", - "trailingComma": "none" -} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index cde2c60935..6e35f94739 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,8 +1,8 @@ { "recommendations": [ "dbaeumer.vscode-eslint", - "esbenp.prettier-vscode", "editorconfig.editorconfig", - "lokalise.i18n-ally" + "lokalise.i18n-ally", + "biomejs.biome" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 3dd634507f..51f95116fc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,29 +1,30 @@ { "[css]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "biomejs.biome" }, "[javascript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "biomejs.biome" }, "[json]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "biomejs.biome" }, "[jsonc]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "biomejs.biome" }, "[markdown]": { "files.trimTrailingWhitespace": false }, "[scss]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "biomejs.biome" }, "[typescript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "biomejs.biome" }, "[typescriptreact]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "biomejs.biome" }, "editor.codeActionsOnSave": { + "source.fixAll.biome": "explicit", "source.fixAll.eslint": "explicit", "source.organizeImports": "never" }, diff --git a/CLAUDE.md b/CLAUDE.md index e7feaa981d..5c66ab4fed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **Run E2E Tests**: `yarn test:e2e` - Playwright end-to-end tests - **Type Check**: `yarn typecheck` - Checks TypeScript for both node and web - **Lint**: `yarn lint` - ESLint with auto-fix -- **Format**: `yarn format` - Prettier formatting +- **Format**: `yarn format` - Biome formatting ### Build & Release diff --git a/biome.jsonc b/biome.jsonc new file mode 100644 index 0000000000..b86350d70d --- /dev/null +++ b/biome.jsonc @@ -0,0 +1,97 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", + "assist": { + // to sort json + "actions": { + "source": { + "organizeImports": "on", + "useSortedKeys": { + "level": "on", + "options": { + "sortOrder": "lexicographic" + } + } + } + }, + "enabled": true, + "includes": ["**/*.json", "!*.json", "!**/package.json"] + }, + "css": { + "formatter": { + "quoteStyle": "single" + } + }, + "files": { "ignoreUnknown": false }, + "formatter": { + "attributePosition": "auto", + "bracketSameLine": false, + "bracketSpacing": true, + "enabled": true, + "expand": "auto", + "formatWithErrors": true, + "includes": [ + "**", + "!out/**", + "!**/dist/**", + "!build/**", + "!.yarn/**", + "!.github/**", + "!.husky/**", + "!.vscode/**", + "!*.yaml", + "!*.yml", + "!*.mjs", + "!*.cjs", + "!*.md", + "!*.json", + "!src/main/integration/**", + "!**/tailwind.css", + "!**/package.json" + ], + "indentStyle": "space", + "indentWidth": 2, + "lineEnding": "lf", + "lineWidth": 120, + "useEditorconfig": true + }, + "html": { "formatter": { "selfCloseVoidElements": "always" } }, + "javascript": { + "formatter": { + "arrowParentheses": "always", + "attributePosition": "auto", + // To minimize changes in this PR as much as possible, it's set to true. However, setting it to false would make it more convenient to add attributes at the end. + "bracketSameLine": true, + "bracketSpacing": true, + "jsxQuoteStyle": "double", + "quoteProperties": "asNeeded", + "quoteStyle": "single", + "semicolons": "asNeeded", + "trailingCommas": "none" + } + }, + "json": { + "parser": { + "allowComments": true + } + }, + "linter": { + "enabled": true, + "includes": ["!**/tailwind.css", "src/renderer/**/*.{tsx,ts}"], + // only enable sorted tailwind css rule. used as formatter instead of linter + "rules": { + "nursery": { + // to sort tailwind css classes + "useSortedClasses": { + "fix": "safe", + "level": "warn", + "options": { + "functions": ["cn"] + } + } + }, + "recommended": false, + "suspicious": "off" + } + }, + "vcs": { "clientKind": "git", "enabled": false, "useIgnoreFile": false } +} diff --git a/docs/dev.md b/docs/dev.md index 721f557245..0fdff640ec 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -2,7 +2,9 @@ ## IDE Setup -[Cursor](https://www.cursor.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) +- Editor: [Cursor](https://www.cursor.com/), etc. Any VS Code compatible editor. +- Linter: [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) +- Formatter: [Biome](https://marketplace.visualstudio.com/items?itemName=biomejs.biome) ## Project Setup diff --git a/electron-builder.yml b/electron-builder.yml index cf8d4553d8..80722308e4 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -17,52 +17,52 @@ protocols: schemes: - cherrystudio files: - - '**/*' - - '!**/{.vscode,.yarn,.yarn-lock,.github,.cursorrules,.prettierrc}' - - '!electron.vite.config.{js,ts,mjs,cjs}}' - - '!**/{.eslintignore,.eslintrc.js,.eslintrc.json,.eslintcache,root.eslint.config.js,eslint.config.js,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,eslint.config.mjs,dev-app-update.yml,CHANGELOG.md,README.md}' - - '!**/{.env,.env.*,.npmrc,pnpm-lock.yaml}' - - '!**/{tsconfig.json,tsconfig.tsbuildinfo,tsconfig.node.json,tsconfig.web.json}' - - '!**/{.editorconfig,.jekyll-metadata}' - - '!src' - - '!scripts' - - '!local' - - '!docs' - - '!packages' - - '!.swc' - - '!.bin' - - '!._*' - - '!*.log' - - '!stats.html' - - '!*.md' - - '!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}' - - '!**/*.{map,ts,tsx,jsx,less,scss,sass,css.d.ts,d.cts,d.mts,md,markdown,yaml,yml}' - - '!**/{test,tests,__tests__,powered-test,coverage}/**' - - '!**/{example,examples}/**' - - '!**/*.{spec,test}.{js,jsx,ts,tsx}' - - '!**/*.min.*.map' - - '!**/*.d.ts' - - '!**/dist/es6/**' - - '!**/dist/demo/**' - - '!**/amd/**' - - '!**/{.DS_Store,Thumbs.db,thumbs.db,__pycache__}' - - '!**/{LICENSE,license,LICENSE.*,*.LICENSE.txt,NOTICE.txt,README.md,readme.md,CHANGELOG.md}' - - '!node_modules/rollup-plugin-visualizer' - - '!node_modules/js-tiktoken' - - '!node_modules/@tavily/core/node_modules/js-tiktoken' - - '!node_modules/pdf-parse/lib/pdf.js/{v1.9.426,v1.10.88,v2.0.550}' - - '!node_modules/mammoth/{mammoth.browser.js,mammoth.browser.min.js}' - - '!node_modules/selection-hook/prebuilds/**/*' # we rebuild .node, don't use prebuilds - - '!node_modules/selection-hook/node_modules' # we don't need what in the node_modules dir - - '!node_modules/selection-hook/src' # we don't need source files - - '!node_modules/tesseract.js-core/{tesseract-core.js,tesseract-core.wasm,tesseract-core.wasm.js}' # we don't need source files - - '!node_modules/tesseract.js-core/{tesseract-core-lstm.js,tesseract-core-lstm.wasm,tesseract-core-lstm.wasm.js}' # we don't need source files - - '!node_modules/tesseract.js-core/{tesseract-core-simd-lstm.js,tesseract-core-simd-lstm.wasm,tesseract-core-simd-lstm.wasm.js}' # we don't need source files - - '!**/*.{h,iobj,ipdb,tlog,recipe,vcxproj,vcxproj.filters,Makefile,*.Makefile}' # filter .node build files + - "**/*" + - "!**/{.vscode,.yarn,.yarn-lock,.github,.cursorrules,.prettierrc}" + - "!electron.vite.config.{js,ts,mjs,cjs}}" + - "!**/{.eslintignore,.eslintrc.js,.eslintrc.json,.eslintcache,root.eslint.config.js,eslint.config.js,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,eslint.config.mjs,dev-app-update.yml,CHANGELOG.md,README.md,biome.jsonc}" + - "!**/{.env,.env.*,.npmrc,pnpm-lock.yaml}" + - "!**/{tsconfig.json,tsconfig.tsbuildinfo,tsconfig.node.json,tsconfig.web.json}" + - "!**/{.editorconfig,.jekyll-metadata}" + - "!src" + - "!scripts" + - "!local" + - "!docs" + - "!packages" + - "!.swc" + - "!.bin" + - "!._*" + - "!*.log" + - "!stats.html" + - "!*.md" + - "!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}" + - "!**/*.{map,ts,tsx,jsx,less,scss,sass,css.d.ts,d.cts,d.mts,md,markdown,yaml,yml}" + - "!**/{test,tests,__tests__,powered-test,coverage}/**" + - "!**/{example,examples}/**" + - "!**/*.{spec,test}.{js,jsx,ts,tsx}" + - "!**/*.min.*.map" + - "!**/*.d.ts" + - "!**/dist/es6/**" + - "!**/dist/demo/**" + - "!**/amd/**" + - "!**/{.DS_Store,Thumbs.db,thumbs.db,__pycache__}" + - "!**/{LICENSE,license,LICENSE.*,*.LICENSE.txt,NOTICE.txt,README.md,readme.md,CHANGELOG.md}" + - "!node_modules/rollup-plugin-visualizer" + - "!node_modules/js-tiktoken" + - "!node_modules/@tavily/core/node_modules/js-tiktoken" + - "!node_modules/pdf-parse/lib/pdf.js/{v1.9.426,v1.10.88,v2.0.550}" + - "!node_modules/mammoth/{mammoth.browser.js,mammoth.browser.min.js}" + - "!node_modules/selection-hook/prebuilds/**/*" # we rebuild .node, don't use prebuilds + - "!node_modules/selection-hook/node_modules" # we don't need what in the node_modules dir + - "!node_modules/selection-hook/src" # we don't need source files + - "!node_modules/tesseract.js-core/{tesseract-core.js,tesseract-core.wasm,tesseract-core.wasm.js}" # we don't need source files + - "!node_modules/tesseract.js-core/{tesseract-core-lstm.js,tesseract-core-lstm.wasm,tesseract-core-lstm.wasm.js}" # we don't need source files + - "!node_modules/tesseract.js-core/{tesseract-core-simd-lstm.js,tesseract-core-simd-lstm.wasm,tesseract-core-simd-lstm.wasm.js}" # we don't need source files + - "!**/*.{h,iobj,ipdb,tlog,recipe,vcxproj,vcxproj.filters,Makefile,*.Makefile}" # filter .node build files asarUnpack: - resources/** - - '**/*.{metal,exp,lib}' - - 'node_modules/@img/sharp-libvips-*/**' + - "**/*.{metal,exp,lib}" + - "node_modules/@img/sharp-libvips-*/**" win: executableName: Cherry Studio artifactName: ${productName}-${version}-${arch}-setup.${ext} @@ -88,7 +88,7 @@ mac: entitlementsInherit: build/entitlements.mac.plist notarize: false artifactName: ${productName}-${version}-${arch}.${ext} - minimumSystemVersion: '20.1.0' # 最低支持 macOS 11.0 + minimumSystemVersion: "20.1.0" # 最低支持 macOS 11.0 extendInfo: - NSCameraUsageDescription: Application requests access to the device's camera. - NSMicrophoneUsageDescription: Application requests access to the device's microphone. @@ -113,7 +113,7 @@ linux: rpm: # Workaround for electron build issue on rpm package: # https://github.com/electron/forge/issues/3594 - fpm: ['--rpm-rpmbuild-define=_build_id_links none'] + fpm: ["--rpm-rpmbuild-define=_build_id_links none"] publish: provider: generic url: https://releases.cherry-ai.com diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 83f82b5f4c..b7c55793d3 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -4,7 +4,9 @@ import { defineConfig, externalizeDepsPlugin } from 'electron-vite' import { resolve } from 'path' import { visualizer } from 'rollup-plugin-visualizer' -import pkg from './package.json' assert { type: 'json' } +// assert not supported by biome +// import pkg from './package.json' assert { type: 'json' } +import pkg from './package.json' const visualizerPlugin = (type: 'renderer' | 'main') => { return process.env[`VISUALIZER_${type.toUpperCase()}`] ? [visualizer({ open: true })] : [] diff --git a/eslint.config.mjs b/eslint.config.mjs index f3a96ca225..bf31b25037 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,4 +1,3 @@ -import electronConfigPrettier from '@electron-toolkit/eslint-config-prettier' import tseslint from '@electron-toolkit/eslint-config-ts' import eslint from '@eslint/js' import eslintReact from '@eslint-react/eslint-plugin' @@ -10,7 +9,6 @@ import unusedImports from 'eslint-plugin-unused-imports' export default defineConfig([ eslint.configs.recommended, tseslint.configs.recommended, - electronConfigPrettier, eslintReact.configs['recommended-typescript'], reactHooks.configs['recommended-latest'], { @@ -26,7 +24,6 @@ export default defineConfig([ 'simple-import-sort/exports': 'error', 'unused-imports/no-unused-imports': 'error', '@eslint-react/no-prop-types': 'error', - 'prettier/prettier': ['error'] } }, // Configuration for ensuring compatibility with the original ESLint(8.x) rules diff --git a/package.json b/package.json index 69f7968a87..20e3d5840f 100644 --- a/package.json +++ b/package.json @@ -65,8 +65,8 @@ "test:e2e": "yarn playwright test", "test:lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts", "test:scripts": "vitest scripts", - "format": "prettier --write .", - "format:check": "prettier --check .", + "format": "biome format --write && biome lint --write", + "format:check": "biome format && biome lint", "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix && yarn typecheck && yarn check:i18n", "prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky", "claude": "dotenv -e .env -- claude" @@ -104,6 +104,7 @@ "@aws-sdk/client-bedrock": "^3.840.0", "@aws-sdk/client-bedrock-runtime": "^3.840.0", "@aws-sdk/client-s3": "^3.840.0", + "@biomejs/biome": "2.2.4", "@cherrystudio/ai-core": "workspace:*", "@cherrystudio/embedjs": "^0.1.31", "@cherrystudio/embedjs-libsql": "^0.1.31", @@ -122,7 +123,6 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@electron-toolkit/eslint-config-prettier": "^3.0.0", "@electron-toolkit/eslint-config-ts": "^3.0.0", "@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/tsconfig": "^1.0.1", @@ -284,9 +284,6 @@ "pdf-lib": "^1.17.1", "pdf-parse": "^1.1.1", "playwright": "^1.52.0", - "prettier": "^3.5.3", - "prettier-plugin-sort-json": "^4.1.1", - "prettier-plugin-tailwindcss": "^0.6.14", "proxy-agent": "^6.5.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -369,11 +366,11 @@ "packageManager": "yarn@4.9.1", "lint-staged": { "*.{js,jsx,ts,tsx,cjs,mjs,cts,mts}": [ - "prettier --write", + "biome format --write", "eslint --fix" ], "*.{json,yml,yaml,css,html}": [ - "prettier --write" + "biome format --write" ] } } diff --git a/packages/aiCore/package.json b/packages/aiCore/package.json index e61db529e2..cdf68d9018 100644 --- a/packages/aiCore/package.json +++ b/packages/aiCore/package.json @@ -13,15 +13,7 @@ "test": "vitest run", "test:watch": "vitest" }, - "keywords": [ - "ai", - "sdk", - "openai", - "anthropic", - "google", - "cherry-studio", - "vercel-ai-sdk" - ], + "keywords": ["ai", "sdk", "openai", "anthropic", "google", "cherry-studio", "vercel-ai-sdk"], "author": "Cherry Studio", "license": "MIT", "repository": { @@ -56,9 +48,7 @@ "engines": { "node": ">=18.0.0" }, - "files": [ - "dist" - ], + "files": ["dist"], "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/packages/aiCore/src/core/plugins/built-in/googleToolsPlugin/index.ts b/packages/aiCore/src/core/plugins/built-in/googleToolsPlugin/index.ts index cb581e17c6..09a741d9f2 100644 --- a/packages/aiCore/src/core/plugins/built-in/googleToolsPlugin/index.ts +++ b/packages/aiCore/src/core/plugins/built-in/googleToolsPlugin/index.ts @@ -24,7 +24,6 @@ export const googleToolsPlugin = (config?: ToolConfig) => if (!typedParams.tools) { typedParams.tools = {} } - // 使用类型安全的方式遍历配置 ;(Object.keys(config) as ToolConfigKey[]).forEach((key) => { if (config[key] && key in toolNameMap && key in google.tools) { diff --git a/packages/aiCore/tsconfig.json b/packages/aiCore/tsconfig.json index 9ee30166c1..110b2106e0 100644 --- a/packages/aiCore/tsconfig.json +++ b/packages/aiCore/tsconfig.json @@ -1,26 +1,21 @@ { "compilerOptions": { - "target": "ES2020", + "allowSyntheticDefaultImports": true, + "declaration": true, + "emitDecoratorMetadata": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "forceConsistentCasingInFileNames": true, "module": "ESNext", "moduleResolution": "bundler", - "declaration": true, - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "allowSyntheticDefaultImports": true, "noEmitOnError": false, - "experimentalDecorators": true, - "emitDecoratorMetadata": true + "outDir": "./dist", + "resolveJsonModule": true, + "rootDir": "./src", + "skipLibCheck": true, + "strict": true, + "target": "ES2020" }, - "include": [ - "src/**/*" - ], - "exclude": [ - "node_modules", - "dist" - ] -} \ No newline at end of file + "exclude": ["node_modules", "dist"], + "include": ["src/**/*"] +} diff --git a/packages/extension-table-plus/package.json b/packages/extension-table-plus/package.json index d34c25ccd7..9601c1f8b8 100755 --- a/packages/extension-table-plus/package.json +++ b/packages/extension-table-plus/package.json @@ -67,13 +67,13 @@ "dist" ], "devDependencies": { + "@biomejs/biome": "2.2.4", "@tiptap/core": "^3.2.0", "@tiptap/pm": "^3.2.0", "eslint": "^9.22.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-unused-imports": "^4.1.4", - "prettier": "^3.5.3", "tsdown": "^0.13.3" }, "peerDependencies": { @@ -87,7 +87,7 @@ }, "scripts": { "build": "tsdown", - "lint": "prettier ./src/ --write && eslint --fix ./src/" + "lint": "biome format ./src/ --write && eslint --fix ./src/" }, "packageManager": "yarn@4.9.1" } diff --git a/scripts/update-languages.ts b/scripts/update-languages.ts index 58640637b1..b84bb1f94c 100644 --- a/scripts/update-languages.ts +++ b/scripts/update-languages.ts @@ -75,17 +75,17 @@ export const languages: Record = ${languagesObjectString}; } /** - * Formats a file using Prettier. + * Formats a file using Biome. * @param filePath The path to the file to format. */ -async function formatWithPrettier(filePath: string): Promise { - console.log('🎨 Formatting file with Prettier...') +async function format(filePath: string): Promise { + console.log('🎨 Formatting file with Biome...') try { - await execAsync(`yarn prettier --write ${filePath}`) - console.log('✅ Prettier formatting complete.') + await execAsync(`yarn biome format --write ${filePath}`) + console.log('✅ Biome formatting complete.') } catch (e: any) { - console.error('❌ Prettier formatting failed:', e.stdout || e.stderr) - throw new Error('Prettier formatting failed.') + console.error('❌ Biome formatting failed:', e.stdout || e.stderr) + throw new Error('Biome formatting failed.') } } @@ -116,7 +116,7 @@ async function updateLanguagesFile(): Promise { await fs.writeFile(LANGUAGES_FILE_PATH, fileContent, 'utf-8') console.log(`✅ Successfully wrote to ${LANGUAGES_FILE_PATH}`) - await formatWithPrettier(LANGUAGES_FILE_PATH) + await format(LANGUAGES_FILE_PATH) await checkTypeScript(LANGUAGES_FILE_PATH) console.log('🎉 Successfully updated languages.ts file!') diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts index 710867da88..073a7f3435 100644 --- a/src/main/services/MCPService.ts +++ b/src/main/services/MCPService.ts @@ -235,7 +235,7 @@ class McpService { try { await inMemoryServer.connect(serverTransport) getServerLogger(server).debug(`In-memory server started`) - } catch (error: Error | any) { + } catch (error: any) { getServerLogger(server).error(`Error starting in-memory server`, error as Error) throw new Error(`Failed to start in-memory server: ${error.message}`) } @@ -419,7 +419,7 @@ class McpService { const transport = await initTransport() try { await client.connect(transport) - } catch (error: Error | any) { + } catch (error: any) { if ( error instanceof Error && (error.name === 'UnauthorizedError' || error.message.includes('Unauthorized')) @@ -852,7 +852,7 @@ class McpService { return { contents: contents } - } catch (error: Error | any) { + } catch (error: any) { getServerLogger(server, { uri }).error(`Failed to get resource`, error as Error) throw new Error(`Failed to get resource ${uri} from server: ${server.name}: ${error.message}`) } diff --git a/src/renderer/src/assets/styles/CommandListPopover.css b/src/renderer/src/assets/styles/CommandListPopover.css index a1bb27ec57..fc4fe2b86e 100644 --- a/src/renderer/src/assets/styles/CommandListPopover.css +++ b/src/renderer/src/assets/styles/CommandListPopover.css @@ -1,6 +1,5 @@ .command-list-popover { /* Base styles are handled inline for theme support */ - /* Arrow styles based on placement */ } diff --git a/src/renderer/src/components/OGCard.tsx b/src/renderer/src/components/OGCard.tsx index 93446b6749..c111aa99fe 100644 --- a/src/renderer/src/components/OGCard.tsx +++ b/src/renderer/src/components/OGCard.tsx @@ -36,7 +36,7 @@ export const OGCard = ({ link, show }: Props) => { const GeneratedGraph = useCallback(() => { return (
-

{metadata['og:title'] || hostname}

+

{metadata['og:title'] || hostname}

) }, [hostname, metadata]) diff --git a/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx b/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx index d6ba841e6d..ae11cc1914 100644 --- a/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx @@ -318,7 +318,7 @@ const MCPToolsButton: FC = ({ ref, setInputValue, resizeTextArea, Toolbar }) await handlePromptResponse(response) - } catch (error: Error | any) { + } catch (error: any) { if (error.message !== 'cancelled') { window.modal.error({ title: t('common.error'), @@ -335,7 +335,7 @@ const MCPToolsButton: FC = ({ ref, setInputValue, resizeTextArea, Toolbar name: prompt.name }) await handlePromptResponse(response) - } catch (error: Error | any) { + } catch (error: any) { window.modal.error({ title: t('common.error'), content: error.message || t('settings.mcp.prompts.genericError') @@ -416,7 +416,7 @@ const MCPToolsButton: FC = ({ ref, setInputValue, resizeTextArea, Toolbar } else { processResourceContent(response as ResourceData) } - } catch (error: Error | any) { + } catch (error: any) { window.modal.error({ title: t('common.error'), content: error.message || t('settings.mcp.resources.genericError') diff --git a/src/renderer/src/pages/home/Messages/MessageContent.tsx b/src/renderer/src/pages/home/Messages/MessageContent.tsx index 61b113a011..420cd7d466 100644 --- a/src/renderer/src/pages/home/Messages/MessageContent.tsx +++ b/src/renderer/src/pages/home/Messages/MessageContent.tsx @@ -15,7 +15,9 @@ const MessageContent: React.FC = ({ message }) => { <> {!isEmpty(message.mentions) && ( - {message.mentions?.map((model) => {'@' + model.name})} + {message.mentions?.map((model) => ( + {'@' + model.name} + ))} )} diff --git a/src/renderer/src/services/BackupService.ts b/src/renderer/src/services/BackupService.ts index 6523608767..985247e43f 100644 --- a/src/renderer/src/services/BackupService.ts +++ b/src/renderer/src/services/BackupService.ts @@ -141,7 +141,11 @@ export async function backupToWebdav({ showMessage = false, customFileName = '', autoBackupProcess = false -}: { showMessage?: boolean; customFileName?: string; autoBackupProcess?: boolean } = {}) { +}: { + showMessage?: boolean + customFileName?: string + autoBackupProcess?: boolean +} = {}) { const notificationService = NotificationService.getInstance() if (isManualBackupRunning) { logger.verbose('Manual backup already in progress') @@ -319,7 +323,11 @@ export async function backupToS3({ showMessage = false, customFileName = '', autoBackupProcess = false -}: { showMessage?: boolean; customFileName?: string; autoBackupProcess?: boolean } = {}) { +}: { + showMessage?: boolean + customFileName?: string + autoBackupProcess?: boolean +} = {}) { const notificationService = NotificationService.getInstance() if (isManualBackupRunning) { logger.verbose('Manual backup already in progress') diff --git a/yarn.lock b/yarn.lock index faa763cc43..7b6ba7b4d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2249,6 +2249,97 @@ __metadata: languageName: node linkType: hard +"@biomejs/biome@npm:2.2.4": + version: 2.2.4 + resolution: "@biomejs/biome@npm:2.2.4" + dependencies: + "@biomejs/cli-darwin-arm64": "npm:2.2.4" + "@biomejs/cli-darwin-x64": "npm:2.2.4" + "@biomejs/cli-linux-arm64": "npm:2.2.4" + "@biomejs/cli-linux-arm64-musl": "npm:2.2.4" + "@biomejs/cli-linux-x64": "npm:2.2.4" + "@biomejs/cli-linux-x64-musl": "npm:2.2.4" + "@biomejs/cli-win32-arm64": "npm:2.2.4" + "@biomejs/cli-win32-x64": "npm:2.2.4" + dependenciesMeta: + "@biomejs/cli-darwin-arm64": + optional: true + "@biomejs/cli-darwin-x64": + optional: true + "@biomejs/cli-linux-arm64": + optional: true + "@biomejs/cli-linux-arm64-musl": + optional: true + "@biomejs/cli-linux-x64": + optional: true + "@biomejs/cli-linux-x64-musl": + optional: true + "@biomejs/cli-win32-arm64": + optional: true + "@biomejs/cli-win32-x64": + optional: true + bin: + biome: bin/biome + checksum: 10c0/7229fcc743db48f3cfd7417fb3f33d1cd9e7dfe42a12ed3c1046cd3110718237bb71ea3fe5c8b0de9bd3df2b918d0be58027602aa3720b64904b63d9cedf53e3 + languageName: node + linkType: hard + +"@biomejs/cli-darwin-arm64@npm:2.2.4": + version: 2.2.4 + resolution: "@biomejs/cli-darwin-arm64@npm:2.2.4" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@biomejs/cli-darwin-x64@npm:2.2.4": + version: 2.2.4 + resolution: "@biomejs/cli-darwin-x64@npm:2.2.4" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@biomejs/cli-linux-arm64-musl@npm:2.2.4": + version: 2.2.4 + resolution: "@biomejs/cli-linux-arm64-musl@npm:2.2.4" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@biomejs/cli-linux-arm64@npm:2.2.4": + version: 2.2.4 + resolution: "@biomejs/cli-linux-arm64@npm:2.2.4" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@biomejs/cli-linux-x64-musl@npm:2.2.4": + version: 2.2.4 + resolution: "@biomejs/cli-linux-x64-musl@npm:2.2.4" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@biomejs/cli-linux-x64@npm:2.2.4": + version: 2.2.4 + resolution: "@biomejs/cli-linux-x64@npm:2.2.4" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@biomejs/cli-win32-arm64@npm:2.2.4": + version: 2.2.4 + resolution: "@biomejs/cli-win32-arm64@npm:2.2.4" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@biomejs/cli-win32-x64@npm:2.2.4": + version: 2.2.4 + resolution: "@biomejs/cli-win32-x64@npm:2.2.4" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@braintree/sanitize-url@npm:^7.0.4": version: 7.1.1 resolution: "@braintree/sanitize-url@npm:7.1.1" @@ -2511,13 +2602,13 @@ __metadata: version: 0.0.0-use.local resolution: "@cherrystudio/extension-table-plus@workspace:packages/extension-table-plus" dependencies: + "@biomejs/biome": "npm:2.2.4" "@tiptap/core": "npm:^3.2.0" "@tiptap/pm": "npm:^3.2.0" eslint: "npm:^9.22.0" eslint-plugin-react-hooks: "npm:^5.2.0" eslint-plugin-simple-import-sort: "npm:^12.1.1" eslint-plugin-unused-imports: "npm:^4.1.4" - prettier: "npm:^3.5.3" tsdown: "npm:^0.13.3" peerDependencies: "@tiptap/core": ^3.0.9 @@ -3099,19 +3190,6 @@ __metadata: languageName: node linkType: hard -"@electron-toolkit/eslint-config-prettier@npm:^3.0.0": - version: 3.0.0 - resolution: "@electron-toolkit/eslint-config-prettier@npm:3.0.0" - dependencies: - eslint-config-prettier: "npm:^10.0.1" - eslint-plugin-prettier: "npm:^5.2.3" - peerDependencies: - eslint: ">= 9.0.0" - prettier: ">= 3.0.0" - checksum: 10c0/c17e272c83b9bcc0ce87b7d116b0eb2606920202c342f65332e0d792f89322de5382b8a345a79d291e45a21d5159eb82fc4e8ec8efbff2f535e336502bb8a998 - languageName: node - linkType: hard - "@electron-toolkit/eslint-config-ts@npm:^3.0.0": version: 3.1.0 resolution: "@electron-toolkit/eslint-config-ts@npm:3.1.0" @@ -7172,13 +7250,6 @@ __metadata: languageName: node linkType: hard -"@pkgr/core@npm:^0.2.3": - version: 0.2.4 - resolution: "@pkgr/core@npm:0.2.4" - checksum: 10c0/2528a443bbbef5d4686614e1d73f834f19ccbc975f62b2a64974a6b97bcdf677b9c5e8948e04808ac4f0d853e2f422adfaae2a06e9e9f4f5cf8af76f1adf8dc1 - languageName: node - linkType: hard - "@playwright/test@npm:^1.52.0": version: 1.52.0 resolution: "@playwright/test@npm:1.52.0" @@ -13008,6 +13079,7 @@ __metadata: "@aws-sdk/client-bedrock": "npm:^3.840.0" "@aws-sdk/client-bedrock-runtime": "npm:^3.840.0" "@aws-sdk/client-s3": "npm:^3.840.0" + "@biomejs/biome": "npm:2.2.4" "@cherrystudio/ai-core": "workspace:*" "@cherrystudio/embedjs": "npm:^0.1.31" "@cherrystudio/embedjs-libsql": "npm:^0.1.31" @@ -13026,7 +13098,6 @@ __metadata: "@dnd-kit/modifiers": "npm:^9.0.0" "@dnd-kit/sortable": "npm:^10.0.0" "@dnd-kit/utilities": "npm:^3.2.2" - "@electron-toolkit/eslint-config-prettier": "npm:^3.0.0" "@electron-toolkit/eslint-config-ts": "npm:^3.0.0" "@electron-toolkit/preload": "npm:^3.0.0" "@electron-toolkit/tsconfig": "npm:^1.0.1" @@ -13199,9 +13270,6 @@ __metadata: pdf-lib: "npm:^1.17.1" pdf-parse: "npm:^1.1.1" playwright: "npm:^1.52.0" - prettier: "npm:^3.5.3" - prettier-plugin-sort-json: "npm:^4.1.1" - prettier-plugin-tailwindcss: "npm:^0.6.14" proxy-agent: "npm:^6.5.0" react: "npm:^19.0.0" react-dom: "npm:^19.0.0" @@ -16978,37 +17046,6 @@ __metadata: languageName: node linkType: hard -"eslint-config-prettier@npm:^10.0.1": - version: 10.1.2 - resolution: "eslint-config-prettier@npm:10.1.2" - peerDependencies: - eslint: ">=7.0.0" - bin: - eslint-config-prettier: bin/cli.js - checksum: 10c0/c22c8e29193cc8fd70becf1c2dd072513f2b3004a175c2a49404c79d1745ba4dc0edc2afd00d16b0e26d24f95813a0469e7445a25104aec218f6d84cdb1697e9 - languageName: node - linkType: hard - -"eslint-plugin-prettier@npm:^5.2.3": - version: 5.2.6 - resolution: "eslint-plugin-prettier@npm:5.2.6" - dependencies: - prettier-linter-helpers: "npm:^1.0.0" - synckit: "npm:^0.11.0" - peerDependencies: - "@types/eslint": ">=8.0.0" - eslint: ">=8.0.0" - eslint-config-prettier: ">= 7.0.0 <10.0.0 || >=10.1.0" - prettier: ">=3.0.0" - peerDependenciesMeta: - "@types/eslint": - optional: true - eslint-config-prettier: - optional: true - checksum: 10c0/9911740a5edac7933d92671381908671c61ffa32a3cee7aed667ebab89831ee2c0b69eb9530f68dbe172ca9d4b3fa3d47350762dc1eb096a3ce125fa31c0e616 - languageName: node - linkType: hard - "eslint-plugin-react-debug@npm:1.48.1": version: 1.48.1 resolution: "eslint-plugin-react-debug@npm:1.48.1" @@ -17585,7 +17622,7 @@ __metadata: languageName: node linkType: hard -"fast-diff@npm:^1.1.2, fast-diff@npm:^1.3.0": +"fast-diff@npm:^1.3.0": version: 1.3.0 resolution: "fast-diff@npm:1.3.0" checksum: 10c0/5c19af237edb5d5effda008c891a18a585f74bf12953be57923f17a3a4d0979565fc64dbc73b9e20926b9d895f5b690c618cbb969af0cf022e3222471220ad29 @@ -23608,97 +23645,6 @@ __metadata: languageName: node linkType: hard -"prettier-linter-helpers@npm:^1.0.0": - version: 1.0.0 - resolution: "prettier-linter-helpers@npm:1.0.0" - dependencies: - fast-diff: "npm:^1.1.2" - checksum: 10c0/81e0027d731b7b3697ccd2129470ed9913ecb111e4ec175a12f0fcfab0096516373bf0af2fef132af50cafb0a905b74ff57996d615f59512bb9ac7378fcc64ab - languageName: node - linkType: hard - -"prettier-plugin-sort-json@npm:^4.1.1": - version: 4.1.1 - resolution: "prettier-plugin-sort-json@npm:4.1.1" - peerDependencies: - prettier: ^3.0.0 - checksum: 10c0/b3b86ea679e95d0329c367aa860af4033d8a3b83a0656d7393f2005b2df78e20d19fbe2c40fe7b531262210e40060318a4287459c2a4f61c6a6bd1c2757e2b7d - languageName: node - linkType: hard - -"prettier-plugin-tailwindcss@npm:^0.6.14": - version: 0.6.14 - resolution: "prettier-plugin-tailwindcss@npm:0.6.14" - peerDependencies: - "@ianvs/prettier-plugin-sort-imports": "*" - "@prettier/plugin-hermes": "*" - "@prettier/plugin-oxc": "*" - "@prettier/plugin-pug": "*" - "@shopify/prettier-plugin-liquid": "*" - "@trivago/prettier-plugin-sort-imports": "*" - "@zackad/prettier-plugin-twig": "*" - prettier: ^3.0 - prettier-plugin-astro: "*" - prettier-plugin-css-order: "*" - prettier-plugin-import-sort: "*" - prettier-plugin-jsdoc: "*" - prettier-plugin-marko: "*" - prettier-plugin-multiline-arrays: "*" - prettier-plugin-organize-attributes: "*" - prettier-plugin-organize-imports: "*" - prettier-plugin-sort-imports: "*" - prettier-plugin-style-order: "*" - prettier-plugin-svelte: "*" - peerDependenciesMeta: - "@ianvs/prettier-plugin-sort-imports": - optional: true - "@prettier/plugin-hermes": - optional: true - "@prettier/plugin-oxc": - optional: true - "@prettier/plugin-pug": - optional: true - "@shopify/prettier-plugin-liquid": - optional: true - "@trivago/prettier-plugin-sort-imports": - optional: true - "@zackad/prettier-plugin-twig": - optional: true - prettier-plugin-astro: - optional: true - prettier-plugin-css-order: - optional: true - prettier-plugin-import-sort: - optional: true - prettier-plugin-jsdoc: - optional: true - prettier-plugin-marko: - optional: true - prettier-plugin-multiline-arrays: - optional: true - prettier-plugin-organize-attributes: - optional: true - prettier-plugin-organize-imports: - optional: true - prettier-plugin-sort-imports: - optional: true - prettier-plugin-style-order: - optional: true - prettier-plugin-svelte: - optional: true - checksum: 10c0/1bf635be28b30b3f171a184497eecf512601d19328e402dd2eb1ede52aa57b4f5b605eae2929f4916de9ba22526f3d730d9afa90334db09d17c59f463f7b26d8 - languageName: node - linkType: hard - -"prettier@npm:^3.5.3": - version: 3.5.3 - resolution: "prettier@npm:3.5.3" - bin: - prettier: bin/prettier.cjs - checksum: 10c0/3880cb90b9dc0635819ab52ff571518c35bd7f15a6e80a2054c05dbc8a3aa6e74f135519e91197de63705bcb38388ded7e7230e2178432a1468005406238b877 - languageName: node - linkType: hard - "pretty-format@npm:^27.0.2": version: 27.5.1 resolution: "pretty-format@npm:27.5.1" @@ -26857,16 +26803,6 @@ __metadata: languageName: node linkType: hard -"synckit@npm:^0.11.0": - version: 0.11.4 - resolution: "synckit@npm:0.11.4" - dependencies: - "@pkgr/core": "npm:^0.2.3" - tslib: "npm:^2.8.1" - checksum: 10c0/dd2965a37c93c0b652bf07b1fd8d1639a803b65cf34c0cb1b827b8403044fc3b09ec87f681d922a324825127ee95b2e0394e7caccb502f407892d63e903c5276 - languageName: node - linkType: hard - "tailwind-merge@npm:3.3.1": version: 3.3.1 resolution: "tailwind-merge@npm:3.3.1" @@ -27509,7 +27445,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.8.0, tslib@npm:^2.8.1": +"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.8.0": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 From 4d1d3e316fbc4efde41d19ad447a448598592478 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Mon, 15 Sep 2025 19:42:13 +0800 Subject: [PATCH 17/99] feat: use oxlint to speed up lint (#10168) * build: add eslint-plugin-oxlint dependency Add new eslint plugin to enhance linting capabilities with oxlint rules * build(eslint): add oxlint plugin to eslint config Add oxlint plugin as recommended in the documentation to enhance linting capabilities * build: add oxlint v1.15.0 as a dependency * build: add oxlint to linting commands Add oxlint alongside eslint in test:lint and lint scripts for enhanced static analysis * build: add oxlint configuration file Configure oxlint with a comprehensive set of rules for JavaScript/TypeScript code quality checks * chore: update oxlint configuration and related settings - Add oxc to editor code actions on save - Update oxlint configs to use eslint, typescript, and unicorn presets - Extend ignore patterns in oxlint configuration - Simplify oxlint command in package.json scripts - Add oxlint-tsgolint dependency * fix: lint warning * chore: update oxlintrc from eslint.recommended * refactor(lint): update eslint and oxlint configurations - Add src/preload to eslint ignore patterns - Update oxlint env to es2022 and add environment overrides - Adjust several lint rule severities and configurations * fix: lint error * fix(file): replace eslint-disable with oxlint-disable in sanitizeFilename The linter was changed from ESLint to oxlint, so the directive needs to be updated accordingly. * fix: enforce stricter linting by failing on warnings in test:lint script * feat: add recommended ts-eslint rules into exlint * docs: remove outdated comment in oxlint config file * style: disable typescript/no-require-imports rule in oxlint config * docs(utils): fix comment typo from NODE to NOTE * fix(MessageErrorBoundary): correct error description display condition The error description was incorrectly showing in production and hiding in development. Fix the logic to show detailed errors only in development mode * chore: add oxc-vscode extension to recommended list * ci(workflows): reorder format check step in pr-ci.yml * chore: update yarn.lock --- .github/workflows/pr-ci.yml | 6 +- .oxlintrc.json | 215 ++++++++++++++++++ .vscode/extensions.json | 1 + .vscode/settings.json | 1 + eslint.config.mjs | 9 +- package.json | 7 +- src/main/apiServer/middleware/error.ts | 2 +- src/main/ipc.ts | 6 +- src/main/knowledge/embedjs/loader/index.ts | 2 +- src/main/knowledge/embedjs/loader/odLoader.ts | 2 +- src/main/services/ExportService.ts | 2 +- src/main/services/MistralClientManager.ts | 2 +- src/main/services/ProxyManager.ts | 2 +- .../services/ocr/builtin/SystemOcrService.ts | 8 +- .../services/remotefile/FileServiceManager.ts | 2 +- src/main/utils/file.ts | 2 +- src/main/utils/index.ts | 15 +- src/preload/index.ts | 3 - .../src/aiCore/chunk/AiSdkToChunkAdapter.ts | 2 +- .../aiCore/legacy/clients/BaseApiClient.ts | 2 +- .../clients/anthropic/AnthropicAPIClient.ts | 2 +- .../legacy/clients/aws/AwsBedrockAPIClient.ts | 2 +- .../legacy/clients/ppio/PPIOAPIClient.ts | 2 +- .../middleware/common/LoggingMiddleware.ts | 2 +- .../src/aiCore/plugins/telemetryPlugin.ts | 1 + .../MinApp/MinappPopupContainer.tsx | 6 +- .../src/components/Tags/CustomTag.tsx | 2 +- .../src/pages/home/Messages/CitationsList.tsx | 2 +- .../home/Messages/MessageErrorBoundary.tsx | 5 +- .../minapps/components/MinimalToolbar.tsx | 5 +- .../src/pages/paintings/TokenFluxPage.tsx | 2 +- .../WebSearchProvider/LocalSearchProvider.ts | 2 +- src/renderer/src/queue/NotificationQueue.ts | 2 +- src/renderer/src/store/messageBlock.ts | 2 +- src/renderer/src/store/migrate.ts | 2 +- src/renderer/src/store/thunk/messageThunk.ts | 2 +- src/renderer/src/utils/translate.ts | 2 +- tests/__mocks__/MainLoggerService.ts | 2 +- tests/__mocks__/RendererLoggerService.ts | 2 +- yarn.lock | 187 +++++++++++++++ 40 files changed, 464 insertions(+), 61 deletions(-) create mode 100644 .oxlintrc.json diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index e694c69159..137208bff0 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -46,12 +46,12 @@ jobs: - name: Install Dependencies run: yarn install - - name: Format Check - run: yarn format:check - - name: Lint Check run: yarn test:lint + - name: Format Check + run: yarn format:check + - name: Type Check run: yarn typecheck diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000000..6f4accbece --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,215 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "categories": {}, + "env": { + "es2022": true + }, + "globals": {}, + "ignorePatterns": [ + "node_modules/**", + "build/**", + "dist/**", + "out/**", + "local/**", + ".yarn/**", + ".gitignore", + "scripts/cloudflare-worker.js", + "src/main/integration/nutstore/sso/lib/**", + "src/main/integration/cherryin/index.js", + "src/main/integration/nutstore/sso/lib/**", + "src/renderer/src/ui/**", + "packages/**/dist", + "eslint.config.mjs" + ], + "overrides": [ + // set different env + { + "env": { + "node": true + }, + "files": ["src/main/**", "resources/scripts/**", "scripts/**", "playwright.config.ts", "electron.vite.config.ts"] + }, + { + "env": { + "browser": true + }, + "files": [ + "src/renderer/**/*.{ts,tsx}", + "packages/aiCore/**", + "packages/extension-table-plus/**", + "resources/js/**" + ] + }, + { + "env": { + "node": true, + "vitest": true + }, + "files": ["**/__tests__/*.test.{ts,tsx}", "tests/**"] + }, + { + "env": { + "browser": true, + "node": true + }, + "files": ["src/preload/**"] + } + ], + // We don't use the React plugin here because its behavior differs slightly from that of ESLint's React plugin. + "plugins": ["unicorn", "typescript", "oxc", "import"], + "rules": { + "constructor-super": "error", + "for-direction": "error", + "getter-return": "error", + "no-array-constructor": "off", + // "import/no-cycle": "error", // tons of error, bro + "no-async-promise-executor": "error", + "no-caller": "warn", + "no-case-declarations": "error", + "no-class-assign": "error", + "no-compare-neg-zero": "error", + "no-cond-assign": "error", + "no-const-assign": "error", + "no-constant-binary-expression": "error", + "no-constant-condition": "error", + "no-control-regex": "error", + "no-debugger": "error", + "no-delete-var": "error", + "no-dupe-args": "error", + "no-dupe-class-members": "error", + "no-dupe-else-if": "error", + "no-dupe-keys": "error", + "no-duplicate-case": "error", + "no-empty": "error", + "no-empty-character-class": "error", + "no-empty-pattern": "error", + "no-empty-static-block": "error", + "no-eval": "warn", + "no-ex-assign": "error", + "no-extra-boolean-cast": "error", + "no-fallthrough": "warn", + "no-func-assign": "error", + "no-global-assign": "error", + "no-import-assign": "error", + "no-invalid-regexp": "error", + "no-irregular-whitespace": "error", + "no-loss-of-precision": "error", + "no-misleading-character-class": "error", + "no-new-native-nonconstructor": "error", + "no-nonoctal-decimal-escape": "error", + "no-obj-calls": "error", + "no-octal": "error", + "no-prototype-builtins": "error", + "no-redeclare": "error", + "no-regex-spaces": "error", + "no-self-assign": "error", + "no-setter-return": "error", + "no-shadow-restricted-names": "error", + "no-sparse-arrays": "error", + "no-this-before-super": "error", + "no-unassigned-vars": "warn", + "no-undef": "error", + "no-unexpected-multiline": "error", + "no-unreachable": "error", + "no-unsafe-finally": "error", + "no-unsafe-negation": "error", + "no-unsafe-optional-chaining": "error", + "no-unused-expressions": "off", // this rule disallow us to use expression to call function, like `condition && fn()` + "no-unused-labels": "error", + "no-unused-private-class-members": "error", + "no-unused-vars": ["error", { "caughtErrors": "none" }], + "no-useless-backreference": "error", + "no-useless-catch": "error", + "no-useless-escape": "error", + "no-useless-rename": "warn", + "no-with": "error", + "oxc/bad-array-method-on-arguments": "warn", + "oxc/bad-char-at-comparison": "warn", + "oxc/bad-comparison-sequence": "warn", + "oxc/bad-min-max-func": "warn", + "oxc/bad-object-literal-comparison": "warn", + "oxc/bad-replace-all-arg": "warn", + "oxc/const-comparisons": "warn", + "oxc/double-comparisons": "warn", + "oxc/erasing-op": "warn", + "oxc/missing-throw": "warn", + "oxc/number-arg-out-of-range": "warn", + "oxc/only-used-in-recursion": "off", // manually off bacause of existing warning. may turn it on in the future + "oxc/uninvoked-array-callback": "warn", + "require-yield": "error", + "typescript/await-thenable": "warn", + // "typescript/ban-ts-comment": "error", + "typescript/no-array-constructor": "error", + // "typescript/consistent-type-imports": "error", + "typescript/no-array-delete": "warn", + "typescript/no-base-to-string": "warn", + "typescript/no-duplicate-enum-values": "error", + "typescript/no-duplicate-type-constituents": "warn", + "typescript/no-empty-object-type": "off", + "typescript/no-explicit-any": "off", // not safe but too many errors + "typescript/no-extra-non-null-assertion": "error", + "typescript/no-floating-promises": "warn", + "typescript/no-for-in-array": "warn", + "typescript/no-implied-eval": "warn", + "typescript/no-meaningless-void-operator": "warn", + "typescript/no-misused-new": "error", + "typescript/no-misused-spread": "warn", + "typescript/no-namespace": "error", + "typescript/no-non-null-asserted-optional-chain": "off", // it's off now. but may turn it on. + "typescript/no-redundant-type-constituents": "warn", + "typescript/no-require-imports": "off", + "typescript/no-this-alias": "error", + "typescript/no-unnecessary-parameter-property-assignment": "warn", + "typescript/no-unnecessary-type-constraint": "error", + "typescript/no-unsafe-declaration-merging": "error", + "typescript/no-unsafe-function-type": "error", + "typescript/no-unsafe-unary-minus": "warn", + "typescript/no-useless-empty-export": "warn", + "typescript/no-wrapper-object-types": "error", + "typescript/prefer-as-const": "error", + "typescript/prefer-namespace-keyword": "error", + "typescript/require-array-sort-compare": "warn", + "typescript/restrict-template-expressions": "warn", + "typescript/triple-slash-reference": "error", + "typescript/unbound-method": "warn", + "unicorn/no-await-in-promise-methods": "warn", + "unicorn/no-empty-file": "off", // manually off bacause of existing warning. may turn it on in the future + "unicorn/no-invalid-fetch-options": "warn", + "unicorn/no-invalid-remove-event-listener": "warn", + "unicorn/no-new-array": "off", // manually off bacause of existing warning. may turn it on in the future + "unicorn/no-single-promise-in-promise-methods": "warn", + "unicorn/no-thenable": "off", // manually off bacause of existing warning. may turn it on in the future + "unicorn/no-unnecessary-await": "warn", + "unicorn/no-useless-fallback-in-spread": "warn", + "unicorn/no-useless-length-check": "warn", + "unicorn/no-useless-spread": "off", // manually off bacause of existing warning. may turn it on in the future + "unicorn/prefer-set-size": "warn", + "unicorn/prefer-string-starts-ends-with": "warn", + "use-isnan": "error", + "valid-typeof": "error" + }, + "settings": { + "jsdoc": { + "augmentsExtendsReplacesDocs": false, + "exemptDestructuredRootsFromChecks": false, + "ignoreInternal": false, + "ignorePrivate": false, + "ignoreReplacesDocs": true, + "implementsReplacesDocs": false, + "overrideReplacesDocs": true, + "tagNamePreference": {} + }, + "jsx-a11y": { + "attributes": {}, + "components": {}, + "polymorphicPropName": null + }, + "next": { + "rootDir": [] + }, + "react": { + "formComponents": [], + "linkComponents": [] + } + } +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 6e35f94739..167792154a 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -3,6 +3,7 @@ "dbaeumer.vscode-eslint", "editorconfig.editorconfig", "lokalise.i18n-ally", + "oxc.oxc-vscode", "biomejs.biome" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 51f95116fc..141179f38c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -26,6 +26,7 @@ "editor.codeActionsOnSave": { "source.fixAll.biome": "explicit", "source.fixAll.eslint": "explicit", + "source.fixAll.oxc": "explicit", "source.organizeImports": "never" }, "editor.formatOnSave": true, diff --git a/eslint.config.mjs b/eslint.config.mjs index bf31b25037..8eda2e6fa8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -2,6 +2,7 @@ import tseslint from '@electron-toolkit/eslint-config-ts' import eslint from '@eslint/js' import eslintReact from '@eslint-react/eslint-plugin' import { defineConfig } from 'eslint/config' +import oxlint from 'eslint-plugin-oxlint' import reactHooks from 'eslint-plugin-react-hooks' import simpleImportSort from 'eslint-plugin-simple-import-sort' import unusedImports from 'eslint-plugin-unused-imports' @@ -50,7 +51,7 @@ export default defineConfig([ { // LoggerService Custom Rules - only apply to src directory files: ['src/**/*.{ts,tsx,js,jsx}'], - ignores: ['src/**/__tests__/**', 'src/**/__mocks__/**', 'src/**/*.test.*'], + ignores: ['src/**/__tests__/**', 'src/**/__mocks__/**', 'src/**/*.test.*', 'src/preload/**'], rules: { 'no-restricted-syntax': [ process.env.PRCI ? 'error' : 'warn', @@ -125,5 +126,9 @@ export default defineConfig([ 'src/renderer/src/ui/**', 'packages/**/dist' ] - } + }, + // turn off oxlint supported rules. + ...oxlint.configs['flat/eslint'], + ...oxlint.configs['flat/typescript'], + ...oxlint.configs['flat/unicorn'] ]) diff --git a/package.json b/package.json index 20e3d5840f..502775a8ba 100644 --- a/package.json +++ b/package.json @@ -63,11 +63,11 @@ "test:ui": "vitest --ui", "test:watch": "vitest", "test:e2e": "yarn playwright test", - "test:lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts", + "test:lint": "oxlint --deny-warnings && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts", "test:scripts": "vitest scripts", + "lint": "oxlint --fix && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix && yarn typecheck && yarn check:i18n", "format": "biome format --write && biome lint --write", "format:check": "biome format && biome lint", - "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix && yarn typecheck && yarn check:i18n", "prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky", "claude": "dotenv -e .env -- claude" }, @@ -245,6 +245,7 @@ "emoji-picker-element": "^1.22.1", "epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch", "eslint": "^9.22.0", + "eslint-plugin-oxlint": "^1.15.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-unused-imports": "^4.1.4", @@ -280,6 +281,8 @@ "notion-helper": "^1.3.22", "npx-scope-finder": "^1.2.0", "openai": "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch", + "oxlint": "^1.15.0", + "oxlint-tsgolint": "^0.2.0", "p-queue": "^8.1.0", "pdf-lib": "^1.17.1", "pdf-parse": "^1.1.1", diff --git a/src/main/apiServer/middleware/error.ts b/src/main/apiServer/middleware/error.ts index 65eef5e43d..6aa1819ddd 100644 --- a/src/main/apiServer/middleware/error.ts +++ b/src/main/apiServer/middleware/error.ts @@ -4,7 +4,7 @@ import { loggerService } from '../../services/LoggerService' const logger = loggerService.withContext('ApiServerErrorHandler') -// eslint-disable-next-line @typescript-eslint/no-unused-vars +// oxlint-disable-next-line @typescript-eslint/no-unused-vars export const errorHandler = (err: Error, _req: Request, res: Response, _next: NextFunction) => { logger.error('API Server Error:', err) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 5b4f0db1e8..d889a098f8 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -11,7 +11,7 @@ import { handleZoomFactor } from '@main/utils/zoom' import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, UpgradeChannel } from '@shared/config/constant' import { IpcChannel } from '@shared/IpcChannel' -import { FileMetadata, Provider, Shortcut, ThemeMode } from '@types' +import { FileMetadata, OcrProvider, Provider, Shortcut, SupportedOcrFile, ThemeMode } from '@types' import checkDiskSpace from 'check-disk-space' import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPreferences, webContents } from 'electron' import fontList from 'font-list' @@ -827,7 +827,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.CodeTools_Run, codeToolsService.run) // OCR - ipcMain.handle(IpcChannel.OCR_ocr, (_, ...args: Parameters) => ocrService.ocr(...args)) + ipcMain.handle(IpcChannel.OCR_ocr, (_, file: SupportedOcrFile, provider: OcrProvider) => + ocrService.ocr(file, provider) + ) // CherryIN ipcMain.handle(IpcChannel.Cherryin_GetSignature, (_, params) => generateSignature(params)) diff --git a/src/main/knowledge/embedjs/loader/index.ts b/src/main/knowledge/embedjs/loader/index.ts index 6d9b4c7ace..4b38418194 100644 --- a/src/main/knowledge/embedjs/loader/index.ts +++ b/src/main/knowledge/embedjs/loader/index.ts @@ -139,9 +139,9 @@ export async function addFileLoader( if (jsonParsed) { loaderReturn = await ragApplication.addLoader(new JsonLoader({ object: jsonObject }), forceReload) - break } // fallthrough - JSON 解析失败时作为文本处理 + // oxlint-disable-next-line no-fallthrough 利用switch特性,刻意不break default: // 文本类型处理(默认) // 如果是其他文本类型且尚未读取文件,则读取文件 diff --git a/src/main/knowledge/embedjs/loader/odLoader.ts b/src/main/knowledge/embedjs/loader/odLoader.ts index ad2a0e119d..03825bf4db 100644 --- a/src/main/knowledge/embedjs/loader/odLoader.ts +++ b/src/main/knowledge/embedjs/loader/odLoader.ts @@ -11,7 +11,7 @@ export enum OdType { OdtLoader = 'OdtLoader', OdsLoader = 'OdsLoader', OdpLoader = 'OdpLoader', - undefined = 'undefined' + Undefined = 'undefined' } export class OdLoader extends BaseLoader<{ type: string }> { diff --git a/src/main/services/ExportService.ts b/src/main/services/ExportService.ts index 8aa10cb466..c6dfa604cf 100644 --- a/src/main/services/ExportService.ts +++ b/src/main/services/ExportService.ts @@ -1,4 +1,4 @@ -/* eslint-disable no-case-declarations */ +/* oxlint-disable no-case-declarations */ // ExportService import { loggerService } from '@logger' diff --git a/src/main/services/MistralClientManager.ts b/src/main/services/MistralClientManager.ts index fa4aa53df8..c2efe35d1d 100644 --- a/src/main/services/MistralClientManager.ts +++ b/src/main/services/MistralClientManager.ts @@ -5,7 +5,7 @@ export class MistralClientManager { private static instance: MistralClientManager private client: Mistral | null = null - // eslint-disable-next-line @typescript-eslint/no-empty-function + // oxlint-disable-next-line @typescript-eslint/no-empty-function private constructor() {} public static getInstance(): MistralClientManager { diff --git a/src/main/services/ProxyManager.ts b/src/main/services/ProxyManager.ts index 860324bc62..0e188c1009 100644 --- a/src/main/services/ProxyManager.ts +++ b/src/main/services/ProxyManager.ts @@ -235,7 +235,7 @@ export class ProxyManager { https.request = this.bindHttpMethod(this.originalHttpsRequest, agent) } - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + // oxlint-disable-next-line @typescript-eslint/no-unsafe-function-type private bindHttpMethod(originalMethod: Function, agent: http.Agent | https.Agent) { return (...args: any[]) => { let url: string | URL | undefined diff --git a/src/main/services/ocr/builtin/SystemOcrService.ts b/src/main/services/ocr/builtin/SystemOcrService.ts index 34a8bb8ce9..b496df398e 100644 --- a/src/main/services/ocr/builtin/SystemOcrService.ts +++ b/src/main/services/ocr/builtin/SystemOcrService.ts @@ -1,13 +1,7 @@ import { isLinux, isWin } from '@main/constant' import { loadOcrImage } from '@main/utils/ocr' import { OcrAccuracy, recognize } from '@napi-rs/system-ocr' -import { - ImageFileMetadata, - isImageFileMetadata as isImageFileMetadata, - OcrResult, - OcrSystemConfig, - SupportedOcrFile -} from '@types' +import { ImageFileMetadata, isImageFileMetadata, OcrResult, OcrSystemConfig, SupportedOcrFile } from '@types' import { OcrBaseService } from './OcrBaseService' diff --git a/src/main/services/remotefile/FileServiceManager.ts b/src/main/services/remotefile/FileServiceManager.ts index f456ba285d..14f622757a 100644 --- a/src/main/services/remotefile/FileServiceManager.ts +++ b/src/main/services/remotefile/FileServiceManager.ts @@ -9,7 +9,7 @@ export class FileServiceManager { private static instance: FileServiceManager private services: Map = new Map() - // eslint-disable-next-line @typescript-eslint/no-empty-function + // oxlint-disable-next-line @typescript-eslint/no-empty-function private constructor() {} static getInstance(): FileServiceManager { diff --git a/src/main/utils/file.ts b/src/main/utils/file.ts index f9363a500e..5c197e8971 100644 --- a/src/main/utils/file.ts +++ b/src/main/utils/file.ts @@ -420,7 +420,7 @@ export function sanitizeFilename(fileName: string, replacement = '_'): string { // 移除或替换非法字符 let sanitized = fileName - // eslint-disable-next-line no-control-regex + // oxlint-disable-next-line no-control-regex .replace(/[<>:"/\\|?*\x00-\x1f]/g, replacement) // Windows 非法字符 .replace(/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i, replacement + '$2') // Windows 保留名 .replace(/[\s.]+$/, '') // 移除末尾的空格和点 diff --git a/src/main/utils/index.ts b/src/main/utils/index.ts index 4fea14c9fe..6b2ac42788 100644 --- a/src/main/utils/index.ts +++ b/src/main/utils/index.ts @@ -36,13 +36,14 @@ export function debounce(func: (...args: any[]) => void, wait: number, immediate } } -export function dumpPersistState() { - const persistState = JSON.parse(localStorage.getItem('persist:cherry-studio') || '{}') - for (const key in persistState) { - persistState[key] = JSON.parse(persistState[key]) - } - return JSON.stringify(persistState) -} +// NOTE: It's an unused function. localStorage should not be accessed in main process. +// export function dumpPersistState() { +// const persistState = JSON.parse(localStorage.getItem('persist:cherry-studio') || '{}') +// for (const key in persistState) { +// persistState[key] = JSON.parse(persistState[key]) +// } +// return JSON.stringify(persistState) +// } export const runAsyncFunction = async (fn: () => void) => { await fn() diff --git a/src/preload/index.ts b/src/preload/index.ts index 12cab36118..6633629802 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -475,13 +475,10 @@ if (process.contextIsolated) { contextBridge.exposeInMainWorld('electron', electronAPI) contextBridge.exposeInMainWorld('api', api) } catch (error) { - // eslint-disable-next-line no-restricted-syntax console.error('[Preload]Failed to expose APIs:', error as Error) } } else { - // @ts-ignore (define in dts) window.electron = electronAPI - // @ts-ignore (define in dts) window.api = api } diff --git a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts index 8af4388d5f..a65c6fe790 100644 --- a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts +++ b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts @@ -314,7 +314,7 @@ export class AiSdkToChunkAdapter { // === 源和文件相关事件 === case 'source': if (chunk.sourceType === 'url') { - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line @typescript-eslint/no-unused-vars const { sourceType: _, ...rest } = chunk final.webSearchResults.push(rest) } diff --git a/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts b/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts index 430b9e4df7..9fb687a265 100644 --- a/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts @@ -84,7 +84,7 @@ export abstract class BaseApiClient< * 用于判断客户端是否支持特定功能,避免instanceof检查的类型收窄问题 * 对于装饰器模式的客户端(如AihubmixAPIClient),应该返回其内部实际使用的客户端类型 */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line @typescript-eslint/no-unused-vars public getClientCompatibilityType(_model?: Model): string[] { // 默认返回类的名称 return [this.constructor.name] diff --git a/src/renderer/src/aiCore/legacy/clients/anthropic/AnthropicAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/anthropic/AnthropicAPIClient.ts index dea98f2808..aef3e07905 100644 --- a/src/renderer/src/aiCore/legacy/clients/anthropic/AnthropicAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/anthropic/AnthropicAPIClient.ts @@ -177,7 +177,7 @@ export class AnthropicAPIClient extends BaseApiClient< } // @ts-ignore sdk未提供 - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line @typescript-eslint/no-unused-vars override async generateImage(generateImageParams: GenerateImageParams): Promise { return [] } diff --git a/src/renderer/src/aiCore/legacy/clients/aws/AwsBedrockAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/aws/AwsBedrockAPIClient.ts index 9bcadd0c55..1de8a724c4 100644 --- a/src/renderer/src/aiCore/legacy/clients/aws/AwsBedrockAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/aws/AwsBedrockAPIClient.ts @@ -455,7 +455,7 @@ export class AwsBedrockAPIClient extends BaseApiClient< } // @ts-ignore sdk未提供 - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line @typescript-eslint/no-unused-vars override async generateImage(_generateImageParams: GenerateImageParams): Promise { return [] } diff --git a/src/renderer/src/aiCore/legacy/clients/ppio/PPIOAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/ppio/PPIOAPIClient.ts index 684d550698..fd282ac815 100644 --- a/src/renderer/src/aiCore/legacy/clients/ppio/PPIOAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/ppio/PPIOAPIClient.ts @@ -11,7 +11,7 @@ export class PPIOAPIClient extends OpenAIAPIClient { super(provider) } - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line @typescript-eslint/no-unused-vars override getClientCompatibilityType(_model?: Model): string[] { return ['OpenAIAPIClient'] } diff --git a/src/renderer/src/aiCore/legacy/middleware/common/LoggingMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/common/LoggingMiddleware.ts index dd79ef457d..acd371d777 100644 --- a/src/renderer/src/aiCore/legacy/middleware/common/LoggingMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/common/LoggingMiddleware.ts @@ -44,7 +44,7 @@ const stringifyArgsForLogging = (args: any[]): string => { */ export const createGenericLoggingMiddleware: () => MethodMiddleware = () => { const middlewareName = 'GenericLoggingMiddleware' - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line @typescript-eslint/no-unused-vars return (_: MiddlewareAPI) => (next) => async (ctx, args) => { const methodName = ctx.methodName const logPrefix = `[${middlewareName} (${methodName})]` diff --git a/src/renderer/src/aiCore/plugins/telemetryPlugin.ts b/src/renderer/src/aiCore/plugins/telemetryPlugin.ts index 6eb66575c5..75bf6e116c 100644 --- a/src/renderer/src/aiCore/plugins/telemetryPlugin.ts +++ b/src/renderer/src/aiCore/plugins/telemetryPlugin.ts @@ -66,6 +66,7 @@ class AdapterTracer { spanName: name, topicId: this.topicId, modelName: this.modelName, + // oxlint-disable-next-line no-undef False alarm. see https://github.com/oxc-project/oxc/issues/4232 argCount: arguments.length }) diff --git a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx index 8416774406..848173e9b1 100644 --- a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx +++ b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx @@ -12,7 +12,7 @@ import { } from '@ant-design/icons' import { loggerService } from '@logger' import WindowControls from '@renderer/components/WindowControls' -import { isLinux, isMac, isWin } from '@renderer/config/constant' +import { isDev, isLinux, isMac, isWin } from '@renderer/config/constant' import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' import { useBridge } from '@renderer/hooks/useBridge' import { useMinappPopup } from '@renderer/hooks/useMinappPopup' @@ -170,8 +170,6 @@ const MinappPopupContainer: React.FC = () => { const { isLeftNavbar } = useNavbarPosition() - const isInDevelopment = process.env.NODE_ENV === 'development' - const { setTimeoutTimer } = useTimer() useBridge() @@ -477,7 +475,7 @@ const MinappPopupContainer: React.FC = () => { - {isInDevelopment && ( + {isDev && ( handleOpenDevTools(appInfo.id)}> diff --git a/src/renderer/src/components/Tags/CustomTag.tsx b/src/renderer/src/components/Tags/CustomTag.tsx index 561b3edf41..3ecba381d4 100644 --- a/src/renderer/src/components/Tags/CustomTag.tsx +++ b/src/renderer/src/components/Tags/CustomTag.tsx @@ -43,7 +43,7 @@ const CustomTag: FC = ({ ...(disabled && { cursor: 'not-allowed' }), ...style }}> - {icon && icon} {children} + {icon} {children} {closable && ( = ({ citation }) => { {citation.number} {citation.content && } - {citation.content && citation.content} + {citation.content ?? ''} ) diff --git a/src/renderer/src/pages/home/Messages/MessageErrorBoundary.tsx b/src/renderer/src/pages/home/Messages/MessageErrorBoundary.tsx index 52f017aed6..c4a64d58cf 100644 --- a/src/renderer/src/pages/home/Messages/MessageErrorBoundary.tsx +++ b/src/renderer/src/pages/home/Messages/MessageErrorBoundary.tsx @@ -1,3 +1,4 @@ +import { isProd } from '@renderer/config/constant' import { Alert } from 'antd' import React from 'react' import { useTranslation } from 'react-i18next' @@ -17,9 +18,7 @@ const ErrorFallback = ({ fallback, error }: { fallback?: React.ReactNode; error? // 如果有详细错误信息,添加到描述中 const errorDescription = - process.env.NODE_ENV !== 'production' && error - ? `${t('error.render.description')}: ${error.message}` - : t('error.render.description') + !isProd && error ? `${t('error.render.description')}: ${error.message}` : t('error.render.description') return fallback || } diff --git a/src/renderer/src/pages/minapps/components/MinimalToolbar.tsx b/src/renderer/src/pages/minapps/components/MinimalToolbar.tsx index 60a43b8684..dcbe7adeff 100644 --- a/src/renderer/src/pages/minapps/components/MinimalToolbar.tsx +++ b/src/renderer/src/pages/minapps/components/MinimalToolbar.tsx @@ -8,6 +8,7 @@ import { PushpinOutlined, ReloadOutlined } from '@ant-design/icons' +import { isDev } from '@renderer/config/constant' import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' import { useMinapps } from '@renderer/hooks/useMinapps' import { useSettings } from '@renderer/hooks/useSettings' @@ -37,8 +38,6 @@ const MinimalToolbar: FC = ({ app, webviewRef, currentUrl, onReload, onOp const navigate = useNavigate() const [canGoBack, setCanGoBack] = useState(false) const [canGoForward, setCanGoForward] = useState(false) - - const isInDevelopment = process.env.NODE_ENV === 'development' const canPinned = DEFAULT_MIN_APPS.some((item) => item.id === app.id) const isPinned = pinned.some((item) => item.id === app.id) const canOpenExternalLink = app.url.startsWith('http://') || app.url.startsWith('https://') @@ -139,7 +138,7 @@ const MinimalToolbar: FC = ({ app, webviewRef, currentUrl, onReload, onOp - {isInDevelopment && ( + {isDev && ( diff --git a/src/renderer/src/pages/paintings/TokenFluxPage.tsx b/src/renderer/src/pages/paintings/TokenFluxPage.tsx index 0860b62ee9..ea5e4ed47f 100644 --- a/src/renderer/src/pages/paintings/TokenFluxPage.tsx +++ b/src/renderer/src/pages/paintings/TokenFluxPage.tsx @@ -298,7 +298,7 @@ const TokenFluxPage: FC<{ Options: string[] }> = ({ Options }) => { // Set form data from painting's input params if (newPainting.inputParams) { // Filter out the prompt from inputParams since it's handled separately - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line @typescript-eslint/no-unused-vars const { prompt, ...formInputParams } = newPainting.inputParams setFormData(formInputParams) } else { diff --git a/src/renderer/src/providers/WebSearchProvider/LocalSearchProvider.ts b/src/renderer/src/providers/WebSearchProvider/LocalSearchProvider.ts index 7911646630..2a17dc3a26 100644 --- a/src/renderer/src/providers/WebSearchProvider/LocalSearchProvider.ts +++ b/src/renderer/src/providers/WebSearchProvider/LocalSearchProvider.ts @@ -95,7 +95,7 @@ export default class LocalSearchProvider extends BaseWebSearchProvider { return query } - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line @typescript-eslint/no-unused-vars protected parseValidUrls(_htmlContent: string): SearchItem[] { throw new Error('Not implemented') } diff --git a/src/renderer/src/queue/NotificationQueue.ts b/src/renderer/src/queue/NotificationQueue.ts index f256212837..9f8c9cb799 100644 --- a/src/renderer/src/queue/NotificationQueue.ts +++ b/src/renderer/src/queue/NotificationQueue.ts @@ -8,7 +8,7 @@ export class NotificationQueue { private queue = new PQueue({ concurrency: 1 }) private listeners: NotificationListener[] = [] - // eslint-disable-next-line @typescript-eslint/no-empty-function + // oxlint-disable-next-line @typescript-eslint/no-empty-function private constructor() {} public static getInstance(): NotificationQueue { diff --git a/src/renderer/src/store/messageBlock.ts b/src/renderer/src/store/messageBlock.ts index 368696a4d0..524889bb32 100644 --- a/src/renderer/src/store/messageBlock.ts +++ b/src/renderer/src/store/messageBlock.ts @@ -230,7 +230,7 @@ export const formatCitationsFromBlock = (block: CitationMessageBlock | undefined break case WebSearchSource.AISDK: formattedCitations = - (block.response.results && (block.response.results as AISDKWebSearchResult[]))?.map((result, index) => ({ + (block.response?.results as AISDKWebSearchResult[])?.map((result, index) => ({ number: index + 1, url: result.url, title: result.title || new URL(result.url).hostname, diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 13fb71f544..c939007d0b 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1394,7 +1394,7 @@ const migrateConfig = { if (state.websearch?.providers) { state.websearch.providers = state.websearch.providers.map((provider) => { if (provider.id === 'exa' || provider.id === 'tavily') { - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line @typescript-eslint/no-unused-vars const { basicAuthUsername, basicAuthPassword, ...rest } = provider return rest } diff --git a/src/renderer/src/store/thunk/messageThunk.ts b/src/renderer/src/store/thunk/messageThunk.ts index 42195eb8bb..8d7004ecc7 100644 --- a/src/renderer/src/store/thunk/messageThunk.ts +++ b/src/renderer/src/store/thunk/messageThunk.ts @@ -1171,7 +1171,7 @@ export const updateMessageAndBlocksThunk = try { // 1. 更新 Redux Store if (messageUpdates && messageId) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // oxlint-disable-next-line @typescript-eslint/no-unused-vars const { id: msgId, ...actualMessageChanges } = messageUpdates // Separate ID from actual changes // Only dispatch message update if there are actual changes beyond the ID diff --git a/src/renderer/src/utils/translate.ts b/src/renderer/src/utils/translate.ts index 89d6726c18..f717ee015a 100644 --- a/src/renderer/src/utils/translate.ts +++ b/src/renderer/src/utils/translate.ts @@ -1,7 +1,7 @@ import { loggerService } from '@logger' import { isQwenMTModel } from '@renderer/config/models' import { LANG_DETECT_PROMPT } from '@renderer/config/prompts' -import { builtinLanguages as builtinLanguages, LanguagesEnum, UNKNOWN } from '@renderer/config/translate' +import { builtinLanguages, LanguagesEnum, UNKNOWN } from '@renderer/config/translate' import db from '@renderer/databases' import i18n from '@renderer/i18n' import { fetchChatCompletion } from '@renderer/services/ApiService' diff --git a/tests/__mocks__/MainLoggerService.ts b/tests/__mocks__/MainLoggerService.ts index 43b1f33e4a..e7c8a6f198 100644 --- a/tests/__mocks__/MainLoggerService.ts +++ b/tests/__mocks__/MainLoggerService.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-empty-function */ +/* oslint-disable @typescript-eslint/no-empty-function */ // Simple mock LoggerService class for main process export class MockMainLoggerService { diff --git a/tests/__mocks__/RendererLoggerService.ts b/tests/__mocks__/RendererLoggerService.ts index 40f77635eb..90769dc5b8 100644 --- a/tests/__mocks__/RendererLoggerService.ts +++ b/tests/__mocks__/RendererLoggerService.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-empty-function */ +/* oxlint-disable @typescript-eslint/no-empty-function */ // Simple mock LoggerService class for renderer process export class MockRendererLoggerService { diff --git a/yarn.lock b/yarn.lock index 7b6ba7b4d6..daaf739c67 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7225,6 +7225,104 @@ __metadata: languageName: node linkType: hard +"@oxlint-tsgolint/darwin-arm64@npm:0.2.0": + version: 0.2.0 + resolution: "@oxlint-tsgolint/darwin-arm64@npm:0.2.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@oxlint-tsgolint/darwin-x64@npm:0.2.0": + version: 0.2.0 + resolution: "@oxlint-tsgolint/darwin-x64@npm:0.2.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@oxlint-tsgolint/linux-arm64@npm:0.2.0": + version: 0.2.0 + resolution: "@oxlint-tsgolint/linux-arm64@npm:0.2.0" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@oxlint-tsgolint/linux-x64@npm:0.2.0": + version: 0.2.0 + resolution: "@oxlint-tsgolint/linux-x64@npm:0.2.0" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@oxlint-tsgolint/win32-arm64@npm:0.2.0": + version: 0.2.0 + resolution: "@oxlint-tsgolint/win32-arm64@npm:0.2.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@oxlint-tsgolint/win32-x64@npm:0.2.0": + version: 0.2.0 + resolution: "@oxlint-tsgolint/win32-x64@npm:0.2.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@oxlint/darwin-arm64@npm:1.15.0": + version: 1.15.0 + resolution: "@oxlint/darwin-arm64@npm:1.15.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@oxlint/darwin-x64@npm:1.15.0": + version: 1.15.0 + resolution: "@oxlint/darwin-x64@npm:1.15.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@oxlint/linux-arm64-gnu@npm:1.15.0": + version: 1.15.0 + resolution: "@oxlint/linux-arm64-gnu@npm:1.15.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@oxlint/linux-arm64-musl@npm:1.15.0": + version: 1.15.0 + resolution: "@oxlint/linux-arm64-musl@npm:1.15.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@oxlint/linux-x64-gnu@npm:1.15.0": + version: 1.15.0 + resolution: "@oxlint/linux-x64-gnu@npm:1.15.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@oxlint/linux-x64-musl@npm:1.15.0": + version: 1.15.0 + resolution: "@oxlint/linux-x64-musl@npm:1.15.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@oxlint/win32-arm64@npm:1.15.0": + version: 1.15.0 + resolution: "@oxlint/win32-arm64@npm:1.15.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@oxlint/win32-x64@npm:1.15.0": + version: 1.15.0 + resolution: "@oxlint/win32-x64@npm:1.15.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@pdf-lib/standard-fonts@npm:^1.0.0": version: 1.0.0 resolution: "@pdf-lib/standard-fonts@npm:1.0.0" @@ -13224,6 +13322,7 @@ __metadata: emoji-picker-element: "npm:^1.22.1" epub: "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch" eslint: "npm:^9.22.0" + eslint-plugin-oxlint: "npm:^1.15.0" eslint-plugin-react-hooks: "npm:^5.2.0" eslint-plugin-simple-import-sort: "npm:^12.1.1" eslint-plugin-unused-imports: "npm:^4.1.4" @@ -13266,6 +13365,8 @@ __metadata: officeparser: "npm:^4.2.0" openai: "patch:openai@npm%3A5.12.2#~/.yarn/patches/openai-npm-5.12.2-30b075401c.patch" os-proxy-config: "npm:^1.1.2" + oxlint: "npm:^1.15.0" + oxlint-tsgolint: "npm:^0.2.0" p-queue: "npm:^8.1.0" pdf-lib: "npm:^1.17.1" pdf-parse: "npm:^1.1.1" @@ -17046,6 +17147,15 @@ __metadata: languageName: node linkType: hard +"eslint-plugin-oxlint@npm:^1.15.0": + version: 1.15.0 + resolution: "eslint-plugin-oxlint@npm:1.15.0" + dependencies: + jsonc-parser: "npm:^3.3.1" + checksum: 10c0/1c16e4a70fe3bc7ca4cda28393bb00f20d2b009121d5804dc3e9a247adb2773236e542078d6a0f4d4168e4ba68cb88fe5ad8efaee52bec24f72c16b1dad2990c + languageName: node + linkType: hard + "eslint-plugin-react-debug@npm:1.48.1": version: 1.48.1 resolution: "eslint-plugin-react-debug@npm:1.48.1" @@ -19922,6 +20032,13 @@ __metadata: languageName: node linkType: hard +"jsonc-parser@npm:^3.3.1": + version: 3.3.1 + resolution: "jsonc-parser@npm:3.3.1" + checksum: 10c0/269c3ae0a0e4f907a914bf334306c384aabb9929bd8c99f909275ebd5c2d3bc70b9bcd119ad794f339dec9f24b6a4ee9cd5a8ab2e6435e730ad4075388fc2ab6 + languageName: node + linkType: hard + "jsonfile@npm:^4.0.0": version: 4.0.0 resolution: "jsonfile@npm:4.0.0" @@ -22982,6 +23099,76 @@ __metadata: languageName: node linkType: hard +"oxlint-tsgolint@npm:^0.2.0": + version: 0.2.0 + resolution: "oxlint-tsgolint@npm:0.2.0" + dependencies: + "@oxlint-tsgolint/darwin-arm64": "npm:0.2.0" + "@oxlint-tsgolint/darwin-x64": "npm:0.2.0" + "@oxlint-tsgolint/linux-arm64": "npm:0.2.0" + "@oxlint-tsgolint/linux-x64": "npm:0.2.0" + "@oxlint-tsgolint/win32-arm64": "npm:0.2.0" + "@oxlint-tsgolint/win32-x64": "npm:0.2.0" + dependenciesMeta: + "@oxlint-tsgolint/darwin-arm64": + optional: true + "@oxlint-tsgolint/darwin-x64": + optional: true + "@oxlint-tsgolint/linux-arm64": + optional: true + "@oxlint-tsgolint/linux-x64": + optional: true + "@oxlint-tsgolint/win32-arm64": + optional: true + "@oxlint-tsgolint/win32-x64": + optional: true + bin: + tsgolint: bin/tsgolint.js + checksum: 10c0/b2117a0d07c5c876a6608d710838c934ef456cf7cff668fba9455d380eb8e3a7d9841c8f3a03d59bbc77b0f1342d5dca0e69557cac361a1afa8b8eb3d1b114c6 + languageName: node + linkType: hard + +"oxlint@npm:^1.15.0": + version: 1.15.0 + resolution: "oxlint@npm:1.15.0" + dependencies: + "@oxlint/darwin-arm64": "npm:1.15.0" + "@oxlint/darwin-x64": "npm:1.15.0" + "@oxlint/linux-arm64-gnu": "npm:1.15.0" + "@oxlint/linux-arm64-musl": "npm:1.15.0" + "@oxlint/linux-x64-gnu": "npm:1.15.0" + "@oxlint/linux-x64-musl": "npm:1.15.0" + "@oxlint/win32-arm64": "npm:1.15.0" + "@oxlint/win32-x64": "npm:1.15.0" + peerDependencies: + oxlint-tsgolint: ">=0.2.0" + dependenciesMeta: + "@oxlint/darwin-arm64": + optional: true + "@oxlint/darwin-x64": + optional: true + "@oxlint/linux-arm64-gnu": + optional: true + "@oxlint/linux-arm64-musl": + optional: true + "@oxlint/linux-x64-gnu": + optional: true + "@oxlint/linux-x64-musl": + optional: true + "@oxlint/win32-arm64": + optional: true + "@oxlint/win32-x64": + optional: true + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + bin: + oxc_language_server: bin/oxc_language_server + oxlint: bin/oxlint + checksum: 10c0/3eb2a27b972f2a02200b068345ab6a3a17f7bc29c4546c6b3478727388d8d59b94a554f9b6bb1320b71a75cc598b728de0ffee5e4e70ac27457104b8efebb257 + languageName: node + linkType: hard + "p-cancelable@npm:^2.0.0": version: 2.1.1 resolution: "p-cancelable@npm:2.1.1" From 85b8724c73ea0423ace1b2e62d863c78bb693442 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Tue, 16 Sep 2025 00:11:47 +0800 Subject: [PATCH 18/99] chore: add eslint cache to gitignore and enable cache in lint commands (#10167) Enable eslint cache to improve linting performance and add .eslintcache to gitignore --- .gitignore | 1 + package.json | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 39b5630926..fcaa2be164 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ dist out mcp_server stats.html +.eslintcache # ENV .env diff --git a/package.json b/package.json index 502775a8ba..b01cc696da 100644 --- a/package.json +++ b/package.json @@ -63,9 +63,9 @@ "test:ui": "vitest --ui", "test:watch": "vitest", "test:e2e": "yarn playwright test", - "test:lint": "oxlint --deny-warnings && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts", + "test:lint": "oxlint --deny-warnings && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --cache", "test:scripts": "vitest scripts", - "lint": "oxlint --fix && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix && yarn typecheck && yarn check:i18n", + "lint": "oxlint --fix && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --cache && yarn typecheck && yarn check:i18n", "format": "biome format --write && biome lint --write", "format:check": "biome format && biome lint", "prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky", From d44654f003fb377eee5b8e788284971588a6903b Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Tue, 16 Sep 2025 00:53:46 +0800 Subject: [PATCH 19/99] chore: add tailwindcss and vitest extensions to vscode config (#10169) --- .vscode/extensions.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 167792154a..79046aa441 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -3,6 +3,8 @@ "dbaeumer.vscode-eslint", "editorconfig.editorconfig", "lokalise.i18n-ally", + "bradlc.vscode-tailwindcss", + "vitest.explorer", "oxc.oxc-vscode", "biomejs.biome" ] From c641b116baed315d6de722dbb11cb31f201c722a Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Tue, 16 Sep 2025 02:10:28 +0800 Subject: [PATCH 20/99] fix(markdown): broken layout in translate page if rendered as long markdown (#10187) * style(markdown): improve code block styling and layout - Add text-wrap to pre elements for better readability - Force background color for code blocks with !important - Change display to grid in translate page for consistent layout - Add overflow hidden to output container * style(markdown): remove redundant !important from code block styling Move !important declaration to output container's markdown pre selector where it's actually needed --- src/renderer/src/assets/styles/markdown.css | 1 + src/renderer/src/pages/translate/TranslatePage.tsx | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/assets/styles/markdown.css b/src/renderer/src/assets/styles/markdown.css index 5a1ca236d6..395247c0b0 100644 --- a/src/renderer/src/assets/styles/markdown.css +++ b/src/renderer/src/assets/styles/markdown.css @@ -121,6 +121,7 @@ border-radius: 5px; word-break: keep-all; white-space: pre; + text-wrap: wrap; } .markdown code { diff --git a/src/renderer/src/pages/translate/TranslatePage.tsx b/src/renderer/src/pages/translate/TranslatePage.tsx index 91171fd441..e883955eb1 100644 --- a/src/renderer/src/pages/translate/TranslatePage.tsx +++ b/src/renderer/src/pages/translate/TranslatePage.tsx @@ -846,7 +846,8 @@ const ContentContainer = styled.div<{ $historyDrawerVisible: boolean }>` ` const AreaContainer = styled.div` - display: flex; + display: grid; + grid-template-columns: 1fr 1fr; flex: 1; gap: 8px; ` @@ -917,6 +918,11 @@ const OutputContainer = styled.div` border-radius: 10px; padding: 10px 5px; height: calc(100vh - var(--navbar-height) - 70px); + overflow: hidden; + + & > div > .markdown > pre { + background-color: var(--color-background-mute) !important; + } &:hover .copy-button { opacity: 1; From 5451e2f34ab530986c474f37e9a7393a7797ed3f Mon Sep 17 00:00:00 2001 From: SuYao Date: Tue, 16 Sep 2025 10:16:42 +0800 Subject: [PATCH 21/99] Perf/tsgo (#10188) * feat: update tsgo * chore: update alpha.15 * feat: add script * chore: update ai-core version to alpha.16 and add npm registry settings * chore --- .vscode/extensions.json | 3 +- .vscode/settings.json | 3 +- .yarnrc.yml | 2 + package.json | 23 +- packages/aiCore/package.json | 32 +- src/main/ipc.ts | 3 +- src/main/services/NotificationService.ts | 2 +- src/preload/index.ts | 2 +- .../aiCore/prepareParams/parameterBuilder.ts | 10 +- src/renderer/src/aiCore/utils/mcp.ts | 2 +- src/renderer/src/types/index.ts | 1 + tsconfig.node.json | 13 +- tsconfig.web.json | 21 +- yarn.lock | 338 ++++++++++-------- 14 files changed, 250 insertions(+), 205 deletions(-) diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 79046aa441..a08379caed 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -6,6 +6,7 @@ "bradlc.vscode-tailwindcss", "vitest.explorer", "oxc.oxc-vscode", - "biomejs.biome" + "biomejs.biome", + "typescriptteam.native-preview" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 141179f38c..2d62fde832 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -47,5 +47,6 @@ "search.exclude": { "**/dist/**": true, ".yarn/releases/**": true - } + }, + "typescript.experimental.useTsgo": true } diff --git a/.yarnrc.yml b/.yarnrc.yml index e1e4cf05ca..f127d5c149 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -5,3 +5,5 @@ httpTimeout: 300000 nodeLinker: node-modules yarnPath: .yarn/releases/yarn-4.9.1.cjs +npmRegistryServer: https://registry.npmjs.org +npmPublishRegistry: https://registry.npmjs.org diff --git a/package.json b/package.json index b01cc696da..b3da1832bf 100644 --- a/package.json +++ b/package.json @@ -48,8 +48,8 @@ "analyze:renderer": "VISUALIZER_RENDERER=true yarn build", "analyze:main": "VISUALIZER_MAIN=true yarn build", "typecheck": "concurrently -n \"node,web\" -c \"cyan,magenta\" \"npm run typecheck:node\" \"npm run typecheck:web\"", - "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", - "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", + "typecheck:node": "tsgo --noEmit -p tsconfig.node.json --composite false", + "typecheck:web": "tsgo --noEmit -p tsconfig.web.json --composite false", "check:i18n": "tsx scripts/check-i18n.ts", "sync:i18n": "tsx scripts/sync-i18n.ts", "update:i18n": "dotenv -e .env -- tsx scripts/update-i18n.ts", @@ -69,7 +69,10 @@ "format": "biome format --write && biome lint --write", "format:check": "biome format && biome lint", "prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky", - "claude": "dotenv -e .env -- claude" + "claude": "dotenv -e .env -- claude", + "release:aicore:alpha": "yarn workspace @cherrystudio/ai-core version prerelease --immediate && yarn workspace @cherrystudio/ai-core npm publish --tag alpha --access public", + "release:aicore:beta": "yarn workspace @cherrystudio/ai-core version prerelease --immediate && yarn workspace @cherrystudio/ai-core npm publish --tag beta --access public", + "release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core npm publish --access public" }, "dependencies": { "@libsql/client": "0.14.0", @@ -94,10 +97,10 @@ "@agentic/exa": "^7.3.3", "@agentic/searxng": "^7.3.3", "@agentic/tavily": "^7.3.3", - "@ai-sdk/amazon-bedrock": "^3.0.0", - "@ai-sdk/google-vertex": "^3.0.25", - "@ai-sdk/mistral": "^2.0.0", - "@ai-sdk/perplexity": "^2.0.8", + "@ai-sdk/amazon-bedrock": "^3.0.21", + "@ai-sdk/google-vertex": "^3.0.27", + "@ai-sdk/mistral": "^2.0.14", + "@ai-sdk/perplexity": "^2.0.9", "@ant-design/v5-patch-for-react-19": "^1.0.3", "@anthropic-ai/sdk": "^0.41.0", "@anthropic-ai/vertex-sdk": "patch:@anthropic-ai/vertex-sdk@npm%3A0.11.4#~/.yarn/patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch", @@ -105,7 +108,7 @@ "@aws-sdk/client-bedrock-runtime": "^3.840.0", "@aws-sdk/client-s3": "^3.840.0", "@biomejs/biome": "2.2.4", - "@cherrystudio/ai-core": "workspace:*", + "@cherrystudio/ai-core": "workspace:^1.0.0-alpha.16", "@cherrystudio/embedjs": "^0.1.31", "@cherrystudio/embedjs-libsql": "^0.1.31", "@cherrystudio/embedjs-loader-csv": "^0.1.31", @@ -200,6 +203,7 @@ "@types/tinycolor2": "^1", "@types/turndown": "^5.0.5", "@types/word-extractor": "^1", + "@typescript/native-preview": "latest", "@uiw/codemirror-extensions-langs": "^4.25.1", "@uiw/codemirror-themes-all": "^4.25.1", "@uiw/react-codemirror": "^4.25.1", @@ -211,7 +215,7 @@ "@viz-js/lang-dot": "^1.0.5", "@viz-js/viz": "^3.14.0", "@xyflow/react": "^12.4.4", - "ai": "^5.0.38", + "ai": "^5.0.44", "antd": "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch", "archiver": "^7.0.1", "async-mutex": "^0.5.0", @@ -233,6 +237,7 @@ "diff": "^8.0.2", "docx": "^9.0.2", "dompurify": "^3.2.6", + "dotenv": "^17.2.2", "dotenv-cli": "^7.4.2", "electron": "37.4.0", "electron-builder": "26.0.15", diff --git a/packages/aiCore/package.json b/packages/aiCore/package.json index cdf68d9018..292b679d82 100644 --- a/packages/aiCore/package.json +++ b/packages/aiCore/package.json @@ -1,6 +1,6 @@ { "name": "@cherrystudio/ai-core", - "version": "1.0.0-alpha.14", + "version": "1.0.0-alpha.16", "description": "Cherry Studio AI Core - Unified AI Provider Interface Based on Vercel AI SDK", "main": "dist/index.js", "module": "dist/index.mjs", @@ -13,7 +13,15 @@ "test": "vitest run", "test:watch": "vitest" }, - "keywords": ["ai", "sdk", "openai", "anthropic", "google", "cherry-studio", "vercel-ai-sdk"], + "keywords": [ + "ai", + "sdk", + "openai", + "anthropic", + "google", + "cherry-studio", + "vercel-ai-sdk" + ], "author": "Cherry Studio", "license": "MIT", "repository": { @@ -28,15 +36,15 @@ "ai": "^5.0.26" }, "dependencies": { - "@ai-sdk/anthropic": "^2.0.5", - "@ai-sdk/azure": "^2.0.16", - "@ai-sdk/deepseek": "^1.0.9", - "@ai-sdk/google": "^2.0.13", - "@ai-sdk/openai": "^2.0.26", - "@ai-sdk/openai-compatible": "^1.0.9", + "@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/openai": "^2.0.30", + "@ai-sdk/openai-compatible": "^1.0.17", "@ai-sdk/provider": "^2.0.0", - "@ai-sdk/provider-utils": "^3.0.4", - "@ai-sdk/xai": "^2.0.9", + "@ai-sdk/provider-utils": "^3.0.9", + "@ai-sdk/xai": "^2.0.18", "zod": "^4.1.5" }, "devDependencies": { @@ -48,7 +56,9 @@ "engines": { "node": ">=18.0.0" }, - "files": ["dist"], + "files": [ + "dist" + ], "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/src/main/ipc.ts b/src/main/ipc.ts index d889a098f8..9805b7c6e6 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -11,11 +11,10 @@ import { handleZoomFactor } from '@main/utils/zoom' import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, UpgradeChannel } from '@shared/config/constant' import { IpcChannel } from '@shared/IpcChannel' -import { FileMetadata, OcrProvider, Provider, Shortcut, SupportedOcrFile, ThemeMode } from '@types' +import { FileMetadata, Notification, OcrProvider, Provider, Shortcut, SupportedOcrFile, ThemeMode } from '@types' import checkDiskSpace from 'check-disk-space' import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPreferences, webContents } from 'electron' import fontList from 'font-list' -import { Notification } from 'src/renderer/src/types/notification' import { apiServerService } from './services/ApiServerService' import appService from './services/AppService' diff --git a/src/main/services/NotificationService.ts b/src/main/services/NotificationService.ts index 2ceb12ee40..fa9261aa7e 100644 --- a/src/main/services/NotificationService.ts +++ b/src/main/services/NotificationService.ts @@ -1,5 +1,5 @@ +import { Notification } from '@types' import { Notification as ElectronNotification } from 'electron' -import { Notification } from 'src/renderer/src/types/notification' import { windowService } from './WindowService' diff --git a/src/preload/index.ts b/src/preload/index.ts index 6633629802..cd241237b8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -5,6 +5,7 @@ import { UpgradeChannel } from '@shared/config/constant' import type { LogLevel, LogSourceWithContext } from '@shared/config/logger' import type { FileChangeEvent } from '@shared/config/types' import { IpcChannel } from '@shared/IpcChannel' +import type { Notification } from '@types' import { AddMemoryOptions, AssistantMessage, @@ -28,7 +29,6 @@ import { WebDavConfig } from '@types' import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron' -import { Notification } from 'src/renderer/src/types/notification' import { CreateDirectoryOptions } from 'webdav' import type { ActionItem } from '../renderer/src/types/selectionTypes' diff --git a/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts b/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts index ff51b07973..3f9e9ff071 100644 --- a/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts +++ b/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts @@ -18,7 +18,7 @@ import { import { getAssistantSettings, getDefaultModel } from '@renderer/services/AssistantService' import { type Assistant, type MCPTool, type Provider } from '@renderer/types' import type { StreamTextParams } from '@renderer/types/aiCoreTypes' -import type { ModelMessage } from 'ai' +import type { ModelMessage, Tool } from 'ai' import { stepCountIs } from 'ai' import { getAiSdkProviderId } from '../provider/factory' @@ -29,6 +29,8 @@ import { getTemperature, getTopP } from './modelParameters' const logger = loggerService.withContext('parameterBuilder') +type ProviderDefinedTool = Extract, { type: 'provider-defined' }> + /** * 构建 AI SDK 流式参数 * 这是主要的参数构建函数,整合所有转换逻辑 @@ -113,9 +115,9 @@ export async function buildStreamTextParams( tools = {} } if (aiSdkProviderId === 'google-vertex') { - tools.google_search = vertex.tools.googleSearch({}) + tools.google_search = vertex.tools.googleSearch({}) as ProviderDefinedTool } else if (aiSdkProviderId === 'google-vertex-anthropic') { - tools.web_search = vertexAnthropic.tools.webSearch_20250305({}) + tools.web_search = vertexAnthropic.tools.webSearch_20250305({}) as ProviderDefinedTool } } @@ -124,7 +126,7 @@ export async function buildStreamTextParams( if (!tools) { tools = {} } - tools.url_context = vertex.tools.urlContext({}) + tools.url_context = vertex.tools.urlContext({}) as ProviderDefinedTool } // 构建基础参数 diff --git a/src/renderer/src/aiCore/utils/mcp.ts b/src/renderer/src/aiCore/utils/mcp.ts index e7f6b5a393..9606d9ea6e 100644 --- a/src/renderer/src/aiCore/utils/mcp.ts +++ b/src/renderer/src/aiCore/utils/mcp.ts @@ -9,7 +9,7 @@ import { JSONSchema7 } from 'json-schema' const logger = loggerService.withContext('MCP-utils') // Setup tools configuration based on provided parameters -export function setupToolsConfig(mcpTools?: MCPTool[]): Record | undefined { +export function setupToolsConfig(mcpTools?: MCPTool[]): Record> | undefined { let tools: ToolSet = {} if (!mcpTools?.length) { diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 9518f1b38a..cd2562e55a 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -17,6 +17,7 @@ import type { BaseTool, MCPTool } from './tool' export * from './knowledge' export * from './mcp' +export * from './notification' export * from './ocr' export type Assistant = { diff --git a/tsconfig.node.json b/tsconfig.node.json index 0e9a295f97..b6f9061cdf 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -19,14 +19,13 @@ "electron-vite/node", "vitest/globals" ], - "baseUrl": ".", "paths": { - "@logger": ["src/main/services/LoggerService"], - "@main/*": ["src/main/*"], - "@types": ["src/renderer/src/types/index.ts"], - "@shared/*": ["packages/shared/*"], - "@mcp-trace/*": ["packages/mcp-trace/*"], - "@modelcontextprotocol/sdk/*": ["node_modules/@modelcontextprotocol/sdk/dist/esm/*"] + "@logger": ["./src/main/services/LoggerService"], + "@main/*": ["./src/main/*"], + "@types": ["./src/renderer/src/types/index.ts"], + "@shared/*": ["./packages/shared/*"], + "@mcp-trace/*": ["./packages/mcp-trace/*"], + "@modelcontextprotocol/sdk/*": ["./node_modules/@modelcontextprotocol/sdk/dist/esm/*"] }, "experimentalDecorators": true, "emitDecoratorMetadata": true, diff --git a/tsconfig.web.json b/tsconfig.web.json index 5936bfaa03..07c1b41066 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -16,19 +16,18 @@ "incremental": true, "tsBuildInfoFile": ".tsbuildinfo/tsconfig.web.tsbuildinfo", "jsx": "react-jsx", - "baseUrl": ".", "moduleResolution": "bundler", "paths": { - "@logger": ["src/renderer/src/services/LoggerService"], - "@renderer/*": ["src/renderer/src/*"], - "@shared/*": ["packages/shared/*"], - "@types": ["src/renderer/src/types/index.ts"], - "@mcp-trace/*": ["packages/mcp-trace/*"], - "@cherrystudio/ai-core/provider": ["packages/aiCore/src/core/providers/index.ts"], - "@cherrystudio/ai-core/built-in/plugins": ["packages/aiCore/src/core/plugins/built-in/index.ts"], - "@cherrystudio/ai-core/*": ["packages/aiCore/src/*"], - "@cherrystudio/ai-core": ["packages/aiCore/src/index.ts"], - "@cherrystudio/extension-table-plus": ["packages/extension-table-plus/src/index.ts"] + "@logger": ["./src/renderer/src/services/LoggerService"], + "@renderer/*": ["./src/renderer/src/*"], + "@shared/*": ["./packages/shared/*"], + "@types": ["./src/renderer/src/types/index.ts"], + "@mcp-trace/*": ["./packages/mcp-trace/*"], + "@cherrystudio/ai-core/provider": ["./packages/aiCore/src/core/providers/index.ts"], + "@cherrystudio/ai-core/built-in/plugins": ["./packages/aiCore/src/core/plugins/built-in/index.ts"], + "@cherrystudio/ai-core/*": ["./packages/aiCore/src/*"], + "@cherrystudio/ai-core": ["./packages/aiCore/src/index.ts"], + "@cherrystudio/extension-table-plus": ["./packages/extension-table-plus/src/index.ts"] }, "experimentalDecorators": true, "emitDecoratorMetadata": true, diff --git a/yarn.lock b/yarn.lock index daaf739c67..630c46c05c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -74,221 +74,157 @@ __metadata: languageName: node linkType: hard -"@ai-sdk/amazon-bedrock@npm:^3.0.0": - version: 3.0.8 - resolution: "@ai-sdk/amazon-bedrock@npm:3.0.8" +"@ai-sdk/amazon-bedrock@npm:^3.0.21": + version: 3.0.21 + resolution: "@ai-sdk/amazon-bedrock@npm:3.0.21" dependencies: - "@ai-sdk/anthropic": "npm:2.0.4" + "@ai-sdk/anthropic": "npm:2.0.17" "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.3" + "@ai-sdk/provider-utils": "npm:3.0.9" "@smithy/eventstream-codec": "npm:^4.0.1" "@smithy/util-utf8": "npm:^4.0.0" aws4fetch: "npm:^1.0.20" peerDependencies: zod: ^3.25.76 || ^4 - checksum: 10c0/d7b303b8581e9d28e9ac375b3718ef3f7fff3353d18185870f0b90fd542eb9398d029768502981e9e45a6b64137a7029f591993afd0b18e9ef74525f625524f7 + checksum: 10c0/2d15baaad53e389666cede9673e2b43f5299e2cedb70f5b7afc656b7616e73775a9108c2cc1beee4644ff4c66ad41c8dd0b412373dd05caa4fc3d477c4343ea8 languageName: node linkType: hard -"@ai-sdk/anthropic@npm:2.0.15": - version: 2.0.15 - resolution: "@ai-sdk/anthropic@npm:2.0.15" +"@ai-sdk/anthropic@npm:2.0.17, @ai-sdk/anthropic@npm:^2.0.17": + version: 2.0.17 + resolution: "@ai-sdk/anthropic@npm:2.0.17" dependencies: "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.8" + "@ai-sdk/provider-utils": "npm:3.0.9" peerDependencies: zod: ^3.25.76 || ^4 - checksum: 10c0/9597b32be8b83dab67b23f162ca66cde385213fb1665f54091d59430789becf73e2b4fcd2be66ceb13020409f59cd8f9da7dae23adf183bc9eb7ce94f55bde96 + checksum: 10c0/783b6a953f3854c4303ad7c30dd56d4706486c7d1151adb17071d87933418c59c26bce53d5c26d34c4d4728eaac4a856ce49a336caed26a7216f982fea562814 languageName: node linkType: hard -"@ai-sdk/anthropic@npm:2.0.4": - version: 2.0.4 - resolution: "@ai-sdk/anthropic@npm:2.0.4" +"@ai-sdk/azure@npm:^2.0.30": + version: 2.0.30 + resolution: "@ai-sdk/azure@npm:2.0.30" dependencies: + "@ai-sdk/openai": "npm:2.0.30" "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.3" + "@ai-sdk/provider-utils": "npm:3.0.9" peerDependencies: zod: ^3.25.76 || ^4 - checksum: 10c0/2e5a997b6e2d9a2964c4681418643fd2f347df78ac1f9677a0cc6a3a3454920d05c663e35521d8922f0a382ec77a25e4b92204b3760a1da05876bf00d41adc39 + checksum: 10c0/22af450e28026547badc891a627bcb3cfa2d030864089947172506810f06cfa4c74c453aabd6a0d5c05ede5ffdee381b9278772ce781eca0c7c826c7d7ae3dc3 languageName: node linkType: hard -"@ai-sdk/anthropic@npm:^2.0.5": - version: 2.0.5 - resolution: "@ai-sdk/anthropic@npm:2.0.5" +"@ai-sdk/deepseek@npm:^1.0.17": + version: 1.0.17 + resolution: "@ai-sdk/deepseek@npm:1.0.17" dependencies: + "@ai-sdk/openai-compatible": "npm:1.0.17" "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.4" + "@ai-sdk/provider-utils": "npm:3.0.9" peerDependencies: zod: ^3.25.76 || ^4 - checksum: 10c0/aaca0d4b2e00715c513a7c688d6b6116eaf29d1d37f005c150f1229200713fb1c393c81a8b01ac29af954fb1ee213f3a537861227051865abe51aa547dca364e + checksum: 10c0/c408701343bb28ed0b3e034b8789e6de1dfd6cfc6a9b53feb68f155889e29a9fbbcf05bd99e63f60809cf05ee4b158abaccdf1cbcd9df92c0987094220a61d08 languageName: node linkType: hard -"@ai-sdk/azure@npm:^2.0.16": - version: 2.0.16 - resolution: "@ai-sdk/azure@npm:2.0.16" +"@ai-sdk/gateway@npm:1.0.23": + version: 1.0.23 + resolution: "@ai-sdk/gateway@npm:1.0.23" dependencies: - "@ai-sdk/openai": "npm:2.0.16" "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.4" + "@ai-sdk/provider-utils": "npm:3.0.9" peerDependencies: zod: ^3.25.76 || ^4 - checksum: 10c0/49bd9d27cba3104ba5d8a82c70a16dd475572585c5187e5bc29c9d46a30a373338181b29f37dfe9f61f50b5b82e86808139c93da225eb1721cb15e1a8b97cceb + checksum: 10c0/b1e1a6ab63b9191075eed92c586cd927696f8997ad24f056585aee3f5fffd283d981aa6b071a2560ecda4295445b80a4cfd321fa63c06e7ac54a06bc4c84887f languageName: node linkType: hard -"@ai-sdk/deepseek@npm:^1.0.9": - version: 1.0.9 - resolution: "@ai-sdk/deepseek@npm:1.0.9" +"@ai-sdk/google-vertex@npm:^3.0.27": + version: 3.0.27 + resolution: "@ai-sdk/google-vertex@npm:3.0.27" dependencies: - "@ai-sdk/openai-compatible": "npm:1.0.9" + "@ai-sdk/anthropic": "npm:2.0.17" + "@ai-sdk/google": "npm:2.0.14" "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.4" - peerDependencies: - zod: ^3.25.76 || ^4 - checksum: 10c0/b02a000a98a6df9808d472bf63640ee96297f9acce7422de0d198ffda40edcbcadc0946ae383464b80a92ac033a3a61cf71fa1bc640c08cac589bebc8d5623b9 - languageName: node - linkType: hard - -"@ai-sdk/gateway@npm:1.0.20": - version: 1.0.20 - resolution: "@ai-sdk/gateway@npm:1.0.20" - dependencies: - "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.8" - peerDependencies: - zod: ^3.25.76 || ^4 - checksum: 10c0/c25e98aab2513f783b2b552245b027e5a73b209d974e25bbfae0e69b67fd3468bba0bf57085ca3d7259b4dc8881e7f40fca769f698f0b1eb028a849f587ad09c - languageName: node - linkType: hard - -"@ai-sdk/google-vertex@npm:^3.0.25": - version: 3.0.25 - resolution: "@ai-sdk/google-vertex@npm:3.0.25" - dependencies: - "@ai-sdk/anthropic": "npm:2.0.15" - "@ai-sdk/google": "npm:2.0.13" - "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.8" + "@ai-sdk/provider-utils": "npm:3.0.9" google-auth-library: "npm:^9.15.0" peerDependencies: zod: ^3.25.76 || ^4 - checksum: 10c0/ed67a439fc4a446aa7353d258c61497198aecdf0de55500d2abbea86109bbf1ff4570fffdfcf58508db1c887a2095a71322777634f76326a45e259d28ef0b801 + checksum: 10c0/7017838aef9c04c18ce9acec52eb602ee0a38d68a7496977a3898411f1ac235b2d7776011fa686084b90b0881e65c69596014e5465b8ed0d0e313b5db1f967a7 languageName: node linkType: hard -"@ai-sdk/google@npm:2.0.13, @ai-sdk/google@npm:^2.0.13": - version: 2.0.13 - resolution: "@ai-sdk/google@npm:2.0.13" +"@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: "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.8" + "@ai-sdk/provider-utils": "npm:3.0.9" peerDependencies: zod: ^3.25.76 || ^4 - checksum: 10c0/a05210de11d7ab41d49bcd0330c37f4116441b149d8ccc9b6bc5eaa12ea42bae82364dc2cd09502734b15115071f07395525806ea4998930b285b1ce74102186 + checksum: 10c0/2c04839cf58c33514a54c9de8190c363b5cacfbfc8404fea5d2ec36ad0af5ced4fc571f978e7aa35876bd9afae138f4c700d2bc1f64a78a37d0401f6797bf8f3 languageName: node linkType: hard -"@ai-sdk/mistral@npm:^2.0.0": - version: 2.0.4 - resolution: "@ai-sdk/mistral@npm:2.0.4" +"@ai-sdk/mistral@npm:^2.0.14": + version: 2.0.14 + resolution: "@ai-sdk/mistral@npm:2.0.14" dependencies: "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.3" + "@ai-sdk/provider-utils": "npm:3.0.9" peerDependencies: zod: ^3.25.76 || ^4 - checksum: 10c0/cca88cba855d4952551ca0be748e21f0d1b54537d0c7e08f30facdfbdbac7e6894ff4a1ceb53657aaf6e4380bbaa39d3cc37d1f734d777cdc1caba004c87221f + checksum: 10c0/420be3a039095830aaf59b6f82c1f986ff4800ba5b9438e1dd85530026a42c9454a6e632b6a1a1839816609f4752d0a19140d8943ad78bb976fb5d6a37714e16 languageName: node linkType: hard -"@ai-sdk/openai-compatible@npm:1.0.9, @ai-sdk/openai-compatible@npm:^1.0.9": - version: 1.0.9 - resolution: "@ai-sdk/openai-compatible@npm:1.0.9" +"@ai-sdk/openai-compatible@npm:1.0.17, @ai-sdk/openai-compatible@npm:^1.0.17": + version: 1.0.17 + resolution: "@ai-sdk/openai-compatible@npm:1.0.17" dependencies: "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.4" + "@ai-sdk/provider-utils": "npm:3.0.9" peerDependencies: zod: ^3.25.76 || ^4 - checksum: 10c0/a98505438f7a4c0d5c1aee9fb03aae00ff726c1c5ba0eff45d00ddc30ab9f25de634fcfd111a634bd654042150b9f16a131ce3f45887f9661c0241e3807d6ad4 + checksum: 10c0/53ab6111e0f44437a2e268a51fb747600844d85b0cd0d170fb87a7b68af3eb21d7728d7bbf14d71c9fcf36e7a0f94ad75f0ad6b1070e473c867ab08ef84f6564 languageName: node linkType: hard -"@ai-sdk/openai@npm:2.0.16": - version: 2.0.16 - resolution: "@ai-sdk/openai@npm:2.0.16" +"@ai-sdk/openai@npm:2.0.30, @ai-sdk/openai@npm:^2.0.30": + version: 2.0.30 + resolution: "@ai-sdk/openai@npm:2.0.30" dependencies: "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.4" + "@ai-sdk/provider-utils": "npm:3.0.9" peerDependencies: zod: ^3.25.76 || ^4 - checksum: 10c0/1ea694bd096175a67a383e73fd1f4434eeaa7ddc6c378e44f295333d9a7b4153251d405dac2d8da330f95e4d5ef58641cc8533a3e63ff4d250b3cbc66f9abfea + checksum: 10c0/90a57c1b10dac46c0bbe7e16cf9202557fb250d9f0e94a2a5fb7d95b5ea77815a56add78b00238d3823f0313c9b2c42abe865478d28a6196f72b341d32dd40af languageName: node linkType: hard -"@ai-sdk/openai@npm:^2.0.26": - version: 2.0.26 - resolution: "@ai-sdk/openai@npm:2.0.26" +"@ai-sdk/perplexity@npm:^2.0.9": + version: 2.0.9 + resolution: "@ai-sdk/perplexity@npm:2.0.9" dependencies: "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.8" + "@ai-sdk/provider-utils": "npm:3.0.9" peerDependencies: zod: ^3.25.76 || ^4 - checksum: 10c0/b8cb01c0c38525c38901f41f1693cd15589932a2aceddea14bed30f44719532a5e74615fb0e974eff1a0513048ac204c27456ff8829a9c811d1461cc635c9cc5 + checksum: 10c0/2023aadc26c41430571c4897df79074e7a95a12f2238ad57081355484066bcf9e8dfde1da60fa6af12fc9fb2a195899326f753c69f4913dc005a33367f150349 languageName: node linkType: hard -"@ai-sdk/perplexity@npm:^2.0.8": - version: 2.0.8 - resolution: "@ai-sdk/perplexity@npm:2.0.8" - dependencies: - "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.8" - peerDependencies: - zod: ^3.25.76 || ^4 - checksum: 10c0/acfd6c09c4c0ef5af7eeec6e8bc20b90b24d1d3fc2bc8ee9de4e40770fc0c17ca2c8db8f0248ff07264b71e5aa65f64d37a165db2f43fee84c1b3513cb97983c - languageName: node - linkType: hard - -"@ai-sdk/provider-utils@npm:3.0.3": - version: 3.0.3 - resolution: "@ai-sdk/provider-utils@npm:3.0.3" - dependencies: - "@ai-sdk/provider": "npm:2.0.0" - "@standard-schema/spec": "npm:^1.0.0" - eventsource-parser: "npm:^3.0.3" - zod-to-json-schema: "npm:^3.24.1" - peerDependencies: - zod: ^3.25.76 || ^4 - checksum: 10c0/f02e26a6b85ef728862505b150475ef2e52d60130ca64b23316ff7b952f1817b01f959b9e48819dad64d82a96ba4ad538610d69dbbfe5be4b4b38469c16a6ccf - languageName: node - linkType: hard - -"@ai-sdk/provider-utils@npm:3.0.4, @ai-sdk/provider-utils@npm:^3.0.4": - version: 3.0.4 - resolution: "@ai-sdk/provider-utils@npm:3.0.4" - dependencies: - "@ai-sdk/provider": "npm:2.0.0" - "@standard-schema/spec": "npm:^1.0.0" - eventsource-parser: "npm:^3.0.3" - zod-to-json-schema: "npm:^3.24.1" - peerDependencies: - zod: ^3.25.76 || ^4 - checksum: 10c0/6732b99310561d72262cdeef40cc58190afa55248dca0eb3a378ef87fede12086e534c68687e0fe5ef5b092da41f3e745857ce3f9b248a272a78c0dc268dffd4 - languageName: node - linkType: hard - -"@ai-sdk/provider-utils@npm:3.0.8": - version: 3.0.8 - resolution: "@ai-sdk/provider-utils@npm:3.0.8" +"@ai-sdk/provider-utils@npm:3.0.9, @ai-sdk/provider-utils@npm:^3.0.9": + version: 3.0.9 + resolution: "@ai-sdk/provider-utils@npm:3.0.9" dependencies: "@ai-sdk/provider": "npm:2.0.0" "@standard-schema/spec": "npm:^1.0.0" eventsource-parser: "npm:^3.0.5" peerDependencies: zod: ^3.25.76 || ^4 - checksum: 10c0/f466657c886cbb9f7ecbcd2dd1abc51a88af9d3f1cff030f7e97e70a4790a99f3338ad886e9c0dccf04dacdcc84522c7d57119b9a4e8e1d84f2dae9c893c397e + checksum: 10c0/f8b659343d7e22ae099f7b6fc514591c0408012eb0aa00f7a912798b6d7d7305cafa8f18a07c7adec0bb5d39d9b6256b76d65c5393c3fc843d1361c52f1f8080 languageName: node linkType: hard @@ -301,16 +237,16 @@ __metadata: languageName: node linkType: hard -"@ai-sdk/xai@npm:^2.0.9": - version: 2.0.9 - resolution: "@ai-sdk/xai@npm:2.0.9" +"@ai-sdk/xai@npm:^2.0.18": + version: 2.0.18 + resolution: "@ai-sdk/xai@npm:2.0.18" dependencies: - "@ai-sdk/openai-compatible": "npm:1.0.9" + "@ai-sdk/openai-compatible": "npm:1.0.17" "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.4" + "@ai-sdk/provider-utils": "npm:3.0.9" peerDependencies: zod: ^3.25.76 || ^4 - checksum: 10c0/15a3ace8e06b42ee148d8d100cdf946919e0763c45fb1b85454e313d4de43426c6d162c333d07ad338a9de415dc9e68c50411a6ec0305dbc5edb7d623c2023da + checksum: 10c0/7134501a2d315ec13605558aa24d7f5662885fe8b0491a634abefeb0c5c88517149677d1beff0c8abeec78a6dcd14573a2f57d96fa54a1d63d03820ac7ff827a languageName: node linkType: hard @@ -2373,19 +2309,19 @@ __metadata: languageName: node linkType: hard -"@cherrystudio/ai-core@workspace:*, @cherrystudio/ai-core@workspace:packages/aiCore": +"@cherrystudio/ai-core@workspace:^1.0.0-alpha.16, @cherrystudio/ai-core@workspace:packages/aiCore": version: 0.0.0-use.local resolution: "@cherrystudio/ai-core@workspace:packages/aiCore" dependencies: - "@ai-sdk/anthropic": "npm:^2.0.5" - "@ai-sdk/azure": "npm:^2.0.16" - "@ai-sdk/deepseek": "npm:^1.0.9" - "@ai-sdk/google": "npm:^2.0.13" - "@ai-sdk/openai": "npm:^2.0.26" - "@ai-sdk/openai-compatible": "npm:^1.0.9" + "@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/openai": "npm:^2.0.30" + "@ai-sdk/openai-compatible": "npm:^1.0.17" "@ai-sdk/provider": "npm:^2.0.0" - "@ai-sdk/provider-utils": "npm:^3.0.4" - "@ai-sdk/xai": "npm:^2.0.9" + "@ai-sdk/provider-utils": "npm:^3.0.9" + "@ai-sdk/xai": "npm:^2.0.18" tsdown: "npm:^0.12.9" typescript: "npm:^5.0.0" vitest: "npm:^3.2.4" @@ -12414,6 +12350,87 @@ __metadata: languageName: node linkType: hard +"@typescript/native-preview-darwin-arm64@npm:7.0.0-dev.20250915.1": + version: 7.0.0-dev.20250915.1 + resolution: "@typescript/native-preview-darwin-arm64@npm:7.0.0-dev.20250915.1" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@typescript/native-preview-darwin-x64@npm:7.0.0-dev.20250915.1": + version: 7.0.0-dev.20250915.1 + resolution: "@typescript/native-preview-darwin-x64@npm:7.0.0-dev.20250915.1" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@typescript/native-preview-linux-arm64@npm:7.0.0-dev.20250915.1": + version: 7.0.0-dev.20250915.1 + resolution: "@typescript/native-preview-linux-arm64@npm:7.0.0-dev.20250915.1" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@typescript/native-preview-linux-arm@npm:7.0.0-dev.20250915.1": + version: 7.0.0-dev.20250915.1 + resolution: "@typescript/native-preview-linux-arm@npm:7.0.0-dev.20250915.1" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@typescript/native-preview-linux-x64@npm:7.0.0-dev.20250915.1": + version: 7.0.0-dev.20250915.1 + resolution: "@typescript/native-preview-linux-x64@npm:7.0.0-dev.20250915.1" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@typescript/native-preview-win32-arm64@npm:7.0.0-dev.20250915.1": + version: 7.0.0-dev.20250915.1 + resolution: "@typescript/native-preview-win32-arm64@npm:7.0.0-dev.20250915.1" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@typescript/native-preview-win32-x64@npm:7.0.0-dev.20250915.1": + version: 7.0.0-dev.20250915.1 + resolution: "@typescript/native-preview-win32-x64@npm:7.0.0-dev.20250915.1" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@typescript/native-preview@npm:latest": + version: 7.0.0-dev.20250915.1 + resolution: "@typescript/native-preview@npm:7.0.0-dev.20250915.1" + dependencies: + "@typescript/native-preview-darwin-arm64": "npm:7.0.0-dev.20250915.1" + "@typescript/native-preview-darwin-x64": "npm:7.0.0-dev.20250915.1" + "@typescript/native-preview-linux-arm": "npm:7.0.0-dev.20250915.1" + "@typescript/native-preview-linux-arm64": "npm:7.0.0-dev.20250915.1" + "@typescript/native-preview-linux-x64": "npm:7.0.0-dev.20250915.1" + "@typescript/native-preview-win32-arm64": "npm:7.0.0-dev.20250915.1" + "@typescript/native-preview-win32-x64": "npm:7.0.0-dev.20250915.1" + dependenciesMeta: + "@typescript/native-preview-darwin-arm64": + optional: true + "@typescript/native-preview-darwin-x64": + optional: true + "@typescript/native-preview-linux-arm": + optional: true + "@typescript/native-preview-linux-arm64": + optional: true + "@typescript/native-preview-linux-x64": + optional: true + "@typescript/native-preview-win32-arm64": + optional: true + "@typescript/native-preview-win32-x64": + optional: true + bin: + tsgo: bin/tsgo.js + checksum: 10c0/88c8c4d497e610b05ef3a429959364fff7f0fc2b77f191909c15f886b21b06ceabdd9f89d9e5f903ee87076cfeca4d61ee609d2df897326ed115e23e01650fec + languageName: node + linkType: hard + "@uiw/codemirror-extensions-basic-setup@npm:4.25.1": version: 4.25.1 resolution: "@uiw/codemirror-extensions-basic-setup@npm:4.25.1" @@ -13167,10 +13184,10 @@ __metadata: "@agentic/exa": "npm:^7.3.3" "@agentic/searxng": "npm:^7.3.3" "@agentic/tavily": "npm:^7.3.3" - "@ai-sdk/amazon-bedrock": "npm:^3.0.0" - "@ai-sdk/google-vertex": "npm:^3.0.25" - "@ai-sdk/mistral": "npm:^2.0.0" - "@ai-sdk/perplexity": "npm:^2.0.8" + "@ai-sdk/amazon-bedrock": "npm:^3.0.21" + "@ai-sdk/google-vertex": "npm:^3.0.27" + "@ai-sdk/mistral": "npm:^2.0.14" + "@ai-sdk/perplexity": "npm:^2.0.9" "@ant-design/v5-patch-for-react-19": "npm:^1.0.3" "@anthropic-ai/sdk": "npm:^0.41.0" "@anthropic-ai/vertex-sdk": "patch:@anthropic-ai/vertex-sdk@npm%3A0.11.4#~/.yarn/patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch" @@ -13178,7 +13195,7 @@ __metadata: "@aws-sdk/client-bedrock-runtime": "npm:^3.840.0" "@aws-sdk/client-s3": "npm:^3.840.0" "@biomejs/biome": "npm:2.2.4" - "@cherrystudio/ai-core": "workspace:*" + "@cherrystudio/ai-core": "workspace:^1.0.0-alpha.16" "@cherrystudio/embedjs": "npm:^0.1.31" "@cherrystudio/embedjs-libsql": "npm:^0.1.31" "@cherrystudio/embedjs-loader-csv": "npm:^0.1.31" @@ -13277,6 +13294,7 @@ __metadata: "@types/tinycolor2": "npm:^1" "@types/turndown": "npm:^5.0.5" "@types/word-extractor": "npm:^1" + "@typescript/native-preview": "npm:latest" "@uiw/codemirror-extensions-langs": "npm:^4.25.1" "@uiw/codemirror-themes-all": "npm:^4.25.1" "@uiw/react-codemirror": "npm:^4.25.1" @@ -13288,7 +13306,7 @@ __metadata: "@viz-js/lang-dot": "npm:^1.0.5" "@viz-js/viz": "npm:^3.14.0" "@xyflow/react": "npm:^12.4.4" - ai: "npm:^5.0.38" + ai: "npm:^5.0.44" antd: "patch:antd@npm%3A5.27.0#~/.yarn/patches/antd-npm-5.27.0-aa91c36546.patch" archiver: "npm:^7.0.1" async-mutex: "npm:^0.5.0" @@ -13310,6 +13328,7 @@ __metadata: diff: "npm:^8.0.2" docx: "npm:^9.0.2" dompurify: "npm:^3.2.6" + dotenv: "npm:^17.2.2" dotenv-cli: "npm:^7.4.2" electron: "npm:37.4.0" electron-builder: "npm:26.0.15" @@ -13546,17 +13565,17 @@ __metadata: languageName: node linkType: hard -"ai@npm:^5.0.38": - version: 5.0.38 - resolution: "ai@npm:5.0.38" +"ai@npm:^5.0.44": + version: 5.0.44 + resolution: "ai@npm:5.0.44" dependencies: - "@ai-sdk/gateway": "npm:1.0.20" + "@ai-sdk/gateway": "npm:1.0.23" "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.8" + "@ai-sdk/provider-utils": "npm:3.0.9" "@opentelemetry/api": "npm:1.9.0" peerDependencies: zod: ^3.25.76 || ^4 - checksum: 10c0/9ea7a76ae5609574e9edb2f9541e2fe9cf0e7296547c5e9ae30ec000206c967b4c07fbb03b85f9027493f6877e15f6bfbe454faa793fca860826acf306982fc5 + checksum: 10c0/528c7e165f75715194204051ce0aa341d8dca7d5536c2abcf3df83ccda7399ed5d91deaa45a81340f93d2461b1c2fc5f740f7804dfd396927c71b0667403569b languageName: node linkType: hard @@ -16612,6 +16631,13 @@ __metadata: languageName: node linkType: hard +"dotenv@npm:^17.2.2": + version: 17.2.2 + resolution: "dotenv@npm:17.2.2" + checksum: 10c0/be66513504590aff6eccb14167625aed9bd42ce80547f4fe5d195860211971a7060949b57108dfaeaf90658f79e40edccd3f233f0a978bff507b5b1565ae162b + languageName: node + linkType: hard + "dts-resolver@npm:^2.1.1": version: 2.1.1 resolution: "dts-resolver@npm:2.1.1" @@ -17561,7 +17587,7 @@ __metadata: languageName: node linkType: hard -"eventsource-parser@npm:^3.0.0, eventsource-parser@npm:^3.0.3": +"eventsource-parser@npm:^3.0.0": version: 3.0.3 resolution: "eventsource-parser@npm:3.0.3" checksum: 10c0/2594011630efba56cafafc8ed6bd9a50db8f6d5dd62089b0950346e7961828c16efe07a588bdea3ba79e568fd9246c8163824a2ffaade767e1fdb2270c1fae0b From 1a5138c5b122a7dccc2359e6d6f9e95adf319ade Mon Sep 17 00:00:00 2001 From: one Date: Tue, 16 Sep 2025 11:10:14 +0800 Subject: [PATCH 22/99] refactor: quick panel and inputbar tools (#10142) * refactor: add QuickPanelReservedSymbol * refactor: pass assistant id instead of assistant object * refactor: simplify InputbarTools props * refactor: add root symbol * refactor: merge file panel to attachment button * refactor: extract ActionButton for reuse * chore: update CLAUDE.md * fix: colors * refactor: add more reserved symbols * fix: keep updateAssistant stable * refactor(components): replace styled-components with cn utility in ActionIconButton - Replace styled-components with cn utility from @heroui/react for better maintainability - Add new --icon and --color-icon CSS variables for consistent icon coloring - Simplify button styling using Tailwind CSS classes --------- Co-authored-by: icarus --- CLAUDE.md | 1 + src/renderer/src/assets/styles/tailwind.css | 3 + .../components/Buttons/ActionIconButton.tsx | 30 +++ src/renderer/src/components/Buttons/index.ts | 1 + src/renderer/src/components/ContentSearch.tsx | 26 +-- .../src/components/QuickPanel/types.ts | 13 ++ src/renderer/src/hooks/useAssistant.ts | 2 +- src/renderer/src/i18n/locales/en-us.json | 3 +- src/renderer/src/i18n/locales/zh-cn.json | 3 +- src/renderer/src/i18n/locales/zh-tw.json | 3 +- .../pages/home/Inputbar/AttachmentButton.tsx | 109 +++++++-- .../home/Inputbar/GenerateImageButton.tsx | 13 +- .../src/pages/home/Inputbar/Inputbar.tsx | 206 ++---------------- .../src/pages/home/Inputbar/InputbarTools.tsx | 195 +++++++++-------- .../home/Inputbar/KnowledgeBaseButton.tsx | 24 +- .../pages/home/Inputbar/MCPToolsButton.tsx | 29 ++- .../home/Inputbar/MentionModelsButton.tsx | 20 +- .../pages/home/Inputbar/NewContextButton.tsx | 9 +- .../home/Inputbar/QuickPhrasesButton.tsx | 28 ++- .../pages/home/Inputbar/ThinkingButton.tsx | 83 +++---- .../src/pages/home/Inputbar/TokenCount.tsx | 1 - .../pages/home/Inputbar/UrlContextbutton.tsx | 20 +- .../pages/home/Inputbar/WebSearchButton.tsx | 38 ++-- .../src/pages/home/Messages/MessageEditor.tsx | 15 +- src/renderer/src/store/assistants.ts | 4 +- 25 files changed, 421 insertions(+), 458 deletions(-) create mode 100644 src/renderer/src/components/Buttons/ActionIconButton.tsx create mode 100644 src/renderer/src/components/Buttons/index.ts diff --git a/CLAUDE.md b/CLAUDE.md index 5c66ab4fed..58b01fe853 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **Prerequisites**: Node.js v22.x.x or higher, Yarn 4.9.1 - **Setup Yarn**: `corepack enable && corepack prepare yarn@4.9.1 --activate` - **Install Dependencies**: `yarn install` +- **Add New Dependencies**: `yarn add -D` for renderer-specific dependencies, `yarn add` for others. ### Development diff --git a/src/renderer/src/assets/styles/tailwind.css b/src/renderer/src/assets/styles/tailwind.css index 4d9e0d4f00..f05b01b65c 100644 --- a/src/renderer/src/assets/styles/tailwind.css +++ b/src/renderer/src/assets/styles/tailwind.css @@ -53,6 +53,7 @@ --sidebar-accent-foreground: oklch(0.21 0.006 285.885); --sidebar-border: oklch(0.92 0.004 286.32); --sidebar-ring: oklch(0.705 0.015 286.067); + --icon: #00000099; } .dark { @@ -87,6 +88,7 @@ --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.552 0.016 285.938); + --icon: #ffffff99; } @theme inline { @@ -128,6 +130,7 @@ --color-sidebar-ring: var(--sidebar-ring); --animate-marquee: marquee var(--duration) infinite linear; --animate-marquee-vertical: marquee-vertical var(--duration) linear infinite; + --color-icon: var(--icon); @keyframes marquee { from { transform: translateX(0); diff --git a/src/renderer/src/components/Buttons/ActionIconButton.tsx b/src/renderer/src/components/Buttons/ActionIconButton.tsx new file mode 100644 index 0000000000..1448008090 --- /dev/null +++ b/src/renderer/src/components/Buttons/ActionIconButton.tsx @@ -0,0 +1,30 @@ +import { cn } from '@heroui/react' +import { Button, ButtonProps } from 'antd' +import React, { memo } from 'react' + +interface ActionIconButtonProps extends ButtonProps { + children: React.ReactNode + active?: boolean +} + +/** + * A simple action button rendered as an icon + */ +const ActionIconButton: React.FC = ({ children, active = false, className, ...props }) => { + return ( + + ) +} + +export default memo(ActionIconButton) diff --git a/src/renderer/src/components/Buttons/index.ts b/src/renderer/src/components/Buttons/index.ts new file mode 100644 index 0000000000..623c2f21cc --- /dev/null +++ b/src/renderer/src/components/Buttons/index.ts @@ -0,0 +1 @@ +export { default as ActionIconButton } from './ActionIconButton' diff --git a/src/renderer/src/components/ContentSearch.tsx b/src/renderer/src/components/ContentSearch.tsx index 084a79a439..d322f41616 100644 --- a/src/renderer/src/components/ContentSearch.tsx +++ b/src/renderer/src/components/ContentSearch.tsx @@ -1,4 +1,4 @@ -import { ToolbarButton } from '@renderer/pages/home/Inputbar/Inputbar' +import { ActionIconButton } from '@renderer/components/Buttons' import NarrowLayout from '@renderer/pages/home/Messages/NarrowLayout' import { Tooltip } from 'antd' import { debounce } from 'lodash' @@ -364,23 +364,23 @@ export const ContentSearch = React.forwardRef( {showUserToggle && ( - + - + )} - + - + - + - + @@ -397,15 +397,15 @@ export const ContentSearch = React.forwardRef( )} - + - - + + - - + + - + diff --git a/src/renderer/src/components/QuickPanel/types.ts b/src/renderer/src/components/QuickPanel/types.ts index 97e072dea0..812ec153a6 100644 --- a/src/renderer/src/components/QuickPanel/types.ts +++ b/src/renderer/src/components/QuickPanel/types.ts @@ -1,5 +1,18 @@ import React from 'react' +export enum QuickPanelReservedSymbol { + Root = '/', + File = 'file', + KnowledgeBase = '#', + MentionModels = '@', + QuickPhrases = 'quick-phrases', + Thinking = 'thinking', + WebSearch = '?', + Mcp = 'mcp', + McpPrompt = 'mcp-prompt', + McpResource = 'mcp-resource' +} + export type QuickPanelCloseAction = 'enter' | 'click' | 'esc' | 'outsideclick' | 'enter_empty' | string | undefined export type QuickPanelTriggerInfo = { type: 'input' | 'button' diff --git a/src/renderer/src/hooks/useAssistant.ts b/src/renderer/src/hooks/useAssistant.ts index 258048f0b1..096c91b5a1 100644 --- a/src/renderer/src/hooks/useAssistant.ts +++ b/src/renderer/src/hooks/useAssistant.ts @@ -172,7 +172,7 @@ export function useAssistant(id: string) { (model: Model) => assistant && dispatch(setModel({ assistantId: assistant?.id, model })), [assistant, dispatch] ), - updateAssistant: (assistant: Assistant) => dispatch(updateAssistant(assistant)), + updateAssistant: useCallback((assistant: Partial) => dispatch(updateAssistant(assistant)), [dispatch]), updateAssistantSettings } } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index c4e7d2aa72..4ac54e374a 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -362,8 +362,9 @@ "translate": "Translate to {{target_language}}", "translating": "Translating...", "upload": { + "attachment": "Upload attachment", "document": "Upload document file (model does not support images)", - "label": "Upload image or document file", + "image_or_document": "Upload image or document file", "upload_from_local": "Upload local file..." }, "url_context": "URL Context", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index ecf4e8c431..248dbad623 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -363,8 +363,9 @@ "translate": "翻译成 {{target_language}}", "translating": "翻译中...", "upload": { + "attachment": "上传附件", "document": "上传文档(模型不支持图片)", - "label": "上传图片或文档", + "image_or_document": "上传图片或文档", "upload_from_local": "上传本地文件..." }, "url_context": "网页上下文", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 5f4118bd95..4b31ca44be 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -362,8 +362,9 @@ "translate": "翻譯成 {{target_language}}", "translating": "翻譯中...", "upload": { + "attachment": "上傳附件", "document": "上傳文件(模型不支援圖片)", - "label": "上傳圖片或文件", + "image_or_document": "上傳圖片或文件", "upload_from_local": "上傳本地文件..." }, "url_context": "網頁上下文", diff --git a/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx b/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx index dd7cd8b301..df0dbf9f5a 100644 --- a/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx @@ -1,12 +1,17 @@ -import { FileType } from '@renderer/types' -import { filterSupportedFiles } from '@renderer/utils/file' +import { ActionIconButton } from '@renderer/components/Buttons' +import { QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel' +import { useKnowledgeBases } from '@renderer/hooks/useKnowledge' +import { FileType, KnowledgeBase, KnowledgeItem } from '@renderer/types' +import { filterSupportedFiles, formatFileSize } from '@renderer/utils/file' import { Tooltip } from 'antd' -import { Paperclip } from 'lucide-react' -import { FC, useCallback, useImperativeHandle, useState } from 'react' +import dayjs from 'dayjs' +import { FileSearch, FileText, Paperclip, Upload } from 'lucide-react' +import { Dispatch, FC, SetStateAction, useCallback, useImperativeHandle, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' export interface AttachmentButtonRef { openQuickPanel: () => void + openFileSelectDialog: () => void } interface Props { @@ -14,24 +19,17 @@ interface Props { couldAddImageFile: boolean extensions: string[] files: FileType[] - setFiles: (files: FileType[]) => void - ToolbarButton: any + setFiles: Dispatch> disabled?: boolean } -const AttachmentButton: FC = ({ - ref, - couldAddImageFile, - extensions, - files, - setFiles, - ToolbarButton, - disabled -}) => { +const AttachmentButton: FC = ({ ref, couldAddImageFile, extensions, files, setFiles, disabled }) => { const { t } = useTranslation() + const quickPanel = useQuickPanel() + const { bases: knowledgeBases } = useKnowledgeBases() const [selecting, setSelecting] = useState(false) - const onSelectFile = useCallback(async () => { + const openFileSelectDialog = useCallback(async () => { if (selecting) { return } @@ -70,23 +68,88 @@ const AttachmentButton: FC = ({ } }, [extensions, files, selecting, setFiles, t]) + const openKnowledgeFileList = useCallback( + (base: KnowledgeBase) => { + quickPanel.open({ + title: base.name, + list: base.items + .filter((file): file is KnowledgeItem => ['file'].includes(file.type)) + .map((file) => { + const fileContent = file.content as FileType + return { + label: fileContent.origin_name || fileContent.name, + description: + formatFileSize(fileContent.size) + ' · ' + dayjs(fileContent.created_at).format('YYYY-MM-DD HH:mm'), + icon: , + isSelected: files.some((f) => f.path === fileContent.path), + action: async ({ item }) => { + item.isSelected = !item.isSelected + if (fileContent.path) { + setFiles((prevFiles) => { + const fileExists = prevFiles.some((f) => f.path === fileContent.path) + if (fileExists) { + return prevFiles.filter((f) => f.path !== fileContent.path) + } else { + return fileContent ? [...prevFiles, fileContent] : prevFiles + } + }) + } + } + } + }), + symbol: QuickPanelReservedSymbol.File, + multiple: true + }) + }, + [files, quickPanel, setFiles] + ) + + const items = useMemo(() => { + return [ + { + label: t('chat.input.upload.upload_from_local'), + description: '', + icon: , + action: () => openFileSelectDialog() + }, + ...knowledgeBases.map((base) => { + const length = base.items?.filter( + (item): item is KnowledgeItem => ['file', 'note'].includes(item.type) && typeof item.content !== 'string' + ).length + return { + label: base.name, + description: `${length} ${t('files.count')}`, + icon: , + disabled: length === 0, + isMenu: true, + action: () => openKnowledgeFileList(base) + } + }) + ] + }, [knowledgeBases, openFileSelectDialog, openKnowledgeFileList, t]) + const openQuickPanel = useCallback(() => { - onSelectFile() - }, [onSelectFile]) + quickPanel.open({ + title: t('chat.input.upload.attachment'), + list: items, + symbol: QuickPanelReservedSymbol.File + }) + }, [items, quickPanel, t]) useImperativeHandle(ref, () => ({ - openQuickPanel + openQuickPanel, + openFileSelectDialog })) return ( - - - + 0} disabled={disabled}> + + ) } diff --git a/src/renderer/src/pages/home/Inputbar/GenerateImageButton.tsx b/src/renderer/src/pages/home/Inputbar/GenerateImageButton.tsx index eadcea8b82..dc6553188c 100644 --- a/src/renderer/src/pages/home/Inputbar/GenerateImageButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/GenerateImageButton.tsx @@ -1,3 +1,4 @@ +import { ActionIconButton } from '@renderer/components/Buttons' import { isGenerateImageModel } from '@renderer/config/models' import { Assistant, Model } from '@renderer/types' import { Tooltip } from 'antd' @@ -8,11 +9,10 @@ import { useTranslation } from 'react-i18next' interface Props { assistant: Assistant model: Model - ToolbarButton: any onEnableGenerateImage: () => void } -const GenerateImageButton: FC = ({ model, ToolbarButton, assistant, onEnableGenerateImage }) => { +const GenerateImageButton: FC = ({ model, assistant, onEnableGenerateImage }) => { const { t } = useTranslation() return ( @@ -23,9 +23,12 @@ const GenerateImageButton: FC = ({ model, ToolbarButton, assistant, onEna } mouseLeaveDelay={0} arrow> - - - + + + ) } diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 14d209fb27..864ea71a21 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -1,25 +1,23 @@ import { HolderOutlined } from '@ant-design/icons' import { loggerService } from '@logger' -import { QuickPanelView, useQuickPanel } from '@renderer/components/QuickPanel' +import { ActionIconButton } from '@renderer/components/Buttons' +import { QuickPanelReservedSymbol, QuickPanelView, useQuickPanel } from '@renderer/components/QuickPanel' import TranslateButton from '@renderer/components/TranslateButton' import { isAutoEnableImageGenerationModel, isGenerateImageModel, isGenerateImageModels, isMandatoryWebSearchModel, - isSupportedReasoningEffortModel, - isSupportedThinkingTokenModel, isVisionModel, isVisionModels, isWebSearchModel } from '@renderer/config/models' import db from '@renderer/databases' import { useAssistant } from '@renderer/hooks/useAssistant' -import { useKnowledgeBases } from '@renderer/hooks/useKnowledge' import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations' import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime' import { useSettings } from '@renderer/hooks/useSettings' -import { useShortcut, useShortcutDisplay } from '@renderer/hooks/useShortcuts' +import { useShortcut } from '@renderer/hooks/useShortcuts' import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon' import { useTimer } from '@renderer/hooks/useTimer' import useTranslate from '@renderer/hooks/useTranslate' @@ -27,7 +25,6 @@ import { getDefaultTopic } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import FileManager from '@renderer/services/FileManager' import { checkRateLimit, getUserMessage } from '@renderer/services/MessagesService' -import { getModelUniqId } from '@renderer/services/ModelService' import PasteService from '@renderer/services/PasteService' import { spanManagerService } from '@renderer/services/SpanManagerService' import { estimateTextTokens as estimateTxtTokens, estimateUserPromptUsage } from '@renderer/services/TokenService' @@ -36,9 +33,9 @@ import WebSearchService from '@renderer/services/WebSearchService' import { useAppDispatch, useAppSelector } from '@renderer/store' import { setSearching } from '@renderer/store/runtime' import { sendMessage as _sendMessage } from '@renderer/store/thunk/messageThunk' -import { Assistant, FileType, FileTypes, KnowledgeBase, KnowledgeItem, Model, Topic } from '@renderer/types' +import { Assistant, FileType, KnowledgeBase, Model, Topic } from '@renderer/types' import type { MessageInputBaseParams } from '@renderer/types/newMessage' -import { classNames, delay, filterSupportedFiles, formatFileSize } from '@renderer/utils' +import { classNames, delay, filterSupportedFiles } from '@renderer/utils' import { formatQuotedText } from '@renderer/utils/formats' import { getFilesFromDropEvent, @@ -46,14 +43,12 @@ import { getTextFromDropEvent, isSendMessageKeyPressed } from '@renderer/utils/input' -import { isPromptToolUse, isSupportedToolUse } from '@renderer/utils/mcp-tools' import { documentExts, imageExts, textExts } from '@shared/config/constant' import { IpcChannel } from '@shared/IpcChannel' -import { Button, Tooltip } from 'antd' +import { Tooltip } from 'antd' import TextArea, { TextAreaRef } from 'antd/es/input/TextArea' -import dayjs from 'dayjs' import { debounce, isEmpty } from 'lodash' -import { CirclePause, FileSearch, FileText, Upload } from 'lucide-react' +import { CirclePause } from 'lucide-react' import React, { CSSProperties, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -114,7 +109,6 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const [textareaHeight, setTextareaHeight] = useState() const startDragY = useRef(0) const startHeight = useRef(0) - const { bases: knowledgeBases } = useKnowledgeBases() const isMultiSelectMode = useAppSelector((state) => state.runtime.chat.isMultiSelectMode) const isVisionAssistant = useMemo(() => isVisionModel(model), [model]) const isGenerateImageAssistant = useMemo(() => isGenerateImageModel(model), [model]) @@ -134,11 +128,6 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = [mentionedModels, isGenerateImageAssistant] ) - // 仅允许在不含图片文件时mention非视觉模型 - const couldMentionNotVisionModel = useMemo(() => { - return !files.some((file) => file.type === FileTypes.IMAGE) - }, [files]) - // 允许在支持视觉或生成图片时添加图片文件 const couldAddImageFile = useMemo(() => { return isVisionSupported || isGenerateImageSupported @@ -185,8 +174,6 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const inputTokenCount = showInputEstimatedTokens ? tokenCount : 0 - const newTopicShortcut = useShortcutDisplay('new_topic') - const cleanTopicShortcut = useShortcutDisplay('clear_topic') const inputEmpty = isEmpty(text.trim()) && files.length === 0 _text = text @@ -279,72 +266,6 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = } }, [isTranslating, text, getLanguageByLangcode, targetLanguage, setTimeoutTimer, resizeTextArea]) - const openKnowledgeFileList = useCallback( - (base: KnowledgeBase) => { - quickPanel.open({ - title: base.name, - list: base.items - .filter((file): file is KnowledgeItem => ['file'].includes(file.type)) - .map((file) => { - const fileContent = file.content as FileType - return { - label: fileContent.origin_name || fileContent.name, - description: - formatFileSize(fileContent.size) + ' · ' + dayjs(fileContent.created_at).format('YYYY-MM-DD HH:mm'), - icon: , - isSelected: files.some((f) => f.path === fileContent.path), - action: async ({ item }) => { - item.isSelected = !item.isSelected - if (fileContent.path) { - setFiles((prevFiles) => { - const fileExists = prevFiles.some((f) => f.path === fileContent.path) - if (fileExists) { - return prevFiles.filter((f) => f.path !== fileContent.path) - } else { - return fileContent ? [...prevFiles, fileContent] : prevFiles - } - }) - } - } - } - }), - symbol: 'file', - multiple: true - }) - }, - [files, quickPanel] - ) - - const openSelectFileMenu = useCallback(() => { - quickPanel.open({ - title: t('chat.input.upload.label'), - list: [ - { - label: t('chat.input.upload.upload_from_local'), - description: '', - icon: , - action: () => { - inputbarToolsRef.current?.openAttachmentQuickPanel() - } - }, - ...knowledgeBases.map((base) => { - const length = base.items?.filter( - (item): item is KnowledgeItem => ['file', 'note'].includes(item.type) && typeof item.content !== 'string' - ).length - return { - label: base.name, - description: `${length} ${t('files.count')}`, - icon: , - disabled: length === 0, - isMenu: true, - action: () => openKnowledgeFileList(base) - } - }) - ], - symbol: 'file' - }) - }, [knowledgeBases, openKnowledgeFileList, quickPanel, t, inputbarToolsRef]) - const handleKeyDown = (event: React.KeyboardEvent) => { // 按下Tab键,自动选中${xxx} if (event.key === 'Tab' && inputFocus) { @@ -512,35 +433,31 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const lastSymbol = newText[cursorPosition - 1] // 触发符号为 '/':若当前未打开或符号不同,则切换/打开 - if (enableQuickPanelTriggers && lastSymbol === '/') { - if (quickPanel.isVisible && quickPanel.symbol !== '/') { + if (enableQuickPanelTriggers && lastSymbol === QuickPanelReservedSymbol.Root) { + if (quickPanel.isVisible && quickPanel.symbol !== QuickPanelReservedSymbol.Root) { quickPanel.close('switch-symbol') } - if (!quickPanel.isVisible || quickPanel.symbol !== '/') { + if (!quickPanel.isVisible || quickPanel.symbol !== QuickPanelReservedSymbol.Root) { const quickPanelMenu = inputbarToolsRef.current?.getQuickPanelMenu({ - t, - files, - couldAddImageFile, text: newText, - openSelectFileMenu, translate }) || [] quickPanel.open({ title: t('settings.quickPanel.title'), list: quickPanelMenu, - symbol: '/' + symbol: QuickPanelReservedSymbol.Root }) } } // 触发符号为 '@':若当前未打开或符号不同,则切换/打开 - if (enableQuickPanelTriggers && lastSymbol === '@') { - if (quickPanel.isVisible && quickPanel.symbol !== '@') { + if (enableQuickPanelTriggers && lastSymbol === QuickPanelReservedSymbol.MentionModels) { + if (quickPanel.isVisible && quickPanel.symbol !== QuickPanelReservedSymbol.MentionModels) { quickPanel.close('switch-symbol') } - if (!quickPanel.isVisible || quickPanel.symbol !== '@') { + if (!quickPanel.isVisible || quickPanel.symbol !== QuickPanelReservedSymbol.MentionModels) { inputbarToolsRef.current?.openMentionModelsPanel({ type: 'input', position: cursorPosition - 1, @@ -549,7 +466,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = } } }, - [enableQuickPanelTriggers, quickPanel, t, files, couldAddImageFile, openSelectFileMenu, translate] + [enableQuickPanelTriggers, quickPanel, t, translate] ) const onPaste = useCallback( @@ -765,11 +682,6 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = setSelectedKnowledgeBases(showKnowledgeIcon ? (assistant.knowledge_bases ?? []) : []) }, [assistant.id, assistant.knowledge_bases, showKnowledgeIcon]) - const handleKnowledgeBaseSelect = (bases?: KnowledgeBase[]) => { - updateAssistant({ ...assistant, knowledge_bases: bases }) - setSelectedKnowledgeBases(bases ?? []) - } - const handleRemoveModel = (model: Model) => { setMentionedModels(mentionedModels.filter((m) => m.id !== model.id)) } @@ -783,10 +695,6 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = setSelectedKnowledgeBases(newKnowledgeBases ?? []) } - const onEnableGenerateImage = () => { - updateAssistant({ ...assistant, enableGenerateImage: !assistant.enableGenerateImage }) - } - useEffect(() => { if (!isWebSearchModel(model) && assistant.enableWebSearch) { updateAssistant({ ...assistant, enableWebSearch: false }) @@ -806,24 +714,6 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = } }, [assistant, model, updateAssistant]) - const onMentionModel = useCallback( - (model: Model) => { - // 我想应该没有模型是只支持视觉而不支持文本的? - if (isVisionModel(model) || couldMentionNotVisionModel) { - setMentionedModels((prev) => { - const modelId = getModelUniqId(model) - const exists = prev.some((m) => getModelUniqId(m) === modelId) - return exists ? prev.filter((m) => getModelUniqId(m) !== modelId) : [...prev, model] - }) - } else { - logger.error('Cannot add non-vision model when images are uploaded') - } - }, - [couldMentionNotVisionModel] - ) - - const onClearMentionModels = useCallback(() => setMentionedModels([]), [setMentionedModels]) - const onToggleExpanded = () => { const currentlyExpanded = expanded || !!textareaHeight const shouldExpand = !currentlyExpanded @@ -848,8 +738,6 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = } const isExpanded = expanded || !!textareaHeight - const showThinkingButton = isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model) - const showMcpTools = isSupportedToolUse(assistant) || isPromptToolUse(assistant) if (isMultiSelectMode) { return null @@ -921,47 +809,38 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = {loading && ( - + - + )} @@ -1076,45 +955,4 @@ const ToolbarMenu = styled.div` gap: 6px; ` -export const ToolbarButton = styled(Button)` - width: 30px; - height: 30px; - font-size: 16px; - border-radius: 50%; - transition: all 0.3s ease; - color: var(--color-icon); - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; - padding: 0; - &.anticon, - &.iconfont { - transition: all 0.3s ease; - color: var(--color-icon); - } - .icon-a-addchat { - font-size: 18px; - margin-bottom: -2px; - } - &:hover { - background-color: var(--color-background-soft); - .anticon, - .iconfont { - color: var(--color-text-1); - } - } - &.active { - background-color: var(--color-primary) !important; - .anticon, - .iconfont, - .chevron-icon { - color: var(--color-white-soft); - } - &:hover { - background-color: var(--color-primary); - } - } -` - export default Inputbar diff --git a/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx b/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx index 82a0071ee9..36b17a8bc2 100644 --- a/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx +++ b/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx @@ -1,12 +1,26 @@ import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd' +import { loggerService } from '@logger' +import { ActionIconButton } from '@renderer/components/Buttons' import { QuickPanelListItem } from '@renderer/components/QuickPanel' -import { isGeminiModel, isGenerateImageModel, isMandatoryWebSearchModel } from '@renderer/config/models' +import { + isGeminiModel, + isGenerateImageModel, + isMandatoryWebSearchModel, + isSupportedReasoningEffortModel, + isSupportedThinkingTokenModel, + isVisionModel +} from '@renderer/config/models' import { isSupportUrlContextProvider } from '@renderer/config/providers' +import { useAssistant } from '@renderer/hooks/useAssistant' +import { useShortcutDisplay } from '@renderer/hooks/useShortcuts' +import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon' import { getProviderByModel } from '@renderer/services/AssistantService' +import { getModelUniqId } from '@renderer/services/ModelService' import { useAppDispatch, useAppSelector } from '@renderer/store' import { setIsCollapsed, setToolOrder } from '@renderer/store/inputTools' -import { Assistant, FileType, KnowledgeBase, Model } from '@renderer/types' +import { FileType, FileTypes, KnowledgeBase, Model } from '@renderer/types' import { classNames } from '@renderer/utils' +import { isPromptToolUse, isSupportedToolUse } from '@renderer/utils/mcp-tools' import { Divider, Dropdown, Tooltip } from 'antd' import { ItemType } from 'antd/es/menu/interface' import { @@ -32,7 +46,6 @@ import styled from 'styled-components' import AttachmentButton, { AttachmentButtonRef } from './AttachmentButton' import GenerateImageButton from './GenerateImageButton' -import { ToolbarButton } from './Inputbar' import KnowledgeBaseButton, { KnowledgeBaseButtonRef } from './KnowledgeBaseButton' import MCPToolsButton, { MCPToolsButtonRef } from './MCPToolsButton' import MentionModelsButton, { MentionModelsButtonRef } from './MentionModelsButton' @@ -42,47 +55,33 @@ import ThinkingButton, { ThinkingButtonRef } from './ThinkingButton' import UrlContextButton, { UrlContextButtonRef } from './UrlContextbutton' import WebSearchButton, { WebSearchButtonRef } from './WebSearchButton' +const logger = loggerService.withContext('InputbarTools') + export interface InputbarToolsRef { - getQuickPanelMenu: (params: { - t: (key: string, options?: any) => string - files: FileType[] - couldAddImageFile: boolean - text: string - openSelectFileMenu: () => void - translate: () => void - }) => QuickPanelListItem[] + getQuickPanelMenu: (params: { text: string; translate: () => void }) => QuickPanelListItem[] openMentionModelsPanel: (triggerInfo?: { type: 'input' | 'button'; position?: number; originalText?: string }) => void openAttachmentQuickPanel: () => void } export interface InputbarToolsProps { - assistant: Assistant + assistantId: string model: Model files: FileType[] - setFiles: (files: FileType[]) => void + setFiles: Dispatch> extensions: string[] - showThinkingButton: boolean - showKnowledgeIcon: boolean - showMcpTools: boolean - selectedKnowledgeBases: KnowledgeBase[] - handleKnowledgeBaseSelect: (bases?: KnowledgeBase[]) => void setText: Dispatch> resizeTextArea: () => void - mentionModels: Model[] - onMentionModel: (model: Model) => void - onClearMentionModels: () => void - couldMentionNotVisionModel: boolean + selectedKnowledgeBases: KnowledgeBase[] + setSelectedKnowledgeBases: Dispatch> + mentionedModels: Model[] + setMentionedModels: Dispatch> couldAddImageFile: boolean - onEnableGenerateImage: () => void isExpanded: boolean onToggleExpanded: () => void addNewTopic: () => void clearTopic: () => void onNewContext: () => void - - newTopicShortcut: string - cleanTopicShortcut: string } interface ToolButtonConfig { @@ -100,34 +99,27 @@ const DraggablePortal = ({ children, isDragging }) => { const InputbarTools = ({ ref, - assistant, + assistantId, model, files, setFiles, - showThinkingButton, - showKnowledgeIcon, - showMcpTools, - selectedKnowledgeBases, - handleKnowledgeBaseSelect, setText, resizeTextArea, - mentionModels, - onMentionModel, - onClearMentionModels, - couldMentionNotVisionModel, + selectedKnowledgeBases, + setSelectedKnowledgeBases, + mentionedModels, + setMentionedModels, couldAddImageFile, - onEnableGenerateImage, isExpanded: isExpended, onToggleExpanded: onToggleExpended, addNewTopic, clearTopic, onNewContext, - newTopicShortcut, - cleanTopicShortcut, extensions }: InputbarToolsProps & { ref?: React.RefObject }) => { const { t } = useTranslation() const dispatch = useAppDispatch() + const { assistant, updateAssistant } = useAssistant(assistantId) const quickPhrasesButtonRef = useRef(null) const mentionModelsButtonRef = useRef(null) @@ -143,6 +135,54 @@ const InputbarTools = ({ const [targetTool, setTargetTool] = useState(null) + const showThinkingButton = useMemo( + () => isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model), + [model] + ) + + const showMcpServerButton = useMemo(() => isSupportedToolUse(assistant) || isPromptToolUse(assistant), [assistant]) + + const knowledgeSidebarEnabled = useSidebarIconShow('knowledge') + const showKnowledgeBaseButton = knowledgeSidebarEnabled && showMcpServerButton + + const handleKnowledgeBaseSelect = useCallback( + (bases?: KnowledgeBase[]) => { + updateAssistant({ knowledge_bases: bases }) + setSelectedKnowledgeBases(bases ?? []) + }, + [setSelectedKnowledgeBases, updateAssistant] + ) + + // 仅允许在不含图片文件时mention非视觉模型 + const couldMentionNotVisionModel = useMemo(() => { + return !files.some((file) => file.type === FileTypes.IMAGE) + }, [files]) + + const onMentionModel = useCallback( + (model: Model) => { + // 我想应该没有模型是只支持视觉而不支持文本的? + if (isVisionModel(model) || couldMentionNotVisionModel) { + setMentionedModels((prev) => { + const modelId = getModelUniqId(model) + const exists = prev.some((m) => getModelUniqId(m) === modelId) + return exists ? prev.filter((m) => getModelUniqId(m) !== modelId) : [...prev, model] + }) + } else { + logger.error('Cannot add non-vision model when images are uploaded') + } + }, + [couldMentionNotVisionModel, setMentionedModels] + ) + + const onClearMentionModels = useCallback(() => setMentionedModels([]), [setMentionedModels]) + + const onEnableGenerateImage = useCallback(() => { + updateAssistant({ enableGenerateImage: !assistant.enableGenerateImage }) + }, [assistant.enableGenerateImage, updateAssistant]) + + const newTopicShortcut = useShortcutDisplay('new_topic') + const clearTopicShortcut = useShortcutDisplay('clear_topic') + const toggleToolVisibility = useCallback( (toolKey: string, isVisible: boolean | undefined) => { const newToolOrder = { @@ -164,15 +204,8 @@ const InputbarTools = ({ [dispatch, toolOrder.hidden, toolOrder.visible] ) - const getQuickPanelMenuImpl = (params: { - t: (key: string, options?: any) => string - files: FileType[] - couldAddImageFile: boolean - text: string - openSelectFileMenu: () => void - translate: () => void - }): QuickPanelListItem[] => { - const { t, files, couldAddImageFile, text, openSelectFileMenu, translate } = params + const getQuickPanelMenuImpl = (params: { text: string; translate: () => void }): QuickPanelListItem[] => { + const { text, translate } = params return [ { @@ -249,11 +282,13 @@ const InputbarTools = ({ } }, { - label: couldAddImageFile ? t('chat.input.upload.label') : t('chat.input.upload.document'), + label: couldAddImageFile ? t('chat.input.upload.attachment') : t('chat.input.upload.document'), description: '', icon: , isMenu: true, - action: openSelectFileMenu + action: () => { + attachmentButtonRef.current?.openQuickPanel() + } }, { label: t('translate.title'), @@ -313,15 +348,15 @@ const InputbarTools = ({ title={t('chat.input.new_topic', { Command: newTopicShortcut })} mouseLeaveDelay={0} arrow> - + - + ) }, { key: 'attachment', - label: t('chat.input.upload.label'), + label: t('chat.input.upload.image_or_document'), component: ( ) }, { key: 'thinking', label: t('chat.input.thinking.label'), - component: ( - - ), + component: , condition: showThinkingButton }, { key: 'web_search', label: t('chat.input.web_search.label'), - component: , + component: , condition: !isMandatoryWebSearchModel(model) }, { key: 'url_context', label: t('chat.input.url_context'), - component: , + component: , condition: isGeminiModel(model) && isSupportUrlContextProvider(getProviderByModel(model)) }, { @@ -361,36 +393,29 @@ const InputbarTools = ({ ref={knowledgeBaseButtonRef} selectedBases={selectedKnowledgeBases} onSelect={handleKnowledgeBaseSelect} - ToolbarButton={ToolbarButton} disabled={files.length > 0} /> ), - condition: showKnowledgeIcon + condition: showKnowledgeBaseButton }, { key: 'mcp_tools', label: t('settings.mcp.title'), component: ( ), - condition: showMcpTools + condition: showMcpServerButton }, { key: 'generate_image', label: t('chat.input.generate_image'), component: ( - + ), condition: isGenerateImageModel(model) }, @@ -400,10 +425,9 @@ const InputbarTools = ({ component: ( ) }, @@ -429,12 +452,12 @@ const InputbarTools = ({ component: ( - + - + ) }, @@ -447,22 +470,22 @@ const InputbarTools = ({ title={isExpended ? t('chat.input.collapse') : t('chat.input.expand')} mouseLeaveDelay={0} arrow> - + {isExpended ? : } - + ) }, { key: 'new_context', label: t('chat.input.new.context', { Command: '' }), - component: + component: } ] }, [ addNewTopic, assistant, - cleanTopicShortcut, + clearTopicShortcut, clearTopic, couldAddImageFile, couldMentionNotVisionModel, @@ -470,7 +493,7 @@ const InputbarTools = ({ files, handleKnowledgeBaseSelect, isExpended, - mentionModels, + mentionedModels, model, newTopicShortcut, onClearMentionModels, @@ -482,8 +505,8 @@ const InputbarTools = ({ selectedKnowledgeBases, setFiles, setText, - showKnowledgeIcon, - showMcpTools, + showKnowledgeBaseButton, + showMcpServerButton, showThinkingButton, t ]) @@ -628,14 +651,14 @@ const InputbarTools = ({ placement="top" title={isCollapse ? t('chat.input.tools.expand') : t('chat.input.tools.collapse')} arrow> - dispatch(setIsCollapsed(!isCollapse))}> + dispatch(setIsCollapsed(!isCollapse))}> - + )} diff --git a/src/renderer/src/pages/home/Inputbar/KnowledgeBaseButton.tsx b/src/renderer/src/pages/home/Inputbar/KnowledgeBaseButton.tsx index bdb1a45353..552017e424 100644 --- a/src/renderer/src/pages/home/Inputbar/KnowledgeBaseButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/KnowledgeBaseButton.tsx @@ -1,4 +1,5 @@ -import { QuickPanelListItem, useQuickPanel } from '@renderer/components/QuickPanel' +import { ActionIconButton } from '@renderer/components/Buttons' +import { QuickPanelListItem, QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel' import { useAppSelector } from '@renderer/store' import { KnowledgeBase } from '@renderer/types' import { Tooltip } from 'antd' @@ -16,10 +17,9 @@ interface Props { selectedBases?: KnowledgeBase[] onSelect: (bases: KnowledgeBase[]) => void disabled?: boolean - ToolbarButton: any } -const KnowledgeBaseButton: FC = ({ ref, selectedBases, onSelect, disabled, ToolbarButton }) => { +const KnowledgeBaseButton: FC = ({ ref, selectedBases, onSelect, disabled }) => { const { t } = useTranslation() const navigate = useNavigate() const quickPanel = useQuickPanel() @@ -77,7 +77,7 @@ const KnowledgeBaseButton: FC = ({ ref, selectedBases, onSelect, disabled quickPanel.open({ title: t('chat.input.knowledge_base'), list: baseItems, - symbol: '#', + symbol: QuickPanelReservedSymbol.KnowledgeBase, multiple: true, afterAction({ item }) { item.isSelected = !item.isSelected @@ -86,7 +86,7 @@ const KnowledgeBaseButton: FC = ({ ref, selectedBases, onSelect, disabled }, [baseItems, quickPanel, t]) const handleOpenQuickPanel = useCallback(() => { - if (quickPanel.isVisible && quickPanel.symbol === '#') { + if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.KnowledgeBase) { quickPanel.close() } else { openQuickPanel() @@ -95,7 +95,7 @@ const KnowledgeBaseButton: FC = ({ ref, selectedBases, onSelect, disabled // 监听 selectedBases 变化,动态更新已打开的 QuickPanel 列表状态 useEffect(() => { - if (quickPanel.isVisible && quickPanel.symbol === '#') { + if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.KnowledgeBase) { // 直接使用重新计算的 baseItems,因为它已经包含了最新的 isSelected 状态 quickPanel.updateList(baseItems) } @@ -107,12 +107,12 @@ const KnowledgeBaseButton: FC = ({ ref, selectedBases, onSelect, disabled return ( - - 0 ? 'var(--color-primary)' : 'var(--color-icon)'} - /> - + 0} + disabled={disabled}> + + ) } diff --git a/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx b/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx index ae11cc1914..6680177b24 100644 --- a/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx @@ -1,4 +1,5 @@ -import { QuickPanelListItem, useQuickPanel } from '@renderer/components/QuickPanel' +import { ActionIconButton } from '@renderer/components/Buttons' +import { QuickPanelListItem, QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel' import { isGeminiModel } from '@renderer/config/models' import { isGeminiWebSearchProvider, isSupportUrlContextProvider } from '@renderer/config/providers' import { useAssistant } from '@renderer/hooks/useAssistant' @@ -6,7 +7,7 @@ import { useMCPServers } from '@renderer/hooks/useMCPServers' import { useTimer } from '@renderer/hooks/useTimer' import { getProviderByModel } from '@renderer/services/AssistantService' import { EventEmitter } from '@renderer/services/EventService' -import { Assistant, MCPPrompt, MCPResource, MCPServer } from '@renderer/types' +import { MCPPrompt, MCPResource, MCPServer } from '@renderer/types' import { isToolUseModeFunction } from '@renderer/utils/assistant' import { Form, Input, Tooltip } from 'antd' import { CircleX, Hammer, Plus } from 'lucide-react' @@ -21,11 +22,10 @@ export interface MCPToolsButtonRef { } interface Props { - assistant: Assistant + assistantId: string ref?: React.RefObject setInputValue: React.Dispatch> resizeTextArea: () => void - ToolbarButton: any } // 添加类型定义 @@ -113,14 +113,14 @@ const extractPromptContent = (response: any): string | null => { return null } -const MCPToolsButton: FC = ({ ref, setInputValue, resizeTextArea, ToolbarButton, ...props }) => { +const MCPToolsButton: FC = ({ ref, setInputValue, resizeTextArea, assistantId }) => { const { activedMcpServers } = useMCPServers() const { t } = useTranslation() const quickPanel = useQuickPanel() const navigate = useNavigate() const [form] = Form.useForm() - const { updateAssistant, assistant } = useAssistant(props.assistant.id) + const { assistant, updateAssistant } = useAssistant(assistantId) const model = assistant.model const { setTimeoutTimer } = useTimer() @@ -228,7 +228,7 @@ const MCPToolsButton: FC = ({ ref, setInputValue, resizeTextArea, Toolbar quickPanel.open({ title: t('settings.mcp.title'), list: menuItems, - symbol: 'mcp', + symbol: QuickPanelReservedSymbol.Mcp, multiple: true, afterAction({ item }) { item.isSelected = !item.isSelected @@ -377,7 +377,7 @@ const MCPToolsButton: FC = ({ ref, setInputValue, resizeTextArea, Toolbar quickPanel.open({ title: t('settings.mcp.title'), list: prompts, - symbol: 'mcp-prompt', + symbol: QuickPanelReservedSymbol.McpPrompt, multiple: true }) }, [promptList, quickPanel, t]) @@ -465,13 +465,13 @@ const MCPToolsButton: FC = ({ ref, setInputValue, resizeTextArea, Toolbar quickPanel.open({ title: t('settings.mcp.title'), list: resourcesList, - symbol: 'mcp-resource', + symbol: QuickPanelReservedSymbol.McpResource, multiple: true }) }, [resourcesList, quickPanel, t]) const handleOpenQuickPanel = useCallback(() => { - if (quickPanel.isVisible && quickPanel.symbol === 'mcp') { + if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.Mcp) { quickPanel.close() } else { openQuickPanel() @@ -486,12 +486,9 @@ const MCPToolsButton: FC = ({ ref, setInputValue, resizeTextArea, Toolbar return ( - - 0 ? 'var(--color-primary)' : 'var(--color-icon)'} - /> - + 0}> + + ) } diff --git a/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx b/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx index b252de981d..ceaa748bf5 100644 --- a/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx @@ -1,6 +1,6 @@ +import { ActionIconButton } from '@renderer/components/Buttons' import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel' -import { useQuickPanel } from '@renderer/components/QuickPanel' -import { QuickPanelListItem } from '@renderer/components/QuickPanel/types' +import { type QuickPanelListItem, QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel' import { getModelLogo, isEmbeddingModel, isRerankModel, isVisionModel } from '@renderer/config/models' import db from '@renderer/databases' import { useProviders } from '@renderer/hooks/useProvider' @@ -27,7 +27,6 @@ interface Props { onClearMentionModels: () => void couldMentionNotVisionModel: boolean files: FileType[] - ToolbarButton: any setText: React.Dispatch> } @@ -38,7 +37,6 @@ const MentionModelsButton: FC = ({ onClearMentionModels, couldMentionNotVisionModel, files, - ToolbarButton, setText }) => { const { providers } = useProviders() @@ -242,7 +240,7 @@ const MentionModelsButton: FC = ({ quickPanel.open({ title: t('agents.edit.model.select.title'), list: modelItems, - symbol: '@', + symbol: QuickPanelReservedSymbol.MentionModels, multiple: true, triggerInfo: triggerInfo || { type: 'button' }, afterAction({ item }) { @@ -274,7 +272,7 @@ const MentionModelsButton: FC = ({ ) const handleOpenQuickPanel = useCallback(() => { - if (quickPanel.isVisible && quickPanel.symbol === '@') { + if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.MentionModels) { quickPanel.close() } else { openQuickPanel({ type: 'button' }) @@ -286,7 +284,7 @@ const MentionModelsButton: FC = ({ useEffect(() => { // 检查files是否变化 if (filesRef.current !== files) { - if (quickPanel.isVisible && quickPanel.symbol === '@') { + if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.MentionModels) { quickPanel.close() } filesRef.current = files @@ -295,7 +293,7 @@ const MentionModelsButton: FC = ({ // 监听 mentionedModels 变化,动态更新已打开的 QuickPanel 列表状态 useEffect(() => { - if (quickPanel.isVisible && quickPanel.symbol === '@') { + if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.MentionModels) { // 直接使用重新计算的 modelItems,因为它已经包含了最新的 isSelected 状态 quickPanel.updateList(modelItems) } @@ -307,9 +305,9 @@ const MentionModelsButton: FC = ({ return ( - - 0 ? 'var(--color-primary)' : 'var(--color-icon)'} /> - + 0}> + + ) } diff --git a/src/renderer/src/pages/home/Inputbar/NewContextButton.tsx b/src/renderer/src/pages/home/Inputbar/NewContextButton.tsx index 26c9941cff..7cf9237a20 100644 --- a/src/renderer/src/pages/home/Inputbar/NewContextButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/NewContextButton.tsx @@ -1,15 +1,14 @@ +import { ActionIconButton } from '@renderer/components/Buttons' import { useShortcut, useShortcutDisplay } from '@renderer/hooks/useShortcuts' import { Tooltip } from 'antd' import { Eraser } from 'lucide-react' import { FC } from 'react' import { useTranslation } from 'react-i18next' - interface Props { onNewContext: () => void - ToolbarButton: any } -const NewContextButton: FC = ({ onNewContext, ToolbarButton }) => { +const NewContextButton: FC = ({ onNewContext }) => { const newContextShortcut = useShortcutDisplay('toggle_new_context') const { t } = useTranslation() @@ -21,9 +20,9 @@ const NewContextButton: FC = ({ onNewContext, ToolbarButton }) => { title={t('chat.input.new.context', { Command: newContextShortcut })} mouseLeaveDelay={0} arrow> - + - + ) } diff --git a/src/renderer/src/pages/home/Inputbar/QuickPhrasesButton.tsx b/src/renderer/src/pages/home/Inputbar/QuickPhrasesButton.tsx index a6d0de67c6..e01447b3fa 100644 --- a/src/renderer/src/pages/home/Inputbar/QuickPhrasesButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/QuickPhrasesButton.tsx @@ -1,11 +1,14 @@ +import { ActionIconButton } from '@renderer/components/Buttons' +import { + type QuickPanelListItem, + type QuickPanelOpenOptions, + QuickPanelReservedSymbol +} from '@renderer/components/QuickPanel' import { useQuickPanel } from '@renderer/components/QuickPanel' -import { QuickPanelListItem, QuickPanelOpenOptions } from '@renderer/components/QuickPanel/types' import { useAssistant } from '@renderer/hooks/useAssistant' import { useTimer } from '@renderer/hooks/useTimer' import QuickPhraseService from '@renderer/services/QuickPhraseService' -import { useAppSelector } from '@renderer/store' import { QuickPhrase } from '@renderer/types' -import { Assistant } from '@renderer/types' import { Input, Modal, Radio, Space, Tooltip } from 'antd' import { BotMessageSquare, Plus, Zap } from 'lucide-react' import { memo, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react' @@ -20,21 +23,16 @@ interface Props { ref?: React.RefObject setInputValue: React.Dispatch> resizeTextArea: () => void - ToolbarButton: any - assistantObj: Assistant + assistantId: string } -const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, ToolbarButton, assistantObj }: Props) => { +const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, assistantId }: Props) => { const [quickPhrasesList, setQuickPhrasesList] = useState([]) const [isModalOpen, setIsModalOpen] = useState(false) const [formData, setFormData] = useState({ title: '', content: '', location: 'global' }) const { t } = useTranslation() const quickPanel = useQuickPanel() - const activeAssistantId = useAppSelector( - (state) => - state.assistants.assistants.find((a) => a.id === assistantObj.id)?.id || state.assistants.defaultAssistant.id - ) - const { assistant, updateAssistant } = useAssistant(activeAssistantId) + const { assistant, updateAssistant } = useAssistant(assistantId) const { setTimeoutTimer } = useTimer() const loadQuickListPhrases = useCallback( @@ -135,7 +133,7 @@ const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, ToolbarButton, () => ({ title: t('settings.quickPhrase.title'), list: phraseItems, - symbol: 'quick-phrases' + symbol: QuickPanelReservedSymbol.QuickPhrases }), [phraseItems, t] ) @@ -145,7 +143,7 @@ const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, ToolbarButton, }, [quickPanel, quickPanelOpenOptions]) const handleOpenQuickPanel = useCallback(() => { - if (quickPanel.isVisible && quickPanel.symbol === 'quick-phrases') { + if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.QuickPhrases) { quickPanel.close() } else { openQuickPanel() @@ -159,9 +157,9 @@ const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, ToolbarButton, return ( <> - + - + model: Model - assistant: Assistant - ToolbarButton: any + assistantId: string } -const ThinkingButton: FC = ({ ref, model, assistant, ToolbarButton }): ReactElement => { +const ThinkingButton: FC = ({ ref, model, assistantId }): ReactElement => { const { t } = useTranslation() const quickPanel = useQuickPanel() - const { updateAssistantSettings } = useAssistant(assistant.id) + const { assistant, updateAssistantSettings } = useAssistant(assistantId) const currentReasoningEffort = useMemo(() => { return assistant.settings?.reasoning_effort || 'off' @@ -49,27 +49,6 @@ const ThinkingButton: FC = ({ ref, model, assistant, ToolbarButton }): Re return MODEL_SUPPORTED_OPTIONS[modelType] }, [model, modelType]) - const createThinkingIcon = useCallback((option?: ThinkingOption, isActive: boolean = false) => { - const iconColor = isActive ? 'var(--color-primary)' : 'var(--color-icon)' - - switch (true) { - case option === 'minimal': - return - case option === 'low': - return - case option === 'medium': - return - case option === 'high': - return - case option === 'auto': - return - case option === 'off': - return - default: - return - } - }, []) - const onThinkingChange = useCallback( (option?: ThinkingOption) => { const isEnabled = option !== undefined && option !== 'off' @@ -98,11 +77,11 @@ const ThinkingButton: FC = ({ ref, model, assistant, ToolbarButton }): Re level: option, label: getReasoningEffortOptionsLabel(option), description: '', - icon: createThinkingIcon(option), + icon: ThinkingIcon(option), isSelected: currentReasoningEffort === option, action: () => onThinkingChange(option) })) - }, [createThinkingIcon, currentReasoningEffort, supportedOptions, onThinkingChange]) + }, [currentReasoningEffort, supportedOptions, onThinkingChange]) const isThinkingEnabled = currentReasoningEffort !== undefined && currentReasoningEffort !== 'off' @@ -114,12 +93,12 @@ const ThinkingButton: FC = ({ ref, model, assistant, ToolbarButton }): Re quickPanel.open({ title: t('assistants.settings.reasoning_effort.label'), list: panelItems, - symbol: 'thinking' + symbol: QuickPanelReservedSymbol.Thinking }) }, [quickPanel, panelItems, t]) const handleOpenQuickPanel = useCallback(() => { - if (quickPanel.isVisible && quickPanel.symbol === 'thinking') { + if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.Thinking) { quickPanel.close() return } @@ -131,12 +110,6 @@ const ThinkingButton: FC = ({ ref, model, assistant, ToolbarButton }): Re openQuickPanel() }, [openQuickPanel, quickPanel, isThinkingEnabled, supportedOptions, disableThinking]) - // 获取当前应显示的图标 - const getThinkingIcon = useCallback(() => { - // 不再判断选项是否支持,依赖 useAssistant 更新选项为支持选项的行为 - return createThinkingIcon(currentReasoningEffort, currentReasoningEffort !== 'off') - }, [createThinkingIcon, currentReasoningEffort]) - useImperativeHandle(ref, () => ({ openQuickPanel })) @@ -151,11 +124,41 @@ const ThinkingButton: FC = ({ ref, model, assistant, ToolbarButton }): Re } mouseLeaveDelay={0} arrow> - - {getThinkingIcon()} - + + {ThinkingIcon(currentReasoningEffort)} + ) } +const ThinkingIcon = (option?: ThinkingOption) => { + let IconComponent: React.FC> | null = null + + switch (option) { + case 'minimal': + IconComponent = MdiLightbulbOn30 + break + case 'low': + IconComponent = MdiLightbulbOn50 + break + case 'medium': + IconComponent = MdiLightbulbOn80 + break + case 'high': + IconComponent = MdiLightbulbOn + break + case 'auto': + IconComponent = MdiLightbulbAutoOutline + break + case 'off': + IconComponent = MdiLightbulbOffOutline + break + default: + IconComponent = MdiLightbulbOffOutline + break + } + + return +} + export default ThinkingButton diff --git a/src/renderer/src/pages/home/Inputbar/TokenCount.tsx b/src/renderer/src/pages/home/Inputbar/TokenCount.tsx index d1d8f5f06e..7316e3549f 100644 --- a/src/renderer/src/pages/home/Inputbar/TokenCount.tsx +++ b/src/renderer/src/pages/home/Inputbar/TokenCount.tsx @@ -11,7 +11,6 @@ type Props = { estimateTokenCount: number inputTokenCount: number contextCount: { current: number; max: number } - ToolbarButton: any } & React.HTMLAttributes const TokenCount: FC = ({ estimateTokenCount, inputTokenCount, contextCount }) => { diff --git a/src/renderer/src/pages/home/Inputbar/UrlContextbutton.tsx b/src/renderer/src/pages/home/Inputbar/UrlContextbutton.tsx index e5e4c939b7..3b96d0cd0f 100644 --- a/src/renderer/src/pages/home/Inputbar/UrlContextbutton.tsx +++ b/src/renderer/src/pages/home/Inputbar/UrlContextbutton.tsx @@ -1,6 +1,6 @@ +import { ActionIconButton } from '@renderer/components/Buttons' import { useAssistant } from '@renderer/hooks/useAssistant' import { useTimer } from '@renderer/hooks/useTimer' -import { Assistant } from '@renderer/types' import { isToolUseModeFunction } from '@renderer/utils/assistant' import { Tooltip } from 'antd' import { Link } from 'lucide-react' @@ -13,13 +13,12 @@ export interface UrlContextButtonRef { interface Props { ref?: React.RefObject - assistant: Assistant - ToolbarButton: any + assistantId: string } -const UrlContextButton: FC = ({ assistant, ToolbarButton }) => { +const UrlContextButton: FC = ({ assistantId }) => { const { t } = useTranslation() - const { updateAssistant } = useAssistant(assistant.id) + const { assistant, updateAssistant } = useAssistant(assistantId) const { setTimeoutTimer } = useTimer() const urlContentNewState = !assistant.enableUrlContext @@ -48,14 +47,9 @@ const UrlContextButton: FC = ({ assistant, ToolbarButton }) => { return ( - - - + + + ) } diff --git a/src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx b/src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx index 343b5dbe44..cb3c5fb6d9 100644 --- a/src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx @@ -1,7 +1,8 @@ import { BaiduOutlined, GoogleOutlined } from '@ant-design/icons' import { loggerService } from '@logger' +import { ActionIconButton } from '@renderer/components/Buttons' import { BingLogo, BochaLogo, ExaLogo, SearXNGLogo, TavilyLogo, ZhipuLogo } from '@renderer/components/Icons' -import { QuickPanelListItem, useQuickPanel } from '@renderer/components/QuickPanel' +import { QuickPanelListItem, QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel' import { isGeminiModel, isWebSearchModel } from '@renderer/config/models' import { isGeminiWebSearchProvider } from '@renderer/config/providers' import { useAssistant } from '@renderer/hooks/useAssistant' @@ -9,7 +10,7 @@ import { useTimer } from '@renderer/hooks/useTimer' import { useWebSearchProviders } from '@renderer/hooks/useWebSearchProviders' import { getProviderByModel } from '@renderer/services/AssistantService' import WebSearchService from '@renderer/services/WebSearchService' -import { Assistant, WebSearchProvider, WebSearchProviderId } from '@renderer/types' +import { WebSearchProvider, WebSearchProviderId } from '@renderer/types' import { hasObjectKey } from '@renderer/utils' import { isToolUseModeFunction } from '@renderer/utils/assistant' import { Tooltip } from 'antd' @@ -23,17 +24,16 @@ export interface WebSearchButtonRef { interface Props { ref?: React.RefObject - assistant: Assistant - ToolbarButton: any + assistantId: string } const logger = loggerService.withContext('WebSearchButton') -const WebSearchButton: FC = ({ ref, assistant, ToolbarButton }) => { +const WebSearchButton: FC = ({ ref, assistantId }) => { const { t } = useTranslation() const quickPanel = useQuickPanel() const { providers } = useWebSearchProviders() - const { updateAssistant } = useAssistant(assistant.id) + const { assistant, updateAssistant } = useAssistant(assistantId) const { setTimeoutTimer } = useTimer() // 注意:assistant.enableWebSearch 有不同的语义 @@ -44,24 +44,24 @@ const WebSearchButton: FC = ({ ref, assistant, ToolbarButton }) => { ({ pid, size = 18, color }: { pid?: WebSearchProviderId; size?: number; color?: string }) => { switch (pid) { case 'bocha': - return + return case 'exa': // size微调,视觉上和其他图标平衡一些 - return + return case 'tavily': - return + return case 'zhipu': - return + return case 'searxng': - return + return case 'local-baidu': return case 'local-bing': - return + return case 'local-google': return default: - return + return } }, [] @@ -165,13 +165,13 @@ const WebSearchButton: FC = ({ ref, assistant, ToolbarButton }) => { quickPanel.open({ title: t('chat.input.web_search.label'), list: providerItems, - symbol: '?', + symbol: QuickPanelReservedSymbol.WebSearch, pageSize: 9 }) }, [quickPanel, t, providerItems]) const handleOpenQuickPanel = useCallback(() => { - if (quickPanel.isVisible && quickPanel.symbol === '?') { + if (quickPanel.isVisible && quickPanel.symbol === QuickPanelReservedSymbol.WebSearch) { quickPanel.close() } else { openQuickPanel() @@ -190,17 +190,15 @@ const WebSearchButton: FC = ({ ref, assistant, ToolbarButton }) => { openQuickPanel })) - const color = enableWebSearch ? 'var(--color-primary)' : 'var(--color-icon)' - return ( - - - + + + ) } diff --git a/src/renderer/src/pages/home/Messages/MessageEditor.tsx b/src/renderer/src/pages/home/Messages/MessageEditor.tsx index 3d079fb24b..953dccaa29 100644 --- a/src/renderer/src/pages/home/Messages/MessageEditor.tsx +++ b/src/renderer/src/pages/home/Messages/MessageEditor.tsx @@ -1,4 +1,5 @@ import { loggerService } from '@logger' +import { ActionIconButton } from '@renderer/components/Buttons' import CustomTag from '@renderer/components/Tags/CustomTag' import TranslateButton from '@renderer/components/TranslateButton' import { isGenerateImageModel, isVisionModel } from '@renderer/config/models' @@ -25,7 +26,6 @@ import styled from 'styled-components' import AttachmentButton, { AttachmentButtonRef } from '../Inputbar/AttachmentButton' import { FileNameRender, getFileIcon } from '../Inputbar/AttachmentPreview' -import { ToolbarButton } from '../Inputbar/Inputbar' interface Props { message: Message @@ -346,27 +346,26 @@ const MessageBlockEditor: FC = ({ message, topicId, onSave, onResend, onC setFiles={setFiles} couldAddImageFile={couldAddImageFile} extensions={extensions} - ToolbarButton={ToolbarButton} /> )} - + - + - + - + {message.role === 'user' && ( - + - + )} diff --git a/src/renderer/src/store/assistants.ts b/src/renderer/src/store/assistants.ts index 15a735bc7f..8f53977835 100644 --- a/src/renderer/src/store/assistants.ts +++ b/src/renderer/src/store/assistants.ts @@ -46,8 +46,8 @@ const assistantsSlice = createSlice({ removeAssistant: (state, action: PayloadAction<{ id: string }>) => { state.assistants = state.assistants.filter((c) => c.id !== action.payload.id) }, - updateAssistant: (state, action: PayloadAction) => { - state.assistants = state.assistants.map((c) => (c.id === action.payload.id ? action.payload : c)) + updateAssistant: (state, action: PayloadAction>) => { + state.assistants = state.assistants.map((c) => (c.id === action.payload.id ? { ...c, ...action.payload } : c)) }, updateAssistantSettings: ( state, From ee95fad7e5cbe27d54259d59baf380d4a3553ec5 Mon Sep 17 00:00:00 2001 From: liyuyun-lyy Date: Tue, 16 Sep 2025 21:24:24 +0800 Subject: [PATCH 23/99] feat: add support for iflow cli (#10198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 本x --- packages/shared/config/constant.ts | 3 ++- src/main/services/CodeToolsService.ts | 4 ++++ src/renderer/src/pages/code/index.ts | 12 ++++++++++-- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/shared/config/constant.ts b/packages/shared/config/constant.ts index 18246cd1e7..3dc2a45a6a 100644 --- a/packages/shared/config/constant.ts +++ b/packages/shared/config/constant.ts @@ -216,5 +216,6 @@ export enum codeTools { qwenCode = 'qwen-code', claudeCode = 'claude-code', geminiCli = 'gemini-cli', - openaiCodex = 'openai-codex' + openaiCodex = 'openai-codex', + iFlowCli = 'iflow-cli' } diff --git a/src/main/services/CodeToolsService.ts b/src/main/services/CodeToolsService.ts index 6e3bb19022..1372bf1f88 100644 --- a/src/main/services/CodeToolsService.ts +++ b/src/main/services/CodeToolsService.ts @@ -51,6 +51,8 @@ class CodeToolsService { return '@openai/codex' case codeTools.qwenCode: return '@qwen-code/qwen-code' + case codeTools.iFlowCli: + return '@iflow-ai/iflow-cli' default: throw new Error(`Unsupported CLI tool: ${cliTool}`) } @@ -66,6 +68,8 @@ class CodeToolsService { return 'codex' case codeTools.qwenCode: return 'qwen' + case codeTools.iFlowCli: + return 'iflow' default: throw new Error(`Unsupported CLI tool: ${cliTool}`) } diff --git a/src/renderer/src/pages/code/index.ts b/src/renderer/src/pages/code/index.ts index 2c36da3ec6..f286704d39 100644 --- a/src/renderer/src/pages/code/index.ts +++ b/src/renderer/src/pages/code/index.ts @@ -19,7 +19,8 @@ export const CLI_TOOLS = [ { value: codeTools.claudeCode, label: 'Claude Code' }, { value: codeTools.qwenCode, label: 'Qwen Code' }, { value: codeTools.geminiCli, label: 'Gemini CLI' }, - { value: codeTools.openaiCodex, label: 'OpenAI Codex' } + { value: codeTools.openaiCodex, label: 'OpenAI Codex' }, + { value: codeTools.iFlowCli, label: 'iFlow CLI' } ] export const GEMINI_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api'] @@ -35,7 +36,8 @@ export const CLI_TOOL_PROVIDER_MAP: Record Pr providers.filter((p) => p.type === 'gemini' || GEMINI_SUPPORTED_PROVIDERS.includes(p.id)), [codeTools.qwenCode]: (providers) => providers.filter((p) => p.type.includes('openai')), [codeTools.openaiCodex]: (providers) => - providers.filter((p) => p.id === 'openai' || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(p.id)) + providers.filter((p) => p.id === 'openai' || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(p.id)), + [codeTools.iFlowCli]: (providers) => providers.filter((p) => p.type.includes('openai')) } export const getCodeToolsApiBaseUrl = (model: Model, type: EndpointType) => { @@ -144,6 +146,12 @@ export const generateToolEnvironment = ({ env.OPENAI_MODEL = model.id env.OPENAI_MODEL_PROVIDER = modelProvider.id break + + case codeTools.iFlowCli: + env.IFLOW_API_KEY = apiKey + env.IFLOW_BASE_URL = baseUrl + env.IFLOW_MODEL_NAME = model.id + break } return env From a9093b1deaf83074cac21d25400fc8ba97ca2b87 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Tue, 16 Sep 2025 22:31:38 +0800 Subject: [PATCH 24/99] chore: update biome format command to ignore unmatched files (#10207) Add --no-errors-on-unmatched flag to biome format commands in lint-staged configuration to prevent errors when no matching files are found --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index b3da1832bf..9e42453df2 100644 --- a/package.json +++ b/package.json @@ -374,11 +374,11 @@ "packageManager": "yarn@4.9.1", "lint-staged": { "*.{js,jsx,ts,tsx,cjs,mjs,cts,mts}": [ - "biome format --write", + "biome format --write --no-errors-on-unmatched", "eslint --fix" ], "*.{json,yml,yaml,css,html}": [ - "biome format --write" + "biome format --write --no-errors-on-unmatched" ] } } From cc860e48b10d3ff398374c86dab82873f8836544 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 16 Sep 2025 22:45:10 +0800 Subject: [PATCH 25/99] fix: update navbar position handling in useAppInit hook - Added isLeftNavbar to the useNavbarPosition hook for improved layout management. - Adjusted background style logic to use isLeftNavbar instead of isTopNavbar for better compatibility with left-aligned navigation. - Simplified condition for transparent window styling on macOS. --- src/renderer/src/hooks/useAppInit.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/hooks/useAppInit.ts b/src/renderer/src/hooks/useAppInit.ts index 571e5877a8..ca61511696 100644 --- a/src/renderer/src/hooks/useAppInit.ts +++ b/src/renderer/src/hooks/useAppInit.ts @@ -38,7 +38,7 @@ export function useAppInit() { customCss, enableDataCollection } = useSettings() - const { isTopNavbar } = useNavbarPosition() + const { isTopNavbar, isLeftNavbar } = useNavbarPosition() const { minappShow } = useRuntime() const { setDefaultModel, setQuickModel, setTranslateModel } = useDefaultModel() const avatar = useLiveQuery(() => db.settings.get('image://avatar')) @@ -102,16 +102,15 @@ export function useAppInit() { }, [language]) useEffect(() => { - const transparentWindow = windowStyle === 'transparent' && isMac && !minappShow + const isMacTransparentWindow = windowStyle === 'transparent' && isMac - if (minappShow && isTopNavbar) { - window.root.style.background = - windowStyle === 'transparent' && isMac ? 'var(--color-background)' : 'var(--navbar-background)' + if (minappShow && isLeftNavbar) { + window.root.style.background = isMacTransparentWindow ? 'var(--color-background)' : 'var(--navbar-background)' return } - window.root.style.background = transparentWindow ? 'var(--navbar-background-mac)' : 'var(--navbar-background)' - }, [windowStyle, minappShow, theme, isTopNavbar]) + window.root.style.background = isMacTransparentWindow ? 'var(--navbar-background-mac)' : 'var(--navbar-background)' + }, [windowStyle, minappShow, theme]) useEffect(() => { if (isLocalAi) { From 7319fc5ef48d00c512df42dc12558c930e04afd7 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 16 Sep 2025 22:33:10 +0800 Subject: [PATCH 26/99] chore: bump version to v1.6.0-rc.1 --- electron-builder.yml | 31 ++++++++++++---------------- package.json | 2 +- src/renderer/src/hooks/useAppInit.ts | 4 ++-- 3 files changed, 16 insertions(+), 21 deletions(-) diff --git a/electron-builder.yml b/electron-builder.yml index 80722308e4..0660319150 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -126,24 +126,19 @@ artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | ✨ 新功能: - - 重构知识库模块,提升文档处理能力和搜索性能 - - 新增 PaddleOCR 支持,增强文档识别能力 - - 支持自定义窗口控制按钮样式 - - 新增 AI SDK 包,扩展 AI 能力集成 - - 支持标签页拖拽重排序功能 - - 增强笔记编辑器的同步和日志功能 + - 集成 Perplexity SDK 和 Anthropic OAuth + - 支持 API 服务器模式,提供外部调用接口 + - 新增字体自定义设置功能 + - 笔记支持文件夹批量上传 + - 集成 HeroUI 和 Tailwind CSS 提升界面体验 - 🔧 性能优化: - - 优化 MCP 服务的日志记录和错误处理 - - 改进 WebView 服务的 User-Agent 处理 - - 优化迷你应用的标题栏样式和状态栏适配 - - 重构依赖管理,清理和优化 package.json + 🚀 性能优化: + - 优化大文件上传,支持 OpenAI 标准文件服务 + - 重构 MCP 服务,改进错误处理和状态管理 🐛 问题修复: - - 修复输入栏无限状态更新循环问题 - - 修复窗口控制提示框的鼠标悬停延迟 - - 修复翻译输入框粘贴多内容源的处理 - - 修复导航服务初始化时序问题 - - 修复 MCP 通过 JSON 添加时的参数转换 - - 修复模型作用域服务器同步时的 URL 格式 - - 标准化工具提示图标样式 + - 修复 WebSearch RAG 并发问题 + - 修复翻译页面长文本渲染布局问题 + - 修复笔记拖拽排序和无限循环问题 + - 修复 macOS CodeTool 工作目录错误 + - 修复多个 UI 组件的响应式设计问题 diff --git a/package.json b/package.json index 9e42453df2..16a9b58a87 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.6.0-beta.7", + "version": "1.6.0-rc.1", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", diff --git a/src/renderer/src/hooks/useAppInit.ts b/src/renderer/src/hooks/useAppInit.ts index ca61511696..e9e3b55706 100644 --- a/src/renderer/src/hooks/useAppInit.ts +++ b/src/renderer/src/hooks/useAppInit.ts @@ -38,7 +38,7 @@ export function useAppInit() { customCss, enableDataCollection } = useSettings() - const { isTopNavbar, isLeftNavbar } = useNavbarPosition() + const { isLeftNavbar } = useNavbarPosition() const { minappShow } = useRuntime() const { setDefaultModel, setQuickModel, setTranslateModel } = useDefaultModel() const avatar = useLiveQuery(() => db.settings.get('image://avatar')) @@ -110,7 +110,7 @@ export function useAppInit() { } window.root.style.background = isMacTransparentWindow ? 'var(--navbar-background-mac)' : 'var(--navbar-background)' - }, [windowStyle, minappShow, theme]) + }, [windowStyle, minappShow, theme, isLeftNavbar]) useEffect(() => { if (isLocalAi) { From 4f91a321a06f530a334de40be7c0a00677477558 Mon Sep 17 00:00:00 2001 From: SuYao Date: Wed, 17 Sep 2025 14:07:45 +0800 Subject: [PATCH 27/99] chore: update dependencies and VSCode settings (#10206) * chore: update dependencies and VSCode settings * chore * chore: ts --- .vscode/extensions.json | 3 +-- .vscode/settings.json | 3 +-- package.json | 3 +-- yarn.lock | 18 +++++------------- 4 files changed, 8 insertions(+), 19 deletions(-) diff --git a/.vscode/extensions.json b/.vscode/extensions.json index a08379caed..79046aa441 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -6,7 +6,6 @@ "bradlc.vscode-tailwindcss", "vitest.explorer", "oxc.oxc-vscode", - "biomejs.biome", - "typescriptteam.native-preview" + "biomejs.biome" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 2d62fde832..141179f38c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -47,6 +47,5 @@ "search.exclude": { "**/dist/**": true, ".yarn/releases/**": true - }, - "typescript.experimental.useTsgo": true + } } diff --git a/package.json b/package.json index 16a9b58a87..bcbcbd10a5 100644 --- a/package.json +++ b/package.json @@ -237,7 +237,6 @@ "diff": "^8.0.2", "docx": "^9.0.2", "dompurify": "^3.2.6", - "dotenv": "^17.2.2", "dotenv-cli": "^7.4.2", "electron": "37.4.0", "electron-builder": "26.0.15", @@ -333,7 +332,7 @@ "tsx": "^4.20.3", "turndown-plugin-gfm": "^1.0.2", "tw-animate-css": "^1.3.8", - "typescript": "^5.6.2", + "typescript": "^5.8.2", "undici": "6.21.2", "unified": "^11.0.5", "uuid": "^10.0.0", diff --git a/yarn.lock b/yarn.lock index 630c46c05c..1d414c0861 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13328,7 +13328,6 @@ __metadata: diff: "npm:^8.0.2" docx: "npm:^9.0.2" dompurify: "npm:^3.2.6" - dotenv: "npm:^17.2.2" dotenv-cli: "npm:^7.4.2" electron: "npm:37.4.0" electron-builder: "npm:26.0.15" @@ -13437,7 +13436,7 @@ __metadata: turndown: "npm:7.2.0" turndown-plugin-gfm: "npm:^1.0.2" tw-animate-css: "npm:^1.3.8" - typescript: "npm:^5.6.2" + typescript: "npm:^5.8.2" undici: "npm:6.21.2" unified: "npm:^11.0.5" uuid: "npm:^10.0.0" @@ -16631,13 +16630,6 @@ __metadata: languageName: node linkType: hard -"dotenv@npm:^17.2.2": - version: 17.2.2 - resolution: "dotenv@npm:17.2.2" - checksum: 10c0/be66513504590aff6eccb14167625aed9bd42ce80547f4fe5d195860211971a7060949b57108dfaeaf90658f79e40edccd3f233f0a978bff507b5b1565ae162b - languageName: node - linkType: hard - "dts-resolver@npm:^2.1.1": version: 2.1.1 resolution: "dts-resolver@npm:2.1.1" @@ -27775,7 +27767,7 @@ __metadata: languageName: node linkType: hard -"typescript@npm:^5.0.0": +"typescript@npm:^5.0.0, typescript@npm:^5.8.2": version: 5.9.2 resolution: "typescript@npm:5.9.2" bin: @@ -27785,7 +27777,7 @@ __metadata: languageName: node linkType: hard -"typescript@npm:^5.4.3, typescript@npm:^5.6.2": +"typescript@npm:^5.4.3": version: 5.8.3 resolution: "typescript@npm:5.8.3" bin: @@ -27795,7 +27787,7 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A^5.0.0#optional!builtin": +"typescript@patch:typescript@npm%3A^5.0.0#optional!builtin, typescript@patch:typescript@npm%3A^5.8.2#optional!builtin": version: 5.9.2 resolution: "typescript@patch:typescript@npm%3A5.9.2#optional!builtin::version=5.9.2&hash=5786d5" bin: @@ -27805,7 +27797,7 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A^5.4.3#optional!builtin, typescript@patch:typescript@npm%3A^5.6.2#optional!builtin": +"typescript@patch:typescript@npm%3A^5.4.3#optional!builtin": version: 5.8.3 resolution: "typescript@patch:typescript@npm%3A5.8.3#optional!builtin::version=5.8.3&hash=5786d5" bin: From 1481149e51101fecd4409f658da766ab96ec28a5 Mon Sep 17 00:00:00 2001 From: one Date: Wed, 17 Sep 2025 14:18:48 +0800 Subject: [PATCH 28/99] fix: wrap MessageEditor with QuickPanelProvider (#10223) * fix: wrap MessageEditor with QuickPanelProvider * style: revert formatting --- src/renderer/src/pages/home/Chat.tsx | 32 ++++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index 4ea6bcdd57..d6d7f08d90 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -155,23 +155,23 @@ const Chat: FC = (props) => { flex={1} justify="space-between" style={{ maxWidth: chatMaxWidth, height: mainHeight }}> - - } - filter={contentSearchFilter} - includeUser={filterIncludeUser} - onIncludeUserChange={userOutlinedItemClickHandler} - /> - {messageNavigation === 'buttons' && } + + } + filter={contentSearchFilter} + includeUser={filterIncludeUser} + onIncludeUserChange={userOutlinedItemClickHandler} + /> + {messageNavigation === 'buttons' && } {isMultiSelectMode && } From a8bf55abc2b36f620d3c42280cb342007f2554ee Mon Sep 17 00:00:00 2001 From: SuYao Date: Wed, 17 Sep 2025 15:10:30 +0800 Subject: [PATCH 29/99] =?UTF-8?q?refactor:=20=E5=B0=86=E7=BD=91=E7=BB=9C?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E5=8F=82=E6=95=B0=E7=94=A8=E4=BA=8E=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=86=85=E7=BD=AE=E6=90=9C=E7=B4=A2=20(#10213)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Remove local provider option files and use external packages Replace local implementation of XAI and OpenRouter provider options with external packages (@ai-sdk/xai and @openrouter/ai-sdk-provider). Update web search plugin to support additional providers including OpenAI Chat and OpenRouter, with improved configuration mapping. * Bump @cherrystudio/ai-core to v1.0.0-alpha.17 fix i18n * fix(i18n): Auto update translations for PR #10213 --------- Co-authored-by: GitHub Action --- package.json | 2 +- packages/aiCore/package.json | 2 +- packages/aiCore/src/core/options/factory.ts | 2 +- .../aiCore/src/core/options/openrouter.ts | 38 -------- packages/aiCore/src/core/options/types.ts | 5 +- packages/aiCore/src/core/options/xai.ts | 86 ------------------ .../aiCore/src/core/plugins/built-in/index.ts | 8 +- .../built-in/webSearchPlugin/helper.ts | 20 ++++- .../plugins/built-in/webSearchPlugin/index.ts | 17 +++- .../built-in/webSearchPlugin/openrouter.ts | 26 ++++++ packages/aiCore/src/core/providers/schemas.ts | 7 +- .../middleware/AiSdkMiddlewareBuilder.ts | 3 + .../src/aiCore/plugins/PluginBuilder.ts | 5 +- .../aiCore/prepareParams/parameterBuilder.ts | 27 +++++- src/renderer/src/aiCore/utils/websearch.ts | 88 +++++++++++++++++-- src/renderer/src/config/models/utils.ts | 5 ++ src/renderer/src/i18n/locales/en-us.json | 7 +- src/renderer/src/i18n/locales/zh-cn.json | 8 +- src/renderer/src/i18n/locales/zh-tw.json | 7 +- src/renderer/src/i18n/translate/el-gr.json | 10 ++- src/renderer/src/i18n/translate/es-es.json | 10 ++- src/renderer/src/i18n/translate/fr-fr.json | 10 ++- src/renderer/src/i18n/translate/ja-jp.json | 10 ++- src/renderer/src/i18n/translate/pt-pt.json | 10 ++- src/renderer/src/i18n/translate/ru-ru.json | 10 ++- .../pages/home/Inputbar/ThinkingButton.tsx | 19 +++- .../pages/home/Inputbar/WebSearchButton.tsx | 16 +++- src/renderer/src/services/ApiService.ts | 4 +- src/renderer/src/store/websearch.ts | 2 + .../__tests__/blacklistMatchPattern.test.ts | 27 +++++- .../src/utils/blacklistMatchPattern.ts | 37 ++++++++ yarn.lock | 4 +- 32 files changed, 359 insertions(+), 173 deletions(-) delete mode 100644 packages/aiCore/src/core/options/openrouter.ts delete mode 100644 packages/aiCore/src/core/options/xai.ts create mode 100644 packages/aiCore/src/core/plugins/built-in/webSearchPlugin/openrouter.ts diff --git a/package.json b/package.json index bcbcbd10a5..b1bd7c8d72 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "@aws-sdk/client-bedrock-runtime": "^3.840.0", "@aws-sdk/client-s3": "^3.840.0", "@biomejs/biome": "2.2.4", - "@cherrystudio/ai-core": "workspace:^1.0.0-alpha.16", + "@cherrystudio/ai-core": "workspace:^1.0.0-alpha.17", "@cherrystudio/embedjs": "^0.1.31", "@cherrystudio/embedjs-libsql": "^0.1.31", "@cherrystudio/embedjs-loader-csv": "^0.1.31", diff --git a/packages/aiCore/package.json b/packages/aiCore/package.json index 292b679d82..642feff7c1 100644 --- a/packages/aiCore/package.json +++ b/packages/aiCore/package.json @@ -1,6 +1,6 @@ { "name": "@cherrystudio/ai-core", - "version": "1.0.0-alpha.16", + "version": "1.0.0-alpha.17", "description": "Cherry Studio AI Core - Unified AI Provider Interface Based on Vercel AI SDK", "main": "dist/index.js", "module": "dist/index.mjs", diff --git a/packages/aiCore/src/core/options/factory.ts b/packages/aiCore/src/core/options/factory.ts index 4350e9241b..ffeb15185c 100644 --- a/packages/aiCore/src/core/options/factory.ts +++ b/packages/aiCore/src/core/options/factory.ts @@ -59,7 +59,7 @@ export function createGoogleOptions(options: ExtractProviderOptions<'google'>) { /** * 创建OpenRouter供应商选项的便捷函数 */ -export function createOpenRouterOptions(options: ExtractProviderOptions<'openrouter'>) { +export function createOpenRouterOptions(options: ExtractProviderOptions<'openrouter'> | Record) { return createProviderOptions('openrouter', options) } diff --git a/packages/aiCore/src/core/options/openrouter.ts b/packages/aiCore/src/core/options/openrouter.ts deleted file mode 100644 index b351f8fda1..0000000000 --- a/packages/aiCore/src/core/options/openrouter.ts +++ /dev/null @@ -1,38 +0,0 @@ -export type OpenRouterProviderOptions = { - models?: string[] - - /** - * https://openrouter.ai/docs/use-cases/reasoning-tokens - * One of `max_tokens` or `effort` is required. - * If `exclude` is true, reasoning will be removed from the response. Default is false. - */ - reasoning?: { - exclude?: boolean - } & ( - | { - max_tokens: number - } - | { - effort: 'high' | 'medium' | 'low' - } - ) - - /** - * A unique identifier representing your end-user, which can - * help OpenRouter to monitor and detect abuse. - */ - user?: string - - extraBody?: Record - - /** - * Enable usage accounting to get detailed token usage information. - * https://openrouter.ai/docs/use-cases/usage-accounting - */ - usage?: { - /** - * When true, includes token usage information in the response. - */ - include: boolean - } -} diff --git a/packages/aiCore/src/core/options/types.ts b/packages/aiCore/src/core/options/types.ts index 724dc30698..8571fd2296 100644 --- a/packages/aiCore/src/core/options/types.ts +++ b/packages/aiCore/src/core/options/types.ts @@ -2,9 +2,8 @@ import { type AnthropicProviderOptions } from '@ai-sdk/anthropic' import { type GoogleGenerativeAIProviderOptions } from '@ai-sdk/google' import { type OpenAIResponsesProviderOptions } from '@ai-sdk/openai' import { type SharedV2ProviderMetadata } from '@ai-sdk/provider' - -import { type OpenRouterProviderOptions } from './openrouter' -import { type XaiProviderOptions } from './xai' +import { type XaiProviderOptions } from '@ai-sdk/xai' +import { type OpenRouterProviderOptions } from '@openrouter/ai-sdk-provider' export type ProviderOptions = SharedV2ProviderMetadata[T] diff --git a/packages/aiCore/src/core/options/xai.ts b/packages/aiCore/src/core/options/xai.ts deleted file mode 100644 index 7fe5672778..0000000000 --- a/packages/aiCore/src/core/options/xai.ts +++ /dev/null @@ -1,86 +0,0 @@ -// copy from @ai-sdk/xai/xai-chat-options.ts -// 如果@ai-sdk/xai暴露出了xaiProviderOptions就删除这个文件 - -import { z } from 'zod' - -const webSourceSchema = z.object({ - type: z.literal('web'), - country: z.string().length(2).optional(), - excludedWebsites: z.array(z.string()).max(5).optional(), - allowedWebsites: z.array(z.string()).max(5).optional(), - safeSearch: z.boolean().optional() -}) - -const xSourceSchema = z.object({ - type: z.literal('x'), - xHandles: z.array(z.string()).optional() -}) - -const newsSourceSchema = z.object({ - type: z.literal('news'), - country: z.string().length(2).optional(), - excludedWebsites: z.array(z.string()).max(5).optional(), - safeSearch: z.boolean().optional() -}) - -const rssSourceSchema = z.object({ - type: z.literal('rss'), - links: z.array(z.url()).max(1) // currently only supports one RSS link -}) - -const searchSourceSchema = z.discriminatedUnion('type', [ - webSourceSchema, - xSourceSchema, - newsSourceSchema, - rssSourceSchema -]) - -export const xaiProviderOptions = z.object({ - /** - * reasoning effort for reasoning models - * only supported by grok-3-mini and grok-3-mini-fast models - */ - reasoningEffort: z.enum(['low', 'high']).optional(), - - searchParameters: z - .object({ - /** - * search mode preference - * - "off": disables search completely - * - "auto": model decides whether to search (default) - * - "on": always enables search - */ - mode: z.enum(['off', 'auto', 'on']), - - /** - * whether to return citations in the response - * defaults to true - */ - returnCitations: z.boolean().optional(), - - /** - * start date for search data (ISO8601 format: YYYY-MM-DD) - */ - fromDate: z.string().optional(), - - /** - * end date for search data (ISO8601 format: YYYY-MM-DD) - */ - toDate: z.string().optional(), - - /** - * maximum number of search results to consider - * defaults to 20 - */ - maxSearchResults: z.number().min(1).max(50).optional(), - - /** - * data sources to search from - * defaults to ["web", "x"] if not specified - */ - sources: z.array(searchSourceSchema).optional() - }) - .optional() -}) - -export type XaiProviderOptions = z.infer diff --git a/packages/aiCore/src/core/plugins/built-in/index.ts b/packages/aiCore/src/core/plugins/built-in/index.ts index e7dcae8738..1f8916b09a 100644 --- a/packages/aiCore/src/core/plugins/built-in/index.ts +++ b/packages/aiCore/src/core/plugins/built-in/index.ts @@ -7,5 +7,9 @@ export const BUILT_IN_PLUGIN_PREFIX = 'built-in:' export { googleToolsPlugin } from './googleToolsPlugin' export { createLoggingPlugin } from './logging' export { createPromptToolUsePlugin } from './toolUsePlugin/promptToolUsePlugin' -export type { PromptToolUseConfig, ToolUseRequestContext, ToolUseResult } from './toolUsePlugin/type' -export { webSearchPlugin } from './webSearchPlugin' +export type { + PromptToolUseConfig, + ToolUseRequestContext, + ToolUseResult +} from './toolUsePlugin/type' +export { webSearchPlugin, type WebSearchPluginConfig } from './webSearchPlugin' 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 a7c7187bca..4845ce4ace 100644 --- a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts +++ b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts @@ -3,13 +3,16 @@ import { google } from '@ai-sdk/google' import { openai } from '@ai-sdk/openai' import { ProviderOptionsMap } from '../../../options/types' +import { OpenRouterSearchConfig } from './openrouter' /** * 从 AI SDK 的工具函数中提取参数类型,以确保类型安全。 */ -type OpenAISearchConfig = Parameters[0] -type AnthropicSearchConfig = Parameters[0] -type GoogleSearchConfig = Parameters[0] +export type OpenAISearchConfig = NonNullable[0]> +export type OpenAISearchPreviewConfig = NonNullable[0]> +export type AnthropicSearchConfig = NonNullable[0]> +export type GoogleSearchConfig = NonNullable[0]> +export type XAISearchConfig = NonNullable /** * 插件初始化时接收的完整配置对象 @@ -18,10 +21,12 @@ type GoogleSearchConfig = Parameters[0] */ export interface WebSearchPluginConfig { openai?: OpenAISearchConfig + 'openai-chat'?: OpenAISearchPreviewConfig anthropic?: AnthropicSearchConfig xai?: ProviderOptionsMap['xai']['searchParameters'] google?: GoogleSearchConfig 'google-vertex'?: GoogleSearchConfig + openrouter?: OpenRouterSearchConfig } /** @@ -31,6 +36,7 @@ export const DEFAULT_WEB_SEARCH_CONFIG: WebSearchPluginConfig = { google: {}, 'google-vertex': {}, openai: {}, + 'openai-chat': {}, xai: { mode: 'on', returnCitations: true, @@ -39,6 +45,14 @@ export const DEFAULT_WEB_SEARCH_CONFIG: WebSearchPluginConfig = { }, anthropic: { maxUses: 5 + }, + openrouter: { + plugins: [ + { + id: 'web', + max_results: 5 + } + ] } } diff --git a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts index d941e0d0c7..abd3ce3e2c 100644 --- a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts +++ b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts @@ -6,7 +6,7 @@ import { anthropic } from '@ai-sdk/anthropic' import { google } from '@ai-sdk/google' import { openai } from '@ai-sdk/openai' -import { createXaiOptions, mergeProviderOptions } from '../../../options' +import { createOpenRouterOptions, createXaiOptions, mergeProviderOptions } from '../../../options' import { definePlugin } from '../../' import type { AiRequestContext } from '../../types' import { DEFAULT_WEB_SEARCH_CONFIG, WebSearchPluginConfig } from './helper' @@ -31,6 +31,13 @@ export const webSearchPlugin = (config: WebSearchPluginConfig = DEFAULT_WEB_SEAR } break } + case 'openai-chat': { + if (config['openai-chat']) { + if (!params.tools) params.tools = {} + params.tools.web_search_preview = openai.tools.webSearchPreview(config['openai-chat']) + } + break + } case 'anthropic': { if (config.anthropic) { @@ -56,6 +63,14 @@ export const webSearchPlugin = (config: WebSearchPluginConfig = DEFAULT_WEB_SEAR } break } + + case 'openrouter': { + if (config.openrouter) { + const searchOptions = createOpenRouterOptions(config.openrouter) + params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions) + } + break + } } return params diff --git a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/openrouter.ts b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/openrouter.ts new file mode 100644 index 0000000000..ebf1e7bf9a --- /dev/null +++ b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/openrouter.ts @@ -0,0 +1,26 @@ +export type OpenRouterSearchConfig = { + plugins?: Array<{ + id: 'web' + /** + * Maximum number of search results to include (default: 5) + */ + max_results?: number + /** + * Custom search prompt to guide the search query + */ + search_prompt?: string + }> + /** + * Built-in web search options for models that support native web search + */ + web_search_options?: { + /** + * Maximum number of search results to include + */ + max_results?: number + /** + * Custom search prompt to guide the search query + */ + search_prompt?: string + } +} diff --git a/packages/aiCore/src/core/providers/schemas.ts b/packages/aiCore/src/core/providers/schemas.ts index e4b8d8aa64..73ea4b8c14 100644 --- a/packages/aiCore/src/core/providers/schemas.ts +++ b/packages/aiCore/src/core/providers/schemas.ts @@ -25,7 +25,8 @@ export const baseProviderIds = [ 'xai', 'azure', 'azure-responses', - 'deepseek' + 'deepseek', + 'openrouter' ] as const /** @@ -38,6 +39,10 @@ export const baseProviderIdSchema = z.enum(baseProviderIds) */ export type BaseProviderId = z.infer +export const isBaseProvider = (id: ProviderId): id is BaseProviderId => { + return baseProviderIdSchema.safeParse(id).success +} + type BaseProvider = { id: BaseProviderId name: string diff --git a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts index f0d3b2eb59..ffbe66da22 100644 --- a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts +++ b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts @@ -1,3 +1,4 @@ +import { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins' import { loggerService } from '@logger' import type { MCPTool, Message, Model, Provider } from '@renderer/types' import type { Chunk } from '@renderer/types/chunk' @@ -26,6 +27,8 @@ export interface AiSdkMiddlewareConfig { enableUrlContext: boolean mcpTools?: MCPTool[] uiMessages?: Message[] + // 内置搜索配置 + webSearchPluginConfig?: WebSearchPluginConfig } /** diff --git a/src/renderer/src/aiCore/plugins/PluginBuilder.ts b/src/renderer/src/aiCore/plugins/PluginBuilder.ts index d94ed4aab9..7c5478eb77 100644 --- a/src/renderer/src/aiCore/plugins/PluginBuilder.ts +++ b/src/renderer/src/aiCore/plugins/PluginBuilder.ts @@ -30,9 +30,8 @@ export function buildPlugins( } // 1. 模型内置搜索 - if (middlewareConfig.enableWebSearch) { - // 内置了默认搜索参数,如果改的话可以传config进去 - plugins.push(webSearchPlugin()) + if (middlewareConfig.enableWebSearch && middlewareConfig.webSearchPluginConfig) { + plugins.push(webSearchPlugin(middlewareConfig.webSearchPluginConfig)) } // 2. 支持工具调用时添加搜索插件 if (middlewareConfig.isSupportedToolUse || middlewareConfig.isPromptToolUse) { diff --git a/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts b/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts index 3f9e9ff071..0a89e73c62 100644 --- a/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts +++ b/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts @@ -5,6 +5,8 @@ import { vertexAnthropic } from '@ai-sdk/google-vertex/anthropic/edge' import { vertex } from '@ai-sdk/google-vertex/edge' +import { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins' +import { isBaseProvider } from '@cherrystudio/ai-core/core/providers/schemas' import { loggerService } from '@logger' import { isGenerateImageModel, @@ -16,8 +18,11 @@ import { isWebSearchModel } from '@renderer/config/models' import { getAssistantSettings, getDefaultModel } from '@renderer/services/AssistantService' +import store from '@renderer/store' +import { CherryWebSearchConfig } from '@renderer/store/websearch' import { type Assistant, type MCPTool, type Provider } from '@renderer/types' import type { StreamTextParams } from '@renderer/types/aiCoreTypes' +import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern' import type { ModelMessage, Tool } from 'ai' import { stepCountIs } from 'ai' @@ -25,6 +30,7 @@ import { getAiSdkProviderId } from '../provider/factory' import { setupToolsConfig } from '../utils/mcp' import { buildProviderOptions } from '../utils/options' import { getAnthropicThinkingBudget } from '../utils/reasoning' +import { buildProviderBuiltinWebSearchConfig } from '../utils/websearch' import { getTemperature, getTopP } from './modelParameters' const logger = loggerService.withContext('parameterBuilder') @@ -42,6 +48,7 @@ export async function buildStreamTextParams( options: { mcpTools?: MCPTool[] webSearchProviderId?: string + webSearchConfig?: CherryWebSearchConfig requestOptions?: { signal?: AbortSignal timeout?: number @@ -57,6 +64,7 @@ export async function buildStreamTextParams( enableGenerateImage: boolean enableUrlContext: boolean } + webSearchPluginConfig?: WebSearchPluginConfig }> { const { mcpTools } = options @@ -93,6 +101,12 @@ export async function buildStreamTextParams( // } // 构建真正的 providerOptions + const webSearchConfig: CherryWebSearchConfig = { + maxResults: store.getState().websearch.maxResults, + excludeDomains: store.getState().websearch.excludeDomains, + searchWithTime: store.getState().websearch.searchWithTime + } + const providerOptions = buildProviderOptions(assistant, model, provider, { enableReasoning, enableWebSearch, @@ -109,15 +123,21 @@ export async function buildStreamTextParams( maxTokens -= getAnthropicThinkingBudget(assistant, model) } - // google-vertex | google-vertex-anthropic + let webSearchPluginConfig: WebSearchPluginConfig | undefined = undefined if (enableWebSearch) { + if (isBaseProvider(aiSdkProviderId)) { + webSearchPluginConfig = buildProviderBuiltinWebSearchConfig(aiSdkProviderId, webSearchConfig) + } if (!tools) { tools = {} } if (aiSdkProviderId === 'google-vertex') { tools.google_search = vertex.tools.googleSearch({}) as ProviderDefinedTool } else if (aiSdkProviderId === 'google-vertex-anthropic') { - tools.web_search = vertexAnthropic.tools.webSearch_20250305({}) as ProviderDefinedTool + tools.web_search = vertexAnthropic.tools.webSearch_20250305({ + maxUses: webSearchConfig.maxResults, + blockedDomains: mapRegexToPatterns(webSearchConfig.excludeDomains) + }) as ProviderDefinedTool } } @@ -151,7 +171,8 @@ export async function buildStreamTextParams( return { params, modelId: model.id, - capabilities: { enableReasoning, enableWebSearch, enableGenerateImage, enableUrlContext } + capabilities: { enableReasoning, enableWebSearch, enableGenerateImage, enableUrlContext }, + webSearchPluginConfig } } diff --git a/src/renderer/src/aiCore/utils/websearch.ts b/src/renderer/src/aiCore/utils/websearch.ts index d2d0345826..e95f3e60cf 100644 --- a/src/renderer/src/aiCore/utils/websearch.ts +++ b/src/renderer/src/aiCore/utils/websearch.ts @@ -1,6 +1,13 @@ +import { + AnthropicSearchConfig, + OpenAISearchConfig, + WebSearchPluginConfig +} from '@cherrystudio/ai-core/core/plugins/built-in/webSearchPlugin/helper' +import { BaseProviderId } from '@cherrystudio/ai-core/provider' import { isOpenAIWebSearchChatCompletionOnlyModel } from '@renderer/config/models' -import { WEB_SEARCH_PROMPT_FOR_OPENROUTER } from '@renderer/config/prompts' +import { CherryWebSearchConfig } from '@renderer/store/websearch' import { Model } from '@renderer/types' +import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern' export function getWebSearchParams(model: Model): Record { if (model.provider === 'hunyuan') { @@ -21,11 +28,78 @@ export function getWebSearchParams(model: Model): Record { web_search_options: {} } } - - if (model.provider === 'openrouter') { - return { - plugins: [{ id: 'web', search_prompts: WEB_SEARCH_PROMPT_FOR_OPENROUTER }] - } - } return {} } + +/** + * range in [0, 100] + * @param maxResults + */ +function mapMaxResultToOpenAIContextSize(maxResults: number): OpenAISearchConfig['searchContextSize'] { + if (maxResults <= 33) return 'low' + if (maxResults <= 66) return 'medium' + return 'high' +} + +export function buildProviderBuiltinWebSearchConfig( + providerId: BaseProviderId, + webSearchConfig: CherryWebSearchConfig +): WebSearchPluginConfig { + switch (providerId) { + case 'openai': { + return { + openai: { + searchContextSize: mapMaxResultToOpenAIContextSize(webSearchConfig.maxResults) + } + } + } + case 'openai-chat': { + return { + 'openai-chat': { + searchContextSize: mapMaxResultToOpenAIContextSize(webSearchConfig.maxResults) + } + } + } + case 'anthropic': { + const anthropicSearchOptions: AnthropicSearchConfig = { + maxUses: webSearchConfig.maxResults, + blockedDomains: mapRegexToPatterns(webSearchConfig.excludeDomains) + } + return { + anthropic: anthropicSearchOptions + } + } + case 'xai': { + return { + xai: { + maxSearchResults: webSearchConfig.maxResults, + returnCitations: true, + sources: [ + { + type: 'web', + excludedWebsites: mapRegexToPatterns(webSearchConfig.excludeDomains) + }, + { type: 'news' }, + { type: 'x' } + ], + mode: 'on' + } + } + } + case 'openrouter': { + return { + openrouter: { + plugins: [ + { + id: 'web', + max_results: webSearchConfig.maxResults + } + ] + } + } + } + default: { + throw new Error(`Unsupported provider: ${providerId}`) + } + } +} diff --git a/src/renderer/src/config/models/utils.ts b/src/renderer/src/config/models/utils.ts index 3fd27308f8..39078e2924 100644 --- a/src/renderer/src/config/models/utils.ts +++ b/src/renderer/src/config/models/utils.ts @@ -229,6 +229,11 @@ export const isGPT5SeriesModel = (model: Model) => { return modelId.includes('gpt-5') } +export const isGPT5SeriesReasoningModel = (model: Model) => { + const modelId = getLowerBaseModelName(model.id) + return modelId.includes('gpt-5') && !modelId.includes('chat') +} + export const isGeminiModel = (model: Model) => { const modelId = getLowerBaseModelName(model.id) return modelId.includes('gemini') diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 4ac54e374a..768525d9b2 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -660,7 +660,12 @@ "title": "Topics", "unpin": "Unpin Topic" }, - "translate": "Translate" + "translate": "Translate", + "web_search": { + "warning": { + "openai": "The GPT-5 model's minimal reasoning effort does not support web search." + } + } }, "code": { "auto_update_to_latest": "Automatically update to latest version", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 248dbad623..551338e9d5 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -132,7 +132,6 @@ }, "title": "API 服务器" }, - "assistants": { "abbr": "助手", "clear": { @@ -661,7 +660,12 @@ "title": "话题", "unpin": "取消固定" }, - "translate": "翻译" + "translate": "翻译", + "web_search": { + "warning": { + "openai": "GPT5 模型 minimal 思考强度不支持网络搜索" + } + } }, "code": { "auto_update_to_latest": "检查更新并安装最新版本", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 4b31ca44be..fe7bf4b760 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -660,7 +660,12 @@ "title": "話題", "unpin": "取消固定" }, - "translate": "翻譯" + "translate": "翻譯", + "web_search": { + "warning": { + "openai": "GPT-5 模型的最小推理力度不支援網路搜尋。" + } + } }, "code": { "auto_update_to_latest": "檢查更新並安裝最新版本", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index f95e243e5d..615eda601e 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -362,8 +362,9 @@ "translate": "Μετάφραση στο {{target_language}}", "translating": "Μετάφραση...", "upload": { + "attachment": "Μεταφόρτωση συνημμένου", "document": "Φόρτωση έγγραφου (το μοντέλο δεν υποστηρίζει εικόνες)", - "label": "Φόρτωση εικόνας ή έγγραφου", + "image_or_document": "Μεταφόρτωση εικόνας ή εγγράφου", "upload_from_local": "Μεταφόρτωση αρχείου από τον υπολογιστή..." }, "url_context": "Περιεχόμενο ιστοσελίδας", @@ -659,7 +660,12 @@ "title": "Θέματα", "unpin": "Ξεκαρφίτσωμα" }, - "translate": "Μετάφραση" + "translate": "Μετάφραση", + "web_search": { + "warning": { + "openai": "Το μοντέλο GPT5 με ελάχιστη ένταση σκέψης δεν υποστηρίζει αναζήτηση στο διαδίκτυο" + } + } }, "code": { "auto_update_to_latest": "Έλεγχος για ενημερώσεις και εγκατάσταση της τελευταίας έκδοσης", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 536e355f55..a210344a41 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -362,8 +362,9 @@ "translate": "Traducir a {{target_language}}", "translating": "Traduciendo...", "upload": { + "attachment": "Subir archivo adjunto", "document": "Subir documento (el modelo no admite imágenes)", - "label": "Subir imagen o documento", + "image_or_document": "Subir imagen o documento", "upload_from_local": "Subir archivo local..." }, "url_context": "Contexto de la página web", @@ -659,7 +660,12 @@ "title": "Tema", "unpin": "Quitar fijación" }, - "translate": "Traducir" + "translate": "Traducir", + "web_search": { + "warning": { + "openai": "El modelo GPT5 con intensidad de pensamiento mínima no admite búsqueda en la web." + } + } }, "code": { "auto_update_to_latest": "Comprobar actualizaciones e instalar la versión más reciente", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 00daec2d3f..3795f5ab01 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -362,8 +362,9 @@ "translate": "Traduire en {{target_language}}", "translating": "Traduction en cours...", "upload": { + "attachment": "Télécharger la pièce jointe", "document": "Télécharger un document (le modèle ne prend pas en charge les images)", - "label": "Télécharger une image ou un document", + "image_or_document": "Télécharger une image ou un document", "upload_from_local": "Télécharger un fichier local..." }, "url_context": "Contexte de la page web", @@ -659,7 +660,12 @@ "title": "Sujet", "unpin": "Annuler le fixage" }, - "translate": "Traduire" + "translate": "Traduire", + "web_search": { + "warning": { + "openai": "Le modèle GPT5 avec une intensité de réflexion minimale ne prend pas en charge la recherche sur Internet." + } + } }, "code": { "auto_update_to_latest": "Vérifier les mises à jour et installer la dernière version", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 3cdeca306e..f83153d90b 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -362,8 +362,9 @@ "translate": "{{target_language}}に翻訳", "translating": "翻訳中...", "upload": { + "attachment": "添付ファイルをアップロード", "document": "ドキュメントをアップロード(モデルは画像をサポートしません)", - "label": "画像またはドキュメントをアップロード", + "image_or_document": "画像またはドキュメントをアップロード", "upload_from_local": "ローカルファイルをアップロード..." }, "url_context": "URLコンテキスト", @@ -659,7 +660,12 @@ "title": "トピック", "unpin": "固定解除" }, - "translate": "翻訳" + "translate": "翻訳", + "web_search": { + "warning": { + "openai": "GPT5モデルの最小思考強度ではネット検索はサポートされません" + } + } }, "code": { "auto_update_to_latest": "最新バージョンを自動的に更新する", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index b58593bb15..7759fc2706 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -362,8 +362,9 @@ "translate": "Traduzir para {{target_language}}", "translating": "Traduzindo...", "upload": { + "attachment": "Carregar anexo", "document": "Carregar documento (o modelo não suporta imagens)", - "label": "Carregar imagem ou documento", + "image_or_document": "Carregar imagem ou documento", "upload_from_local": "Fazer upload de arquivo local..." }, "url_context": "Contexto da Página da Web", @@ -659,7 +660,12 @@ "title": "Tópicos", "unpin": "Desfixar" }, - "translate": "Traduzir" + "translate": "Traduzir", + "web_search": { + "warning": { + "openai": "O modelo GPT5 com intensidade mínima de pensamento não suporta pesquisa na web" + } + } }, "code": { "auto_update_to_latest": "Verificar atualizações e instalar a versão mais recente", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 6364e5b83d..cec3dd0e74 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -362,8 +362,9 @@ "translate": "Перевести на {{target_language}}", "translating": "Перевод...", "upload": { + "attachment": "Загрузить вложение", "document": "Загрузить документ (модель не поддерживает изображения)", - "label": "Загрузить изображение или документ", + "image_or_document": "Загрузить изображение или документ", "upload_from_local": "Загрузить локальный файл..." }, "url_context": "Контекст страницы", @@ -659,7 +660,12 @@ "title": "Топики", "unpin": "Открепленные темы" }, - "translate": "Перевести" + "translate": "Перевести", + "web_search": { + "warning": { + "openai": "Модель GPT5 с минимальной интенсивностью мышления не поддерживает поиск в интернете" + } + } }, "code": { "auto_update_to_latest": "Автоматически обновлять до последней версии", diff --git a/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx b/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx index 5ee0c628c8..7e2b7edd38 100644 --- a/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx @@ -8,7 +8,13 @@ import { MdiLightbulbOn80 } from '@renderer/components/Icons/SVGIcon' import { QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel' -import { getThinkModelType, isDoubaoThinkingAutoModel, MODEL_SUPPORTED_OPTIONS } from '@renderer/config/models' +import { + getThinkModelType, + isDoubaoThinkingAutoModel, + isGPT5SeriesReasoningModel, + isOpenAIWebSearchModel, + MODEL_SUPPORTED_OPTIONS +} from '@renderer/config/models' import { useAssistant } from '@renderer/hooks/useAssistant' import { getReasoningEffortOptionsLabel } from '@renderer/i18n/label' import { Model, ThinkingOption } from '@renderer/types' @@ -61,6 +67,15 @@ const ThinkingButton: FC = ({ ref, model, assistantId }): ReactElement => }) return } + if ( + isOpenAIWebSearchModel(model) && + isGPT5SeriesReasoningModel(model) && + assistant.enableWebSearch && + option === 'minimal' + ) { + window.toast.warning(t('chat.web_search.warning.openai')) + return + } updateAssistantSettings({ reasoning_effort: option, reasoning_effort_cache: option, @@ -68,7 +83,7 @@ const ThinkingButton: FC = ({ ref, model, assistantId }): ReactElement => }) return }, - [updateAssistantSettings] + [updateAssistantSettings, assistant.enableWebSearch, model, t] ) const panelItems = useMemo(() => { diff --git a/src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx b/src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx index cb3c5fb6d9..807781bac4 100644 --- a/src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/WebSearchButton.tsx @@ -3,7 +3,12 @@ import { loggerService } from '@logger' import { ActionIconButton } from '@renderer/components/Buttons' import { BingLogo, BochaLogo, ExaLogo, SearXNGLogo, TavilyLogo, ZhipuLogo } from '@renderer/components/Icons' import { QuickPanelListItem, QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel' -import { isGeminiModel, isWebSearchModel } from '@renderer/config/models' +import { + isGeminiModel, + isGPT5SeriesReasoningModel, + isOpenAIWebSearchModel, + isWebSearchModel +} from '@renderer/config/models' import { isGeminiWebSearchProvider } from '@renderer/config/providers' import { useAssistant } from '@renderer/hooks/useAssistant' import { useTimer } from '@renderer/hooks/useTimer' @@ -115,6 +120,15 @@ const WebSearchButton: FC = ({ ref, assistantId }) => { update.enableWebSearch = false window.toast.warning(t('chat.mcp.warning.gemini_web_search')) } + if ( + isOpenAIWebSearchModel(model) && + isGPT5SeriesReasoningModel(model) && + update.enableWebSearch && + assistant.settings?.reasoning_effort === 'minimal' + ) { + update.enableWebSearch = false + window.toast.warning(t('chat.web_search.warning.openai')) + } setTimeoutTimer('updateSelectedWebSearchBuiltin', () => updateAssistant(update), 200) }, [assistant, setTimeoutTimer, t, updateAssistant]) diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index 01807027ba..d954fc1f85 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -117,7 +117,8 @@ export async function fetchChatCompletion({ const { params: aiSdkParams, modelId, - capabilities + capabilities, + webSearchPluginConfig } = await buildStreamTextParams(messages, assistant, provider, { mcpTools: mcpTools, webSearchProviderId: assistant.webSearchProviderId, @@ -132,6 +133,7 @@ export async function fetchChatCompletion({ isPromptToolUse: isPromptToolUse(assistant), isSupportedToolUse: isSupportedToolUse(assistant), isImageGenerationEndpoint: isDedicatedImageGenerationModel(assistant.model || getDefaultModel()), + webSearchPluginConfig: webSearchPluginConfig, enableWebSearch: capabilities.enableWebSearch, enableGenerateImage: capabilities.enableGenerateImage, enableUrlContext: capabilities.enableUrlContext, diff --git a/src/renderer/src/store/websearch.ts b/src/renderer/src/store/websearch.ts index 1e3fe2a25b..16029ccf47 100644 --- a/src/renderer/src/store/websearch.ts +++ b/src/renderer/src/store/websearch.ts @@ -41,6 +41,8 @@ export interface WebSearchState { providerConfig: Record } +export type CherryWebSearchConfig = Pick + export const initialState: WebSearchState = { defaultProvider: 'local-bing', providers: WEB_SEARCH_PROVIDERS, diff --git a/src/renderer/src/utils/__tests__/blacklistMatchPattern.test.ts b/src/renderer/src/utils/__tests__/blacklistMatchPattern.test.ts index 4656e30562..a5c0983479 100644 --- a/src/renderer/src/utils/__tests__/blacklistMatchPattern.test.ts +++ b/src/renderer/src/utils/__tests__/blacklistMatchPattern.test.ts @@ -26,7 +26,7 @@ import { describe, expect, it } from 'vitest' -import { MatchPatternMap } from '../blacklistMatchPattern' +import { mapRegexToPatterns, MatchPatternMap } from '../blacklistMatchPattern' function get(map: MatchPatternMap, url: string) { return map.get(url).sort() @@ -161,3 +161,28 @@ describe('blacklistMatchPattern', () => { expect(get(map, 'https://b.mozilla.org/path/')).toEqual([0, 1, 2, 6]) }) }) + +describe('mapRegexToPatterns', () => { + it('extracts domains from regex patterns', () => { + const result = mapRegexToPatterns([ + '/example\\.com/', + '/(?:www\\.)?sub\\.example\\.co\\.uk/', + '/api\\.service\\.io/', + 'https://baidu.com' + ]) + + expect(result).toEqual(['example.com', 'sub.example.co.uk', 'api.service.io', 'baidu.com']) + }) + + it('deduplicates domains across multiple patterns', () => { + const result = mapRegexToPatterns(['/example\\.com/', '/(example\\.com|test\\.org)/']) + + expect(result).toEqual(['example.com', 'test.org']) + }) + + it('ignores patterns without domain matches', () => { + const result = mapRegexToPatterns(['', 'plain-domain.com', '/^https?:\\/\\/[^/]+$/']) + + expect(result).toEqual(['plain-domain.com']) + }) +}) diff --git a/src/renderer/src/utils/blacklistMatchPattern.ts b/src/renderer/src/utils/blacklistMatchPattern.ts index 9e78ab58fb..b00e07a785 100644 --- a/src/renderer/src/utils/blacklistMatchPattern.ts +++ b/src/renderer/src/utils/blacklistMatchPattern.ts @@ -202,6 +202,43 @@ export async function parseSubscribeContent(url: string): Promise { throw error } } + +export function mapRegexToPatterns(patterns: string[]): string[] { + const patternSet = new Set() + const domainMatcher = /[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+/g + + patterns.forEach((pattern) => { + if (!pattern) { + return + } + + // Handle regex patterns (wrapped in /) + if (pattern.startsWith('/') && pattern.endsWith('/')) { + const rawPattern = pattern.slice(1, -1) + const normalizedPattern = rawPattern.replace(/\\\./g, '.').replace(/\\\//g, '/') + const matches = normalizedPattern.match(domainMatcher) + + if (matches) { + matches.forEach((match) => { + patternSet.add(match.replace(/http(s)?:\/\//g, '').toLowerCase()) + }) + } + } else if (pattern.includes('://')) { + // Handle URLs with protocol (e.g., https://baidu.com) + const matches = pattern.match(domainMatcher) + if (matches) { + matches.forEach((match) => { + patternSet.add(match.replace(/http(s)?:\/\//g, '').toLowerCase()) + }) + } + } else { + patternSet.add(pattern.toLowerCase()) + } + }) + + return Array.from(patternSet) +} + export async function filterResultWithBlacklist( response: WebSearchProviderResponse, websearch: WebSearchState diff --git a/yarn.lock b/yarn.lock index 1d414c0861..71280de91a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2309,7 +2309,7 @@ __metadata: languageName: node linkType: hard -"@cherrystudio/ai-core@workspace:^1.0.0-alpha.16, @cherrystudio/ai-core@workspace:packages/aiCore": +"@cherrystudio/ai-core@workspace:^1.0.0-alpha.17, @cherrystudio/ai-core@workspace:packages/aiCore": version: 0.0.0-use.local resolution: "@cherrystudio/ai-core@workspace:packages/aiCore" dependencies: @@ -13195,7 +13195,7 @@ __metadata: "@aws-sdk/client-bedrock-runtime": "npm:^3.840.0" "@aws-sdk/client-s3": "npm:^3.840.0" "@biomejs/biome": "npm:2.2.4" - "@cherrystudio/ai-core": "workspace:^1.0.0-alpha.16" + "@cherrystudio/ai-core": "workspace:^1.0.0-alpha.17" "@cherrystudio/embedjs": "npm:^0.1.31" "@cherrystudio/embedjs-libsql": "npm:^0.1.31" "@cherrystudio/embedjs-loader-csv": "npm:^0.1.31" From dec68ee297cb8a4a8ac421185c5bfffe36af4668 Mon Sep 17 00:00:00 2001 From: SuYao Date: Wed, 17 Sep 2025 15:54:54 +0800 Subject: [PATCH 30/99] Fix/ts (#10226) * chore: ts * chore: yarn.lock --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index b1bd7c8d72..964be0e0fd 100644 --- a/package.json +++ b/package.json @@ -332,7 +332,7 @@ "tsx": "^4.20.3", "turndown-plugin-gfm": "^1.0.2", "tw-animate-css": "^1.3.8", - "typescript": "^5.8.2", + "typescript": "~5.8.2", "undici": "6.21.2", "unified": "^11.0.5", "uuid": "^10.0.0", diff --git a/yarn.lock b/yarn.lock index 71280de91a..23a168d8f9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13436,7 +13436,7 @@ __metadata: turndown: "npm:7.2.0" turndown-plugin-gfm: "npm:^1.0.2" tw-animate-css: "npm:^1.3.8" - typescript: "npm:^5.8.2" + typescript: "npm:~5.8.2" undici: "npm:6.21.2" unified: "npm:^11.0.5" uuid: "npm:^10.0.0" @@ -27767,7 +27767,7 @@ __metadata: languageName: node linkType: hard -"typescript@npm:^5.0.0, typescript@npm:^5.8.2": +"typescript@npm:^5.0.0": version: 5.9.2 resolution: "typescript@npm:5.9.2" bin: @@ -27777,7 +27777,7 @@ __metadata: languageName: node linkType: hard -"typescript@npm:^5.4.3": +"typescript@npm:^5.4.3, typescript@npm:~5.8.2": version: 5.8.3 resolution: "typescript@npm:5.8.3" bin: @@ -27787,7 +27787,7 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A^5.0.0#optional!builtin, typescript@patch:typescript@npm%3A^5.8.2#optional!builtin": +"typescript@patch:typescript@npm%3A^5.0.0#optional!builtin": version: 5.9.2 resolution: "typescript@patch:typescript@npm%3A5.9.2#optional!builtin::version=5.9.2&hash=5786d5" bin: @@ -27797,7 +27797,7 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A^5.4.3#optional!builtin": +"typescript@patch:typescript@npm%3A^5.4.3#optional!builtin, typescript@patch:typescript@npm%3A~5.8.2#optional!builtin": version: 5.8.3 resolution: "typescript@patch:typescript@npm%3A5.8.3#optional!builtin::version=5.8.3&hash=5786d5" bin: From 77535b002ac58a1da7f75f71fb0d158418513c97 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Wed, 17 Sep 2025 16:33:10 +0800 Subject: [PATCH 31/99] feat(Inputbar): cache mentioned models state between renders (#10197) Use a ref to track mentioned models state and cache it in a module-level variable when component unmounts to preserve state between renders --- src/renderer/src/pages/home/Inputbar/Inputbar.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 864ea71a21..9a130e45ff 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -71,6 +71,7 @@ interface Props { let _text = '' let _files: FileType[] = [] +let _mentionedModelsCache: Model[] = [] const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) => { const [text, setText] = useState(_text) @@ -103,7 +104,8 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const spaceClickTimer = useRef(null) const [isTranslating, setIsTranslating] = useState(false) const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState([]) - const [mentionedModels, setMentionedModels] = useState([]) + const [mentionedModels, setMentionedModels] = useState(_mentionedModelsCache) + const mentionedModelsRef = useRef(mentionedModels) const [isDragging, setIsDragging] = useState(false) const [isFileDragging, setIsFileDragging] = useState(false) const [textareaHeight, setTextareaHeight] = useState() @@ -114,6 +116,10 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const isGenerateImageAssistant = useMemo(() => isGenerateImageModel(model), [model]) const { setTimeoutTimer } = useTimer() + useEffect(() => { + mentionedModelsRef.current = mentionedModels + }, [mentionedModels]) + const isVisionSupported = useMemo( () => (mentionedModels.length > 0 && isVisionModels(mentionedModels)) || @@ -179,6 +185,13 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = _text = text _files = files + useEffect(() => { + // 利用useEffect清理函数在卸载组件时更新状态缓存 + return () => { + _mentionedModelsCache = mentionedModelsRef.current + } + }, []) + const focusTextarea = useCallback(() => { textareaRef.current?.focus() }, []) From 6afaf6244c5e462572b8b7b82207cf5f06321591 Mon Sep 17 00:00:00 2001 From: SuYao Date: Wed, 17 Sep 2025 17:23:23 +0800 Subject: [PATCH 32/99] Fix Anthropic API URL and add endpoint path handling (#10229) * Fix Anthropic API URL and add endpoint path handling - Remove trailing slash from Anthropic API base URL - Add isAnthropicProvider utility function - Update provider settings to show full endpoint URL for Anthropic - Add migration to clean up existing Anthropic provider URLs * Update src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx Co-authored-by: Phantom <59059173+EurFelux@users.noreply.github.com> --------- Co-authored-by: Phantom <59059173+EurFelux@users.noreply.github.com> --- src/renderer/src/config/providers.ts | 2 +- .../settings/ProviderSettings/ProviderSetting.tsx | 14 ++++++++++++-- src/renderer/src/store/index.ts | 2 +- src/renderer/src/store/migrate.ts | 15 +++++++++++++++ src/renderer/src/utils/index.ts | 4 ++++ 5 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts index f28a88c7c7..a710605b64 100644 --- a/src/renderer/src/config/providers.ts +++ b/src/renderer/src/config/providers.ts @@ -1020,7 +1020,7 @@ export const PROVIDER_URLS: Record = { }, anthropic: { api: { - url: 'https://api.anthropic.com/' + url: 'https://api.anthropic.com' }, websites: { official: 'https://anthropic.com/', diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index 0bd7b152f4..68bfc67ba2 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -16,7 +16,13 @@ import { useAppDispatch } from '@renderer/store' import { updateWebSearchProvider } from '@renderer/store/websearch' import { isSystemProvider } from '@renderer/types' import { ApiKeyConnectivity, HealthStatus } from '@renderer/types/healthCheck' -import { formatApiHost, formatApiKeys, getFancyProviderName, isOpenAIProvider } from '@renderer/utils' +import { + formatApiHost, + formatApiKeys, + getFancyProviderName, + isAnthropicProvider, + isOpenAIProvider +} from '@renderer/utils' import { formatErrorMessage } from '@renderer/utils/error' import { Button, Divider, Flex, Input, Select, Space, Switch, Tooltip } from 'antd' import Link from 'antd/es/typography/Link' @@ -212,6 +218,10 @@ const ProviderSetting: FC = ({ providerId }) => { if (provider.type === 'azure-openai') { return formatApiHost(apiHost) + 'openai/v1' } + + if (provider.type === 'anthropic') { + return formatApiHost(apiHost) + 'messages' + } return formatApiHost(apiHost) + 'responses' } @@ -361,7 +371,7 @@ const ProviderSetting: FC = ({ providerId }) => { )} - {isOpenAIProvider(provider) && ( + {(isOpenAIProvider(provider) || isAnthropicProvider(provider)) && ( diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index dc599d0658..3b3cfa3f0c 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: 155, + version: 156, blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'], migrate }, diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index c939007d0b..41fa441945 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -2476,6 +2476,21 @@ const migrateConfig = { logger.error('migrate 155 error', error as Error) return state } + }, + '156': (state: RootState) => { + try { + state.llm.providers.forEach((provider) => { + if (provider.id === SystemProviderIds.anthropic) { + if (provider.apiHost.endsWith('/')) { + provider.apiHost = provider.apiHost.slice(0, -1) + } + } + }) + return state + } catch (error) { + logger.error('migrate 156 error', error as Error) + return state + } } } diff --git a/src/renderer/src/utils/index.ts b/src/renderer/src/utils/index.ts index 828d288ba8..64f943946a 100644 --- a/src/renderer/src/utils/index.ts +++ b/src/renderer/src/utils/index.ts @@ -205,6 +205,10 @@ export function isOpenAIProvider(provider: Provider): boolean { return !['anthropic', 'gemini', 'vertexai'].includes(provider.type) } +export function isAnthropicProvider(provider: Provider): boolean { + return provider.type === 'anthropic' +} + /** * 判断模型是否为用户手动选择 * @param {Model} model 模型对象 From 273475881e9a3cf6d5e306fa9baee25192e82e77 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Wed, 17 Sep 2025 17:18:01 +0800 Subject: [PATCH 33/99] refactor: update HeroUIProvider import in MiniWindowApp component - Changed the import of HeroUIProvider from '@heroui/react' to '@renderer/context/HeroUIProvider' for better context management. --- src/renderer/src/windows/mini/MiniWindowApp.tsx | 2 +- src/renderer/src/windows/selection/action/entryPoint.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/windows/mini/MiniWindowApp.tsx b/src/renderer/src/windows/mini/MiniWindowApp.tsx index b459a3e027..0f6d8bc60d 100644 --- a/src/renderer/src/windows/mini/MiniWindowApp.tsx +++ b/src/renderer/src/windows/mini/MiniWindowApp.tsx @@ -1,9 +1,9 @@ import '@renderer/databases' -import { HeroUIProvider } from '@heroui/react' import { ErrorBoundary } from '@renderer/components/ErrorBoundary' import { ToastPortal } from '@renderer/components/ToastPortal' import { getToastUtilities } from '@renderer/components/TopView/toast' +import { HeroUIProvider } from '@renderer/context/HeroUIProvider' import { useSettings } from '@renderer/hooks/useSettings' import store, { persistor } from '@renderer/store' import { useEffect } from 'react' diff --git a/src/renderer/src/windows/selection/action/entryPoint.tsx b/src/renderer/src/windows/selection/action/entryPoint.tsx index 44f24ccb97..5aa8d3f745 100644 --- a/src/renderer/src/windows/selection/action/entryPoint.tsx +++ b/src/renderer/src/windows/selection/action/entryPoint.tsx @@ -2,13 +2,13 @@ import '@renderer/assets/styles/index.css' import '@renderer/assets/styles/tailwind.css' import '@ant-design/v5-patch-for-react-19' -import { HeroUIProvider } from '@heroui/react' import KeyvStorage from '@kangfenmao/keyv-storage' import { loggerService } from '@logger' import { ToastPortal } from '@renderer/components/ToastPortal' import { getToastUtilities } from '@renderer/components/TopView/toast' import AntdProvider from '@renderer/context/AntdProvider' import { CodeStyleProvider } from '@renderer/context/CodeStyleProvider' +import { HeroUIProvider } from '@renderer/context/HeroUIProvider' import { ThemeProvider } from '@renderer/context/ThemeProvider' import storeSyncService from '@renderer/services/StoreSyncService' import store, { persistor } from '@renderer/store' From 6c9fc598d4a35f99c2c343d2e065118ae5ea53bb Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Wed, 17 Sep 2025 18:57:08 +0800 Subject: [PATCH 34/99] bump: version 1.6.0-rc.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- electron-builder.yml | 24 ++++++++++-------------- package.json | 2 +- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/electron-builder.yml b/electron-builder.yml index 0660319150..6b7056ed3a 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -125,20 +125,16 @@ afterSign: scripts/notarize.js artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | - ✨ 新功能: - - 集成 Perplexity SDK 和 Anthropic OAuth - - 支持 API 服务器模式,提供外部调用接口 - - 新增字体自定义设置功能 - - 笔记支持文件夹批量上传 - - 集成 HeroUI 和 Tailwind CSS 提升界面体验 + 🐛 问题修复: + - 修复 Anthropic API URL 处理,移除尾部斜杠并添加端点路径处理 + - 修复 MessageEditor 缺少 QuickPanelProvider 包装的问题 + - 修复 MiniWindow 高度问题 🚀 性能优化: - - 优化大文件上传,支持 OpenAI 标准文件服务 - - 重构 MCP 服务,改进错误处理和状态管理 + - 优化输入栏提及模型状态缓存,在渲染间保持状态 + - 重构网络搜索参数支持模型内置搜索,新增 OpenAI Chat 和 OpenRouter 支持 - 🐛 问题修复: - - 修复 WebSearch RAG 并发问题 - - 修复翻译页面长文本渲染布局问题 - - 修复笔记拖拽排序和无限循环问题 - - 修复 macOS CodeTool 工作目录错误 - - 修复多个 UI 组件的响应式设计问题 + 🔧 重构改进: + - 更新 HeroUIProvider 导入路径,改善上下文管理 + - 更新依赖项和 VSCode 开发环境配置 + - 升级 @cherrystudio/ai-core 到 v1.0.0-alpha.17 diff --git a/package.json b/package.json index 964be0e0fd..8873b7a4a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.6.0-rc.1", + "version": "1.6.0-rc.2", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", From 89d5bd817bf4a633fbab89f38a437d5dcee36656 Mon Sep 17 00:00:00 2001 From: SuYao Date: Wed, 17 Sep 2025 20:01:47 +0800 Subject: [PATCH 35/99] fix: Add AWS Bedrock reasoning extraction middleware (#10231) * Add AWS Bedrock reasoning extraction middleware - Add 'reasoning' tag to tagNameArray for broader reasoning support - Add AWS Bedrock case with gpt-oss model-specific reasoning extraction - Add openai-chat and openrouter cases to provider options switch - Remove unused zod import * Add OpenRouter provider support Updates ai-core to version alpha.18 with OpenRouter integration and improves provider ID resolution for OpenAI API hosts. --- package.json | 2 +- packages/aiCore/package.json | 2 +- packages/aiCore/src/core/providers/schemas.ts | 10 +++++++++- .../src/aiCore/middleware/AiSdkMiddlewareBuilder.ts | 12 +++++++++++- src/renderer/src/aiCore/provider/factory.ts | 3 +++ src/renderer/src/aiCore/utils/options.ts | 5 ++++- yarn.lock | 4 ++-- 7 files changed, 31 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 8873b7a4a4..b76e5f6f21 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "@aws-sdk/client-bedrock-runtime": "^3.840.0", "@aws-sdk/client-s3": "^3.840.0", "@biomejs/biome": "2.2.4", - "@cherrystudio/ai-core": "workspace:^1.0.0-alpha.17", + "@cherrystudio/ai-core": "workspace:^1.0.0-alpha.18", "@cherrystudio/embedjs": "^0.1.31", "@cherrystudio/embedjs-libsql": "^0.1.31", "@cherrystudio/embedjs-loader-csv": "^0.1.31", diff --git a/packages/aiCore/package.json b/packages/aiCore/package.json index 642feff7c1..75ed6ea34e 100644 --- a/packages/aiCore/package.json +++ b/packages/aiCore/package.json @@ -1,6 +1,6 @@ { "name": "@cherrystudio/ai-core", - "version": "1.0.0-alpha.17", + "version": "1.0.0-alpha.18", "description": "Cherry Studio AI Core - Unified AI Provider Interface Based on Vercel AI SDK", "main": "dist/index.js", "module": "dist/index.mjs", diff --git a/packages/aiCore/src/core/providers/schemas.ts b/packages/aiCore/src/core/providers/schemas.ts index 73ea4b8c14..83338cf057 100644 --- a/packages/aiCore/src/core/providers/schemas.ts +++ b/packages/aiCore/src/core/providers/schemas.ts @@ -9,7 +9,9 @@ import { createDeepSeek } from '@ai-sdk/deepseek' import { createGoogleGenerativeAI } from '@ai-sdk/google' import { createOpenAI, type OpenAIProviderSettings } from '@ai-sdk/openai' import { createOpenAICompatible } from '@ai-sdk/openai-compatible' +import { LanguageModelV2 } from '@ai-sdk/provider' import { createXai } from '@ai-sdk/xai' +import { createOpenRouter } from '@openrouter/ai-sdk-provider' import { customProvider, Provider } from 'ai' import { z } from 'zod' @@ -46,7 +48,7 @@ export const isBaseProvider = (id: ProviderId): id is BaseProviderId => { type BaseProvider = { id: BaseProviderId name: string - creator: (options: any) => Provider + creator: (options: any) => Provider | LanguageModelV2 supportsImageGeneration: boolean } @@ -124,6 +126,12 @@ export const baseProviders = [ name: 'DeepSeek', creator: createDeepSeek, supportsImageGeneration: false + }, + { + id: 'openrouter', + name: 'OpenRouter', + creator: createOpenRouter, + supportsImageGeneration: true } ] as const satisfies BaseProvider[] diff --git a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts index ffbe66da22..eabdf1815f 100644 --- a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts +++ b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts @@ -140,7 +140,7 @@ export function buildAiSdkMiddlewares(config: AiSdkMiddlewareConfig): LanguageMo return builder.build() } -const tagNameArray = ['think', 'thought'] +const tagNameArray = ['think', 'thought', 'reasoning'] /** * 添加provider特定的中间件 @@ -167,6 +167,16 @@ function addProviderSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config: case 'gemini': // Gemini特定中间件 break + case 'aws-bedrock': { + if (config.model?.id.includes('gpt-oss')) { + const tagName = tagNameArray[2] + builder.add({ + name: 'thinking-tag-extraction', + middleware: extractReasoningMiddleware({ tagName }) + }) + } + break + } default: // 其他provider的通用处理 break diff --git a/src/renderer/src/aiCore/provider/factory.ts b/src/renderer/src/aiCore/provider/factory.ts index 617758753e..bfcd3da383 100644 --- a/src/renderer/src/aiCore/provider/factory.ts +++ b/src/renderer/src/aiCore/provider/factory.ts @@ -69,6 +69,9 @@ export function getAiSdkProviderId(provider: Provider): ProviderId | 'openai-com return resolvedFromType } } + if (provider.apiHost.includes('api.openai.com')) { + return 'openai-chat' + } // 3. 最后的fallback(通常会成为openai-compatible) return provider.id as ProviderId } diff --git a/src/renderer/src/aiCore/utils/options.ts b/src/renderer/src/aiCore/utils/options.ts index f85c8c7879..dec475fc8b 100644 --- a/src/renderer/src/aiCore/utils/options.ts +++ b/src/renderer/src/aiCore/utils/options.ts @@ -82,6 +82,7 @@ export function buildProviderOptions( // 应该覆盖所有类型 switch (baseProviderId) { case 'openai': + case 'openai-chat': case 'azure': providerSpecificOptions = { ...buildOpenAIProviderOptions(assistant, model, capabilities), @@ -101,13 +102,15 @@ export function buildProviderOptions( providerSpecificOptions = buildXAIProviderOptions(assistant, model, capabilities) break case 'deepseek': - case 'openai-compatible': + case 'openrouter': + case 'openai-compatible': { // 对于其他 provider,使用通用的构建逻辑 providerSpecificOptions = { ...buildGenericProviderOptions(assistant, model, capabilities), serviceTier: serviceTierSetting } break + } default: throw new Error(`Unsupported base provider ${baseProviderId}`) } diff --git a/yarn.lock b/yarn.lock index 23a168d8f9..2393eaec5b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2309,7 +2309,7 @@ __metadata: languageName: node linkType: hard -"@cherrystudio/ai-core@workspace:^1.0.0-alpha.17, @cherrystudio/ai-core@workspace:packages/aiCore": +"@cherrystudio/ai-core@workspace:^1.0.0-alpha.18, @cherrystudio/ai-core@workspace:packages/aiCore": version: 0.0.0-use.local resolution: "@cherrystudio/ai-core@workspace:packages/aiCore" dependencies: @@ -13195,7 +13195,7 @@ __metadata: "@aws-sdk/client-bedrock-runtime": "npm:^3.840.0" "@aws-sdk/client-s3": "npm:^3.840.0" "@biomejs/biome": "npm:2.2.4" - "@cherrystudio/ai-core": "workspace:^1.0.0-alpha.17" + "@cherrystudio/ai-core": "workspace:^1.0.0-alpha.18" "@cherrystudio/embedjs": "npm:^0.1.31" "@cherrystudio/embedjs-libsql": "npm:^0.1.31" "@cherrystudio/embedjs-loader-csv": "npm:^0.1.31" From c76df7fb16c8bc726dad39e68e2174615ec4bf2b Mon Sep 17 00:00:00 2001 From: SuYao Date: Wed, 17 Sep 2025 23:10:58 +0800 Subject: [PATCH 36/99] fix: Remove maxTokens check from Anthropic thinking budget (#10240) Remove maxTokens check from Anthropic thinking budget --- src/renderer/src/aiCore/utils/reasoning.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/aiCore/utils/reasoning.ts b/src/renderer/src/aiCore/utils/reasoning.ts index 385d8183c5..bee07b1e0d 100644 --- a/src/renderer/src/aiCore/utils/reasoning.ts +++ b/src/renderer/src/aiCore/utils/reasoning.ts @@ -312,7 +312,7 @@ export function getOpenAIReasoningParams(assistant: Assistant, model: Model): Re export function getAnthropicThinkingBudget(assistant: Assistant, model: Model): number { const { maxTokens, reasoning_effort: reasoningEffort } = getAssistantSettings(assistant) - if (maxTokens === undefined || reasoningEffort === undefined) { + if (reasoningEffort === undefined) { return 0 } const effortRatio = EFFORT_RATIO[reasoningEffort] From ca597b9b9bbf77b4feb565c2d6a02a682c6d0fe3 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Wed, 17 Sep 2025 23:24:02 +0800 Subject: [PATCH 37/99] CI: improve claude translator for review, quote and email (#10230) * ci(claude-translator): extend workflow to handle pull request review events - Add support for pull_request_review and pull_request_review_comment events - Update condition logic to include new event types - Expand claude_args to include pull request review related API commands - Enhance prompt to handle new event types and more translation scenarios * ci(workflows): update concurrency group in claude-translator workflow Add github.event.review.id as additional fallback for concurrency group naming * fix(workflow): correct API method for pull_request_review event Use PATCH instead of PUT and update body parameter to match API requirements * ci: clarify comment ID label in workflow output Update the label for comment ID in workflow output to explicitly indicate when it refers to review comments * ci: fix syntax error in GitHub workflow file * fix(workflow): correct HTTP method for pull_request_review event Use PUT instead of PATCH for updating pull request reviews as per GitHub API requirements --- .github/workflows/claude-translator.yml | 61 +++++++++++++++++++------ 1 file changed, 46 insertions(+), 15 deletions(-) diff --git a/.github/workflows/claude-translator.yml b/.github/workflows/claude-translator.yml index ab2b6f7e4f..ff317f8532 100644 --- a/.github/workflows/claude-translator.yml +++ b/.github/workflows/claude-translator.yml @@ -1,6 +1,6 @@ name: Claude Translator concurrency: - group: translator-${{ github.event.comment.id || github.event.issue.number }} + group: translator-${{ github.event.comment.id || github.event.issue.number || github.event.review.id }} cancel-in-progress: false on: @@ -8,14 +8,18 @@ on: types: [opened] issue_comment: types: [created, edited] + pull_request_review: + types: [submitted, edited] + pull_request_review_comment: + types: [created, edited] jobs: translate: if: | (github.event_name == 'issues') || - (github.event_name == 'issue_comment' && github.event.sender.type != 'Bot') && - ((github.event_name == 'issue_comment' && github.event.action == 'created' && !contains(github.event.comment.body, 'This issue was translated by Claude')) || - (github.event_name == 'issue_comment' && github.event.action == 'edited')) + (github.event_name == 'issue_comment' && github.event.sender.type != 'Bot') || + (github.event_name == 'pull_request_review' && github.event.sender.type != 'Bot') || + (github.event_name == 'pull_request_review_comment' && github.event.sender.type != 'Bot') runs-on: ubuntu-latest permissions: contents: read @@ -37,23 +41,44 @@ jobs: # Now `contents: read` is safe for files, but we could make a fine-grained token to control it. # See: https://github.com/anthropics/claude-code-action/blob/main/docs/security.md github_token: ${{ secrets.TOKEN_GITHUB_WRITE }} - allowed_non_write_users: '*' + allowed_non_write_users: "*" claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - claude_args: '--allowed-tools Bash(gh issue:*),Bash(gh api:repos/*/issues:*)' + claude_args: "--allowed-tools Bash(gh issue:*),Bash(gh api:repos/*/issues:*),Bash(gh api:repos/*/pulls/*/reviews/*),Bash(gh api:repos/*/pulls/comments/*)" prompt: | - 你是一个多语言翻译助手。请完成以下任务: + 你是一个多语言翻译助手。你需要响应 GitHub Webhooks 中的以下四种事件: + + - issues + - issue_comment + - pull_request_review + - pull_request_review_comment + + 请完成以下任务: + + 1. 获取当前事件的完整信息。 + + - 如果当前事件是 issues,就获取该 issues 的信息。 + - 如果当前事件是 issue_comment,就获取该 comment 的信息。 + - 如果当前事件是 pull_request_review,就获取该 review 的信息。 + - 如果当前事件是 pull_request_review_comment,就获取该 comment 的信息。 - 1. 获取当前issue/comment的完整信息 2. 智能检测内容。 - 1. 如果是已经遵循格式要求翻译过的issue/comment,检查翻译内容和原始内容是否匹配。若不匹配,则重新翻译一次令其匹配,并遵循格式要求;若匹配,则跳过任务。 - 2. 如果是未翻译过的issue/comment,检查其内容语言。若不是英文,则翻译成英文;若已经是英文,则跳过任务。 + + - 如果获取到的信息是已经遵循格式要求翻译过的内容,则检查翻译内容和原始内容是否匹配。若不匹配,则重新翻译一次令其匹配,并遵循格式要求; + - 如果获取到的信息是未翻译过的内容,检查其内容语言。若不是英文,则翻译成英文; + - 如果获取到的信息是部分翻译为英文的内容,则将其翻译为英文; + - 如果获取到的信息包含了对已翻译内容的引用,则将引用内容清理为仅含英文的内容。引用的内容不能够包含"This xxx was translated by Claude"和"Original Content`等内容。 + - 如果获取到的信息包含了其他类型的引用,即对非 Claude 翻译的内容的引用,则直接照原样引用,不进行翻译。 + - 如果获取到的信息是通过邮件回复的内容,则在翻译时应当将邮件内容的引用放到最后。在原始内容和翻译内容中只需要回复的内容本身,不要包含对邮件内容的引用。 + - 如果获取到的信息本身不需要任何处理,则跳过任务。 + 3. 格式要求: + - 标题:英文翻译(如果非英文) - 内容格式: > [!NOTE] - > This issue/comment was translated by Claude. + > This issue/comment/review was translated by Claude. - [英文翻译内容] + [翻译内容] ---
@@ -62,15 +87,21 @@ jobs:
4. 使用gh工具更新: + - 根据环境信息中的Event类型选择正确的命令: - - 如果Event是'issues':gh issue edit [ISSUE_NUMBER] --title "[英文标题]" --body "[翻译内容 + 原始内容]" - - 如果Event是'issue_comment':gh api -X PATCH /repos/[REPO]/issues/comments/[COMMENT_ID] -f body="[翻译内容 + 原始内容]" + - 如果 Event 是 'issues': gh issue edit [ISSUE_NUMBER] --title "[英文标题]" --body "[翻译内容 + 原始内容]" + - 如果 Event 是 'issue_comment': gh api -X PATCH /repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }} -f body="[翻译内容 + 原始内容]" + - 如果 Event 是 'pull_request_review': gh api -X PUT /repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/reviews/${{ github.event.review.id }} -f body="[翻译内容]" + - 如果 Event 是 'pull_request_review_comment': gh api -X PATCH /repos/${{ github.repository }}/pulls/comments/${{ github.event.comment.id }} -f body="[翻译内容 + 原始内容]" 环境信息: - Event: ${{ github.event_name }} - Issue Number: ${{ github.event.issue.number }} - Repository: ${{ github.repository }} - - Comment ID: ${{ github.event.comment.id || 'N/A' }} (only available for comment events) + - (Review) Comment ID: ${{ github.event.comment.id || 'N/A' }} + - Pull Request Number: ${{ github.event.pull_request.number || 'N/A' }} + - Review ID: ${{ github.event.review.id || 'N/A' }} + 使用以下命令获取完整信息: gh issue view ${{ github.event.issue.number }} --json title,body,comments From 1d0fc26025f1b72b4800b782c16daf6b3d3022bb Mon Sep 17 00:00:00 2001 From: SuYao Date: Thu, 18 Sep 2025 15:43:07 +0800 Subject: [PATCH 38/99] fix formatApiHost (#10236) * Add .codebuddy and .zed to .gitignore and fix formatApiHost Prevent formatApiHost from processing undefined/empty host values and ignore editor-specific directories * Refactor reasoning tag selection logic for providers Move gpt-oss model handling from aws-bedrock case to openai case and consolidate tag selection logic into a single if-else chain. * Extract reasoning tag name into helper function * fix test * Replace array indexing with named object properties for reasoning tags Improves code readability by using descriptive property names instead of magic array indices when selecting reasoning tag names by model type. * Move host validation to start of formatApiHost --- .gitignore | 2 ++ .../middleware/AiSdkMiddlewareBuilder.ts | 21 +++++++++++-------- src/renderer/src/utils/__tests__/api.test.ts | 2 +- src/renderer/src/utils/api.ts | 4 ++++ 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index fcaa2be164..f4cc92ce30 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,8 @@ local .qwen/* .trae/* .claude-code-router/* +.codebuddy/* +.zed/* CLAUDE.local.md # vitest diff --git a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts index eabdf1815f..1f18e49bad 100644 --- a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts +++ b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts @@ -140,7 +140,17 @@ export function buildAiSdkMiddlewares(config: AiSdkMiddlewareConfig): LanguageMo return builder.build() } -const tagNameArray = ['think', 'thought', 'reasoning'] +const tagName = { + reasoning: 'reasoning', + think: 'think', + thought: 'thought' +} + +function getReasoningTagName(modelId: string | undefined): string { + if (modelId?.includes('gpt-oss')) return tagName.reasoning + if (modelId?.includes('gemini')) return tagName.thought + return tagName.think +} /** * 添加provider特定的中间件 @@ -156,7 +166,7 @@ function addProviderSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config: case 'openai': case 'azure-openai': { if (config.enableReasoning) { - const tagName = config.model?.id.includes('gemini') ? tagNameArray[1] : tagNameArray[0] + const tagName = getReasoningTagName(config.model?.id.toLowerCase()) builder.add({ name: 'thinking-tag-extraction', middleware: extractReasoningMiddleware({ tagName }) @@ -168,13 +178,6 @@ function addProviderSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config: // Gemini特定中间件 break case 'aws-bedrock': { - if (config.model?.id.includes('gpt-oss')) { - const tagName = tagNameArray[2] - builder.add({ - name: 'thinking-tag-extraction', - middleware: extractReasoningMiddleware({ tagName }) - }) - } break } default: diff --git a/src/renderer/src/utils/__tests__/api.test.ts b/src/renderer/src/utils/__tests__/api.test.ts index ee91e2ad2b..f25ac2e68f 100644 --- a/src/renderer/src/utils/__tests__/api.test.ts +++ b/src/renderer/src/utils/__tests__/api.test.ts @@ -25,7 +25,7 @@ describe('api', () => { }) it('should handle empty string gracefully', () => { - expect(formatApiHost('')).toBe('/v1/') + expect(formatApiHost('')).toBe('') }) }) diff --git a/src/renderer/src/utils/api.ts b/src/renderer/src/utils/api.ts index 5e9b8f91a6..62d0db5623 100644 --- a/src/renderer/src/utils/api.ts +++ b/src/renderer/src/utils/api.ts @@ -20,6 +20,10 @@ export function formatApiKeys(value: string): string { * @returns {string} 格式化后的 API 主机地址。 */ export function formatApiHost(host: string, apiVersion: string = 'v1'): string { + if (!host) { + return '' + } + const forceUseOriginalHost = () => { if (host.endsWith('/')) { return true From 5dac1f5867d8a62984ae41bb0b62085c3e9e658c Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Thu, 18 Sep 2025 17:33:06 +0800 Subject: [PATCH 39/99] feat: support more terminal in code tools (#10192) * feat(CodeTools): add support for terminal selection on macOS - Introduced terminal selection functionality in CodeTools, allowing users to choose from available terminal applications. - Implemented caching for terminal availability checks to enhance performance. - Updated CodeToolsService to preload available terminals and check their availability. - Enhanced UI in CodeToolsPage to display terminal options and handle user selection. - Added new IPC channel for retrieving available terminals from the main process. * lint errs * format * support wezterm * support terminal * support ghostty * support warp kitty * fix github scanner issues * fix all github issues * support windows * support windows * suppport hyper * Refactor terminal command execution for macOS applications to use shell scripts instead of AppleScript, improving compatibility and performance. * Remove Hyper terminal configuration from shared constants * update lint * fix(i18n): Auto update translations for PR #10192 * fix platform checking * format * feat: add Tabby terminal configuration for macOS * fix wrap terminal * delete warp --------- Co-authored-by: GitHub Action --- packages/shared/IpcChannel.ts | 4 + packages/shared/config/constant.ts | 239 +++++++++++ src/main/ipc.ts | 10 + src/main/services/CodeToolsService.ts | 375 ++++++++++++++++-- src/preload/index.ts | 14 +- src/renderer/src/hooks/useCodeTools.ts | 13 +- src/renderer/src/i18n/locales/en-us.json | 7 + src/renderer/src/i18n/locales/zh-cn.json | 7 + src/renderer/src/i18n/locales/zh-tw.json | 7 + src/renderer/src/i18n/translate/el-gr.json | 7 + src/renderer/src/i18n/translate/es-es.json | 7 + src/renderer/src/i18n/translate/fr-fr.json | 7 + src/renderer/src/i18n/translate/ja-jp.json | 7 + src/renderer/src/i18n/translate/pt-pt.json | 7 + src/renderer/src/i18n/translate/ru-ru.json | 7 + src/renderer/src/pages/code/CodeToolsPage.tsx | 106 ++++- src/renderer/src/store/codeTools.ts | 14 +- 17 files changed, 802 insertions(+), 36 deletions(-) diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index a2ef66284c..1e925984a8 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -321,6 +321,10 @@ export enum IpcChannel { // CodeTools CodeTools_Run = 'code-tools:run', + CodeTools_GetAvailableTerminals = 'code-tools:get-available-terminals', + CodeTools_SetCustomTerminalPath = 'code-tools:set-custom-terminal-path', + CodeTools_GetCustomTerminalPath = 'code-tools:get-custom-terminal-path', + CodeTools_RemoveCustomTerminalPath = 'code-tools:remove-custom-terminal-path', // OCR OCR_ocr = 'ocr:ocr', diff --git a/packages/shared/config/constant.ts b/packages/shared/config/constant.ts index 3dc2a45a6a..9ce35e3a5d 100644 --- a/packages/shared/config/constant.ts +++ b/packages/shared/config/constant.ts @@ -219,3 +219,242 @@ export enum codeTools { openaiCodex = 'openai-codex', iFlowCli = 'iflow-cli' } + +export enum terminalApps { + systemDefault = 'Terminal', + iterm2 = 'iTerm2', + kitty = 'kitty', + alacritty = 'Alacritty', + wezterm = 'WezTerm', + ghostty = 'Ghostty', + tabby = 'Tabby', + // Windows terminals + windowsTerminal = 'WindowsTerminal', + powershell = 'PowerShell', + cmd = 'CMD', + wsl = 'WSL' +} + +export interface TerminalConfig { + id: string + name: string + bundleId?: string + customPath?: string // For user-configured terminal paths on Windows +} + +export interface TerminalConfigWithCommand extends TerminalConfig { + command: (directory: string, fullCommand: string) => { command: string; args: string[] } +} + +export const MACOS_TERMINALS: TerminalConfig[] = [ + { + id: terminalApps.systemDefault, + name: 'Terminal', + bundleId: 'com.apple.Terminal' + }, + { + id: terminalApps.iterm2, + name: 'iTerm2', + bundleId: 'com.googlecode.iterm2' + }, + { + id: terminalApps.kitty, + name: 'kitty', + bundleId: 'net.kovidgoyal.kitty' + }, + { + id: terminalApps.alacritty, + name: 'Alacritty', + bundleId: 'org.alacritty' + }, + { + id: terminalApps.wezterm, + name: 'WezTerm', + bundleId: 'com.github.wez.wezterm' + }, + { + id: terminalApps.ghostty, + name: 'Ghostty', + bundleId: 'com.mitchellh.ghostty' + }, + { + id: terminalApps.tabby, + name: 'Tabby', + bundleId: 'org.tabby' + } +] + +export const WINDOWS_TERMINALS: TerminalConfig[] = [ + { + id: terminalApps.cmd, + name: 'Command Prompt' + }, + { + id: terminalApps.powershell, + name: 'PowerShell' + }, + { + id: terminalApps.windowsTerminal, + name: 'Windows Terminal' + }, + { + id: terminalApps.wsl, + name: 'WSL (Ubuntu/Debian)' + }, + { + id: terminalApps.alacritty, + name: 'Alacritty' + }, + { + id: terminalApps.wezterm, + name: 'WezTerm' + } +] + +export const WINDOWS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [ + { + id: terminalApps.cmd, + name: 'Command Prompt', + command: (_: string, fullCommand: string) => ({ + command: 'cmd', + args: ['/c', 'start', 'cmd', '/k', fullCommand] + }) + }, + { + id: terminalApps.powershell, + name: 'PowerShell', + command: (_: string, fullCommand: string) => ({ + command: 'cmd', + args: ['/c', 'start', 'powershell', '-NoExit', '-Command', `& '${fullCommand}'`] + }) + }, + { + id: terminalApps.windowsTerminal, + name: 'Windows Terminal', + command: (_: string, fullCommand: string) => ({ + command: 'wt', + args: ['cmd', '/k', fullCommand] + }) + }, + { + id: terminalApps.wsl, + name: 'WSL (Ubuntu/Debian)', + command: (_: string, fullCommand: string) => { + // Start WSL in a new window and execute the batch file from within WSL using cmd.exe + // The batch file will run in Windows context but output will be in WSL terminal + return { + command: 'cmd', + args: ['/c', 'start', 'wsl', '-e', 'bash', '-c', `cmd.exe /c '${fullCommand}' ; exec bash`] + } + } + }, + { + id: terminalApps.alacritty, + name: 'Alacritty', + customPath: '', // Will be set by user in settings + command: (_: string, fullCommand: string) => ({ + command: 'alacritty', // Will be replaced with customPath if set + args: ['-e', 'cmd', '/k', fullCommand] + }) + }, + { + id: terminalApps.wezterm, + name: 'WezTerm', + customPath: '', // Will be set by user in settings + command: (_: string, fullCommand: string) => ({ + command: 'wezterm', // Will be replaced with customPath if set + args: ['start', 'cmd', '/k', fullCommand] + }) + } +] + +export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [ + { + id: terminalApps.systemDefault, + name: 'Terminal', + bundleId: 'com.apple.Terminal', + 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'` + ] + }) + }, + { + id: terminalApps.iterm2, + name: 'iTerm2', + bundleId: 'com.googlecode.iterm2', + 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'` + ] + }) + }, + { + id: terminalApps.kitty, + name: 'kitty', + bundleId: 'net.kovidgoyal.kitty', + 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'` + ] + }) + }, + { + id: terminalApps.alacritty, + name: 'Alacritty', + bundleId: 'org.alacritty', + 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'` + ] + }) + }, + { + id: terminalApps.wezterm, + name: 'WezTerm', + bundleId: 'com.github.wez.wezterm', + 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'` + ] + }) + }, + { + id: terminalApps.ghostty, + name: 'Ghostty', + bundleId: 'com.mitchellh.ghostty', + 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'` + ] + }) + }, + { + id: terminalApps.tabby, + name: 'Tabby', + bundleId: 'org.tabby', + command: (directory: string, fullCommand: string) => ({ + command: 'sh', + args: [ + '-c', + `if pgrep -x "Tabby" > /dev/null; then + 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'` + ] + }) + } +] diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 9805b7c6e6..d0ef8ec94a 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -824,6 +824,16 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { // CodeTools ipcMain.handle(IpcChannel.CodeTools_Run, codeToolsService.run) + ipcMain.handle(IpcChannel.CodeTools_GetAvailableTerminals, () => codeToolsService.getAvailableTerminalsForPlatform()) + ipcMain.handle(IpcChannel.CodeTools_SetCustomTerminalPath, (_, terminalId: string, path: string) => + codeToolsService.setCustomTerminalPath(terminalId, path) + ) + ipcMain.handle(IpcChannel.CodeTools_GetCustomTerminalPath, (_, terminalId: string) => + codeToolsService.getCustomTerminalPath(terminalId) + ) + ipcMain.handle(IpcChannel.CodeTools_RemoveCustomTerminalPath, (_, terminalId: string) => + codeToolsService.removeCustomTerminalPath(terminalId) + ) // OCR ipcMain.handle(IpcChannel.OCR_ocr, (_, file: SupportedOcrFile, provider: OcrProvider) => diff --git a/src/main/services/CodeToolsService.ts b/src/main/services/CodeToolsService.ts index 1372bf1f88..74fca367fc 100644 --- a/src/main/services/CodeToolsService.ts +++ b/src/main/services/CodeToolsService.ts @@ -3,11 +3,20 @@ import os from 'node:os' import path from 'node:path' import { loggerService } from '@logger' -import { isWin } from '@main/constant' +import { isMac, isWin } from '@main/constant' import { removeEnvProxy } from '@main/utils' import { isUserInChina } from '@main/utils/ipService' import { getBinaryName } from '@main/utils/process' -import { codeTools } from '@shared/config/constant' +import { + codeTools, + MACOS_TERMINALS, + MACOS_TERMINALS_WITH_COMMANDS, + terminalApps, + TerminalConfig, + TerminalConfigWithCommand, + WINDOWS_TERMINALS, + WINDOWS_TERMINALS_WITH_COMMANDS +} from '@shared/config/constant' import { spawn } from 'child_process' import { promisify } from 'util' @@ -22,7 +31,10 @@ interface VersionInfo { class CodeToolsService { private versionCache: Map = new Map() + private terminalsCache: { terminals: TerminalConfig[]; timestamp: number } | null = null + private customTerminalPaths: Map = new Map() // Store user-configured terminal paths private readonly CACHE_DURATION = 1000 * 60 * 30 // 30 minutes cache + private readonly TERMINALS_CACHE_DURATION = 1000 * 60 * 5 // 5 minutes cache for terminals constructor() { this.getBunPath = this.getBunPath.bind(this) @@ -32,6 +44,23 @@ class CodeToolsService { this.getVersionInfo = this.getVersionInfo.bind(this) this.updatePackage = this.updatePackage.bind(this) this.run = this.run.bind(this) + + if (isMac || isWin) { + this.preloadTerminals() + } + } + + /** + * Preload available terminals in background + */ + private async preloadTerminals(): Promise { + try { + logger.info('Preloading available terminals...') + await this.getAvailableTerminals() + logger.info('Terminal preloading completed') + } catch (error) { + logger.warn('Terminal preloading failed:', error as Error) + } } public async getBunPath() { @@ -75,10 +104,258 @@ class CodeToolsService { } } + /** + * Check if a single terminal is available + */ + private async checkTerminalAvailability(terminal: TerminalConfig): Promise { + try { + if (isMac && terminal.bundleId) { + // macOS: Check if application is installed via bundle ID with timeout + const { stdout } = await execAsync(`mdfind "kMDItemCFBundleIdentifier == '${terminal.bundleId}'"`, { + timeout: 3000 + }) + if (stdout.trim()) { + return terminal + } + } else if (isWin) { + // Windows: Check terminal availability + return await this.checkWindowsTerminalAvailability(terminal) + } else { + // TODO: Check if terminal is available in linux + await execAsync(`which ${terminal.id}`, { timeout: 2000 }) + return terminal + } + } catch (error) { + logger.debug(`Terminal ${terminal.id} not available:`, error as Error) + } + return null + } + + /** + * Check Windows terminal availability (simplified - user configured paths) + */ + private async checkWindowsTerminalAvailability(terminal: TerminalConfig): Promise { + try { + switch (terminal.id) { + case terminalApps.cmd: + // CMD is always available on Windows + return terminal + + case terminalApps.powershell: + // Check for PowerShell in PATH + try { + await execAsync('powershell -Command "Get-Host"', { timeout: 3000 }) + return terminal + } catch { + try { + await execAsync('pwsh -Command "Get-Host"', { timeout: 3000 }) + return terminal + } catch { + return null + } + } + + case terminalApps.windowsTerminal: + // Check for Windows Terminal via where command (doesn't launch the terminal) + try { + await execAsync('where wt', { timeout: 3000 }) + return terminal + } catch { + return null + } + + case terminalApps.wsl: + // Check for WSL + try { + await execAsync('wsl --status', { timeout: 3000 }) + return terminal + } catch { + return null + } + + default: + // For other terminals (Alacritty, WezTerm), check if user has configured custom path + return await this.checkCustomTerminalPath(terminal) + } + } catch (error) { + logger.debug(`Windows terminal ${terminal.id} not available:`, error as Error) + return null + } + } + + /** + * Check if user has configured custom path for terminal + */ + private async checkCustomTerminalPath(terminal: TerminalConfig): Promise { + // Check if user has configured custom path + const customPath = this.customTerminalPaths.get(terminal.id) + if (customPath && fs.existsSync(customPath)) { + try { + await execAsync(`"${customPath}" --version`, { timeout: 3000 }) + return { ...terminal, customPath } + } catch { + return null + } + } + + // Fallback to PATH check + try { + const command = terminal.id === terminalApps.alacritty ? 'alacritty' : 'wezterm' + await execAsync(`${command} --version`, { timeout: 3000 }) + return terminal + } catch { + return null + } + } + + /** + * Set custom path for a terminal (called from settings UI) + */ + public setCustomTerminalPath(terminalId: string, path: string): void { + logger.info(`Setting custom path for terminal ${terminalId}: ${path}`) + this.customTerminalPaths.set(terminalId, path) + // Clear terminals cache to force refresh + this.terminalsCache = null + } + + /** + * Get custom path for a terminal + */ + public getCustomTerminalPath(terminalId: string): string | undefined { + return this.customTerminalPaths.get(terminalId) + } + + /** + * Remove custom path for a terminal + */ + public removeCustomTerminalPath(terminalId: string): void { + logger.info(`Removing custom path for terminal ${terminalId}`) + this.customTerminalPaths.delete(terminalId) + // Clear terminals cache to force refresh + this.terminalsCache = null + } + + /** + * Get available terminals (with caching and parallel checking) + */ + private async getAvailableTerminals(): Promise { + const now = Date.now() + + // Check cache first + if (this.terminalsCache && now - this.terminalsCache.timestamp < this.TERMINALS_CACHE_DURATION) { + logger.info(`Using cached terminals list (${this.terminalsCache.terminals.length} terminals)`) + return this.terminalsCache.terminals + } + + logger.info('Checking available terminals in parallel...') + const startTime = Date.now() + + // Get terminal list based on platform + const terminalList = isWin ? WINDOWS_TERMINALS : MACOS_TERMINALS + + // Check all terminals in parallel + const terminalPromises = terminalList.map((terminal) => this.checkTerminalAvailability(terminal)) + + try { + // Wait for all checks to complete with a global timeout + const results = await Promise.allSettled( + terminalPromises.map((p) => + Promise.race([p, new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000))]) + ) + ) + + const availableTerminals: TerminalConfig[] = [] + results.forEach((result, index) => { + if (result.status === 'fulfilled' && result.value) { + availableTerminals.push(result.value as TerminalConfig) + } else if (result.status === 'rejected') { + logger.debug(`Terminal check failed for ${MACOS_TERMINALS[index].id}:`, result.reason) + } + }) + + const endTime = Date.now() + logger.info( + `Terminal availability check completed in ${endTime - startTime}ms, found ${availableTerminals.length} terminals` + ) + + // Cache the results + this.terminalsCache = { + terminals: availableTerminals, + timestamp: now + } + + return availableTerminals + } catch (error) { + logger.error('Error checking terminal availability:', error as Error) + // Return cached result if available, otherwise empty array + return this.terminalsCache?.terminals || [] + } + } + + /** + * Get terminal config by ID, fallback to system default + */ + private async getTerminalConfig(terminalId?: string): Promise { + const availableTerminals = await this.getAvailableTerminals() + const terminalCommands = isWin ? WINDOWS_TERMINALS_WITH_COMMANDS : MACOS_TERMINALS_WITH_COMMANDS + const defaultTerminal = isWin ? terminalApps.cmd : terminalApps.systemDefault + + if (terminalId) { + let requestedTerminal = terminalCommands.find( + (t) => t.id === terminalId && availableTerminals.some((at) => at.id === t.id) + ) + + if (requestedTerminal) { + // Apply custom path if configured + const customPath = this.customTerminalPaths.get(terminalId) + if (customPath && isWin) { + requestedTerminal = this.applyCustomPath(requestedTerminal, customPath) + } + return requestedTerminal + } else { + logger.warn(`Requested terminal ${terminalId} not available, falling back to system default`) + } + } + + // Fallback to system default Terminal + const systemTerminal = terminalCommands.find( + (t) => t.id === defaultTerminal && availableTerminals.some((at) => at.id === t.id) + ) + if (systemTerminal) { + return systemTerminal + } + + // If even system Terminal is not found, return the first available + const firstAvailable = terminalCommands.find((t) => availableTerminals.some((at) => at.id === t.id)) + if (firstAvailable) { + return firstAvailable + } + + // Last resort fallback + return terminalCommands.find((t) => t.id === defaultTerminal)! + } + + /** + * Apply custom path to terminal configuration + */ + private applyCustomPath(terminal: TerminalConfigWithCommand, customPath: string): TerminalConfigWithCommand { + return { + ...terminal, + customPath, + command: (directory: string, fullCommand: string) => { + const originalCommand = terminal.command(directory, fullCommand) + return { + ...originalCommand, + command: customPath // Replace command with custom path + } + } + } + } + private async isPackageInstalled(cliTool: string): Promise { const executableName = await this.getCliExecutableName(cliTool) const binDir = path.join(os.homedir(), '.cherrystudio', 'bin') - const executablePath = path.join(binDir, executableName + (process.platform === 'win32' ? '.exe' : '')) + const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : '')) // Ensure bin directory exists if (!fs.existsSync(binDir)) { @@ -105,7 +382,7 @@ class CodeToolsService { try { const executableName = await this.getCliExecutableName(cliTool) const binDir = path.join(os.homedir(), '.cherrystudio', 'bin') - const executablePath = path.join(binDir, executableName + (process.platform === 'win32' ? '.exe' : '')) + const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : '')) const { stdout } = await execAsync(`"${executablePath}" --version`, { timeout: 10000 }) // Extract version number from output (format may vary by tool) @@ -191,6 +468,17 @@ class CodeToolsService { } } + /** + * Get available terminals for the current platform + */ + public async getAvailableTerminalsForPlatform(): Promise { + if (isMac || isWin) { + return this.getAvailableTerminals() + } + // For other platforms, return empty array for now + return [] + } + /** * Update a CLI tool to the latest version */ @@ -202,10 +490,9 @@ class CodeToolsService { const bunInstallPath = path.join(os.homedir(), '.cherrystudio') const registryUrl = await this.getNpmRegistryUrl() - const installEnvPrefix = - process.platform === 'win32' - ? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&` - : `export BUN_INSTALL="${bunInstallPath}" && export NPM_CONFIG_REGISTRY="${registryUrl}" &&` + const installEnvPrefix = isWin + ? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&` + : `export BUN_INSTALL="${bunInstallPath}" && export NPM_CONFIG_REGISTRY="${registryUrl}" &&` const updateCommand = `${installEnvPrefix} "${bunPath}" install -g ${packageName}` logger.info(`Executing update command: ${updateCommand}`) @@ -241,7 +528,7 @@ class CodeToolsService { _model: string, directory: string, env: Record, - options: { autoUpdateToLatest?: boolean } = {} + options: { autoUpdateToLatest?: boolean; terminal?: string } = {} ) { logger.info(`Starting CLI tool launch: ${cliTool} in directory: ${directory}`) logger.debug(`Environment variables:`, Object.keys(env)) @@ -251,7 +538,7 @@ class CodeToolsService { const bunPath = await this.getBunPath() const executableName = await this.getCliExecutableName(cliTool) const binDir = path.join(os.homedir(), '.cherrystudio', 'bin') - const executablePath = path.join(binDir, executableName + (process.platform === 'win32' ? '.exe' : '')) + const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : '')) logger.debug(`Package name: ${packageName}`) logger.debug(`Bun path: ${bunPath}`) @@ -295,7 +582,13 @@ class CodeToolsService { // Build environment variable prefix (based on platform) const buildEnvPrefix = (isWindows: boolean) => { - if (Object.keys(env).length === 0) return '' + if (Object.keys(env).length === 0) { + logger.info('No environment variables to set') + return '' + } + + logger.info('Setting environment variables:', Object.keys(env)) + logger.info('Environment variable values:', env) if (isWindows) { // Windows uses set command @@ -304,13 +597,29 @@ class CodeToolsService { .join(' && ') } else { // Unix-like systems use export command - return Object.entries(env) - .map(([key, value]) => `export ${key}="${value.replace(/"/g, '\\"')}"`) + const validEntries = Object.entries(env).filter(([key, value]) => { + if (!key || key.trim() === '') { + return false + } + if (value === undefined || value === null) { + return false + } + return true + }) + + const envCommands = validEntries + .map(([key, value]) => { + const sanitizedValue = String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + const exportCmd = `export ${key}="${sanitizedValue}"` + logger.info(`Setting env var: ${key}="${sanitizedValue}"`) + logger.info(`Export command: ${exportCmd}`) + return exportCmd + }) .join(' && ') + return envCommands } } - // Build command to execute let baseCommand = isWin ? `"${executablePath}"` : `"${bunPath}" "${executablePath}"` // Add configuration parameters for OpenAI Codex @@ -351,20 +660,20 @@ class CodeToolsService { switch (platform) { case 'darwin': { - // macOS - Use osascript to launch terminal and execute command directly, without showing startup command + // macOS - Support multiple terminals const envPrefix = buildEnvPrefix(false) + 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}` - terminalCommand = 'osascript' - terminalArgs = [ - '-e', - `tell application "Terminal" - do script "${fullCommand.replace(/"/g, '\\"')}" - activate -end tell` - ] + const terminalConfig = await this.getTerminalConfig(options.terminal) + logger.info(`Using terminal: ${terminalConfig.name} (${terminalConfig.id})`) + + const { command: cmd, args } = terminalConfig.command(directory, fullCommand) + terminalCommand = cmd + terminalArgs = args break } case 'win32': { @@ -424,9 +733,23 @@ end tell` throw new Error(`Failed to create launch script: ${error}`) } - // Launch bat file - Use safest start syntax, no title parameter - terminalCommand = 'cmd' - terminalArgs = ['/c', 'start', batFilePath] + // Use selected terminal configuration + const terminalConfig = await this.getTerminalConfig(options.terminal) + logger.info(`Using terminal: ${terminalConfig.name} (${terminalConfig.id})`) + + // Get command and args from terminal configuration + // Pass the bat file path as the command to execute + const fullCommand = batFilePath + const { command: cmd, args } = terminalConfig.command(directory, fullCommand) + + // Override if it's a custom terminal with a custom path + if (terminalConfig.customPath) { + terminalCommand = terminalConfig.customPath + terminalArgs = args + } else { + terminalCommand = cmd + terminalArgs = args + } // Set cleanup task (delete temp file after 5 minutes) setTimeout(() => { diff --git a/src/preload/index.ts b/src/preload/index.ts index cd241237b8..af1cac21a1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,7 +1,7 @@ import { electronAPI } from '@electron-toolkit/preload' import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' import { SpanContext } from '@opentelemetry/api' -import { UpgradeChannel } from '@shared/config/constant' +import { TerminalConfig, UpgradeChannel } from '@shared/config/constant' import type { LogLevel, LogSourceWithContext } from '@shared/config/logger' import type { FileChangeEvent } from '@shared/config/types' import { IpcChannel } from '@shared/IpcChannel' @@ -439,8 +439,16 @@ const api = { model: string, directory: string, env: Record, - options?: { autoUpdateToLatest?: boolean } - ) => ipcRenderer.invoke(IpcChannel.CodeTools_Run, cliTool, model, directory, env, options) + options?: { autoUpdateToLatest?: boolean; terminal?: string } + ) => ipcRenderer.invoke(IpcChannel.CodeTools_Run, cliTool, model, directory, env, options), + getAvailableTerminals: (): Promise => + ipcRenderer.invoke(IpcChannel.CodeTools_GetAvailableTerminals), + setCustomTerminalPath: (terminalId: string, path: string): Promise => + ipcRenderer.invoke(IpcChannel.CodeTools_SetCustomTerminalPath, terminalId, path), + getCustomTerminalPath: (terminalId: string): Promise => + ipcRenderer.invoke(IpcChannel.CodeTools_GetCustomTerminalPath, terminalId), + removeCustomTerminalPath: (terminalId: string): Promise => + ipcRenderer.invoke(IpcChannel.CodeTools_RemoveCustomTerminalPath, terminalId) }, ocr: { ocr: (file: SupportedOcrFile, provider: OcrProvider): Promise => diff --git a/src/renderer/src/hooks/useCodeTools.ts b/src/renderer/src/hooks/useCodeTools.ts index db8b21bbeb..4d1527ed98 100644 --- a/src/renderer/src/hooks/useCodeTools.ts +++ b/src/renderer/src/hooks/useCodeTools.ts @@ -8,7 +8,8 @@ import { setCurrentDirectory, setEnvironmentVariables, setSelectedCliTool, - setSelectedModel + setSelectedModel, + setSelectedTerminal } from '@renderer/store/codeTools' import { Model } from '@renderer/types' import { codeTools } from '@shared/config/constant' @@ -35,6 +36,14 @@ export const useCodeTools = () => { [dispatch] ) + // 设置选择的终端 + const setTerminal = useCallback( + (terminal: string) => { + dispatch(setSelectedTerminal(terminal)) + }, + [dispatch] + ) + // 设置环境变量 const setEnvVars = useCallback( (envVars: string) => { @@ -105,6 +114,7 @@ export const useCodeTools = () => { // 状态 selectedCliTool: codeToolsState.selectedCliTool, selectedModel: selectedModel, + selectedTerminal: codeToolsState.selectedTerminal, environmentVariables: environmentVariables, directories: codeToolsState.directories, currentDirectory: codeToolsState.currentDirectory, @@ -113,6 +123,7 @@ export const useCodeTools = () => { // 操作函数 setCliTool, setModel, + setTerminal, setEnvVars, addDir, removeDir, diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 768525d9b2..27a3c655df 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -672,6 +672,10 @@ "bun_required_message": "Bun environment is required to run CLI tools", "cli_tool": "CLI Tool", "cli_tool_placeholder": "Select the CLI tool to use", + "custom_path": "Custom path", + "custom_path_error": "Failed to set custom terminal path", + "custom_path_required": "Custom path required for this terminal", + "custom_path_set": "Custom terminal path set successfully", "description": "Quickly launch multiple code CLI tools to improve development efficiency", "env_vars_help": "Enter custom environment variables (one per line, format: KEY=value)", "environment_variables": "Environment Variables", @@ -690,7 +694,10 @@ "model_placeholder": "Select the model to use", "model_required": "Please select a model", "select_folder": "Select Folder", + "set_custom_path": "Set custom terminal path", "supported_providers": "Supported Providers", + "terminal": "Terminal", + "terminal_placeholder": "Select terminal application", "title": "Code Tools", "update_options": "Update Options", "working_directory": "Working Directory" diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 551338e9d5..f0c674c3bd 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -672,6 +672,10 @@ "bun_required_message": "运行 CLI 工具需要安装 Bun 环境", "cli_tool": "CLI 工具", "cli_tool_placeholder": "选择要使用的 CLI 工具", + "custom_path": "自定义路径", + "custom_path_error": "设置自定义终端路径失败", + "custom_path_required": "此终端需要设置自定义路径", + "custom_path_set": "自定义终端路径设置成功", "description": "快速启动多个代码 CLI 工具,提高开发效率", "env_vars_help": "输入自定义环境变量(每行一个,格式:KEY=value)", "environment_variables": "环境变量", @@ -690,7 +694,10 @@ "model_placeholder": "选择要使用的模型", "model_required": "请选择模型", "select_folder": "选择文件夹", + "set_custom_path": "设置自定义终端路径", "supported_providers": "支持的服务商", + "terminal": "终端", + "terminal_placeholder": "选择终端应用", "title": "代码工具", "update_options": "更新选项", "working_directory": "工作目录" diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index fe7bf4b760..ab2fc860d9 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -672,6 +672,10 @@ "bun_required_message": "運行 CLI 工具需要安裝 Bun 環境", "cli_tool": "CLI 工具", "cli_tool_placeholder": "選擇要使用的 CLI 工具", + "custom_path": "自訂路徑", + "custom_path_error": "設定自訂終端機路徑失敗", + "custom_path_required": "此終端機需要設定自訂路徑", + "custom_path_set": "自訂終端機路徑設定成功", "description": "快速啟動多個程式碼 CLI 工具,提高開發效率", "env_vars_help": "輸入自定義環境變數(每行一個,格式:KEY=value)", "environment_variables": "環境變數", @@ -690,7 +694,10 @@ "model_placeholder": "選擇要使用的模型", "model_required": "請選擇模型", "select_folder": "選擇資料夾", + "set_custom_path": "設定自訂終端機路徑", "supported_providers": "支援的供應商", + "terminal": "終端機", + "terminal_placeholder": "選擇終端機應用程式", "title": "程式碼工具", "update_options": "更新選項", "working_directory": "工作目錄" diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 615eda601e..8741347fcf 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -672,6 +672,10 @@ "bun_required_message": "Για τη λειτουργία του εργαλείου CLI πρέπει να εγκαταστήσετε το περιβάλλον Bun", "cli_tool": "Εργαλείο CLI", "cli_tool_placeholder": "Επιλέξτε το CLI εργαλείο που θέλετε να χρησιμοποιήσετε", + "custom_path": "Προσαρμοσμένη διαδρομή", + "custom_path_error": "Η ρύθμιση της προσαρμοσμένης διαδρομής τερματικού απέτυχε", + "custom_path_required": "Αυτό το τερματικό απαιτεί τη ρύθμιση προσαρμοσμένης διαδρομής", + "custom_path_set": "Η προσαρμοσμένη διαδρομή τερματικού ορίστηκε με επιτυχία", "description": "Εκκίνηση γρήγορα πολλών εργαλείων CLI κώδικα, για αύξηση της αποδοτικότητας ανάπτυξης", "env_vars_help": "Εισαγάγετε προσαρμοσμένες μεταβλητές περιβάλλοντος (μία ανά γραμμή, με τη μορφή: KEY=value)", "environment_variables": "Μεταβλητές περιβάλλοντος", @@ -690,7 +694,10 @@ "model_placeholder": "Επιλέξτε το μοντέλο που θα χρησιμοποιήσετε", "model_required": "Επιλέξτε μοντέλο", "select_folder": "Επιλογή φακέλου", + "set_custom_path": "Ρύθμιση προσαρμοσμένης διαδρομής τερματικού", "supported_providers": "υποστηριζόμενοι πάροχοι", + "terminal": "τερματικό", + "terminal_placeholder": "Επιλέξτε εφαρμογή τερματικού", "title": "Εργαλεία κώδικα", "update_options": "Ενημέρωση επιλογών", "working_directory": "κατάλογος εργασίας" diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index a210344a41..7bbb64a11c 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -672,6 +672,10 @@ "bun_required_message": "Se requiere instalar el entorno Bun para ejecutar la herramienta de línea de comandos", "cli_tool": "Herramienta de línea de comandos", "cli_tool_placeholder": "Seleccione la herramienta de línea de comandos que desea utilizar", + "custom_path": "Ruta personalizada", + "custom_path_error": "Error al establecer la ruta de terminal personalizada", + "custom_path_required": "此终端需要设置自定义路径", + "custom_path_set": "Configuración de ruta de terminal personalizada exitosa", "description": "Inicia rápidamente múltiples herramientas de línea de comandos para código, aumentando la eficiencia del desarrollo", "env_vars_help": "Introduzca variables de entorno personalizadas (una por línea, formato: CLAVE=valor)", "environment_variables": "Variables de entorno", @@ -690,7 +694,10 @@ "model_placeholder": "Seleccionar el modelo que se va a utilizar", "model_required": "Seleccione el modelo", "select_folder": "Seleccionar carpeta", + "set_custom_path": "Establecer ruta de terminal personalizada", "supported_providers": "Proveedores de servicios compatibles", + "terminal": "terminal", + "terminal_placeholder": "Seleccionar aplicación de terminal", "title": "Herramientas de código", "update_options": "Opciones de actualización", "working_directory": "directorio de trabajo" diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 3795f5ab01..ab74489903 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -672,6 +672,10 @@ "bun_required_message": "L'exécution de l'outil en ligne de commande nécessite l'installation de l'environnement Bun", "cli_tool": "Outil CLI", "cli_tool_placeholder": "Sélectionnez l'outil CLI à utiliser", + "custom_path": "Chemin personnalisé", + "custom_path_error": "Échec de la définition du chemin de terminal personnalisé", + "custom_path_required": "Ce terminal nécessite la configuration d’un chemin personnalisé", + "custom_path_set": "Paramétrage personnalisé du chemin du terminal réussi", "description": "Lancer rapidement plusieurs outils CLI de code pour améliorer l'efficacité du développement", "env_vars_help": "Saisissez les variables d'environnement personnalisées (une par ligne, format : KEY=value)", "environment_variables": "variables d'environnement", @@ -690,7 +694,10 @@ "model_placeholder": "Sélectionnez le modèle à utiliser", "model_required": "Veuillez sélectionner le modèle", "select_folder": "Sélectionner le dossier", + "set_custom_path": "Définir un chemin de terminal personnalisé", "supported_providers": "fournisseurs pris en charge", + "terminal": "Terminal", + "terminal_placeholder": "Choisir une application de terminal", "title": "Outils de code", "update_options": "Options de mise à jour", "working_directory": "répertoire de travail" diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index f83153d90b..04bac6fbd0 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -672,6 +672,10 @@ "bun_required_message": "CLI ツールを実行するには Bun 環境が必要です", "cli_tool": "CLI ツール", "cli_tool_placeholder": "使用する CLI ツールを選択してください", + "custom_path": "カスタムパス", + "custom_path_error": "カスタムターミナルパスの設定に失敗しました", + "custom_path_required": "この端末にはカスタムパスを設定する必要があります", + "custom_path_set": "カスタムターミナルパスの設定が成功しました", "description": "開発効率を向上させるために、複数のコード CLI ツールを迅速に起動します", "env_vars_help": "環境変数を設定して、CLI ツールの実行時に使用します。各変数は 1 行ごとに設定してください。", "environment_variables": "環境変数", @@ -690,7 +694,10 @@ "model_placeholder": "使用するモデルを選択してください", "model_required": "モデルを選択してください", "select_folder": "フォルダを選択", + "set_custom_path": "カスタムターミナルパスを設定", "supported_providers": "サポートされているプロバイダー", + "terminal": "端末", + "terminal_placeholder": "ターミナルアプリを選択", "title": "コードツール", "update_options": "更新オプション", "working_directory": "作業ディレクトリ" diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 7759fc2706..4f18f55ef7 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -672,6 +672,10 @@ "bun_required_message": "Executar a ferramenta CLI requer a instalação do ambiente Bun", "cli_tool": "Ferramenta de linha de comando", "cli_tool_placeholder": "Selecione a ferramenta de linha de comando a ser utilizada", + "custom_path": "Caminho personalizado", + "custom_path_error": "Falha ao definir caminho de terminal personalizado", + "custom_path_required": "Este terminal requer a definição de um caminho personalizado", + "custom_path_set": "Configuração personalizada do caminho do terminal bem-sucedida", "description": "Inicie rapidamente várias ferramentas de linha de comando de código, aumentando a eficiência do desenvolvimento", "env_vars_help": "Insira variáveis de ambiente personalizadas (uma por linha, formato: CHAVE=valor)", "environment_variables": "variáveis de ambiente", @@ -690,7 +694,10 @@ "model_placeholder": "Selecione o modelo a ser utilizado", "model_required": "Selecione o modelo", "select_folder": "Selecionar pasta", + "set_custom_path": "Definir caminho personalizado do terminal", "supported_providers": "Provedores de serviço suportados", + "terminal": "terminal", + "terminal_placeholder": "Selecionar aplicativo de terminal", "title": "Ferramenta de código", "update_options": "Opções de atualização", "working_directory": "diretório de trabalho" diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index cec3dd0e74..88ce796529 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -672,6 +672,10 @@ "bun_required_message": "Запуск CLI-инструментов требует установки среды Bun", "cli_tool": "Инструмент", "cli_tool_placeholder": "Выберите CLI-инструмент для использования", + "custom_path": "Пользовательский путь", + "custom_path_error": "Не удалось задать пользовательский путь терминала", + "custom_path_required": "Этот терминал требует установки пользовательского пути", + "custom_path_set": "Пользовательский путь терминала успешно установлен", "description": "Быстро запускает несколько CLI-инструментов для кода, повышая эффективность разработки", "env_vars_help": "Установите переменные окружения для использования при запуске CLI-инструментов. Каждая переменная должна быть на отдельной строке в формате KEY=value", "environment_variables": "Переменные окружения", @@ -690,7 +694,10 @@ "model_placeholder": "Выберите модель для использования", "model_required": "Пожалуйста, выберите модель", "select_folder": "Выберите папку", + "set_custom_path": "Настройка пользовательского пути терминала", "supported_providers": "Поддерживаемые поставщики", + "terminal": "терминал", + "terminal_placeholder": "Выбор приложения терминала", "title": "Инструменты кода", "update_options": "Параметры обновления", "working_directory": "Рабочая директория" diff --git a/src/renderer/src/pages/code/CodeToolsPage.tsx b/src/renderer/src/pages/code/CodeToolsPage.tsx index 4dc0f287cf..14f540f5db 100644 --- a/src/renderer/src/pages/code/CodeToolsPage.tsx +++ b/src/renderer/src/pages/code/CodeToolsPage.tsx @@ -1,6 +1,7 @@ import AiProvider from '@renderer/aiCore' import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' import ModelSelector from '@renderer/components/ModelSelector' +import { isMac, isWin } from '@renderer/config/constant' import { isEmbeddingModel, isRerankModel, isTextToImageModel } from '@renderer/config/models' import { getProviderLogo } from '@renderer/config/providers' import { useCodeTools } from '@renderer/hooks/useCodeTools' @@ -13,9 +14,9 @@ import { getModelUniqId } from '@renderer/services/ModelService' import { useAppDispatch, useAppSelector } from '@renderer/store' import { setIsBunInstalled } from '@renderer/store/mcp' import { Model } from '@renderer/types' -import { codeTools } from '@shared/config/constant' -import { Alert, Avatar, Button, Checkbox, Input, Popover, Select, Space } from 'antd' -import { ArrowUpRight, Download, HelpCircle, Terminal, X } from 'lucide-react' +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' import { FC, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { Link } from 'react-router-dom' @@ -42,12 +43,14 @@ const CodeToolsPage: FC = () => { const { selectedCliTool, selectedModel, + selectedTerminal, environmentVariables, directories, currentDirectory, canLaunch, setCliTool, setModel, + setTerminal, setEnvVars, setCurrentDir, removeDir, @@ -58,6 +61,9 @@ const CodeToolsPage: FC = () => { const [isLaunching, setIsLaunching] = useState(false) const [isInstallingBun, setIsInstallingBun] = useState(false) const [autoUpdateToLatest, setAutoUpdateToLatest] = useState(false) + const [availableTerminals, setAvailableTerminals] = useState([]) + const [isLoadingTerminals, setIsLoadingTerminals] = useState(false) + const [terminalCustomPaths, setTerminalCustomPaths] = useState>({}) const modelPredicate = useCallback( (m: Model) => { @@ -119,6 +125,26 @@ const CodeToolsPage: FC = () => { } }, [dispatch]) + // 获取可用终端 + const loadAvailableTerminals = useCallback(async () => { + if (!isMac && !isWin) return // 仅 macOS 和 Windows 支持 + + try { + setIsLoadingTerminals(true) + const terminals = await window.api.codeTools.getAvailableTerminals() + setAvailableTerminals(terminals) + logger.info( + `Found ${terminals.length} available terminals:`, + terminals.map((t) => t.name) + ) + } catch (error) { + logger.error('Failed to load available terminals:', error as Error) + setAvailableTerminals([]) + } finally { + setIsLoadingTerminals(false) + } + }, []) + // 安装 bun const handleInstallBun = async () => { try { @@ -179,11 +205,37 @@ const CodeToolsPage: FC = () => { // 执行启动操作 const executeLaunch = async (env: Record) => { window.api.codeTools.run(selectedCliTool, selectedModel?.id!, currentDirectory, env, { - autoUpdateToLatest + autoUpdateToLatest, + terminal: selectedTerminal }) window.toast.success(t('code.launch.success')) } + // 设置终端自定义路径 + const handleSetCustomPath = async (terminalId: string) => { + try { + const result = await window.api.file.select({ + properties: ['openFile'], + filters: [ + { name: 'Executable', extensions: ['exe'] }, + { name: 'All Files', extensions: ['*'] } + ] + }) + + if (result && result.length > 0) { + const path = result[0].path + await window.api.codeTools.setCustomTerminalPath(terminalId, path) + setTerminalCustomPaths((prev) => ({ ...prev, [terminalId]: path })) + window.toast.success(t('code.custom_path_set')) + // Reload terminals to reflect changes + loadAvailableTerminals() + } + } catch (error) { + logger.error('Failed to set custom terminal path:', error as Error) + window.toast.error(t('code.custom_path_error')) + } + } + // 处理启动 const handleLaunch = async () => { const validation = validateLaunch() @@ -216,6 +268,11 @@ const CodeToolsPage: FC = () => { checkBunInstallation() }, [checkBunInstallation]) + // 页面加载时获取可用终端 + useEffect(() => { + loadAvailableTerminals() + }, [loadAvailableTerminals]) + return ( @@ -350,6 +407,47 @@ const CodeToolsPage: FC = () => {
{t('code.env_vars_help')}
+ {/* 终端选择 (macOS 和 Windows) */} + {(isMac || isWin) && availableTerminals.length > 0 && ( + +
{t('code.terminal')}
+ + - {provider.id === 'new-api' && ( + {isNewApiProvider(provider) && ( = ({ modelGroups, provid // 添加整组 const wouldAddModels = models.filter((model) => !isModelInProvider(provider, model.id)) - if (provider.id === 'new-api') { + if (isNewApiProvider(provider)) { if (wouldAddModels.every(isValidNewApiModel)) { wouldAddModels.forEach(onAddModel) } else { diff --git a/src/renderer/src/pages/settings/ProviderSettings/ModelList/ManageModelsPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/ModelList/ManageModelsPopup.tsx index 036894dbee..706cd8bfce 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ModelList/ManageModelsPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ModelList/ManageModelsPopup.tsx @@ -13,6 +13,7 @@ import { isWebSearchModel, SYSTEM_MODELS } from '@renderer/config/models' +import { isNewApiProvider } from '@renderer/config/providers' import { useProvider } from '@renderer/hooks/useProvider' import NewApiAddModelPopup from '@renderer/pages/settings/ProviderSettings/ModelList/NewApiAddModelPopup' import NewApiBatchAddModelPopup from '@renderer/pages/settings/ProviderSettings/ModelList/NewApiBatchAddModelPopup' @@ -129,7 +130,7 @@ const PopupContainer: React.FC = ({ providerId, resolve }) => { const onAddModel = useCallback( (model: Model) => { if (!isEmpty(model.name)) { - if (provider.id === 'new-api') { + if (isNewApiProvider(provider)) { if (model.supported_endpoint_types && model.supported_endpoint_types.length > 0) { addModel({ ...model, @@ -160,7 +161,7 @@ const PopupContainer: React.FC = ({ providerId, resolve }) => { content: t('settings.models.manage.add_listed.confirm'), centered: true, onOk: () => { - if (provider.id === 'new-api') { + if (isNewApiProvider(provider)) { if (models.every(isValidNewApiModel)) { wouldAddModel.forEach(onAddModel) } else { diff --git a/src/renderer/src/pages/settings/ProviderSettings/ModelList/ModelList.tsx b/src/renderer/src/pages/settings/ProviderSettings/ModelList/ModelList.tsx index 58468f09bb..2d2af3788a 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ModelList/ModelList.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ModelList/ModelList.tsx @@ -2,7 +2,7 @@ import CollapsibleSearchBar from '@renderer/components/CollapsibleSearchBar' import { LoadingIcon, StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons' import { HStack } from '@renderer/components/Layout' import CustomTag from '@renderer/components/Tags/CustomTag' -import { PROVIDER_URLS } from '@renderer/config/providers' +import { isNewApiProvider, PROVIDER_URLS } from '@renderer/config/providers' import { useProvider } from '@renderer/hooks/useProvider' import { getProviderLabel } from '@renderer/i18n/label' import { SettingHelpLink, SettingHelpText, SettingHelpTextRow, SettingSubtitle } from '@renderer/pages/settings' @@ -86,7 +86,7 @@ const ModelList: React.FC = ({ providerId }) => { }, [provider.id]) const onAddModel = useCallback(() => { - if (provider.id === 'new-api') { + if (isNewApiProvider(provider)) { NewApiAddModelPopup.show({ title: t('settings.models.add.add_model'), provider }) } else { AddModelPopup.show({ title: t('settings.models.add.add_model'), provider }) diff --git a/src/renderer/src/pages/settings/ProviderSettings/ModelList/NewApiAddModelPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/ModelList/NewApiAddModelPopup.tsx index 4e81b39aa2..8812a5926d 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ModelList/NewApiAddModelPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ModelList/NewApiAddModelPopup.tsx @@ -1,6 +1,7 @@ import { TopView } from '@renderer/components/TopView' import { endpointTypeOptions } from '@renderer/config/endpointTypes' import { isNotSupportedTextDelta } from '@renderer/config/models' +import { isNewApiProvider } from '@renderer/config/providers' import { useDynamicLabelWidth } from '@renderer/hooks/useDynamicLabelWidth' import { useProvider } from '@renderer/hooks/useProvider' import { EndpointType, Model, Provider } from '@renderer/types' @@ -60,7 +61,7 @@ const PopupContainer: React.FC = ({ title, provider, resolve, model, endp provider: provider.id, name: values.name ? values.name : id.toUpperCase(), group: values.group ?? getDefaultGroupName(id), - endpoint_type: provider.id === 'new-api' ? values.endpointType : undefined + endpoint_type: isNewApiProvider(provider) ? values.endpointType : undefined } addModel({ ...model, supported_text_delta: !isNotSupportedTextDelta(model) }) diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 8063f7ec92..e26a382fc9 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -2495,6 +2495,7 @@ const migrateConfig = { '157': (state: RootState) => { try { addProvider(state, 'aionly') + state.llm.providers = moveProvider(state.llm.providers, 'aionly', 10) const cherryinProvider = state.llm.providers.find((provider) => provider.id === 'cherryin') From bf2ffb74650527bb8113b6c58051b69f2e54ef4f Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Wed, 24 Sep 2025 17:24:11 +0800 Subject: [PATCH 66/99] chore: bump version to v1.6.0-rc.5 and update release notes --- electron-builder.yml | 19 ++++++++---------- package.json | 2 +- src/renderer/src/pages/home/Navbar.tsx | 4 ++-- .../src/pages/paintings/ZhipuPage.tsx | 20 +++++++------------ 4 files changed, 18 insertions(+), 27 deletions(-) diff --git a/electron-builder.yml b/electron-builder.yml index 0fe2f2d3b9..b08ecb5563 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -125,16 +125,13 @@ afterSign: scripts/notarize.js artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | - ✨ 新功能: - - 新增 CherryIN 服务商 - - 新增 AiOnly AI 服务商 - - 更新 MCP 服务器卡片布局和样式,改为列表视图 + 🎨 界面优化: + - 优化了多个组件的布局和间距,提升视觉体验 + - 改进了导航栏和标签栏的样式显示 + - MCP 服务器卡片宽度调整为 100%,提高响应式布局效果 + - 优化了笔记侧边栏的滚动行为 🐛 问题修复: - - 修复 QwenMT 模型的翻译内容处理逻辑 - - 修复无法将外部笔记添加到知识库的问题 - - 🚀 性能优化: - - 提升输入框响应速度 - - 优化模型切换性能 - - 改进翻译功能的引用和邮件格式处理 + - 修复了小应用打开功能无法正常工作的问题 + - 修复了助手更新时 ID 丢失导致更新失败的问题 + - 确保助手更新时 ID 字段为必填项,防止数据错误 diff --git a/package.json b/package.json index 7f2e00d4df..b33ebc8940 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.6.0-rc.4", + "version": "1.6.0-rc.5", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", diff --git a/src/renderer/src/pages/home/Navbar.tsx b/src/renderer/src/pages/home/Navbar.tsx index 3649537d68..43217d7ca3 100644 --- a/src/renderer/src/pages/home/Navbar.tsx +++ b/src/renderer/src/pages/home/Navbar.tsx @@ -1,7 +1,7 @@ import { Navbar, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar' import { HStack } from '@renderer/components/Layout' import SearchPopup from '@renderer/components/Popups/SearchPopup' -import { isLinux, isWin } from '@renderer/config/constant' +import { isLinux, isMac, isWin } from '@renderer/config/constant' import { useAssistant } from '@renderer/hooks/useAssistant' import { modelGenerating } from '@renderer/hooks/useRuntime' import { useSettings } from '@renderer/hooks/useSettings' @@ -113,7 +113,7 @@ const HeaderNavbar: FC = ({ activeAssistant, setActiveAssistant, activeTo )} - + = ({ Options }) => { } } - const createNewPainting = () => { + const handleAddPainting = () => { if (generating) return const newPainting = getNewPainting() const addedPainting = addPainting('zhipu_paintings', newPainting) @@ -342,12 +342,12 @@ const ZhipuPage: FC<{ Options: string[] }> = ({ Options }) => { return ( - - {t('title.paintings')} - + {t('paintings.title')} {isMac && ( - - )} @@ -482,7 +482,7 @@ const ZhipuPage: FC<{ Options: string[] }> = ({ Options }) => { selectedPainting={painting} onSelectPainting={onSelectPainting} onDeletePainting={onDeletePainting} - onNewPainting={createNewPainting} + onNewPainting={handleAddPainting} /> @@ -556,12 +556,6 @@ const ToolbarMenu = styled.div` gap: 8px; ` -const Title = styled.h1` - margin: 0; - font-size: 18px; - font-weight: 600; -` - const ProviderTitleContainer = styled.div` display: flex; justify-content: space-between; From 09e9b95e08130672248b50fa848c10747a66539c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?George=C2=B7Dong?= <98630204+GeorgeDong32@users.noreply.github.com> Date: Wed, 24 Sep 2025 18:38:13 +0800 Subject: [PATCH 67/99] fix(reasoning): thinking control for ds v3.1 of tencent platform (#10333) * feat(reasoning): add Hunyuan and Tencent TI thinking config * fix: style * fix(reasoning): merge same type providers --- src/renderer/src/aiCore/utils/reasoning.ts | 2 ++ src/renderer/src/config/models/reasoning.ts | 12 +++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/aiCore/utils/reasoning.ts b/src/renderer/src/aiCore/utils/reasoning.ts index bee07b1e0d..18f303c0a3 100644 --- a/src/renderer/src/aiCore/utils/reasoning.ts +++ b/src/renderer/src/aiCore/utils/reasoning.ts @@ -112,6 +112,8 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin return { enable_thinking: true } + case SystemProviderIds.hunyuan: + case SystemProviderIds['tencent-cloud-ti']: case SystemProviderIds.doubao: return { thinking: { diff --git a/src/renderer/src/config/models/reasoning.ts b/src/renderer/src/config/models/reasoning.ts index 74bddd2897..25f13c86e4 100644 --- a/src/renderer/src/config/models/reasoning.ts +++ b/src/renderer/src/config/models/reasoning.ts @@ -93,7 +93,17 @@ export function isSupportedThinkingTokenModel(model?: Model): boolean { // Specifically for DeepSeek V3.1. White list for now if (isDeepSeekHybridInferenceModel(model)) { return ( - ['openrouter', 'dashscope', 'modelscope', 'doubao', 'silicon', 'nvidia', 'ppio'] satisfies SystemProviderId[] + [ + 'openrouter', + 'dashscope', + 'modelscope', + 'doubao', + 'silicon', + 'nvidia', + 'ppio', + 'hunyuan', + 'tencent-cloud-ti' + ] satisfies SystemProviderId[] ).some((id) => id === model.provider) } From 2bafc53b25063eade007f93df2d76cb6f19a3b5b Mon Sep 17 00:00:00 2001 From: "Johnny.H" Date: Wed, 24 Sep 2025 23:27:07 +0800 Subject: [PATCH 68/99] 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 69/99] 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 70/99] 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 71/99] 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 72/99] 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 73/99] 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 74/99] 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 75/99] =?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 76/99] 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 77/99] 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 78/99] 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 79/99] 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 80/99] 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 81/99] 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 82/99] 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 83/99] 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 84/99] 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 85/99] 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 86/99] 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 87/99] =?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 88/99] 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 89/99] 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 90/99] 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 91/99] 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 92/99] 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 93/99] 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 94/99] 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 95/99] 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 96/99] 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 97/99] 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 98/99] 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 99/99] 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