refactor(CodePreview): handle chunking in ShikiStreamService, make the algorithm more robust (#7409)

* refactor(ShikiStreamService, CodePreview): handle chunking in ShikiStreamService, make the algorithm more robust

- Add highlightStreamingCode with improved robustness
- Improve viewport detection

* perf: improve checks for appending

* chore: update comments
This commit is contained in:
one 2025-06-26 13:30:49 +08:00 committed by GitHub
parent 1db93e8b56
commit 5811adfb7f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 147 additions and 67 deletions

View File

@ -4,7 +4,7 @@ import { useSettings } from '@renderer/hooks/useSettings'
import { uuid } from '@renderer/utils' import { uuid } from '@renderer/utils'
import { getReactStyleFromToken } from '@renderer/utils/shiki' import { getReactStyleFromToken } from '@renderer/utils/shiki'
import { ChevronsDownUp, ChevronsUpDown, Text as UnWrapIcon, WrapText as WrapIcon } from 'lucide-react' import { ChevronsDownUp, ChevronsUpDown, Text as UnWrapIcon, WrapText as WrapIcon } from 'lucide-react'
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { memo, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ThemedToken } from 'shiki/core' import { ThemedToken } from 'shiki/core'
import styled from 'styled-components' import styled from 'styled-components'
@ -18,19 +18,20 @@ interface CodePreviewProps {
/** /**
* Shiki * Shiki
* *
* - shiki tokenizer * - shiki tokenizer
* - tokenizer * -
* -
*/ */
const CodePreview = ({ children, language, setTools }: CodePreviewProps) => { const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
const { codeShowLineNumbers, fontSize, codeCollapsible, codeWrappable } = useSettings() const { codeShowLineNumbers, fontSize, codeCollapsible, codeWrappable } = useSettings()
const { activeShikiTheme, highlightCodeChunk, cleanupTokenizers } = useCodeStyle() const { activeShikiTheme, highlightStreamingCode, cleanupTokenizers } = useCodeStyle()
const [isExpanded, setIsExpanded] = useState(!codeCollapsible) const [isExpanded, setIsExpanded] = useState(!codeCollapsible)
const [isUnwrapped, setIsUnwrapped] = useState(!codeWrappable) const [isUnwrapped, setIsUnwrapped] = useState(!codeWrappable)
const [tokenLines, setTokenLines] = useState<ThemedToken[][]>([]) const [tokenLines, setTokenLines] = useState<ThemedToken[][]>([])
const codeContentRef = useRef<HTMLDivElement>(null) const [isInViewport, setIsInViewport] = useState(false)
const prevCodeLengthRef = useRef(0) const codeContainerRef = useRef<HTMLDivElement>(null)
const safeCodeStringRef = useRef(children) const processingRef = useRef(false)
const highlightQueueRef = useRef<Promise<void>>(Promise.resolve()) const latestRequestedContentRef = useRef<string | null>(null)
const callerId = useRef(`${Date.now()}-${uuid()}`).current const callerId = useRef(`${Date.now()}-${uuid()}`).current
const shikiThemeRef = useRef(activeShikiTheme) const shikiThemeRef = useRef(activeShikiTheme)
@ -45,7 +46,7 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
icon: isExpanded ? <ChevronsDownUp className="icon" /> : <ChevronsUpDown className="icon" />, icon: isExpanded ? <ChevronsDownUp className="icon" /> : <ChevronsUpDown className="icon" />,
tooltip: isExpanded ? t('code_block.collapse') : t('code_block.expand'), tooltip: isExpanded ? t('code_block.collapse') : t('code_block.expand'),
visible: () => { visible: () => {
const scrollHeight = codeContentRef.current?.scrollHeight const scrollHeight = codeContainerRef.current?.scrollHeight
return codeCollapsible && (scrollHeight ?? 0) > 350 return codeCollapsible && (scrollHeight ?? 0) > 350
}, },
onClick: () => setIsExpanded((prev) => !prev) onClick: () => setIsExpanded((prev) => !prev)
@ -77,81 +78,63 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
setIsUnwrapped(!codeWrappable) setIsUnwrapped(!codeWrappable)
}, [codeWrappable]) }, [codeWrappable])
// 处理尾部空白字符
const safeCodeString = useMemo(() => {
return typeof children === 'string' ? children.trimEnd() : ''
}, [children])
const highlightCode = useCallback(async () => { const highlightCode = useCallback(async () => {
if (!safeCodeString) return const currentContent = typeof children === 'string' ? children.trimEnd() : ''
if (prevCodeLengthRef.current === safeCodeString.length) return // 记录最新要处理的内容,为了保证最终状态正确
latestRequestedContentRef.current = currentContent
// 捕获当前状态 // 如果正在处理,先跳出,等到完成后会检查是否有新内容
const startPos = prevCodeLengthRef.current if (processingRef.current) return
const endPos = safeCodeString.length
// 添加到处理队列,确保按顺序处理 processingRef.current = true
highlightQueueRef.current = highlightQueueRef.current.then(async () => {
// FIXME: 长度有问题,或者破坏了流式内容,需要清理 tokenizer 并使用完整代码重新高亮
if (prevCodeLengthRef.current > safeCodeString.length || !safeCodeString.startsWith(safeCodeStringRef.current)) {
cleanupTokenizers(callerId)
prevCodeLengthRef.current = 0
safeCodeStringRef.current = ''
const result = await highlightCodeChunk(safeCodeString, language, callerId) try {
setTokenLines(result.lines) // 循环处理,确保会处理最新内容
while (latestRequestedContentRef.current !== null) {
const contentToProcess = latestRequestedContentRef.current
latestRequestedContentRef.current = null // 标记开始处理
prevCodeLengthRef.current = safeCodeString.length // 传入完整内容,让 ShikiStreamService 检测变化并处理增量高亮
safeCodeStringRef.current = safeCodeString const result = await highlightStreamingCode(contentToProcess, language, callerId)
return // 如有结果,更新 tokenLines
if (result.lines.length > 0 || result.recall !== 0) {
setTokenLines((prev) => {
return result.recall === -1
? result.lines
: [...prev.slice(0, Math.max(0, prev.length - result.recall)), ...result.lines]
})
}
} }
} finally {
// 跳过 race condition延迟到后续任务 processingRef.current = false
if (prevCodeLengthRef.current !== startPos) { }
return }, [highlightStreamingCode, language, callerId, children])
}
const incrementalCode = safeCodeString.slice(startPos, endPos)
const result = await highlightCodeChunk(incrementalCode, language, callerId)
setTokenLines((lines) => [...lines.slice(0, Math.max(0, lines.length - result.recall)), ...result.lines])
prevCodeLengthRef.current = endPos
safeCodeStringRef.current = safeCodeString
})
}, [callerId, cleanupTokenizers, highlightCodeChunk, language, safeCodeString])
// 主题变化时强制重新高亮 // 主题变化时强制重新高亮
useEffect(() => { useEffect(() => {
if (shikiThemeRef.current !== activeShikiTheme) { if (shikiThemeRef.current !== activeShikiTheme) {
prevCodeLengthRef.current++
shikiThemeRef.current = activeShikiTheme shikiThemeRef.current = activeShikiTheme
cleanupTokenizers(callerId)
setTokenLines([])
} }
}, [activeShikiTheme]) }, [activeShikiTheme, callerId, cleanupTokenizers])
// 组件卸载时清理资源 // 组件卸载时清理资源
useEffect(() => { useEffect(() => {
return () => cleanupTokenizers(callerId) return () => cleanupTokenizers(callerId)
}, [callerId, cleanupTokenizers]) }, [callerId, cleanupTokenizers])
// 触发代码高亮 // 视口检测逻辑,进入视口后触发第一次代码高亮
// - 进入视口后触发第一次高亮
// - 内容变化后触发之后的高亮
useEffect(() => { useEffect(() => {
let isMounted = true const codeElement = codeContainerRef.current
if (prevCodeLengthRef.current > 0) {
setTimeout(highlightCode, 0)
return
}
const codeElement = codeContentRef.current
if (!codeElement) return if (!codeElement) return
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
if (entries[0].intersectionRatio > 0 && isMounted) { if (entries[0].intersectionRatio > 0) {
setTimeout(highlightCode, 0) setIsInViewport(true)
observer.disconnect() observer.disconnect()
} }
}, },
@ -161,15 +144,18 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
) )
observer.observe(codeElement) observer.observe(codeElement)
return () => observer.disconnect()
}, []) // 只执行一次
return () => { // 触发代码高亮
isMounted = false useEffect(() => {
observer.disconnect() if (!isInViewport) return
}
}, [highlightCode]) setTimeout(highlightCode, 0)
}, [isInViewport, highlightCode])
useEffect(() => { useEffect(() => {
const container = codeContentRef.current const container = codeContainerRef.current
if (!container || !codeShowLineNumbers) return if (!container || !codeShowLineNumbers) return
const digits = Math.max(tokenLines.length.toString().length, 1) const digits = Math.max(tokenLines.length.toString().length, 1)
@ -180,7 +166,7 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
return ( return (
<ContentContainer <ContentContainer
ref={codeContentRef} ref={codeContainerRef}
$lineNumbers={codeShowLineNumbers} $lineNumbers={codeShowLineNumbers}
$wrap={codeWrappable && !isUnwrapped} $wrap={codeWrappable && !isUnwrapped}
$fadeIn={hasHighlightedCode} $fadeIn={hasHighlightedCode}

View File

@ -10,6 +10,7 @@ import { createContext, type PropsWithChildren, use, useCallback, useEffect, use
interface CodeStyleContextType { interface CodeStyleContextType {
highlightCodeChunk: (trunk: string, language: string, callerId: string) => Promise<HighlightChunkResult> highlightCodeChunk: (trunk: string, language: string, callerId: string) => Promise<HighlightChunkResult>
highlightStreamingCode: (code: string, language: string, callerId: string) => Promise<HighlightChunkResult>
cleanupTokenizers: (callerId: string) => void cleanupTokenizers: (callerId: string) => void
getShikiPreProperties: (language: string) => Promise<ShikiPreProperties> getShikiPreProperties: (language: string) => Promise<ShikiPreProperties>
highlightCode: (code: string, language: string) => Promise<string> highlightCode: (code: string, language: string) => Promise<string>
@ -22,6 +23,7 @@ interface CodeStyleContextType {
const defaultCodeStyleContext: CodeStyleContextType = { const defaultCodeStyleContext: CodeStyleContextType = {
highlightCodeChunk: async () => ({ lines: [], recall: 0 }), highlightCodeChunk: async () => ({ lines: [], recall: 0 }),
highlightStreamingCode: async () => ({ lines: [], recall: 0 }),
cleanupTokenizers: () => {}, cleanupTokenizers: () => {},
getShikiPreProperties: async () => ({ class: '', style: '', tabindex: 0 }), getShikiPreProperties: async () => ({ class: '', style: '', tabindex: 0 }),
highlightCode: async () => '', highlightCode: async () => '',
@ -114,6 +116,15 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
shikiStreamService.cleanupTokenizers(callerId) shikiStreamService.cleanupTokenizers(callerId)
}, []) }, [])
// 高亮流式输出的代码
const highlightStreamingCode = useCallback(
async (fullContent: string, language: string, callerId: string) => {
const normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
return shikiStreamService.highlightStreamingCode(fullContent, normalizedLang, activeShikiTheme, callerId)
},
[activeShikiTheme, languageMap]
)
// 获取 Shiki pre 标签属性 // 获取 Shiki pre 标签属性
const getShikiPreProperties = useCallback( const getShikiPreProperties = useCallback(
async (language: string) => { async (language: string) => {
@ -148,6 +159,7 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
const contextValue = useMemo( const contextValue = useMemo(
() => ({ () => ({
highlightCodeChunk, highlightCodeChunk,
highlightStreamingCode,
cleanupTokenizers, cleanupTokenizers,
getShikiPreProperties, getShikiPreProperties,
highlightCode, highlightCode,
@ -159,6 +171,7 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
}), }),
[ [
highlightCodeChunk, highlightCodeChunk,
highlightStreamingCode,
cleanupTokenizers, cleanupTokenizers,
getShikiPreProperties, getShikiPreProperties,
highlightCode, highlightCode,

View File

@ -20,7 +20,7 @@ export type ShikiPreProperties = {
* chunk * chunk
* *
* @param lines * @param lines
* @param recall * @param recall -1
*/ */
export interface HighlightChunkResult { export interface HighlightChunkResult {
lines: ThemedToken[][] lines: ThemedToken[][]
@ -47,6 +47,13 @@ class ShikiStreamService {
} }
}) })
// 缓存每个 callerId 对应的已处理内容
private codeCache = new LRUCache<string, string>({
max: 100, // 最大缓存数量
ttl: 1000 * 60 * 30, // 30分钟过期时间
updateAgeOnGet: true
})
// Worker 相关资源 // Worker 相关资源
private worker: Worker | null = null private worker: Worker | null = null
private workerInitPromise: Promise<void> | null = null private workerInitPromise: Promise<void> | null = null
@ -261,6 +268,72 @@ class ShikiStreamService {
return hast.children[0].properties as ShikiPreProperties return hast.children[0].properties as ShikiPreProperties
} }
/**
*
*
* -
* -
* - tokenizer
*
*
* @param code
* @param language
* @param theme
* @param callerId ID
* @returns recall -1
*/
async highlightStreamingCode(
code: string,
language: string,
theme: string,
callerId: string
): Promise<HighlightChunkResult> {
const cacheKey = `${callerId}-${language}-${theme}`
const lastContent = this.codeCache.get(cacheKey) || ''
let isAppend = false
if (code.length === lastContent.length) {
// 内容没有变化,返回空结果
if (code === lastContent) {
return { lines: [], recall: 0 }
}
} else if (code.length > lastContent.length) {
// 长度增加,可能是追加
isAppend = code.startsWith(lastContent)
}
try {
let result: HighlightChunkResult
if (isAppend) {
// 流式追加,只传输增量
const chunk = code.slice(lastContent.length)
result = await this.highlightCodeChunk(chunk, language, theme, callerId)
} else {
// 非追加变化,重置并处理完整内容
this.cleanupTokenizers(callerId)
this.codeCache.delete(cacheKey) // 清除缓存
result = await this.highlightCodeChunk(code, language, theme, callerId)
// 撤回所有行
result = {
...result,
recall: -1
}
}
// 成功处理后更新缓存
this.codeCache.set(cacheKey, code)
return result
} catch (error) {
// 处理失败时不更新缓存,保持之前的状态
console.error('Failed to highlight streaming code:', error)
throw error
}
}
/** /**
* chunk ThemedToken * chunk ThemedToken
* *
@ -405,6 +478,13 @@ class ShikiStreamService {
}) })
} }
// 清理对应的内容缓存
for (const key of this.codeCache.keys()) {
if (key.startsWith(`${callerId}-`)) {
this.codeCache.delete(key)
}
}
// 再清理主线程中的 tokenizers移除所有以 callerId 开头的缓存项 // 再清理主线程中的 tokenizers移除所有以 callerId 开头的缓存项
for (const key of this.tokenizerCache.keys()) { for (const key of this.tokenizerCache.keys()) {
if (key.startsWith(`${callerId}-`)) { if (key.startsWith(`${callerId}-`)) {
@ -429,6 +509,7 @@ class ShikiStreamService {
this.workerDegradationCache.clear() this.workerDegradationCache.clear()
this.tokenizerCache.clear() this.tokenizerCache.clear()
this.codeCache.clear()
this.highlighter = null this.highlighter = null
this.workerInitPromise = null this.workerInitPromise = null
this.workerInitRetryCount = 0 this.workerInitRetryCount = 0