=== vulnerable chat-preview relevant code === 5:import MarkdownIt from 'markdown-it'; 36: const [htmlContent, setHtmlContent] = useState(''); 44: const md = useRef(null); 56: md.current = new MarkdownIt({ 57: html: true, 86: setHtmlContent(md.current.render(normalizeLatexForKatex(displayedTextRef.current))); 88: setHtmlContent(''); 107: setHtmlContent(md.current.render(normalizeLatexForKatex(nextText))); 109: setHtmlContent(nextText); 326: dangerouslySetInnerHTML={{ __html: htmlContent }} === fixed chat-preview relevant code === 14:import { StreamdownRenderer } from '@/components/markdown/streamdown-renderer' 196: & { 27:interface StreamdownRendererProps { 46:const linkSafety = { enabled: false } 55:function escapeHtml(value: string) { 64:function StreamdownCode({ 70:}: StreamdownCodeProps) { 76: return escapeHtml(code) 82: return escapeHtml(code) 99: dangerouslySetInnerHTML={{ __html: highlightedCode }} 104:function StreamdownLink({ children, ...props }: ComponentProps<'a'>) { 113: a: StreamdownLink as Components['a'], 114: code: StreamdownCode as Components['code'], 117:export function StreamdownRenderer({ markdown, streaming = false, className }: StreamdownRendererProps) { 120: 135:export default StreamdownRenderer === diff === diff --git a/src/app/core/main/chat/chat-preview.tsx b/src/app/core/main/chat/chat-preview.tsx index c023d318..5ce5d099 100644 --- a/src/app/core/main/chat/chat-preview.tsx +++ b/src/app/core/main/chat/chat-preview.tsx @@ -1,333 +1,203 @@ 'use client' -import useSettingStore from "@/stores/setting"; -import React, { useEffect, useRef, useState, useCallback } from 'react'; -import { useTheme } from 'next-themes' -import MarkdownIt from 'markdown-it'; -import katex from '@traptitech/markdown-it-katex'; -import 'katex/dist/katex.min.css'; -import hljs from 'highlight.js/lib/core'; -import javascript from 'highlight.js/lib/languages/javascript'; -import typescript from 'highlight.js/lib/languages/typescript'; -import bash from 'highlight.js/lib/languages/bash'; -import json from 'highlight.js/lib/languages/json'; -import xml from 'highlight.js/lib/languages/xml'; -import css from 'highlight.js/lib/languages/css'; -import 'highlight.js/styles/github.min.css'; -import './chat.css'; -import { advanceStreamingSmoother } from './streaming-smoother'; -import { cn } from "@/lib/utils"; -import { normalizeLatexForKatex } from "@/lib/latex"; -type ThemeType = 'light' | 'dark' | 'system'; +import useSettingStore from '@/stores/setting' +import { + type DragEvent, + useCallback, + useEffect, + useRef, + useState, +} from 'react' +import './chat.css' +import { advanceStreamingSmoother } from './streaming-smoother' +import { cn } from '@/lib/utils' +import { StreamdownRenderer } from '@/components/markdown/streamdown-renderer' type ChatPreviewProps = { - text: string; - streaming?: boolean; // 是否为流式内容 - containerClassName?: string; -}; - -const MIN_RENDER_INTERVAL_MS = 33; - -export default function ChatPreview({text, streaming = false, containerClassName}: ChatPreviewProps) { - const previewRef = useRef(null); - const { theme } = useTheme() - const [mdTheme, setMdTheme] = useState('light') - const { codeTheme, contentTextScale } = useSettingStore() - const [htmlContent, setHtmlContent] = useState(''); - const [, setDisplayedText] = useState(''); - const animationRef = useRef(null); - const displayedTextRef = useRef(''); - const targetTextRef = useRef(''); - const carryCharsRef = useRef(0); - const lastFrameTimeRef = useRef(null); - const lastRenderTimeRef = useRef(0); - const md = useRef(null); - - useEffect(() => { - hljs.registerLanguage('javascript', javascript); - hljs.registerLanguage('typescript', typescript); - hljs.registerLanguage('bash', bash); - hljs.registerLanguage('json', json); - hljs.registerLanguage('html', xml); - hljs.registerLanguage('css', css); - }, []); - - useEffect(() => { - md.current = new MarkdownIt({ - html: true, - linkify: true, - typographer: true, - highlight: function (str, lang): string { - if (lang && hljs.getLanguage(lang)) { - try { - const themeClass = mdTheme === 'dark' ? 'hljs-dark' : 'hljs-light'; - return `
` +
-              hljs.highlight(str, { language: lang, ignoreIllegals: true }).value +
-            '
'; - } catch {} - } - const themeClass = mdTheme === 'dark' ? 'hljs-dark' : 'hljs-light'; - return `
` +
-          (md.current ? md.current.utils.escapeHtml(str) : str) +
-          '
'; - } - }).use(katex, { - throwOnError: false, - errorColor: '#cc0000' - }); - - md.current.renderer.rules.link_open = function (tokens, idx, options, _env, self) { - tokens[idx].attrSet('target', '_blank'); - tokens[idx].attrSet('rel', 'noopener noreferrer'); - return self.renderToken(tokens, idx, options); - } - - if (displayedTextRef.current) { - setHtmlContent(md.current.render(normalizeLatexForKatex(displayedTextRef.current))); - } else { - setHtmlContent(''); - } - }, [mdTheme]); + text: string + streaming?: boolean + containerClassName?: string +} - const renderDisplayedText = useCallback((nextText: string, force = false) => { - displayedTextRef.current = nextText; +const MIN_RENDER_INTERVAL_MS = 33 +const STREAM_BUFFER_CHARS = 2 - if (!force) { - const now = performance.now(); - if (now - lastRenderTimeRef.current < MIN_RENDER_INTERVAL_MS) { - return; - } - lastRenderTimeRef.current = now; - } else { - lastRenderTimeRef.current = performance.now(); - } +function isMacOS() { + if (typeof window === 'undefined') return false + return /Mac|iPhone|iPad|iPod/.test(navigator.userAgent) +} - setDisplayedText(nextText); - if (md.current) { - setHtmlContent(md.current.render(normalizeLatexForKatex(nextText))); - } else { - setHtmlContent(nextText); - } - }, []); +export default function ChatPreview({ text, streaming = false, containerClassName }: ChatPreviewProps) { + const { contentTextScale } = useSettingStore() + const [displayedText, setDisplayedText] = useState('') + const animationRef = useRef(null) + const displayedTextRef = useRef('') + const targetTextRef = useRef('') + const carryCharsRef = useRef(0) + const lastFrameTimeRef = useRef(null) + const lastRenderTimeRef = useRef(0) + const streamingRef = useRef(streaming) + const hasStreamedRef = useRef(streaming) + + const renderDisplayedText = useCallback((nextText: string) => { + displayedTextRef.current = nextText + lastRenderTimeRef.current = performance.now() + setDisplayedText(nextText) + }, []) const stopAnimation = useCallback(() => { if (animationRef.current !== null) { - cancelAnimationFrame(animationRef.current); - animationRef.current = null; + cancelAnimationFrame(animationRef.current) + animationRef.current = null } - lastFrameTimeRef.current = null; - carryCharsRef.current = 0; - }, []); + lastFrameTimeRef.current = null + carryCharsRef.current = 0 + }, []) const tickStreaming = useCallback((frameTime: number) => { - const lastFrameTime = lastFrameTimeRef.current ?? frameTime; - const elapsedMs = frameTime - lastFrameTime; - lastFrameTimeRef.current = frameTime; + if (frameTime - lastRenderTimeRef.current < MIN_RENDER_INTERVAL_MS) { + animationRef.current = requestAnimationFrame(tickStreaming) + return + } + + const lastFrameTime = lastFrameTimeRef.current ?? frameTime + const elapsedMs = frameTime - lastFrameTime + lastFrameTimeRef.current = frameTime + const targetLength = streamingRef.current + ? Math.max( + displayedTextRef.current.length, + targetTextRef.current.length - STREAM_BUFFER_CHARS, + ) + : targetTextRef.current.length const next = advanceStreamingSmoother( { carryChars: carryCharsRef.current, displayedLength: displayedTextRef.current.length, }, - targetTextRef.current.length, + targetLength, elapsedMs, - ); + ) - carryCharsRef.current = next.carryChars; + carryCharsRef.current = next.carryChars if (next.charsAdded > 0) { - renderDisplayedText( - targetTextRef.current.slice(0, next.displayedLength), - ); + renderDisplayedText(targetTextRef.current.slice(0, next.displayedLength)) } - if (next.displayedLength >= targetTextRef.current.length) { - animationRef.current = null; - lastFrameTimeRef.current = null; - carryCharsRef.current = 0; - renderDisplayedText(targetTextRef.current, true); - return; + if (next.displayedLength >= targetLength) { + animationRef.current = null + lastFrameTimeRef.current = null + carryCharsRef.current = 0 + if (!streamingRef.current && next.displayedLength >= targetTextRef.current.length) { + hasStreamedRef.current = false + } + return } - animationRef.current = requestAnimationFrame(tickStreaming); - }, [renderDisplayedText]); + animationRef.current = requestAnimationFrame(tickStreaming) + }, [renderDisplayedText]) const ensureStreamingAnimation = useCallback(() => { if (animationRef.current !== null) { - return; + return } - lastFrameTimeRef.current = null; - animationRef.current = requestAnimationFrame(tickStreaming); - }, [tickStreaming]); + lastFrameTimeRef.current = null + animationRef.current = requestAnimationFrame(tickStreaming) + }, [tickStreaming]) - // 处理流式内容更新 useEffect(() => { + streamingRef.current = streaming + if (streaming) { + hasStreamedRef.current = true + } + if (!streaming) { - stopAnimation(); - targetTextRef.current = text; - renderDisplayedText(text, true); - return; + targetTextRef.current = text + + if (hasStreamedRef.current && text.length > displayedTextRef.current.length) { + ensureStreamingAnimation() + } else { + stopAnimation() + renderDisplayedText(text) + hasStreamedRef.current = false + } + return } - targetTextRef.current = text; + targetTextRef.current = text if (text.length < displayedTextRef.current.length) { - stopAnimation(); - renderDisplayedText(text, true); - return; + stopAnimation() + renderDisplayedText(text) + return } if (text.length === displayedTextRef.current.length) { if (text !== displayedTextRef.current) { - renderDisplayedText(text, true); + renderDisplayedText(text) } - return; + return } - ensureStreamingAnimation(); - }, [text, streaming, ensureStreamingAnimation, renderDisplayedText, stopAnimation]); - - // 清理动画 - useEffect(() => { - return () => { - stopAnimation(); - }; - }, [stopAnimation]); - - useEffect(() => { - if (theme === 'system') { - if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { - setMdTheme('dark') - } else { - setMdTheme('light') - } - } else { - setMdTheme(theme as ThemeType) - } - }, [theme]) + ensureStreamingAnimation() + }, [text, streaming, ensureStreamingAnimation, renderDisplayedText, stopAnimation]) useEffect(() => { - // 加载Markdown主题样式 - const link = document.createElement('link'); - link.id = 'markdown-theme-style'; - link.rel = 'stylesheet'; - switch (theme) { - case 'dark': - link.href = '/markdown/github-markdown-dark.css'; - break; - case 'light': - link.href = '/markdown/github-markdown-light.css'; - break; - case 'system': - if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { - link.href = '/markdown/github-markdown-dark.css'; - } else { - link.href = '/markdown/github-markdown-light.css'; - } - break; - } - - const existingLink = document.getElementById('markdown-theme-style'); - if (existingLink) document.head.removeChild(existingLink); - document.head.appendChild(link); - - // 监听系统主题变化 - const matchMedia = window.matchMedia('(prefers-color-scheme: dark)') - const handler = () => { - if (theme === 'system') { - const themeValue = matchMedia.matches ? 'dark' : 'light' - setMdTheme(themeValue) - } - } - matchMedia.addEventListener('change', handler) return () => { - matchMedia.removeEventListener('change', handler) - } - }, [theme]) - - // 应用正文文字大小缩放 - useEffect(() => { - if (previewRef.current) { - previewRef.current.style.fontSize = `${contentTextScale + 15}%` - } - }, [contentTextScale]) - - // 根据主题选择样式 - const getThemeClass = () => { - if (mdTheme === 'dark') { - return 'markdown-body markdown-dark'; + stopAnimation() } - return 'markdown-body'; - }; + }, [stopAnimation]) - // 应用高亮样式 - const getHighlightStyle = () => { - return codeTheme || 'github'; - }; - - // 检测是否为 macOS - const isMacOS = () => { - if (typeof window === 'undefined') return false; - return /Mac|iPhone|iPad|iPod/.test(navigator.userAgent); - }; - - // 处理文本选中后的拖拽(仅 macOS) - const handleDragStart = (e: React.DragEvent) => { - // 非 macOS 系统直接阻止拖拽 + const handleDragStart = (event: DragEvent) => { if (!isMacOS()) { - e.preventDefault(); - return; - } - - const selection = window.getSelection() - const selectedText = selection?.toString().trim() - - if (selectedText) { - // 设置拖拽数据为选中的文本 - e.dataTransfer.setData('text/plain', selectedText) - e.dataTransfer.effectAllowed = 'copy' - - // 创建自定义拖拽预览图像,只显示选中的文本 - const dragPreview = document.createElement('div') - dragPreview.style.position = 'absolute' - dragPreview.style.left = '-9999px' - dragPreview.style.padding = '8px 12px' - dragPreview.style.backgroundColor = 'rgba(0, 0, 0, 0.8)' - dragPreview.style.color = 'white' - dragPreview.style.borderRadius = '4px' - dragPreview.style.fontSize = '14px' - dragPreview.style.maxWidth = '300px' - dragPreview.style.overflowWrap = 'break-word' - dragPreview.textContent = selectedText.length > 50 ? selectedText.substring(0, 50) + '...' : selectedText - - document.body.appendChild(dragPreview) - e.dataTransfer.setDragImage(dragPreview, 0, 0) - - // 拖拽结束后移除预览元素 - setTimeout(() => { - document.body.removeChild(dragPreview) - }, 0) - } else { - // 如果没有选中文本,阻止拖拽 - e.preventDefault() - } + event.preventDefault() + return + } + + const selectedText = window.getSelection()?.toString().trim() + if (!selectedText) { + event.preventDefault() + return + } + + event.dataTransfer.setData('text/plain', selectedText) + event.dataTransfer.effectAllowed = 'copy' + + const dragPreview = document.createElement('div') + dragPreview.style.position = 'absolute' + dragPreview.style.left = '-9999px' + dragPreview.style.padding = '8px 12px' + dragPreview.style.backgroundColor = 'rgba(0, 0, 0, 0.8)' + dragPreview.style.color = 'white' + dragPreview.style.borderRadius = '4px' + dragPreview.style.fontSize = '14px' + dragPreview.style.maxWidth = '300px' + dragPreview.style.overflowWrap = 'break-word' + dragPreview.textContent = selectedText.length > 50 ? `${selectedText.substring(0, 50)}...` : selectedText + + document.body.appendChild(dragPreview) + event.dataTransfer.setDragImage(dragPreview, 0, 0) + window.setTimeout(() => dragPreview.remove(), 0) } - // 没有内容时不渲染 - if (!text || !text.trim()) { + if (!text.trim()) { return null } return ( -
-
+
+ style={{ fontSize: `${(16 * contentTextScale) / 100}px` }} + > + +
- ); + ) } diff --git a/src/components/markdown/streamdown-renderer.tsx b/src/components/markdown/streamdown-renderer.tsx new file mode 100644 index 00000000..9d8a6f60 --- /dev/null +++ b/src/components/markdown/streamdown-renderer.tsx @@ -0,0 +1,135 @@ +'use client' + +import { createMathPlugin } from '@streamdown/math' +import { cjk } from '@streamdown/cjk' +import hljs from 'highlight.js/lib/core' +import bash from 'highlight.js/lib/languages/bash' +import css from 'highlight.js/lib/languages/css' +import javascript from 'highlight.js/lib/languages/javascript' +import json from 'highlight.js/lib/languages/json' +import typescript from 'highlight.js/lib/languages/typescript' +import xml from 'highlight.js/lib/languages/xml' +import type { ComponentProps } from 'react' +import { useMemo } from 'react' +import { Streamdown, type AnimateOptions, type Components, type PluginConfig } from 'streamdown' +import { normalizeLatexForKatex } from '@/lib/latex' +import { cn } from '@/lib/utils' +import 'highlight.js/styles/github.min.css' +import 'katex/dist/katex.min.css' +import 'streamdown/styles.css' +import './streamdown-renderer.css' + +type StreamdownCodeProps = ComponentProps<'code'> & { + node?: unknown + 'data-block'?: boolean | string +} + +interface StreamdownRendererProps { + markdown: string + streaming?: boolean + className?: string +} + +hljs.registerLanguage('javascript', javascript) +hljs.registerLanguage('typescript', typescript) +hljs.registerLanguage('bash', bash) +hljs.registerLanguage('json', json) +hljs.registerLanguage('html', xml) +hljs.registerLanguage('css', css) + +const math = createMathPlugin({ + singleDollarTextMath: true, + errorColor: 'var(--color-destructive)', +}) + +const plugins: PluginConfig = { cjk, math } +const linkSafety = { enabled: false } +const streamingAnimation: AnimateOptions = { + animation: 'fadeIn', + duration: 180, + easing: 'ease-out', + sep: 'char', + stagger: 8, +} + +function escapeHtml(value: string) { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +function StreamdownCode({ + children, + className, + node, + 'data-block': dataBlock, + ...props +}: StreamdownCodeProps) { + void node + const code = String(children ?? '').replace(/\n$/, '') + const language = className?.match(/language-([\w-]+)/)?.[1] + const highlightedCode = useMemo(() => { + if (!dataBlock || !language || !hljs.getLanguage(language)) { + return escapeHtml(code) + } + + try { + return hljs.highlight(code, { language, ignoreIllegals: true }).value + } catch { + return escapeHtml(code) + } + }, [code, dataBlock, language]) + + if (!dataBlock) { + return ( + + {children} + + ) + } + + return ( + + ) +} + +function StreamdownLink({ children, ...props }: ComponentProps<'a'>) { + return ( + + {children} + + ) +} + +const components: Components = { + a: StreamdownLink as Components['a'], + code: StreamdownCode as Components['code'], +} + +export function StreamdownRenderer({ markdown, streaming = false, className }: StreamdownRendererProps) { + return ( +
+ + {normalizeLatexForKatex(markdown)} + +
+ ) +} + +export default StreamdownRenderer