mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-16 20:24:50 +00:00
* feat(web-shell): stream-highlight code blocks and fix fence-language aliases - Highlight assistant code fences live as they stream in via @shikijs/stream, replacing the per-token whole-block re-tokenize that flickered between plain grey and colored and only settled at the end of the turn. - Fix fence-language aliases (`ts`/`js`/`py`/... and uppercase like `SQL`) that previously rendered unhighlighted because the supported-language gate only matched canonical Shiki ids. - Consolidate the static and streaming paths onto a single lazily-created JavaScript-regex-engine highlighter (no WASM, on-demand language loading) and hand off without a grey flash; bump shiki ^1 -> ^4 and add @shikijs/stream. * fix(web-shell): address review feedback on streaming code highlight - Stabilize the `code` renderer via an IsStreamingContext so the CodeBlock/StreamingCodeBlock instances are reused (not remounted) across the streaming→settled transition, keeping the no-flash hold effective; drop the now-redundant COMPONENTS_STREAMING split. - Log a warning when the streaming highlighter fails to load, instead of failing silently to plain text. - Export `enqueueSuffix` and add unit tests for its no-op/extend/diverge/throw branches, plus a streaming→settled hand-off render test. * fix(web-shell): harden code highlighter (review round 2) - Guard highlightToHtmlSync with try/catch so a synchronous tokenization error falls back to the async path instead of crashing the render tree. - Dedupe concurrent loadLanguage calls via a pending-languages map (two callers could previously both pass the `has` check and double-load). - Add unit tests for codeHighlighter: highlightToHtml output, the sync cold→warm transition, the 30K-char sync bail-out, and concurrent-load dedup. * fix(web-shell): guard oversized streaming fences + review cleanups - Fall back to the plain renderer while streaming once the block or its trailing line exceeds a threshold (`exceedsStreamingLimit`), so a large single-line / minified fence can't pin the UI via @shikijs/stream re-tokenizing the unstable current line on every chunk. - Drop `shell`/`zsh` from SUPPORTED_LANGUAGES — they were dead entries shadowed by the alias map (→ bash). - Tests: add `__resetForTesting` to drop cross-test ordering coupling; add highlighter retry / pending-language cleanup tests, `exceedsStreamingLimit` unit tests, an oversized-fence fallback test, and a highlighter-load-failure fallback test for StreamingCodeBlock. * refactor(web-shell): stream-highlight code blocks via synchronous per-chunk re-render (drop @shikijs/stream) --------- Co-authored-by: 易良 <1204183885@qq.com>
165 lines
5.8 KiB
TypeScript
165 lines
5.8 KiB
TypeScript
/**
|
||
* @license
|
||
* Copyright 2026 Qwen Team
|
||
* SPDX-License-Identifier: Apache-2.0
|
||
*/
|
||
|
||
import {
|
||
createHighlighter,
|
||
type BundledLanguage,
|
||
type Highlighter,
|
||
} from 'shiki';
|
||
|
||
// A single, lazily-created highlighter shared by all code blocks. It uses
|
||
// Shiki's default Oniguruma (WASM) engine — the same engine `main` highlights
|
||
// with — and loads languages on demand, so each language/grammar is fetched at
|
||
// most once. (The JS regex engine was ~12–19x slower and is not used.)
|
||
const THEMES = ['github-light-default', 'github-dark-default'];
|
||
|
||
let highlighterPromise: Promise<Highlighter> | null = null;
|
||
let highlighterInstance: Highlighter | null = null;
|
||
const loadedLanguages = new Set<string>();
|
||
const pendingLanguages = new Map<string, Promise<void>>();
|
||
// Languages whose load rejected. Unlike the highlighter singleton (which retries
|
||
// transient failures because it gates ALL highlighting), a single failed grammar
|
||
// only costs one language, so we stop retrying it — otherwise a permanently
|
||
// broken bundle would re-request on every re-highlight. Cleared on reset.
|
||
const failedLanguages = new Set<string>();
|
||
|
||
// Highlighted-HTML cache, owned here so it shares one invalidation point with
|
||
// the highlighter singleton (see __resetForTesting).
|
||
export const SHIKI_CACHE_MAX = 128;
|
||
const shikiCache = new Map<string, string>();
|
||
|
||
function cacheKey(code: string, lang: string, theme: string): string {
|
||
return `${lang}\0${theme}\0${code}`;
|
||
}
|
||
|
||
function setCache(key: string, html: string): void {
|
||
if (shikiCache.size >= SHIKI_CACHE_MAX) {
|
||
const first = shikiCache.keys().next().value;
|
||
if (first !== undefined) shikiCache.delete(first);
|
||
}
|
||
shikiCache.set(key, html);
|
||
}
|
||
|
||
/** Returns previously-highlighted HTML for this exact code/lang/theme, or null. */
|
||
export function getCachedHtml(
|
||
code: string,
|
||
lang: string,
|
||
theme: string,
|
||
): string | null {
|
||
return shikiCache.get(cacheKey(code, lang, theme)) ?? null;
|
||
}
|
||
|
||
function getHighlighter(): Promise<Highlighter> {
|
||
if (!highlighterPromise) {
|
||
highlighterPromise = createHighlighter({
|
||
langs: [],
|
||
themes: THEMES,
|
||
})
|
||
.then((highlighter) => {
|
||
highlighterInstance = highlighter;
|
||
return highlighter;
|
||
})
|
||
.catch((err) => {
|
||
// Don't cache a rejected promise: a transient failure (e.g. a dynamic
|
||
// import hiccup) would otherwise permanently disable highlighting for
|
||
// the whole session. Reset so the next call retries.
|
||
highlighterPromise = null;
|
||
throw err;
|
||
});
|
||
}
|
||
return highlighterPromise;
|
||
}
|
||
|
||
/** Returns the shared highlighter with `lang` loaded (lazily, cached). */
|
||
export async function getCodeHighlighter(lang: string): Promise<Highlighter> {
|
||
const highlighter = await getHighlighter();
|
||
if (loadedLanguages.has(lang)) return highlighter;
|
||
if (failedLanguages.has(lang)) {
|
||
throw new Error(`shiki: language "${lang}" previously failed to load`);
|
||
}
|
||
// Dedupe concurrent loads of the same language: without this, two callers
|
||
// can both pass the `has` check and call `loadLanguage` twice.
|
||
let pending = pendingLanguages.get(lang);
|
||
if (!pending) {
|
||
pending = highlighter
|
||
.loadLanguage(lang as BundledLanguage)
|
||
.then(() => {
|
||
loadedLanguages.add(lang);
|
||
})
|
||
.catch((err) => {
|
||
failedLanguages.add(lang);
|
||
throw err;
|
||
})
|
||
.finally(() => {
|
||
pendingLanguages.delete(lang);
|
||
});
|
||
pendingLanguages.set(lang, pending);
|
||
}
|
||
await pending;
|
||
return highlighter;
|
||
}
|
||
|
||
// Even with the Oniguruma engine, TextMate tokenization of a very long,
|
||
// unbroken line is roughly O(n²) and runs synchronously on the main thread, so
|
||
// a pathological single line (minified JSON, a long base64 literal, etc.) can
|
||
// still jank. This is a generous safety net — well above normal source — past
|
||
// which a block is rendered as plain text instead of highlighted, on both the
|
||
// streaming and the settled static paths. `main` has no such guard at all.
|
||
export const MAX_HIGHLIGHT_TOTAL_CHARS = 100_000;
|
||
export const MAX_HIGHLIGHT_LINE_CHARS = 20_000;
|
||
|
||
export function isTooLargeToHighlight(code: string): boolean {
|
||
if (code.length > MAX_HIGHLIGHT_TOTAL_CHARS) return true;
|
||
// Bail if any single line exceeds the per-line limit (short-circuits early).
|
||
let lineStart = 0;
|
||
const len = code.length;
|
||
for (let i = 0; i <= len; i++) {
|
||
if (i === len || code.charCodeAt(i) === 10 /* \n */) {
|
||
if (i - lineStart > MAX_HIGHLIGHT_LINE_CHARS) return true;
|
||
lineStart = i + 1;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Synchronously highlights code to HTML *iff* the highlighter and language are
|
||
* already warm (e.g. right after a streaming block settles). Returns null
|
||
* otherwise, so the caller can fall back to the async path (or plain text).
|
||
*
|
||
* The size policy (isTooLargeToHighlight) lives at the caller, which already
|
||
* gates before reaching this function — so it is not re-checked here.
|
||
*/
|
||
export function highlightToHtmlSync(
|
||
code: string,
|
||
lang: string,
|
||
theme: string,
|
||
persist = true,
|
||
): string | null {
|
||
if (highlighterInstance && loadedLanguages.has(lang)) {
|
||
try {
|
||
const html = highlighterInstance.codeToHtml(code, { lang, theme });
|
||
if (persist) setCache(cacheKey(code, lang, theme), html);
|
||
return html;
|
||
} catch (err) {
|
||
// Fall back to the async path rather than crashing the render tree, and
|
||
// log it (the async path logs too) so a failing grammar isn't silent.
|
||
console.warn('[web-shell] sync highlight failed for lang=%s', lang, err);
|
||
return null;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Resets all module-level highlighter state (incl. the HTML cache). Tests only. */
|
||
export function __resetForTesting(): void {
|
||
highlighterPromise = null;
|
||
highlighterInstance = null;
|
||
loadedLanguages.clear();
|
||
pendingLanguages.clear();
|
||
failedLanguages.clear();
|
||
shikiCache.clear();
|
||
}
|