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 1/4] 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 2/4] 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 3/4] 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} + /> +