perf(CodeBlock): improve long codeblock loading experience (#4167)

* perf(CodeBlock): improve long codeblock loading experience

* refactor: use requestIdleCallback rather than observer

* refactor: simplify setting expanded and unwrapped

* refactor: simplify logic

* refactor: revert to observer

* fix: turn mermaid listener to passive to avoid scrolling performance downgrade

* feat: add lru cache for syntax highlighting

* refactor: adjust cache options

* feat: add highlighter cache

* fix: highlighter should be loaded before highlighting

* refactor: reduce cache time

* refactor: adjust cache size and hash

* refactor: decrease cache size

* fix: restore the behaviour of ShowExpandButton

* fix: check streaming status

* fix: empty code

* refactor: improve streaming check

* fix: optimizeDeps excludes

* refactor: adjust cache policy

* feat: add a setting for code caching

* feat: add more settings for code cache

* fix: initialize service

* refactor: prevent accident cache reset, update settings

* refactor: update code cache service

* fix: revert unecessary changes

* refactor: adjust cache settings

* fix: update migrate version

* chore: update to shiki v3

* fix: import path

* refactor: remove highlighter cache, improve fallbacks

* fix: revert path changes

* style: fix lint errors

* style: improve readability

* style: improve readability

* chore: update migrate version

* chore: update packages
This commit is contained in:
one 2025-04-06 08:38:02 +08:00 committed by GitHub
parent 641dfc60b0
commit 95639df35c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 569 additions and 131 deletions

View File

@ -118,6 +118,7 @@
"@types/diff": "^7", "@types/diff": "^7",
"@types/fs-extra": "^11", "@types/fs-extra": "^11",
"@types/lodash": "^4.17.5", "@types/lodash": "^4.17.5",
"@types/lru-cache": "^7.10.10",
"@types/markdown-it": "^14", "@types/markdown-it": "^14",
"@types/md5": "^2.3.5", "@types/md5": "^2.3.5",
"@types/node": "^18.19.9", "@types/node": "^18.19.9",
@ -179,7 +180,7 @@
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"rollup-plugin-visualizer": "^5.12.0", "rollup-plugin-visualizer": "^5.12.0",
"sass": "^1.77.2", "sass": "^1.77.2",
"shiki": "^1.22.2", "shiki": "^3.2.1",
"string-width": "^7.2.0", "string-width": "^7.2.0",
"styled-components": "^6.1.11", "styled-components": "^6.1.11",
"tinycolor2": "^1.6.0", "tinycolor2": "^1.6.0",

View File

@ -1,21 +1,33 @@
import { useTheme } from '@renderer/context/ThemeProvider' import { useTheme } from '@renderer/context/ThemeProvider'
import { useMermaid } from '@renderer/hooks/useMermaid' import { useMermaid } from '@renderer/hooks/useMermaid'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import { CodeCacheService } from '@renderer/services/CodeCacheService'
import { type CodeStyleVarious, ThemeMode } from '@renderer/types' import { type CodeStyleVarious, ThemeMode } from '@renderer/types'
import type React from 'react' import type React from 'react'
import { createContext, type PropsWithChildren, use, useCallback, useEffect, useMemo, useState } from 'react' import { createContext, type PropsWithChildren, use, useCallback, useMemo } from 'react'
import type { BundledLanguage, BundledTheme, HighlighterGeneric } from 'shiki' import { bundledLanguages, bundledThemes, createHighlighter, type Highlighter } from 'shiki'
import { bundledLanguages, bundledThemes, createHighlighter } from 'shiki'
let highlighterPromise: Promise<Highlighter> | 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 { interface SyntaxHighlighterContextType {
codeToHtml: (code: string, language: string) => Promise<string> codeToHtml: (code: string, language: string, enableCache: boolean) => Promise<string>
} }
const SyntaxHighlighterContext = createContext<SyntaxHighlighterContextType | undefined>(undefined) const SyntaxHighlighterContext = createContext<SyntaxHighlighterContextType | undefined>(undefined)
export const SyntaxHighlighterProvider: React.FC<PropsWithChildren> = ({ children }) => { export const SyntaxHighlighterProvider: React.FC<PropsWithChildren> = ({ children }) => {
const { theme } = useTheme() const { theme } = useTheme()
const [highlighter, setHighlighter] = useState<HighlighterGeneric<BundledLanguage, BundledTheme> | null>(null)
const { codeStyle } = useSettings() const { codeStyle } = useSettings()
useMermaid() useMermaid()
@ -27,29 +39,14 @@ export const SyntaxHighlighterProvider: React.FC<PropsWithChildren> = ({ childre
return codeStyle return codeStyle
}, [theme, 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( 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<string, string> = { const languageMap: Record<string, string> = {
vab: 'vb' vab: 'vb'
@ -61,25 +58,41 @@ export const SyntaxHighlighterProvider: React.FC<PropsWithChildren> = ({ childre
const escapedCode = code?.replace(/[<>]/g, (char) => ({ '<': '&lt;', '>': '&gt;' })[char]!) const escapedCode = code?.replace(/[<>]/g, (char) => ({ '<': '&lt;', '>': '&gt;' })[char]!)
try { try {
if (!highlighter.getLoadedLanguages().includes(mappedLanguage as BundledLanguage)) { const highlighter = await getHighlighter()
if (mappedLanguage in bundledLanguages || mappedLanguage === 'text') {
await highlighter.loadLanguage(mappedLanguage as BundledLanguage) if (!highlighter.getLoadedThemes().includes(highlighterTheme)) {
} else { const themeImportFn = bundledThemes[highlighterTheme]
return `<pre style="padding: 10px"><code>${escapedCode}</code></pre>` 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, lang: mappedLanguage,
theme: highlighterTheme theme: highlighterTheme
}) })
// 设置缓存
if (enableCache) {
CodeCacheService.setCachedResult(key, html, _code.length)
}
return html
} catch (error) { } catch (error) {
console.warn(`Error highlighting code for language '${mappedLanguage}':`, error) console.debug(`Error highlighting code for language '${mappedLanguage}':`, error)
return `<pre style="padding: 10px"><code>${escapedCode}</code></pre>` return `<pre style="padding: 10px"><code>${escapedCode}</code></pre>`
} }
} }
}, },
[highlighter, highlighterTheme] [highlighterTheme]
) )
return <SyntaxHighlighterContext value={{ codeToHtml }}>{children}</SyntaxHighlighterContext> return <SyntaxHighlighterContext value={{ codeToHtml }}>{children}</SyntaxHighlighterContext>

View File

@ -40,7 +40,6 @@ export const useMermaid = () => {
useEffect(() => { useEffect(() => {
const handleWheel = (e: WheelEvent) => { const handleWheel = (e: WheelEvent) => {
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
e.preventDefault()
const mermaidElement = (e.target as HTMLElement).closest('.mermaid') const mermaidElement = (e.target as HTMLElement).closest('.mermaid')
if (!mermaidElement) return 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) return () => document.removeEventListener('wheel', handleWheel)
}, []) }, [])
} }

View File

@ -159,6 +159,14 @@
"save": "Save", "save": "Save",
"settings.code_collapsible": "Code block collapsible", "settings.code_collapsible": "Code block collapsible",
"settings.code_wrappable": "Code block wrappable", "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": "Context",
"settings.context_count.tip": "The number of previous messages to keep in the context.", "settings.context_count.tip": "The number of previous messages to keep in the context.",
"settings.max": "Max", "settings.max": "Max",

View File

@ -159,6 +159,14 @@
"save": "保存", "save": "保存",
"settings.code_collapsible": "コードブロック折り畳み", "settings.code_collapsible": "コードブロック折り畳み",
"settings.code_wrappable": "コードブロック折り返し", "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": "コンテキスト",
"settings.context_count.tip": "コンテキストに保持する以前のメッセージの数", "settings.context_count.tip": "コンテキストに保持する以前のメッセージの数",
"settings.max": "最大", "settings.max": "最大",

View File

@ -159,6 +159,14 @@
"save": "Сохранить", "save": "Сохранить",
"settings.code_collapsible": "Блок кода свернут", "settings.code_collapsible": "Блок кода свернут",
"settings.code_wrappable": "Блок кода можно переносить", "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": "Контекст",
"settings.context_count.tip": "Количество предыдущих сообщений, которые нужно сохранить в контексте.", "settings.context_count.tip": "Количество предыдущих сообщений, которые нужно сохранить в контексте.",
"settings.max": "Максимум", "settings.max": "Максимум",

View File

@ -161,6 +161,14 @@
"save": "保存", "save": "保存",
"settings.code_collapsible": "代码块可折叠", "settings.code_collapsible": "代码块可折叠",
"settings.code_wrappable": "代码块可换行", "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": "上下文数",
"settings.context_count.tip": "要保留在上下文中的消息数量,数值越大,上下文越长,消耗的 token 越多。普通聊天建议 5-10", "settings.context_count.tip": "要保留在上下文中的消息数量,数值越大,上下文越长,消耗的 token 越多。普通聊天建议 5-10",
"settings.max": "不限", "settings.max": "不限",

View File

@ -159,6 +159,14 @@
"save": "儲存", "save": "儲存",
"settings.code_collapsible": "程式碼區塊可折疊", "settings.code_collapsible": "程式碼區塊可折疊",
"settings.code_wrappable": "程式碼區塊可自動換行", "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": "上下文",
"settings.context_count.tip": "在上下文中保留的前幾則訊息。", "settings.context_count.tip": "在上下文中保留的前幾則訊息。",
"settings.max": "最大", "settings.max": "最大",

View File

@ -7,7 +7,7 @@ import { useSyntaxHighlighter } from '@renderer/context/SyntaxHighlighterProvide
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import { Tooltip } from 'antd' import { Tooltip } from 'antd'
import dayjs from 'dayjs' 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 { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@ -32,6 +32,8 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ children, className }) => {
const [isUnwrapped, setIsUnwrapped] = useState(!codeWrappable) const [isUnwrapped, setIsUnwrapped] = useState(!codeWrappable)
const [shouldShowExpandButton, setShouldShowExpandButton] = useState(false) const [shouldShowExpandButton, setShouldShowExpandButton] = useState(false)
const codeContentRef = useRef<HTMLDivElement>(null) const codeContentRef = useRef<HTMLDivElement>(null)
const childrenLengthRef = useRef(0)
const isStreamingRef = useRef(false)
const showFooterCopyButton = children && children.length > 500 && !codeCollapsible const showFooterCopyButton = children && children.length > 500 && !codeCollapsible
@ -39,39 +41,69 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ children, className }) => {
const shouldShowExpandButtonRef = useRef(false) const shouldShowExpandButtonRef = useRef(false)
useEffect(() => { const shouldHighlight = useCallback((lang: string) => {
const loadHighlightedCode = async () => { const NON_HIGHLIGHT_LANGS = ['mermaid', 'plantuml', 'svg']
const highlightedHtml = await codeToHtml(children, language) return !NON_HIGHLIGHT_LANGS.includes(lang)
if (codeContentRef.current) { }, [])
codeContentRef.current.innerHTML = highlightedHtml
const isShowExpandButton = codeContentRef.current.scrollHeight > 350 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 if (shouldShowExpandButtonRef.current === isShowExpandButton) return
shouldShowExpandButtonRef.current = isShowExpandButton shouldShowExpandButtonRef.current = isShowExpandButton
setShouldShowExpandButton(shouldShowExpandButtonRef.current) setShouldShowExpandButton(shouldShowExpandButtonRef.current)
} }, [language, codeToHtml, children])
}
loadHighlightedCode()
}, [children, language, codeToHtml])
useEffect(() => { useEffect(() => {
if (!codeCollapsible) { // 跳过非文本代码块
setIsExpanded(true) if (!codeContentRef.current || !shouldHighlight(language)) return
setShouldShowExpandButton(false)
let isMounted = true
const codeElement = codeContentRef.current
if (childrenLengthRef.current > 0 && childrenLengthRef.current !== children?.length) {
isStreamingRef.current = true
} else { } else {
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) setIsExpanded(!codeCollapsible)
if (codeContentRef.current) { setShouldShowExpandButton(codeCollapsible && (codeContentRef.current?.scrollHeight ?? 0) > 350)
setShouldShowExpandButton(codeContentRef.current.scrollHeight > 350)
}
}
}, [codeCollapsible]) }, [codeCollapsible])
useEffect(() => { useEffect(() => {
if (!codeWrappable) { setIsUnwrapped(!codeWrappable)
// 如果未启动代码块换行功能
setIsUnwrapped(true)
} else {
setIsUnwrapped(!codeWrappable) // 被换行
}
}, [codeWrappable]) }, [codeWrappable])
if (language === 'mermaid') { if (language === 'mermaid') {
@ -227,6 +259,7 @@ const CodeBlockWrapper = styled.div`
` `
const CodeContent = styled.div<{ isShowLineNumbers: boolean; isUnwrapped: boolean; isCodeWrappable: boolean }>` const CodeContent = styled.div<{ isShowLineNumbers: boolean; isUnwrapped: boolean; isCodeWrappable: boolean }>`
transition: opacity 0.3s ease;
.shiki { .shiki {
padding: 1em; padding: 1em;

View File

@ -19,6 +19,10 @@ import { useAppDispatch } from '@renderer/store'
import { import {
SendMessageShortcut, SendMessageShortcut,
setAutoTranslateWithSpace, setAutoTranslateWithSpace,
setCodeCacheable,
setCodeCacheMaxSize,
setCodeCacheThreshold,
setCodeCacheTTL,
setCodeCollapsible, setCodeCollapsible,
setCodeShowLineNumbers, setCodeShowLineNumbers,
setCodeStyle, setCodeStyle,
@ -75,6 +79,10 @@ const SettingsTab: FC<Props> = (props) => {
codeShowLineNumbers, codeShowLineNumbers,
codeCollapsible, codeCollapsible,
codeWrappable, codeWrappable,
codeCacheable,
codeCacheMaxSize,
codeCacheTTL,
codeCacheThreshold,
mathEngine, mathEngine,
autoTranslateWithSpace, autoTranslateWithSpace,
pasteLongTextThreshold, pasteLongTextThreshold,
@ -331,6 +339,74 @@ const SettingsTab: FC<Props> = (props) => {
<Switch size="small" checked={codeWrappable} onChange={(checked) => dispatch(setCodeWrappable(checked))} /> <Switch size="small" checked={codeWrappable} onChange={(checked) => dispatch(setCodeWrappable(checked))} />
</SettingRow> </SettingRow>
<SettingDivider /> <SettingDivider />
<SettingRow>
<SettingRowTitleSmall>
{t('chat.settings.code_cacheable')}{' '}
<Tooltip title={t('chat.settings.code_cacheable.tip')}>
<QuestionIcon style={{ marginLeft: 4 }} />
</Tooltip>
</SettingRowTitleSmall>
<Switch size="small" checked={codeCacheable} onChange={(checked) => dispatch(setCodeCacheable(checked))} />
</SettingRow>
{codeCacheable && (
<>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>
{t('chat.settings.code_cache_max_size')}
<Tooltip title={t('chat.settings.code_cache_max_size.tip')}>
<QuestionIcon style={{ marginLeft: 4 }} />
</Tooltip>
</SettingRowTitleSmall>
<InputNumber
size="small"
min={1000}
max={10000}
step={1000}
value={codeCacheMaxSize}
onChange={(value) => dispatch(setCodeCacheMaxSize(value ?? 1000))}
style={{ width: 80 }}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>
{t('chat.settings.code_cache_ttl')}
<Tooltip title={t('chat.settings.code_cache_ttl.tip')}>
<QuestionIcon style={{ marginLeft: 4 }} />
</Tooltip>
</SettingRowTitleSmall>
<InputNumber
size="small"
min={15}
max={720}
step={15}
value={codeCacheTTL}
onChange={(value) => dispatch(setCodeCacheTTL(value ?? 15))}
style={{ width: 80 }}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>
{t('chat.settings.code_cache_threshold')}
<Tooltip title={t('chat.settings.code_cache_threshold.tip')}>
<QuestionIcon style={{ marginLeft: 4 }} />
</Tooltip>
</SettingRowTitleSmall>
<InputNumber
size="small"
min={0}
max={50}
step={1}
value={codeCacheThreshold}
onChange={(value) => dispatch(setCodeCacheThreshold(value ?? 2))}
style={{ width: 80 }}
/>
</SettingRow>
</>
)}
<SettingDivider />
<SettingRow> <SettingRow>
<SettingRowTitleSmall> <SettingRowTitleSmall>
{t('chat.settings.thought_auto_collapse')} {t('chat.settings.thought_auto_collapse')}

View File

@ -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<string, string> | 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<string, string>({
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<string, string>({
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()
}
}

View File

@ -42,7 +42,7 @@ const persistedReducer = persistReducer(
{ {
key: 'cherry-studio', key: 'cherry-studio',
storage, storage,
version: 89, version: 91,
blacklist: ['runtime', 'messages'], blacklist: ['runtime', 'messages'],
migrate migrate
}, },

View File

@ -1186,6 +1186,17 @@ const migrateConfig = {
} catch (error) { } catch (error) {
return state 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
}
} }
} }

View File

@ -51,6 +51,11 @@ export interface SettingsState {
codeShowLineNumbers: boolean codeShowLineNumbers: boolean
codeCollapsible: boolean codeCollapsible: boolean
codeWrappable: boolean codeWrappable: boolean
// 代码块缓存
codeCacheable: boolean
codeCacheMaxSize: number
codeCacheTTL: number
codeCacheThreshold: number
mathEngine: 'MathJax' | 'KaTeX' mathEngine: 'MathJax' | 'KaTeX'
messageStyle: 'plain' | 'bubble' messageStyle: 'plain' | 'bubble'
codeStyle: CodeStyleVarious codeStyle: CodeStyleVarious
@ -139,6 +144,10 @@ const initialState: SettingsState = {
codeShowLineNumbers: false, codeShowLineNumbers: false,
codeCollapsible: false, codeCollapsible: false,
codeWrappable: false, codeWrappable: false,
codeCacheable: false,
codeCacheMaxSize: 1000, // 缓存最大容量,千字符数
codeCacheTTL: 15, // 缓存过期时间,分钟
codeCacheThreshold: 2, // 允许缓存的最小代码长度,千字符数
mathEngine: 'KaTeX', mathEngine: 'KaTeX',
messageStyle: 'plain', messageStyle: 'plain',
codeStyle: 'auto', codeStyle: 'auto',
@ -303,6 +312,18 @@ const settingsSlice = createSlice({
setCodeWrappable: (state, action: PayloadAction<boolean>) => { setCodeWrappable: (state, action: PayloadAction<boolean>) => {
state.codeWrappable = action.payload state.codeWrappable = action.payload
}, },
setCodeCacheable: (state, action: PayloadAction<boolean>) => {
state.codeCacheable = action.payload
},
setCodeCacheMaxSize: (state, action: PayloadAction<number>) => {
state.codeCacheMaxSize = action.payload
},
setCodeCacheTTL: (state, action: PayloadAction<number>) => {
state.codeCacheTTL = action.payload
},
setCodeCacheThreshold: (state, action: PayloadAction<number>) => {
state.codeCacheThreshold = action.payload
},
setMathEngine: (state, action: PayloadAction<'MathJax' | 'KaTeX'>) => { setMathEngine: (state, action: PayloadAction<'MathJax' | 'KaTeX'>) => {
state.mathEngine = action.payload state.mathEngine = action.payload
}, },
@ -471,6 +492,10 @@ export const {
setCodeShowLineNumbers, setCodeShowLineNumbers,
setCodeCollapsible, setCodeCollapsible,
setCodeWrappable, setCodeWrappable,
setCodeCacheable,
setCodeCacheMaxSize,
setCodeCacheTTL,
setCodeCacheThreshold,
setMathEngine, setMathEngine,
setFoldDisplayMode, setFoldDisplayMode,
setGridColumns, setGridColumns,

154
yarn.lock
View File

@ -3079,70 +3079,68 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@shikijs/core@npm:1.29.2": "@shikijs/core@npm:3.2.1":
version: 1.29.2 version: 3.2.1
resolution: "@shikijs/core@npm:1.29.2" resolution: "@shikijs/core@npm:3.2.1"
dependencies: dependencies:
"@shikijs/engine-javascript": "npm:1.29.2" "@shikijs/types": "npm:3.2.1"
"@shikijs/engine-oniguruma": "npm:1.29.2" "@shikijs/vscode-textmate": "npm:^10.0.2"
"@shikijs/types": "npm:1.29.2"
"@shikijs/vscode-textmate": "npm:^10.0.1"
"@types/hast": "npm:^3.0.4" "@types/hast": "npm:^3.0.4"
hast-util-to-html: "npm:^9.0.4" hast-util-to-html: "npm:^9.0.5"
checksum: 10c0/b1bb0567babcee64608224d652ceb4076d387b409fb8ee767f7684c68f03cfaab0e17f42d0a3372fc7be1fe165af9a3a349efc188f6e7c720d4df1108c1ab78c checksum: 10c0/d9d1d5587e40ab15f343dfac4c1f109f6a4b9b97640ec22d5c831917a3932958cbcfce36e90ff92fffe9f4d286f902c0d16d0ad2ebdcfbbaa808a1fb87b4f680
languageName: node languageName: node
linkType: hard linkType: hard
"@shikijs/engine-javascript@npm:1.29.2": "@shikijs/engine-javascript@npm:3.2.1":
version: 1.29.2 version: 3.2.1
resolution: "@shikijs/engine-javascript@npm:1.29.2" resolution: "@shikijs/engine-javascript@npm:3.2.1"
dependencies: dependencies:
"@shikijs/types": "npm:1.29.2" "@shikijs/types": "npm:3.2.1"
"@shikijs/vscode-textmate": "npm:^10.0.1" "@shikijs/vscode-textmate": "npm:^10.0.2"
oniguruma-to-es: "npm:^2.2.0" oniguruma-to-es: "npm:^4.1.0"
checksum: 10c0/b61f9e9079493c19419ff64af6454c4360a32785d47f49b41e87752e66ddbf7466dd9cce67f4d5d4a8447e31d96b4f0a39330e9f26e8bd2bc2f076644e78dff7 checksum: 10c0/7b629bdbc54c51d198628a6c2dcf85db80259b8ebc70b622517837990d75d91a6240cc51cce36f7dc8a0776d0445b5b02a5b23726d9cf4e084712820528cb45c
languageName: node languageName: node
linkType: hard linkType: hard
"@shikijs/engine-oniguruma@npm:1.29.2": "@shikijs/engine-oniguruma@npm:3.2.1":
version: 1.29.2 version: 3.2.1
resolution: "@shikijs/engine-oniguruma@npm:1.29.2" resolution: "@shikijs/engine-oniguruma@npm:3.2.1"
dependencies: dependencies:
"@shikijs/types": "npm:1.29.2" "@shikijs/types": "npm:3.2.1"
"@shikijs/vscode-textmate": "npm:^10.0.1" "@shikijs/vscode-textmate": "npm:^10.0.2"
checksum: 10c0/87d77e05af7fe862df40899a7034cbbd48d3635e27706873025e5035be578584d012f850208e97ca484d5e876bf802d4e23d0394d25026adb678eeb1d1f340ff checksum: 10c0/8c4c51738740f9cfa610ccefaaea2378833820336e4329bb88b9a2208e3deb994b0b7bea2d6657eb915fb668ca2090a2168a84dfeac2b820c1fee00631ca9bed
languageName: node languageName: node
linkType: hard linkType: hard
"@shikijs/langs@npm:1.29.2": "@shikijs/langs@npm:3.2.1":
version: 1.29.2 version: 3.2.1
resolution: "@shikijs/langs@npm:1.29.2" resolution: "@shikijs/langs@npm:3.2.1"
dependencies: dependencies:
"@shikijs/types": "npm:1.29.2" "@shikijs/types": "npm:3.2.1"
checksum: 10c0/137af52ec19ab10bb167ec67e2dc6888d77dedddb3be37708569cb8e8d54c057d09df335261276012d11ac38366ba57b9eae121cc0b7045859638c25648b0563 checksum: 10c0/8a4e8c066795f1e96686bee271ad9783febcb1cece2ebb9815dfb3d59c856ac869cf9dddc7d90cbcd186a782ddc0628b37486fcc4a46516be6825907f0e74178
languageName: node languageName: node
linkType: hard linkType: hard
"@shikijs/themes@npm:1.29.2": "@shikijs/themes@npm:3.2.1":
version: 1.29.2 version: 3.2.1
resolution: "@shikijs/themes@npm:1.29.2" resolution: "@shikijs/themes@npm:3.2.1"
dependencies: dependencies:
"@shikijs/types": "npm:1.29.2" "@shikijs/types": "npm:3.2.1"
checksum: 10c0/1f7d3fc8615890d83b50c73c13e5182438dee579dd9a121d605bbdcc2dc877cafc9f7e23a3e1342345cd0b9161e3af6425b0fbfac949843f22b2a60527a8fb69 checksum: 10c0/674aae42244832142f584037504ab102dc141f9918f5b11b62aa0dc1abb6a763daf74f86124ae5f2362116dd095b5fc62c9a249aa8c14fdae847e5b8b955b11b
languageName: node languageName: node
linkType: hard linkType: hard
"@shikijs/types@npm:1.29.2": "@shikijs/types@npm:3.2.1":
version: 1.29.2 version: 3.2.1
resolution: "@shikijs/types@npm:1.29.2" resolution: "@shikijs/types@npm:3.2.1"
dependencies: dependencies:
"@shikijs/vscode-textmate": "npm:^10.0.1" "@shikijs/vscode-textmate": "npm:^10.0.2"
"@types/hast": "npm:^3.0.4" "@types/hast": "npm:^3.0.4"
checksum: 10c0/37b4ac315effc03e7185aca1da0c2631ac55bdf613897476bd1d879105c41f86ccce6ebd0b78779513d88cc2ee371039f7efd95d604f77f21f180791978822b3 checksum: 10c0/3380fde198d466a8771137b7ca3d4756a54d7d24c6e65f852737472a280c12c07f2123b9ad3d7eb2edec86d8d2c53bc207abe0fc0c7f78d337e52e742dc34edf
languageName: node languageName: node
linkType: hard linkType: hard
"@shikijs/vscode-textmate@npm:^10.0.1": "@shikijs/vscode-textmate@npm:^10.0.2":
version: 10.0.2 version: 10.0.2
resolution: "@shikijs/vscode-textmate@npm:10.0.2" resolution: "@shikijs/vscode-textmate@npm:10.0.2"
checksum: 10c0/36b682d691088ec244de292dc8f91b808f95c89466af421cf84cbab92230f03c8348649c14b3251991b10ce632b0c715e416e992dd5f28ff3221dc2693fd9462 checksum: 10c0/36b682d691088ec244de292dc8f91b808f95c89466af421cf84cbab92230f03c8348649c14b3251991b10ce632b0c715e416e992dd5f28ff3221dc2693fd9462
@ -3494,6 +3492,15 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@types/markdown-it@npm:^14":
version: 14.1.2 version: 14.1.2
resolution: "@types/markdown-it@npm:14.1.2" resolution: "@types/markdown-it@npm:14.1.2"
@ -3942,6 +3949,7 @@ __metadata:
"@types/diff": "npm:^7" "@types/diff": "npm:^7"
"@types/fs-extra": "npm:^11" "@types/fs-extra": "npm:^11"
"@types/lodash": "npm:^4.17.5" "@types/lodash": "npm:^4.17.5"
"@types/lru-cache": "npm:^7.10.10"
"@types/markdown-it": "npm:^14" "@types/markdown-it": "npm:^14"
"@types/md5": "npm:^2.3.5" "@types/md5": "npm:^2.3.5"
"@types/node": "npm:^18.19.9" "@types/node": "npm:^18.19.9"
@ -4020,7 +4028,7 @@ __metadata:
remark-math: "npm:^6.0.0" remark-math: "npm:^6.0.0"
rollup-plugin-visualizer: "npm:^5.12.0" rollup-plugin-visualizer: "npm:^5.12.0"
sass: "npm:^1.77.2" sass: "npm:^1.77.2"
shiki: "npm:^1.22.2" shiki: "npm:^3.2.1"
string-width: "npm:^7.2.0" string-width: "npm:^7.2.0"
styled-components: "npm:^6.1.11" styled-components: "npm:^6.1.11"
tar: "npm:^7.4.3" tar: "npm:^7.4.3"
@ -8599,7 +8607,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"hast-util-to-html@npm:^9.0.4": "hast-util-to-html@npm:^9.0.5":
version: 9.0.5 version: 9.0.5
resolution: "hast-util-to-html@npm:9.0.5" resolution: "hast-util-to-html@npm:9.0.5"
dependencies: dependencies:
@ -10194,6 +10202,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0, lru-cache@npm:^10.4.3":
version: 10.4.3 version: 10.4.3
resolution: "lru-cache@npm:10.4.3" resolution: "lru-cache@npm:10.4.3"
@ -12126,14 +12141,22 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"oniguruma-to-es@npm:^2.2.0": "oniguruma-parser@npm:^0.5.4":
version: 2.3.0 version: 0.5.4
resolution: "oniguruma-to-es@npm:2.3.0" 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: dependencies:
emoji-regex-xs: "npm:^1.0.0" emoji-regex-xs: "npm:^1.0.0"
regex: "npm:^5.1.1" oniguruma-parser: "npm:^0.5.4"
regex-recursion: "npm:^5.1.1" regex: "npm:^6.0.1"
checksum: 10c0/57ad95f3e9a50be75e7d54e582d8d4da4003f983fd04d99ccc9d17d2dc04e30ea64126782f2e758566bcef2c4c55db0d6a3d344f35ca179dd92ea5ca92fc0313 regex-recursion: "npm:^6.0.2"
checksum: 10c0/8f3fc7f524a7fa78cecc3a2af29d19a834563b25de4eb3ed138ffe062075dfedacd997d86951b38cc5c5d6b5c083df1f5a10a442b742df9399eaa6ea9aa68392
languageName: node languageName: node
linkType: hard linkType: hard
@ -14071,13 +14094,12 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"regex-recursion@npm:^5.1.1": "regex-recursion@npm:^6.0.2":
version: 5.1.1 version: 6.0.2
resolution: "regex-recursion@npm:5.1.1" resolution: "regex-recursion@npm:6.0.2"
dependencies: dependencies:
regex: "npm:^5.1.1"
regex-utilities: "npm:^2.3.0" regex-utilities: "npm:^2.3.0"
checksum: 10c0/c61c284bc41f2b271dfa0549d657a5a26397108b860d7cdb15b43080196681c0092bf8cf920a8836213e239d1195c4ccf6db9be9298bce4e68c9daab1febeab9 checksum: 10c0/68e8b6889680e904b75d7f26edaf70a1a4dc1087406bff53face4c2929d918fd77c72223843fe816ac8ed9964f96b4160650e8d5909e26a998c6e9de324dadb1
languageName: node languageName: node
linkType: hard linkType: hard
@ -14088,12 +14110,12 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"regex@npm:^5.1.1": "regex@npm:^6.0.1":
version: 5.1.1 version: 6.0.1
resolution: "regex@npm:5.1.1" resolution: "regex@npm:6.0.1"
dependencies: dependencies:
regex-utilities: "npm:^2.3.0" regex-utilities: "npm:^2.3.0"
checksum: 10c0/314e032f0fe09497ce7a160b99675c4a16c7524f0a24833f567cbbf3a2bebc26bf59737dc5c23f32af7c74aa7a6bd3f809fc72c90c49a05faf8be45677db508a checksum: 10c0/687b3e063d4ca19b0de7c55c24353f868a0fb9ba21512692470d2fb412e3a410894dd5924c91ea49d8cb8fa865e36ec956e52436ae0a256bdc095ff136c30aba
languageName: node languageName: node
linkType: hard linkType: hard
@ -14814,19 +14836,19 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"shiki@npm:^1.22.2": "shiki@npm:^3.2.1":
version: 1.29.2 version: 3.2.1
resolution: "shiki@npm:1.29.2" resolution: "shiki@npm:3.2.1"
dependencies: dependencies:
"@shikijs/core": "npm:1.29.2" "@shikijs/core": "npm:3.2.1"
"@shikijs/engine-javascript": "npm:1.29.2" "@shikijs/engine-javascript": "npm:3.2.1"
"@shikijs/engine-oniguruma": "npm:1.29.2" "@shikijs/engine-oniguruma": "npm:3.2.1"
"@shikijs/langs": "npm:1.29.2" "@shikijs/langs": "npm:3.2.1"
"@shikijs/themes": "npm:1.29.2" "@shikijs/themes": "npm:3.2.1"
"@shikijs/types": "npm:1.29.2" "@shikijs/types": "npm:3.2.1"
"@shikijs/vscode-textmate": "npm:^10.0.1" "@shikijs/vscode-textmate": "npm:^10.0.2"
"@types/hast": "npm:^3.0.4" "@types/hast": "npm:^3.0.4"
checksum: 10c0/9ef452021582c405501077082c4ae8d877027dca6488d2c7a1963ed661567f121b4cc5dea9dfab26689504b612b8a961f3767805cbeaaae3c1d6faa5e6f37eb0 checksum: 10c0/8153b5a354c508815c8a20c03bf182aa863d07e865bc603e136c633c60abb729655c5487109111ed8a3e5a4aff0275f3b714b05c5b129085c8ed69069537f0c0
languageName: node languageName: node
linkType: hard linkType: hard