refactor(SyntaxHighlighter): modularize highlighter logic and improve theme/language loading

* Moved highlighter initialization and loading functions to a new utility file for better organization.
* Simplified theme and language loading in the SyntaxHighlighterProvider using the new utility functions.
* Removed redundant code and improved readability in MessageTools by introducing a new CollapsedContent component for rendering tool responses.
* Updated MCPDescription to use an async function for Shiki instance initialization.
This commit is contained in:
kangfenmao 2025-05-18 22:52:03 +08:00 committed by 亢奋猫
parent e91a449b82
commit 8a54ebc183
5 changed files with 77 additions and 55 deletions

View File

@ -3,22 +3,10 @@ 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 { CodeCacheService } from '@renderer/services/CodeCacheService'
import { type CodeStyleVarious, ThemeMode } from '@renderer/types' import { type CodeStyleVarious, ThemeMode } from '@renderer/types'
import { getHighlighter, loadLanguageIfNeeded, loadThemeIfNeeded } from '@renderer/utils/highlighter'
import type React from 'react' import type React from 'react'
import { createContext, type PropsWithChildren, use, useCallback, useMemo } from 'react' import { createContext, type PropsWithChildren, use, useCallback, useMemo } from 'react'
import { bundledLanguages, bundledThemes, createHighlighter, type Highlighter } from 'shiki' import { bundledThemes } 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, enableCache: boolean) => Promise<string> codeToHtml: (code: string, language: string, enableCache: boolean) => Promise<string>
@ -60,19 +48,8 @@ export const SyntaxHighlighterProvider: React.FC<PropsWithChildren> = ({ childre
try { try {
const highlighter = await getHighlighter() const highlighter = await getHighlighter()
if (!highlighter.getLoadedThemes().includes(highlighterTheme)) { await loadThemeIfNeeded(highlighter, highlighterTheme)
const themeImportFn = bundledThemes[highlighterTheme] await loadLanguageIfNeeded(highlighter, mappedLanguage)
if (themeImportFn) {
await highlighter.loadTheme(await themeImportFn())
}
}
if (!highlighter.getLoadedLanguages().includes(mappedLanguage)) {
const languageImportFn = bundledLanguages[mappedLanguage]
if (languageImportFn) {
await highlighter.loadLanguage(await languageImportFn())
}
}
// 生成高亮HTML // 生成高亮HTML
const html = highlighter.codeToHtml(code, { const html = highlighter.codeToHtml(code, {

View File

@ -39,7 +39,6 @@ const MessageTools: FC<Props> = ({ blocks }) => {
return 'Invalid Result' return 'Invalid Result'
} }
}, [toolResponse]) }, [toolResponse])
const { renderedMarkdown: styledResult } = useShikiWithMarkdownIt(`\`\`\`json\n${resultString}\n\`\`\``)
if (!toolResponse) { if (!toolResponse) {
return null return null
@ -59,8 +58,6 @@ const MessageTools: FC<Props> = ({ blocks }) => {
// Format tool responses for collapse items // Format tool responses for collapse items
const getCollapseItems = () => { const getCollapseItems = () => {
const items: { key: string; label: React.ReactNode; children: React.ReactNode }[] = [] const items: { key: string; label: React.ReactNode; children: React.ReactNode }[] = []
// Add tool responses
// for (const toolResponse of toolResponses) {
const { id, tool, status, response } = toolResponse const { id, tool, status, response } = toolResponse
const isInvoking = status === 'invoking' const isInvoking = status === 'invoking'
const isDone = status === 'done' const isDone = status === 'done'
@ -123,11 +120,10 @@ const MessageTools: FC<Props> = ({ blocks }) => {
), ),
children: isDone && result && ( children: isDone && result && (
<ToolResponseContainer style={{ fontFamily, fontSize: '12px' }}> <ToolResponseContainer style={{ fontFamily, fontSize: '12px' }}>
<div className="markdown" dangerouslySetInnerHTML={{ __html: styledResult }} /> <CollapsedContent isExpanded={activeKeys.includes(id)} resultString={resultString} />
</ToolResponseContainer> </ToolResponseContainer>
) )
}) })
// }
return items return items
} }
@ -140,7 +136,6 @@ const MessageTools: FC<Props> = ({ blocks }) => {
switch (parsedResult.content[0]?.type) { switch (parsedResult.content[0]?.type) {
case 'text': case 'text':
return <PreviewBlock>{parsedResult.content[0].text}</PreviewBlock> return <PreviewBlock>{parsedResult.content[0].text}</PreviewBlock>
// TODO: support other types
default: default:
return <PreviewBlock>{content}</PreviewBlock> return <PreviewBlock>{content}</PreviewBlock>
} }
@ -174,7 +169,6 @@ const MessageTools: FC<Props> = ({ blocks }) => {
styles={{ body: { maxHeight: '80vh', overflow: 'auto' } }}> styles={{ body: { maxHeight: '80vh', overflow: 'auto' } }}>
{expandedResponse && ( {expandedResponse && (
<ExpandedResponseContainer style={{ fontFamily, fontSize }}> <ExpandedResponseContainer style={{ fontFamily, fontSize }}>
{/* mode swtich tabs */}
<Tabs <Tabs
tabBarExtraContent={ tabBarExtraContent={
<ActionButton <ActionButton
@ -200,7 +194,16 @@ const MessageTools: FC<Props> = ({ blocks }) => {
{ {
key: 'raw', key: 'raw',
label: t('message.tools.raw'), label: t('message.tools.raw'),
children: <div className="markdown" dangerouslySetInnerHTML={{ __html: styledResult }} /> children: (
<CollapsedContent
isExpanded={true}
resultString={
typeof expandedResponse.content === 'string'
? expandedResponse.content
: JSON.stringify(expandedResponse.content, null, 2)
}
/>
)
} }
]} ]}
/> />
@ -211,6 +214,19 @@ const MessageTools: FC<Props> = ({ blocks }) => {
) )
} }
// New component to handle collapsed content
const CollapsedContent: FC<{ isExpanded: boolean; resultString: string }> = ({ isExpanded, resultString }) => {
const { renderedMarkdown: styledResult } = useShikiWithMarkdownIt(
isExpanded ? `\`\`\`json\n${resultString}\n\`\`\`` : ''
)
if (!isExpanded) {
return null
}
return <div className="markdown" dangerouslySetInnerHTML={{ __html: styledResult }} />
}
const CollapseContainer = styled(Collapse)` const CollapseContainer = styled(Collapse)`
margin-top: 10px; margin-top: 10px;
margin-bottom: 12px; margin-bottom: 12px;

View File

@ -1,4 +1,5 @@
import { useTheme } from '@renderer/context/ThemeProvider' import { useTheme } from '@renderer/context/ThemeProvider'
import { runAsyncFunction } from '@renderer/utils'
import { getShikiInstance } from '@renderer/utils/shiki' import { getShikiInstance } from '@renderer/utils/shiki'
import { Card } from 'antd' import { Card } from 'antd'
import MarkdownIt from 'markdown-it' import MarkdownIt from 'markdown-it'
@ -30,9 +31,11 @@ const MCPDescription = ({ searchKey }: McpDescriptionProps) => {
}, [md, searchKey]) }, [md, searchKey])
useEffect(() => { useEffect(() => {
const sk = getShikiInstance(theme) runAsyncFunction(async () => {
md.current.use(sk) const sk = await getShikiInstance(theme)
getMcpInfo() md.current.use(sk)
getMcpInfo()
})
}, [getMcpInfo, theme]) }, [getMcpInfo, theme])
return ( return (

View File

@ -0,0 +1,32 @@
import { bundledLanguages, bundledThemes, createHighlighter, type Highlighter } from 'shiki'
let highlighterPromise: Promise<Highlighter> | null = null
export async function getHighlighter() {
if (!highlighterPromise) {
highlighterPromise = createHighlighter({
langs: ['javascript', 'typescript', 'python', 'java', 'markdown', 'json'],
themes: ['one-light', 'material-theme-darker']
})
}
return await highlighterPromise
}
export async function loadLanguageIfNeeded(highlighter: Highlighter, language: string) {
if (!highlighter.getLoadedLanguages().includes(language)) {
const languageImportFn = bundledLanguages[language]
if (languageImportFn) {
await highlighter.loadLanguage(await languageImportFn())
}
}
}
export async function loadThemeIfNeeded(highlighter: Highlighter, theme: string) {
if (!highlighter.getLoadedThemes().includes(theme)) {
const themeImportFn = bundledThemes[theme]
if (themeImportFn) {
await highlighter.loadTheme(await themeImportFn())
}
}
}

View File

@ -1,9 +1,11 @@
import { useTheme } from '@renderer/context/ThemeProvider' import { useTheme } from '@renderer/context/ThemeProvider'
import { ThemeMode } from '@renderer/types' import { ThemeMode } from '@renderer/types'
import { MarkdownItShikiOptions, setupMarkdownIt } from '@shikijs/markdown-it' import { setupMarkdownIt } from '@shikijs/markdown-it'
import MarkdownIt from 'markdown-it' import MarkdownIt from 'markdown-it'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { BuiltinLanguage, BuiltinTheme, bundledLanguages, createHighlighter } from 'shiki'
import { runAsyncFunction } from '.'
import { getHighlighter } from './highlighter'
const defaultOptions = { const defaultOptions = {
themes: { themes: {
@ -13,19 +15,9 @@ const defaultOptions = {
defaultColor: 'light' defaultColor: 'light'
} }
const initHighlighter = async (options: MarkdownItShikiOptions) => { export async function getShikiInstance(theme: ThemeMode) {
const themeNames = ('themes' in options ? Object.values(options.themes) : [options.theme]).filter( const highlighter = await getHighlighter()
Boolean
) as BuiltinTheme[]
return await createHighlighter({
themes: themeNames,
langs: options.langs || (Object.keys(bundledLanguages) as BuiltinLanguage[])
})
}
const highlighter = await initHighlighter(defaultOptions)
export function getShikiInstance(theme: ThemeMode) {
const options = { const options = {
...defaultOptions, ...defaultOptions,
defaultColor: theme defaultColor: theme
@ -46,9 +38,11 @@ export function useShikiWithMarkdownIt(content: string) {
) )
const { theme } = useTheme() const { theme } = useTheme()
useEffect(() => { useEffect(() => {
const sk = getShikiInstance(theme) runAsyncFunction(async () => {
md.current.use(sk) const sk = await getShikiInstance(theme)
setRenderedMarkdown(md.current.render(content)) md.current.use(sk)
setRenderedMarkdown(md.current.render(content))
})
}, [content, theme]) }, [content, theme])
return { return {
renderedMarkdown renderedMarkdown