diff --git a/package.json b/package.json index d338da4e..7334e9b0 100644 --- a/package.json +++ b/package.json @@ -118,6 +118,7 @@ "@types/diff": "^7", "@types/fs-extra": "^11", "@types/lodash": "^4.17.5", + "@types/lru-cache": "^7.10.10", "@types/markdown-it": "^14", "@types/md5": "^2.3.5", "@types/node": "^18.19.9", @@ -179,7 +180,7 @@ "remark-math": "^6.0.0", "rollup-plugin-visualizer": "^5.12.0", "sass": "^1.77.2", - "shiki": "^1.22.2", + "shiki": "^3.2.1", "string-width": "^7.2.0", "styled-components": "^6.1.11", "tinycolor2": "^1.6.0", diff --git a/src/renderer/src/context/SyntaxHighlighterProvider.tsx b/src/renderer/src/context/SyntaxHighlighterProvider.tsx index 3933b786..9e94665d 100644 --- a/src/renderer/src/context/SyntaxHighlighterProvider.tsx +++ b/src/renderer/src/context/SyntaxHighlighterProvider.tsx @@ -1,21 +1,33 @@ import { useTheme } from '@renderer/context/ThemeProvider' import { useMermaid } from '@renderer/hooks/useMermaid' import { useSettings } from '@renderer/hooks/useSettings' +import { CodeCacheService } from '@renderer/services/CodeCacheService' import { type CodeStyleVarious, ThemeMode } from '@renderer/types' import type React from 'react' -import { createContext, type PropsWithChildren, use, useCallback, useEffect, useMemo, useState } from 'react' -import type { BundledLanguage, BundledTheme, HighlighterGeneric } from 'shiki' -import { bundledLanguages, bundledThemes, createHighlighter } from 'shiki' +import { createContext, type PropsWithChildren, use, useCallback, useMemo } from 'react' +import { bundledLanguages, bundledThemes, createHighlighter, type Highlighter } from 'shiki' + +let highlighterPromise: Promise | null = null + +async function getHighlighter() { + if (!highlighterPromise) { + highlighterPromise = createHighlighter({ + langs: ['javascript', 'typescript', 'python', 'java', 'markdown'], + themes: ['one-light', 'material-theme-darker'] + }) + } + + return await highlighterPromise +} interface SyntaxHighlighterContextType { - codeToHtml: (code: string, language: string) => Promise + codeToHtml: (code: string, language: string, enableCache: boolean) => Promise } const SyntaxHighlighterContext = createContext(undefined) export const SyntaxHighlighterProvider: React.FC = ({ children }) => { const { theme } = useTheme() - const [highlighter, setHighlighter] = useState | null>(null) const { codeStyle } = useSettings() useMermaid() @@ -27,29 +39,14 @@ export const SyntaxHighlighterProvider: React.FC = ({ childre return codeStyle }, [theme, codeStyle]) - useEffect(() => { - const initHighlighter = async () => { - const commonLanguages = ['javascript', 'typescript', 'python', 'java', 'markdown'] - - const hl = await createHighlighter({ - themes: [highlighterTheme], - langs: commonLanguages - }) - - setHighlighter(hl) - - // Load all themes and languages - // hl.loadTheme(...(Object.keys(bundledThemes) as BundledTheme[])) - // hl.loadLanguage(...(Object.keys(bundledLanguages) as BundledLanguage[])) - } - - initHighlighter() - }, [highlighterTheme]) - const codeToHtml = useCallback( - async (_code: string, language: string) => { + async (_code: string, language: string, enableCache: boolean) => { { - if (!highlighter) return '' + if (!_code) return '' + + const key = CodeCacheService.generateCacheKey(_code, language, highlighterTheme) + const cached = enableCache ? CodeCacheService.getCachedResult(key) : null + if (cached) return cached const languageMap: Record = { vab: 'vb' @@ -61,25 +58,41 @@ export const SyntaxHighlighterProvider: React.FC = ({ childre const escapedCode = code?.replace(/[<>]/g, (char) => ({ '<': '<', '>': '>' })[char]!) try { - if (!highlighter.getLoadedLanguages().includes(mappedLanguage as BundledLanguage)) { - if (mappedLanguage in bundledLanguages || mappedLanguage === 'text') { - await highlighter.loadLanguage(mappedLanguage as BundledLanguage) - } else { - return `
${escapedCode}
` + const highlighter = await getHighlighter() + + if (!highlighter.getLoadedThemes().includes(highlighterTheme)) { + const themeImportFn = bundledThemes[highlighterTheme] + if (themeImportFn) { + await highlighter.loadTheme(await themeImportFn()) } } - return highlighter.codeToHtml(code, { + if (!highlighter.getLoadedLanguages().includes(mappedLanguage)) { + const languageImportFn = bundledLanguages[mappedLanguage] + if (languageImportFn) { + await highlighter.loadLanguage(await languageImportFn()) + } + } + + // 生成高亮HTML + const html = highlighter.codeToHtml(code, { lang: mappedLanguage, theme: highlighterTheme }) + + // 设置缓存 + if (enableCache) { + CodeCacheService.setCachedResult(key, html, _code.length) + } + + return html } catch (error) { - console.warn(`Error highlighting code for language '${mappedLanguage}':`, error) + console.debug(`Error highlighting code for language '${mappedLanguage}':`, error) return `
${escapedCode}
` } } }, - [highlighter, highlighterTheme] + [highlighterTheme] ) return {children} diff --git a/src/renderer/src/hooks/useMermaid.ts b/src/renderer/src/hooks/useMermaid.ts index 5d6f0b1d..c8efbedf 100644 --- a/src/renderer/src/hooks/useMermaid.ts +++ b/src/renderer/src/hooks/useMermaid.ts @@ -40,7 +40,6 @@ export const useMermaid = () => { useEffect(() => { const handleWheel = (e: WheelEvent) => { if (e.ctrlKey || e.metaKey) { - e.preventDefault() const mermaidElement = (e.target as HTMLElement).closest('.mermaid') if (!mermaidElement) return @@ -61,7 +60,7 @@ export const useMermaid = () => { } } - document.addEventListener('wheel', handleWheel, { passive: false }) + document.addEventListener('wheel', handleWheel, { passive: true }) return () => document.removeEventListener('wheel', handleWheel) }, []) } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 22bfa6fc..9493e039 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -159,6 +159,14 @@ "save": "Save", "settings.code_collapsible": "Code block collapsible", "settings.code_wrappable": "Code block wrappable", + "settings.code_cacheable": "Code block cache", + "settings.code_cacheable.tip": "Caching code blocks can reduce the rendering time of long code blocks, but it will increase memory usage", + "settings.code_cache_max_size": "Max cache size", + "settings.code_cache_max_size.tip": "The maximum number of characters allowed to be cached (thousand characters), calculated according to the highlighted code. The length of the highlighted code is much longer than the pure text.", + "settings.code_cache_ttl": "Cache TTL", + "settings.code_cache_ttl.tip": "Cache expiration time (minutes)", + "settings.code_cache_threshold": "Cache threshold", + "settings.code_cache_threshold.tip": "The minimum number of characters allowed to be cached (thousand characters), calculated according to the actual code. Only code blocks exceeding the threshold will be cached.", "settings.context_count": "Context", "settings.context_count.tip": "The number of previous messages to keep in the context.", "settings.max": "Max", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 6699e86e..141a8fb6 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -159,6 +159,14 @@ "save": "保存", "settings.code_collapsible": "コードブロック折り畳み", "settings.code_wrappable": "コードブロック折り返し", + "settings.code_cacheable": "コードブロックキャッシュ", + "settings.code_cacheable.tip": "コードブロックのキャッシュは長いコードブロックのレンダリング時間を短縮できますが、メモリ使用量が増加します", + "settings.code_cache_max_size": "キャッシュ上限", + "settings.code_cache_max_size.tip": "キャッシュできる文字数の上限(千字符)。ハイライトされたコードの長さは純粋なテキストよりもはるかに長くなります。", + "settings.code_cache_ttl": "キャッシュ期限", + "settings.code_cache_ttl.tip": "キャッシュの有効期限(分単位)。", + "settings.code_cache_threshold": "キャッシュ閾値", + "settings.code_cache_threshold.tip": "キャッシュできる最小のコード長(千字符)。キャッシュできる最小のコード長を超えたコードブロックのみがキャッシュされます。", "settings.context_count": "コンテキスト", "settings.context_count.tip": "コンテキストに保持する以前のメッセージの数", "settings.max": "最大", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index af1a92c5..b142ba26 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -159,6 +159,14 @@ "save": "Сохранить", "settings.code_collapsible": "Блок кода свернут", "settings.code_wrappable": "Блок кода можно переносить", + "settings.code_cacheable": "Кэш блока кода", + "settings.code_cacheable.tip": "Кэширование блока кода может уменьшить время рендеринга длинных блоков кода, но увеличит использование памяти", + "settings.code_cache_max_size": "Максимальный размер кэша", + "settings.code_cache_max_size.tip": "Максимальное количество символов, которое может быть кэшировано (тысяч символов), рассчитывается по кэшированному коду. Длина кэшированного кода значительно превышает длину чистого текста.", + "settings.code_cache_ttl": "Время жизни кэша", + "settings.code_cache_ttl.tip": "Время жизни кэша (минуты)", + "settings.code_cache_threshold": "Пороговое значение кэша", + "settings.code_cache_threshold.tip": "Минимальное количество символов для кэширования (тысяч символов), рассчитывается по фактическому коду. Будут кэшированы только те блоки кода, которые превышают пороговое значение", "settings.context_count": "Контекст", "settings.context_count.tip": "Количество предыдущих сообщений, которые нужно сохранить в контексте.", "settings.max": "Максимум", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index f4cef82c..098e22de 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -161,6 +161,14 @@ "save": "保存", "settings.code_collapsible": "代码块可折叠", "settings.code_wrappable": "代码块可换行", + "settings.code_cacheable": "代码块缓存", + "settings.code_cacheable.tip": "缓存代码块可以减少长代码块的渲染时间,但会增加内存占用", + "settings.code_cache_max_size": "缓存上限", + "settings.code_cache_max_size.tip": "允许缓存的字符数上限(千字符),按照高亮后的代码计算。高亮后的代码长度相比于纯文本会长很多。", + "settings.code_cache_ttl": "缓存期限", + "settings.code_cache_ttl.tip": "缓存过期时间(分钟)", + "settings.code_cache_threshold": "缓存阈值", + "settings.code_cache_threshold.tip": "允许缓存的最小代码长度(千字符),超过阈值的代码块才会被缓存", "settings.context_count": "上下文数", "settings.context_count.tip": "要保留在上下文中的消息数量,数值越大,上下文越长,消耗的 token 越多。普通聊天建议 5-10", "settings.max": "不限", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 5fb31f4c..ea0ac90a 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -159,6 +159,14 @@ "save": "儲存", "settings.code_collapsible": "程式碼區塊可折疊", "settings.code_wrappable": "程式碼區塊可自動換行", + "settings.code_cacheable": "程式碼區塊快取", + "settings.code_cacheable.tip": "快取程式碼區塊可以減少長程式碼區塊的渲染時間,但會增加記憶體使用量", + "settings.code_cache_max_size": "快取上限", + "settings.code_cache_max_size.tip": "允許快取的字元數上限(千字符),按照高亮後的程式碼計算。高亮後的程式碼長度相比純文字會長很多。", + "settings.code_cache_ttl": "快取期限", + "settings.code_cache_ttl.tip": "快取的存活時間(分鐘)", + "settings.code_cache_threshold": "快取門檻", + "settings.code_cache_threshold.tip": "允許快取的最小程式碼長度(千字符),超過門檻的程式碼區塊才會被快取", "settings.context_count": "上下文", "settings.context_count.tip": "在上下文中保留的前幾則訊息。", "settings.max": "最大", diff --git a/src/renderer/src/pages/home/Markdown/CodeBlock.tsx b/src/renderer/src/pages/home/Markdown/CodeBlock.tsx index f27975cf..a494bc1c 100644 --- a/src/renderer/src/pages/home/Markdown/CodeBlock.tsx +++ b/src/renderer/src/pages/home/Markdown/CodeBlock.tsx @@ -7,7 +7,7 @@ import { useSyntaxHighlighter } from '@renderer/context/SyntaxHighlighterProvide import { useSettings } from '@renderer/hooks/useSettings' import { Tooltip } from 'antd' import dayjs from 'dayjs' -import React, { memo, useEffect, useRef, useState } from 'react' +import React, { memo, useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -32,6 +32,8 @@ const CodeBlock: React.FC = ({ children, className }) => { const [isUnwrapped, setIsUnwrapped] = useState(!codeWrappable) const [shouldShowExpandButton, setShouldShowExpandButton] = useState(false) const codeContentRef = useRef(null) + const childrenLengthRef = useRef(0) + const isStreamingRef = useRef(false) const showFooterCopyButton = children && children.length > 500 && !codeCollapsible @@ -39,39 +41,69 @@ const CodeBlock: React.FC = ({ children, className }) => { const shouldShowExpandButtonRef = useRef(false) - useEffect(() => { - const loadHighlightedCode = async () => { - const highlightedHtml = await codeToHtml(children, language) - if (codeContentRef.current) { - codeContentRef.current.innerHTML = highlightedHtml - const isShowExpandButton = codeContentRef.current.scrollHeight > 350 - if (shouldShowExpandButtonRef.current === isShowExpandButton) return - shouldShowExpandButtonRef.current = isShowExpandButton - setShouldShowExpandButton(shouldShowExpandButtonRef.current) - } - } - loadHighlightedCode() - }, [children, language, codeToHtml]) + const shouldHighlight = useCallback((lang: string) => { + const NON_HIGHLIGHT_LANGS = ['mermaid', 'plantuml', 'svg'] + return !NON_HIGHLIGHT_LANGS.includes(lang) + }, []) + + const highlightCode = useCallback(async () => { + if (!codeContentRef.current) return + const codeElement = codeContentRef.current + + // 只在非流式输出状态才尝试启用cache + const highlightedHtml = await codeToHtml(children, language, !isStreamingRef.current) + + codeElement.innerHTML = highlightedHtml + codeElement.style.opacity = '1' + + const isShowExpandButton = codeElement.scrollHeight > 350 + if (shouldShowExpandButtonRef.current === isShowExpandButton) return + shouldShowExpandButtonRef.current = isShowExpandButton + setShouldShowExpandButton(shouldShowExpandButtonRef.current) + }, [language, codeToHtml, children]) useEffect(() => { - if (!codeCollapsible) { - setIsExpanded(true) - setShouldShowExpandButton(false) + // 跳过非文本代码块 + if (!codeContentRef.current || !shouldHighlight(language)) return + + let isMounted = true + const codeElement = codeContentRef.current + + if (childrenLengthRef.current > 0 && childrenLengthRef.current !== children?.length) { + isStreamingRef.current = true } else { - setIsExpanded(!codeCollapsible) - if (codeContentRef.current) { - setShouldShowExpandButton(codeContentRef.current.scrollHeight > 350) - } + isStreamingRef.current = false + codeElement.style.opacity = '0.1' } + + if (childrenLengthRef.current === 0) { + // 挂载时显示原始代码 + codeElement.textContent = children + } + + const observer = new IntersectionObserver(async (entries) => { + if (entries[0].isIntersecting && isMounted) { + setTimeout(highlightCode, 0) + observer.disconnect() + } + }) + + observer.observe(codeElement) + + return () => { + childrenLengthRef.current = children?.length + isMounted = false + observer.disconnect() + } + }, [children, highlightCode, language, shouldHighlight]) + + useEffect(() => { + setIsExpanded(!codeCollapsible) + setShouldShowExpandButton(codeCollapsible && (codeContentRef.current?.scrollHeight ?? 0) > 350) }, [codeCollapsible]) useEffect(() => { - if (!codeWrappable) { - // 如果未启动代码块换行功能 - setIsUnwrapped(true) - } else { - setIsUnwrapped(!codeWrappable) // 被换行 - } + setIsUnwrapped(!codeWrappable) }, [codeWrappable]) if (language === 'mermaid') { @@ -227,6 +259,7 @@ const CodeBlockWrapper = styled.div` ` const CodeContent = styled.div<{ isShowLineNumbers: boolean; isUnwrapped: boolean; isCodeWrappable: boolean }>` + transition: opacity 0.3s ease; .shiki { padding: 1em; diff --git a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx index 9a45d495..f7f74fdd 100644 --- a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx @@ -19,6 +19,10 @@ import { useAppDispatch } from '@renderer/store' import { SendMessageShortcut, setAutoTranslateWithSpace, + setCodeCacheable, + setCodeCacheMaxSize, + setCodeCacheThreshold, + setCodeCacheTTL, setCodeCollapsible, setCodeShowLineNumbers, setCodeStyle, @@ -75,6 +79,10 @@ const SettingsTab: FC = (props) => { codeShowLineNumbers, codeCollapsible, codeWrappable, + codeCacheable, + codeCacheMaxSize, + codeCacheTTL, + codeCacheThreshold, mathEngine, autoTranslateWithSpace, pasteLongTextThreshold, @@ -331,6 +339,74 @@ const SettingsTab: FC = (props) => { dispatch(setCodeWrappable(checked))} /> + + + {t('chat.settings.code_cacheable')}{' '} + + + + + dispatch(setCodeCacheable(checked))} /> + + {codeCacheable && ( + <> + + + + {t('chat.settings.code_cache_max_size')} + + + + + dispatch(setCodeCacheMaxSize(value ?? 1000))} + style={{ width: 80 }} + /> + + + + + {t('chat.settings.code_cache_ttl')} + + + + + dispatch(setCodeCacheTTL(value ?? 15))} + style={{ width: 80 }} + /> + + + + + {t('chat.settings.code_cache_threshold')} + + + + + dispatch(setCodeCacheThreshold(value ?? 2))} + style={{ width: 80 }} + /> + + + )} + {t('chat.settings.thought_auto_collapse')} diff --git a/src/renderer/src/services/CodeCacheService.ts b/src/renderer/src/services/CodeCacheService.ts new file mode 100644 index 00000000..98e011cc --- /dev/null +++ b/src/renderer/src/services/CodeCacheService.ts @@ -0,0 +1,218 @@ +import store from '@renderer/store' +import { LRUCache } from 'lru-cache' + +/** + * FNV-1a哈希函数,用于计算字符串哈希值 + * @param input 输入字符串 + * @param maxInputLength 最大计算长度,默认50000字符 + * @returns 哈希值的36进制字符串表示 + */ +const fastHash = (input: string, maxInputLength: number = 50000) => { + let hash = 2166136261 // FNV偏移基数 + const count = Math.min(input.length, maxInputLength) + for (let i = 0; i < count; i++) { + hash ^= input.charCodeAt(i) + hash *= 16777619 // FNV素数 + hash >>>= 0 // 保持为32位无符号整数 + } + return hash.toString(36) +} + +/** + * 增强的哈希函数,对长内容使用三段采样计算哈希 + * @param input 输入字符串 + * @returns 哈希值或组合哈希值 + */ +const enhancedHash = (input: string) => { + const THRESHOLD = 50000 + + if (input.length <= THRESHOLD) { + return fastHash(input) + } + + const mid = Math.floor(input.length / 2) + + // 三段hash保证唯一性 + const frontSection = input.slice(0, 10000) + const midSection = input.slice(mid - 15000, mid + 15000) + const endSection = input.slice(-10000) + + return `${fastHash(frontSection)}-${fastHash(midSection)}-${fastHash(endSection)}` +} + +// 高亮结果缓存实例 +let highlightCache: LRUCache | null = null + +/** + * 检查缓存设置是否发生变化 + */ +const haveSettingsChanged = (prev: any, current: any) => { + if (!prev || !current) return true + + return ( + prev.codeCacheable !== current.codeCacheable || + prev.codeCacheMaxSize !== current.codeCacheMaxSize || + prev.codeCacheTTL !== current.codeCacheTTL || + prev.codeCacheThreshold !== current.codeCacheThreshold + ) +} + +/** + * 代码缓存服务 + * 提供代码高亮结果的缓存管理和哈希计算功能 + */ +export const CodeCacheService = { + /** + * 缓存上次使用的配置 + */ + _lastConfig: { + codeCacheable: false, + codeCacheMaxSize: 0, + codeCacheTTL: 0, + codeCacheThreshold: 0 + }, + + /** + * 获取当前缓存配置 + * @returns 当前配置对象 + */ + getConfig() { + try { + if (!store || !store.getState) return this._lastConfig + + const { codeCacheable, codeCacheMaxSize, codeCacheTTL, codeCacheThreshold } = store.getState().settings + + return { codeCacheable, codeCacheMaxSize, codeCacheTTL, codeCacheThreshold } + } catch (error) { + console.warn('[CodeCacheService] Failed to get config', error) + return this._lastConfig + } + }, + + /** + * 检查并确保缓存配置是最新的 + * 每次缓存操作前调用 + * @returns 当前缓存实例或null + */ + ensureCache() { + const currentConfig = this.getConfig() + + // 检查配置是否变化 + if (haveSettingsChanged(this._lastConfig, currentConfig)) { + this._lastConfig = currentConfig + this._updateCacheInstance(currentConfig) + } + + return highlightCache + }, + + /** + * 更新缓存实例 + * @param config 缓存配置 + */ + _updateCacheInstance(config: any) { + try { + const { codeCacheable, codeCacheMaxSize, codeCacheTTL } = config + const newMaxSize = codeCacheMaxSize * 1000 + const newTTLMilliseconds = codeCacheTTL * 60 * 1000 + + // 根据配置决定是否创建或清除缓存 + if (codeCacheable) { + if (!highlightCache) { + // 缓存不存在,创建新缓存 + highlightCache = new LRUCache({ + max: 200, // 最大缓存条目数 + maxSize: newMaxSize, // 最大缓存大小 + sizeCalculation: (value) => value.length, // 缓存大小计算 + ttl: newTTLMilliseconds // 缓存过期时间(毫秒) + }) + return + } + + // 尝试从当前缓存获取配置信息 + const maxSize = highlightCache.max || 0 + const ttl = highlightCache.ttl || 0 + + // 检查实际配置是否变化 + if (maxSize !== newMaxSize || ttl !== newTTLMilliseconds) { + console.log('[CodeCacheService] Cache config changed, recreating cache') + highlightCache.clear() + highlightCache = new LRUCache({ + max: 500, + maxSize: newMaxSize, + sizeCalculation: (value) => value.length, + ttl: newTTLMilliseconds + }) + } + } else if (highlightCache) { + // 缓存被禁用,清理资源 + highlightCache.clear() + highlightCache = null + } + } catch (error) { + console.warn('[CodeCacheService] Failed to update cache config', error) + } + }, + + /** + * 生成缓存键 + * @param code 代码内容 + * @param language 代码语言 + * @param theme 高亮主题 + * @returns 缓存键 + */ + generateCacheKey: (code: string, language: string, theme: string) => { + return `${language}|${theme}|${code.length}|${enhancedHash(code)}` + }, + + /** + * 获取缓存的高亮结果 + * @param key 缓存键 + * @returns 缓存的HTML或null + */ + getCachedResult: (key: string) => { + try { + // 确保缓存配置是最新的 + CodeCacheService.ensureCache() + + if (!store || !store.getState) return null + const { codeCacheable } = store.getState().settings + if (!codeCacheable) return null + + return highlightCache?.get(key) || null + } catch (error) { + console.warn('[CodeCacheService] Failed to get cached result', error) + return null + } + }, + + /** + * 设置缓存结果 + * @param key 缓存键 + * @param html 高亮HTML + * @param codeLength 代码长度 + */ + setCachedResult: (key: string, html: string, codeLength: number) => { + try { + // 确保缓存配置是最新的 + CodeCacheService.ensureCache() + + if (!store || !store.getState) return + const { codeCacheable, codeCacheThreshold } = store.getState().settings + + // 判断是否可以缓存 + if (!codeCacheable || codeLength < codeCacheThreshold * 1000) return + + highlightCache?.set(key, html) + } catch (error) { + console.warn('[CodeCacheService] Failed to set cached result', error) + } + }, + + /** + * 清空缓存 + */ + clear: () => { + highlightCache?.clear() + } +} diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index f87894bb..080a9158 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -42,7 +42,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 89, + version: 91, blacklist: ['runtime', 'messages'], migrate }, diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 7a534378..7823c64d 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1186,6 +1186,17 @@ const migrateConfig = { } catch (error) { return state } + }, + '91': (state: RootState) => { + try { + state.settings.codeCacheable = false + state.settings.codeCacheMaxSize = 1000 + state.settings.codeCacheTTL = 15 + state.settings.codeCacheThreshold = 2 + return state + } catch (error) { + return state + } } } diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 23c21dc8..2f3439f8 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -51,6 +51,11 @@ export interface SettingsState { codeShowLineNumbers: boolean codeCollapsible: boolean codeWrappable: boolean + // 代码块缓存 + codeCacheable: boolean + codeCacheMaxSize: number + codeCacheTTL: number + codeCacheThreshold: number mathEngine: 'MathJax' | 'KaTeX' messageStyle: 'plain' | 'bubble' codeStyle: CodeStyleVarious @@ -139,6 +144,10 @@ const initialState: SettingsState = { codeShowLineNumbers: false, codeCollapsible: false, codeWrappable: false, + codeCacheable: false, + codeCacheMaxSize: 1000, // 缓存最大容量,千字符数 + codeCacheTTL: 15, // 缓存过期时间,分钟 + codeCacheThreshold: 2, // 允许缓存的最小代码长度,千字符数 mathEngine: 'KaTeX', messageStyle: 'plain', codeStyle: 'auto', @@ -303,6 +312,18 @@ const settingsSlice = createSlice({ setCodeWrappable: (state, action: PayloadAction) => { state.codeWrappable = action.payload }, + setCodeCacheable: (state, action: PayloadAction) => { + state.codeCacheable = action.payload + }, + setCodeCacheMaxSize: (state, action: PayloadAction) => { + state.codeCacheMaxSize = action.payload + }, + setCodeCacheTTL: (state, action: PayloadAction) => { + state.codeCacheTTL = action.payload + }, + setCodeCacheThreshold: (state, action: PayloadAction) => { + state.codeCacheThreshold = action.payload + }, setMathEngine: (state, action: PayloadAction<'MathJax' | 'KaTeX'>) => { state.mathEngine = action.payload }, @@ -471,6 +492,10 @@ export const { setCodeShowLineNumbers, setCodeCollapsible, setCodeWrappable, + setCodeCacheable, + setCodeCacheMaxSize, + setCodeCacheTTL, + setCodeCacheThreshold, setMathEngine, setFoldDisplayMode, setGridColumns, diff --git a/yarn.lock b/yarn.lock index 2b0bb627..26a4980a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3079,70 +3079,68 @@ __metadata: languageName: node linkType: hard -"@shikijs/core@npm:1.29.2": - version: 1.29.2 - resolution: "@shikijs/core@npm:1.29.2" +"@shikijs/core@npm:3.2.1": + version: 3.2.1 + resolution: "@shikijs/core@npm:3.2.1" dependencies: - "@shikijs/engine-javascript": "npm:1.29.2" - "@shikijs/engine-oniguruma": "npm:1.29.2" - "@shikijs/types": "npm:1.29.2" - "@shikijs/vscode-textmate": "npm:^10.0.1" + "@shikijs/types": "npm:3.2.1" + "@shikijs/vscode-textmate": "npm:^10.0.2" "@types/hast": "npm:^3.0.4" - hast-util-to-html: "npm:^9.0.4" - checksum: 10c0/b1bb0567babcee64608224d652ceb4076d387b409fb8ee767f7684c68f03cfaab0e17f42d0a3372fc7be1fe165af9a3a349efc188f6e7c720d4df1108c1ab78c + hast-util-to-html: "npm:^9.0.5" + checksum: 10c0/d9d1d5587e40ab15f343dfac4c1f109f6a4b9b97640ec22d5c831917a3932958cbcfce36e90ff92fffe9f4d286f902c0d16d0ad2ebdcfbbaa808a1fb87b4f680 languageName: node linkType: hard -"@shikijs/engine-javascript@npm:1.29.2": - version: 1.29.2 - resolution: "@shikijs/engine-javascript@npm:1.29.2" +"@shikijs/engine-javascript@npm:3.2.1": + version: 3.2.1 + resolution: "@shikijs/engine-javascript@npm:3.2.1" dependencies: - "@shikijs/types": "npm:1.29.2" - "@shikijs/vscode-textmate": "npm:^10.0.1" - oniguruma-to-es: "npm:^2.2.0" - checksum: 10c0/b61f9e9079493c19419ff64af6454c4360a32785d47f49b41e87752e66ddbf7466dd9cce67f4d5d4a8447e31d96b4f0a39330e9f26e8bd2bc2f076644e78dff7 + "@shikijs/types": "npm:3.2.1" + "@shikijs/vscode-textmate": "npm:^10.0.2" + oniguruma-to-es: "npm:^4.1.0" + checksum: 10c0/7b629bdbc54c51d198628a6c2dcf85db80259b8ebc70b622517837990d75d91a6240cc51cce36f7dc8a0776d0445b5b02a5b23726d9cf4e084712820528cb45c languageName: node linkType: hard -"@shikijs/engine-oniguruma@npm:1.29.2": - version: 1.29.2 - resolution: "@shikijs/engine-oniguruma@npm:1.29.2" +"@shikijs/engine-oniguruma@npm:3.2.1": + version: 3.2.1 + resolution: "@shikijs/engine-oniguruma@npm:3.2.1" dependencies: - "@shikijs/types": "npm:1.29.2" - "@shikijs/vscode-textmate": "npm:^10.0.1" - checksum: 10c0/87d77e05af7fe862df40899a7034cbbd48d3635e27706873025e5035be578584d012f850208e97ca484d5e876bf802d4e23d0394d25026adb678eeb1d1f340ff + "@shikijs/types": "npm:3.2.1" + "@shikijs/vscode-textmate": "npm:^10.0.2" + checksum: 10c0/8c4c51738740f9cfa610ccefaaea2378833820336e4329bb88b9a2208e3deb994b0b7bea2d6657eb915fb668ca2090a2168a84dfeac2b820c1fee00631ca9bed languageName: node linkType: hard -"@shikijs/langs@npm:1.29.2": - version: 1.29.2 - resolution: "@shikijs/langs@npm:1.29.2" +"@shikijs/langs@npm:3.2.1": + version: 3.2.1 + resolution: "@shikijs/langs@npm:3.2.1" dependencies: - "@shikijs/types": "npm:1.29.2" - checksum: 10c0/137af52ec19ab10bb167ec67e2dc6888d77dedddb3be37708569cb8e8d54c057d09df335261276012d11ac38366ba57b9eae121cc0b7045859638c25648b0563 + "@shikijs/types": "npm:3.2.1" + checksum: 10c0/8a4e8c066795f1e96686bee271ad9783febcb1cece2ebb9815dfb3d59c856ac869cf9dddc7d90cbcd186a782ddc0628b37486fcc4a46516be6825907f0e74178 languageName: node linkType: hard -"@shikijs/themes@npm:1.29.2": - version: 1.29.2 - resolution: "@shikijs/themes@npm:1.29.2" +"@shikijs/themes@npm:3.2.1": + version: 3.2.1 + resolution: "@shikijs/themes@npm:3.2.1" dependencies: - "@shikijs/types": "npm:1.29.2" - checksum: 10c0/1f7d3fc8615890d83b50c73c13e5182438dee579dd9a121d605bbdcc2dc877cafc9f7e23a3e1342345cd0b9161e3af6425b0fbfac949843f22b2a60527a8fb69 + "@shikijs/types": "npm:3.2.1" + checksum: 10c0/674aae42244832142f584037504ab102dc141f9918f5b11b62aa0dc1abb6a763daf74f86124ae5f2362116dd095b5fc62c9a249aa8c14fdae847e5b8b955b11b languageName: node linkType: hard -"@shikijs/types@npm:1.29.2": - version: 1.29.2 - resolution: "@shikijs/types@npm:1.29.2" +"@shikijs/types@npm:3.2.1": + version: 3.2.1 + resolution: "@shikijs/types@npm:3.2.1" dependencies: - "@shikijs/vscode-textmate": "npm:^10.0.1" + "@shikijs/vscode-textmate": "npm:^10.0.2" "@types/hast": "npm:^3.0.4" - checksum: 10c0/37b4ac315effc03e7185aca1da0c2631ac55bdf613897476bd1d879105c41f86ccce6ebd0b78779513d88cc2ee371039f7efd95d604f77f21f180791978822b3 + checksum: 10c0/3380fde198d466a8771137b7ca3d4756a54d7d24c6e65f852737472a280c12c07f2123b9ad3d7eb2edec86d8d2c53bc207abe0fc0c7f78d337e52e742dc34edf languageName: node linkType: hard -"@shikijs/vscode-textmate@npm:^10.0.1": +"@shikijs/vscode-textmate@npm:^10.0.2": version: 10.0.2 resolution: "@shikijs/vscode-textmate@npm:10.0.2" checksum: 10c0/36b682d691088ec244de292dc8f91b808f95c89466af421cf84cbab92230f03c8348649c14b3251991b10ce632b0c715e416e992dd5f28ff3221dc2693fd9462 @@ -3494,6 +3492,15 @@ __metadata: languageName: node linkType: hard +"@types/lru-cache@npm:^7.10.10": + version: 7.10.10 + resolution: "@types/lru-cache@npm:7.10.10" + dependencies: + lru-cache: "npm:*" + checksum: 10c0/ab85558867cb059bebd42074c1cd517eb41efb1db22b9d26dfdc58df01c83ff9c212a562b4ec3d5936418ffb03e626a0f30463026aa5fb5ced41e3b4b4af057f + languageName: node + linkType: hard + "@types/markdown-it@npm:^14": version: 14.1.2 resolution: "@types/markdown-it@npm:14.1.2" @@ -3942,6 +3949,7 @@ __metadata: "@types/diff": "npm:^7" "@types/fs-extra": "npm:^11" "@types/lodash": "npm:^4.17.5" + "@types/lru-cache": "npm:^7.10.10" "@types/markdown-it": "npm:^14" "@types/md5": "npm:^2.3.5" "@types/node": "npm:^18.19.9" @@ -4020,7 +4028,7 @@ __metadata: remark-math: "npm:^6.0.0" rollup-plugin-visualizer: "npm:^5.12.0" sass: "npm:^1.77.2" - shiki: "npm:^1.22.2" + shiki: "npm:^3.2.1" string-width: "npm:^7.2.0" styled-components: "npm:^6.1.11" tar: "npm:^7.4.3" @@ -8599,7 +8607,7 @@ __metadata: languageName: node linkType: hard -"hast-util-to-html@npm:^9.0.4": +"hast-util-to-html@npm:^9.0.5": version: 9.0.5 resolution: "hast-util-to-html@npm:9.0.5" dependencies: @@ -10194,6 +10202,13 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:*": + version: 11.1.0 + resolution: "lru-cache@npm:11.1.0" + checksum: 10c0/85c312f7113f65fae6a62de7985348649937eb34fb3d212811acbf6704dc322a421788aca253b62838f1f07049a84cc513d88f494e373d3756514ad263670a64 + languageName: node + linkType: hard + "lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0, lru-cache@npm:^10.4.3": version: 10.4.3 resolution: "lru-cache@npm:10.4.3" @@ -12126,14 +12141,22 @@ __metadata: languageName: node linkType: hard -"oniguruma-to-es@npm:^2.2.0": - version: 2.3.0 - resolution: "oniguruma-to-es@npm:2.3.0" +"oniguruma-parser@npm:^0.5.4": + version: 0.5.4 + resolution: "oniguruma-parser@npm:0.5.4" + checksum: 10c0/934866661ab8ff379f7605121c96d0f22bede073c7fda5b7ae60da2eebdd1380dff5058118dd67df2cdacbdd275167f3e72cf87dc87882f8b7feda014785de00 + languageName: node + linkType: hard + +"oniguruma-to-es@npm:^4.1.0": + version: 4.1.0 + resolution: "oniguruma-to-es@npm:4.1.0" dependencies: emoji-regex-xs: "npm:^1.0.0" - regex: "npm:^5.1.1" - regex-recursion: "npm:^5.1.1" - checksum: 10c0/57ad95f3e9a50be75e7d54e582d8d4da4003f983fd04d99ccc9d17d2dc04e30ea64126782f2e758566bcef2c4c55db0d6a3d344f35ca179dd92ea5ca92fc0313 + oniguruma-parser: "npm:^0.5.4" + regex: "npm:^6.0.1" + regex-recursion: "npm:^6.0.2" + checksum: 10c0/8f3fc7f524a7fa78cecc3a2af29d19a834563b25de4eb3ed138ffe062075dfedacd997d86951b38cc5c5d6b5c083df1f5a10a442b742df9399eaa6ea9aa68392 languageName: node linkType: hard @@ -14071,13 +14094,12 @@ __metadata: languageName: node linkType: hard -"regex-recursion@npm:^5.1.1": - version: 5.1.1 - resolution: "regex-recursion@npm:5.1.1" +"regex-recursion@npm:^6.0.2": + version: 6.0.2 + resolution: "regex-recursion@npm:6.0.2" dependencies: - regex: "npm:^5.1.1" regex-utilities: "npm:^2.3.0" - checksum: 10c0/c61c284bc41f2b271dfa0549d657a5a26397108b860d7cdb15b43080196681c0092bf8cf920a8836213e239d1195c4ccf6db9be9298bce4e68c9daab1febeab9 + checksum: 10c0/68e8b6889680e904b75d7f26edaf70a1a4dc1087406bff53face4c2929d918fd77c72223843fe816ac8ed9964f96b4160650e8d5909e26a998c6e9de324dadb1 languageName: node linkType: hard @@ -14088,12 +14110,12 @@ __metadata: languageName: node linkType: hard -"regex@npm:^5.1.1": - version: 5.1.1 - resolution: "regex@npm:5.1.1" +"regex@npm:^6.0.1": + version: 6.0.1 + resolution: "regex@npm:6.0.1" dependencies: regex-utilities: "npm:^2.3.0" - checksum: 10c0/314e032f0fe09497ce7a160b99675c4a16c7524f0a24833f567cbbf3a2bebc26bf59737dc5c23f32af7c74aa7a6bd3f809fc72c90c49a05faf8be45677db508a + checksum: 10c0/687b3e063d4ca19b0de7c55c24353f868a0fb9ba21512692470d2fb412e3a410894dd5924c91ea49d8cb8fa865e36ec956e52436ae0a256bdc095ff136c30aba languageName: node linkType: hard @@ -14814,19 +14836,19 @@ __metadata: languageName: node linkType: hard -"shiki@npm:^1.22.2": - version: 1.29.2 - resolution: "shiki@npm:1.29.2" +"shiki@npm:^3.2.1": + version: 3.2.1 + resolution: "shiki@npm:3.2.1" dependencies: - "@shikijs/core": "npm:1.29.2" - "@shikijs/engine-javascript": "npm:1.29.2" - "@shikijs/engine-oniguruma": "npm:1.29.2" - "@shikijs/langs": "npm:1.29.2" - "@shikijs/themes": "npm:1.29.2" - "@shikijs/types": "npm:1.29.2" - "@shikijs/vscode-textmate": "npm:^10.0.1" + "@shikijs/core": "npm:3.2.1" + "@shikijs/engine-javascript": "npm:3.2.1" + "@shikijs/engine-oniguruma": "npm:3.2.1" + "@shikijs/langs": "npm:3.2.1" + "@shikijs/themes": "npm:3.2.1" + "@shikijs/types": "npm:3.2.1" + "@shikijs/vscode-textmate": "npm:^10.0.2" "@types/hast": "npm:^3.0.4" - checksum: 10c0/9ef452021582c405501077082c4ae8d877027dca6488d2c7a1963ed661567f121b4cc5dea9dfab26689504b612b8a961f3767805cbeaaae3c1d6faa5e6f37eb0 + checksum: 10c0/8153b5a354c508815c8a20c03bf182aa863d07e865bc603e136c633c60abb729655c5487109111ed8a3e5a4aff0275f3b714b05c5b129085c8ed69069537f0c0 languageName: node linkType: hard