mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Merge pull request #73024 from NousResearch/bb/desktop-cold-start
perf(desktop): cut renderer cold start by keeping shiki/mermaid off the boot path
This commit is contained in:
commit
3d649f3376
7 changed files with 157 additions and 31 deletions
|
|
@ -7,7 +7,6 @@ import type {
|
|||
ReactNode
|
||||
} from 'react'
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import ShikiHighlighter from 'react-shiki'
|
||||
import { Streamdown } from 'streamdown'
|
||||
|
||||
import { requestComposerFocus, requestComposerInsertRefs } from '@/app/chat/composer/focus'
|
||||
|
|
@ -18,6 +17,7 @@ import { RichCodeBlock } from '@/components/assistant-ui/embeds'
|
|||
import { CodeEditor } from '@/components/chat/code-editor'
|
||||
import { FileDiffPanel } from '@/components/chat/diff-lines'
|
||||
import { chunkTextLines, useFixedRowWindow } from '@/components/chat/fixed-row-window'
|
||||
import { LazyShiki as ShikiHighlighter } from '@/components/chat/shiki-highlighter'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { translateNow, useI18n } from '@/i18n'
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
type SyntaxHighlighterProps,
|
||||
tailBoundedRemend
|
||||
} from '@assistant-ui/react-streamdown'
|
||||
import { code } from '@streamdown/code'
|
||||
import type { code as streamdownCode } from '@streamdown/code'
|
||||
import { type ComponentProps, memo, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { ExpandableBlock } from '@/components/chat/expandable-block'
|
||||
|
|
@ -52,6 +52,41 @@ import { detectEmbed, extractAlert, MarkdownAlert, RichCodeBlock, UrlEmbed } fro
|
|||
// LLM convention). The default false-setting only accepts `$$...$$`.
|
||||
const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true })
|
||||
|
||||
// `@streamdown/code` statically imports ALL of shiki (every grammar + theme —
|
||||
// the single largest chunk in the renderer), so it must never sit on the
|
||||
// entry graph. Load it on first markdown mount and swap it into the plugin
|
||||
// table when it lands; until then fenced code renders through the
|
||||
// `SyntaxHighlighter` override's plain path (same output Shiki's own
|
||||
// `delay` fallback shows), so nothing flashes or reflows unexpectedly.
|
||||
type CodePlugin = typeof streamdownCode
|
||||
let codePluginCache: CodePlugin | null = null
|
||||
|
||||
function useCodePlugin(): CodePlugin | null {
|
||||
const [plugin, setPlugin] = useState(codePluginCache)
|
||||
|
||||
useEffect(() => {
|
||||
if (plugin) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
void import('@streamdown/code').then(({ code }) => {
|
||||
codePluginCache = code
|
||||
|
||||
if (!cancelled) {
|
||||
setPlugin(code)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [plugin])
|
||||
|
||||
return plugin
|
||||
}
|
||||
|
||||
// Replaces Streamdown's `parseIncompleteMarkdown` (full-text remend per
|
||||
// flush) with a tail-bounded repair. Must stay module-scope so the prop
|
||||
// identity is stable across renders.
|
||||
|
|
@ -419,8 +454,10 @@ function MarkdownTextSurface({
|
|||
|
||||
// Keep code parsing enabled while streaming so incomplete fenced blocks still
|
||||
// render as code cards. The expensive Shiki pass is deferred by
|
||||
// `SyntaxHighlighter` below when `isStreaming` is true.
|
||||
const plugins = useMemo(() => ({ math: mathPlugin, code }), [])
|
||||
// `SyntaxHighlighter` below when `isStreaming` is true, and the code plugin
|
||||
// itself arrives async (useCodePlugin) so shiki never blocks cold start.
|
||||
const code = useCodePlugin()
|
||||
const plugins = useMemo(() => (code ? { math: mathPlugin, code } : { math: mathPlugin }), [code])
|
||||
|
||||
const components = useMemo(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import * as React from 'react'
|
||||
import { useShikiHighlighter } from 'react-shiki'
|
||||
import { type BundledLanguage, codeToTokens, type ShikiTransformer, type ThemedToken } from 'shiki'
|
||||
import type { BundledLanguage, ShikiTransformer, ThemedToken } from 'shiki'
|
||||
|
||||
import { chunkLines, type LineChunk, useFixedRowWindow } from '@/components/chat/fixed-row-window'
|
||||
import { exceedsHighlightBudget, SHIKI_THEME } from '@/components/chat/shiki-highlighter'
|
||||
|
|
@ -276,7 +274,8 @@ function parseFullFileDiff(diff: string, fullText: string): DiffLine[] {
|
|||
return out
|
||||
}
|
||||
|
||||
function DiffBody({ lines, syntax }: { lines: DiffLine[]; syntax?: boolean }) {
|
||||
/** Exported for the lazily-loaded SyntaxDiff (syntax-diff.tsx). */
|
||||
export function DiffBody({ lines, syntax }: { lines: DiffLine[]; syntax?: boolean }) {
|
||||
return (
|
||||
<>
|
||||
{lines.map((line, index) => (
|
||||
|
|
@ -383,7 +382,10 @@ function TokenizedDiffBody({
|
|||
let cancelled = false
|
||||
|
||||
setTokens(null)
|
||||
void codeToTokens(code, { lang: language as BundledLanguage, theme })
|
||||
// Dynamic import so the multi-MB shiki chunk stays off the cold-start
|
||||
// path — this effect only runs once a highlightable diff is on screen.
|
||||
void import('shiki')
|
||||
.then(({ codeToTokens }) => codeToTokens(code, { lang: language as BundledLanguage, theme }))
|
||||
.then(result => {
|
||||
if (!cancelled) {
|
||||
setTokens(result.tokens)
|
||||
|
|
@ -446,7 +448,8 @@ function TokenizedDiffBody({
|
|||
|
||||
// Shiki transformer: tag each `.line` with the diff tint for its kind, so the
|
||||
// syntax-highlighted output keeps add/remove backgrounds + the gutter accent.
|
||||
function diffLineTransformer(kinds: DiffKind[]): ShikiTransformer {
|
||||
// Exported for the lazily-loaded SyntaxDiff (syntax-diff.tsx).
|
||||
export function diffLineTransformer(kinds: DiffKind[]): ShikiTransformer {
|
||||
return {
|
||||
line(node, line) {
|
||||
const kind = kinds[line - 1] ?? 'context'
|
||||
|
|
@ -463,18 +466,18 @@ function diffLineTransformer(kinds: DiffKind[]): ShikiTransformer {
|
|||
}
|
||||
|
||||
function SyntaxDiff({ language, lines }: { language: string; lines: DiffLine[] }) {
|
||||
const code = React.useMemo(() => lines.map(line => line.text).join('\n'), [lines])
|
||||
const transformers = React.useMemo(() => [diffLineTransformer(lines.map(line => line.kind))], [lines])
|
||||
|
||||
const highlighted = useShikiHighlighter(code, language, SHIKI_THEME, {
|
||||
defaultColor: 'light-dark()',
|
||||
transformers
|
||||
})
|
||||
|
||||
// Until Shiki resolves, show the plain colored diff so there's no flash.
|
||||
return (highlighted as ReactNode) ?? <DiffBody lines={lines} />
|
||||
// The Shiki hook lives in a lazily-loaded module (syntax-diff.tsx) so the
|
||||
// multi-MB shiki chunk stays off the cold-start path. Until it (and the
|
||||
// highlight itself) resolves, show the plain colored diff — no flash.
|
||||
return (
|
||||
<React.Suspense fallback={<DiffBody lines={lines} />}>
|
||||
<LazySyntaxDiff language={language} lines={lines} />
|
||||
</React.Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
const LazySyntaxDiff = React.lazy(() => import('./syntax-diff'))
|
||||
|
||||
interface DiffLinesProps extends Omit<React.ComponentProps<'pre'>, 'children'> {
|
||||
text: string
|
||||
}
|
||||
|
|
|
|||
15
apps/desktop/src/components/chat/shiki-block.tsx
Normal file
15
apps/desktop/src/components/chat/shiki-block.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
'use client'
|
||||
|
||||
/**
|
||||
* The ONLY static importer of `react-shiki` (and through it the multi-MB
|
||||
* shiki language/theme bundle). Every consumer reaches this module through
|
||||
* `React.lazy(() => import('./shiki-block'))` — see `LazyShiki` in
|
||||
* shiki-highlighter.tsx — so the shiki chunk stays entirely off the
|
||||
* cold-start path and loads on the first highlighted code block instead.
|
||||
*
|
||||
* Do NOT import this module statically from anything the entry graph
|
||||
* reaches, or the chunk moves back into boot.
|
||||
*/
|
||||
import ShikiHighlighter from 'react-shiki'
|
||||
|
||||
export default ShikiHighlighter
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
'use client'
|
||||
|
||||
import type { SyntaxHighlighterProps } from '@assistant-ui/react-streamdown'
|
||||
import { type FC, useMemo } from 'react'
|
||||
import ShikiHighlighter from 'react-shiki'
|
||||
import { type ComponentProps, type FC, lazy, Suspense, useMemo } from 'react'
|
||||
import type ShikiHighlighter from 'react-shiki'
|
||||
|
||||
import {
|
||||
CodeCard,
|
||||
|
|
@ -52,6 +52,20 @@ const MAX_HIGHLIGHT_LINES = 3_000
|
|||
const CHUNK_LINES = 200
|
||||
const EST_LINE_PX = 16
|
||||
|
||||
// react-shiki (and through it the multi-MB shiki grammar/theme bundle) is the
|
||||
// heaviest dependency in the renderer. `shiki-block.tsx` is its only static
|
||||
// importer, so this lazy() is the single seam that keeps shiki out of the
|
||||
// entry chunk — it loads on the first highlighted code block, not at boot.
|
||||
const ShikiBlock = lazy(() => import('./shiki-block'))
|
||||
|
||||
/** Drop-in ShikiHighlighter that suspends on first use and renders the code
|
||||
* as plain preformatted text until the shiki chunk arrives. */
|
||||
export const LazyShiki: FC<ComponentProps<typeof ShikiHighlighter>> = props => (
|
||||
<Suspense fallback={<PlainCode code={String(props.children ?? '')} />}>
|
||||
<ShikiBlock {...props} />
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
export function exceedsHighlightBudget(code: string): boolean {
|
||||
if (code.length > MAX_HIGHLIGHT_CHARS) {
|
||||
return true
|
||||
|
|
@ -161,7 +175,7 @@ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
|
|||
{plain ? (
|
||||
<PlainCode code={trimmed} />
|
||||
) : (
|
||||
<ShikiHighlighter
|
||||
<LazyShiki
|
||||
addDefaultStyles={false}
|
||||
as="div"
|
||||
colorReplacements={SHIKI_COLOR_REPLACEMENTS}
|
||||
|
|
@ -172,7 +186,7 @@ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
|
|||
theme={SHIKI_THEME}
|
||||
>
|
||||
{trimmed}
|
||||
</ShikiHighlighter>
|
||||
</LazyShiki>
|
||||
)}
|
||||
</Pre>
|
||||
</ExpandableBlock>
|
||||
|
|
|
|||
27
apps/desktop/src/components/chat/syntax-diff.tsx
Normal file
27
apps/desktop/src/components/chat/syntax-diff.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
'use client'
|
||||
|
||||
/**
|
||||
* The Shiki-highlighted compact diff body, split out of diff-lines.tsx so the
|
||||
* `react-shiki` static import (and the multi-MB shiki chunk behind it) loads
|
||||
* lazily on first use instead of on the cold-start path. diff-lines.tsx
|
||||
* reaches this through `React.lazy` with a plain `DiffBody` fallback.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useShikiHighlighter } from 'react-shiki'
|
||||
|
||||
import { DiffBody, type DiffLine, diffLineTransformer } from '@/components/chat/diff-lines'
|
||||
import { SHIKI_THEME } from '@/components/chat/shiki-highlighter'
|
||||
|
||||
export default function SyntaxDiff({ language, lines }: { language: string; lines: DiffLine[] }) {
|
||||
const code = useMemo(() => lines.map(line => line.text).join('\n'), [lines])
|
||||
const transformers = useMemo(() => [diffLineTransformer(lines.map(line => line.kind))], [lines])
|
||||
|
||||
const highlighted = useShikiHighlighter(code, language, SHIKI_THEME, {
|
||||
defaultColor: 'light-dark()',
|
||||
transformers
|
||||
})
|
||||
|
||||
// Until Shiki resolves, show the plain colored diff so there's no flash.
|
||||
return (highlighted as ReactNode) ?? <DiffBody lines={lines} />
|
||||
}
|
||||
|
|
@ -53,16 +53,46 @@ export default defineConfig(({ command }) => ({
|
|||
postcss: { plugins: [] }
|
||||
},
|
||||
build: {
|
||||
// Keep desktop packaging stable: Shiki ships many dynamic chunks by
|
||||
// default, and electron-builder can OOM scanning thousands of files.
|
||||
// Collapsing to a single chunk is intentional, so the renderer bundle is
|
||||
// large by design (~22 MB). Raise the warning ceiling above that so the
|
||||
// cosmetic "chunk larger than 500 kB" nag stays quiet, while still acting
|
||||
// as a regression alarm if the bundle balloons well past today's size.
|
||||
// The renderer intentionally ships FEW chunks (not one, not thousands):
|
||||
// · `codeSplitting: false` (the old setup) inlines every `lazy()` /
|
||||
// dynamic import into the entry, so heavyweight lazy-only deps
|
||||
// (mermaid, shiki grammars, katex) are parsed + evaluated on every
|
||||
// cold start even though nothing rendered them. By the time the
|
||||
// bundle hit ~28 MB that eval was ~1s of launch on an M-series.
|
||||
// · Default splitting emits a chunk per shiki grammar/theme — thousands
|
||||
// of files, which electron-builder OOMs scanning (#38888).
|
||||
// `advancedChunks` is the middle ground: heavyweight libraries merge into
|
||||
// a handful of named vendor chunks loaded on first use, app-level dynamic
|
||||
// imports stay lazy, and the file count stays in the tens.
|
||||
chunkSizeWarningLimit: 25000,
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
codeSplitting: false
|
||||
advancedChunks: {
|
||||
groups: [
|
||||
// Shared foundations FIRST (first match wins): an unmatched
|
||||
// module shared by the entry and a heavy chunk gets merged INTO
|
||||
// the heavy chunk, and the entry then statically imports 19 MB of
|
||||
// shiki just to reach react/hast utils — putting the heavy chunk
|
||||
// right back on the boot path.
|
||||
{ name: 'vendor-react', test: /node_modules[\\/](react|react-dom|scheduler)[\\/]/ },
|
||||
{
|
||||
name: 'vendor-md',
|
||||
test: /node_modules[\\/](property-information|hast-util-[^\\/]+|mdast-util-[^\\/]+|micromark[^\\/]*|unist-util-[^\\/]+|vfile[^\\/]*|unified|stringify-entities|space-separated-tokens|comma-separated-tokens|zwitch|html-void-elements|devlop|style-to-js|style-to-object|clsx)[\\/]/
|
||||
},
|
||||
// Shared utility packages the entry ALSO uses — kept out of the
|
||||
// heavy groups for the same boot-path reason.
|
||||
{
|
||||
name: 'vendor-util',
|
||||
test: /node_modules[\\/](lodash-es|es-toolkit|uuid|dayjs|d3-array|d3-color|d3-force|d3-interpolate|d3-time[^\\/]*|dompurify|stylis)[\\/]/
|
||||
},
|
||||
// One chunk per heavyweight, lazy-only library family.
|
||||
// @streamdown/code lives WITH shiki because it statically imports
|
||||
// the full shiki bundle.
|
||||
{ name: 'mermaid', test: /node_modules[\\/](mermaid|cytoscape|dagre|khroma|elkjs|d3|d3-[^\\/]+|@mermaid-js)[\\/]/ },
|
||||
{ name: 'shiki', test: /node_modules[\\/](shiki|@shikijs|react-shiki|@streamdown[\\/]code|oniguruma-to-es|oniguruma-parser|regex(-[^\\/]+)?)[\\/]/ },
|
||||
{ name: 'katex', test: /node_modules[\\/]katex[\\/]/ }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue