Merge pull request #2529 from lc6464/feat/web-code-highlight

feat(web): add markdown syntax highlighting for chat and skills
This commit is contained in:
daming大铭 2026-04-15 18:50:40 +08:00 committed by GitHub
commit 100e576609
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 154 additions and 7 deletions

View file

@ -26,6 +26,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"highlight.js": "^11.11.1",
"i18next": "^26.0.3", "i18next": "^26.0.3",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"jotai": "^2.19.1", "jotai": "^2.19.1",
@ -35,6 +36,7 @@
"react-i18next": "^17.0.2", "react-i18next": "^17.0.2",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-textarea-autosize": "^8.5.9", "react-textarea-autosize": "^8.5.9",
"rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0", "rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",

View file

@ -35,6 +35,9 @@ importers:
dayjs: dayjs:
specifier: ^1.11.20 specifier: ^1.11.20
version: 1.11.20 version: 1.11.20
highlight.js:
specifier: ^11.11.1
version: 11.11.1
i18next: i18next:
specifier: ^26.0.3 specifier: ^26.0.3
version: 26.0.3(typescript@5.9.3) version: 26.0.3(typescript@5.9.3)
@ -62,6 +65,9 @@ importers:
react-textarea-autosize: react-textarea-autosize:
specifier: ^8.5.9 specifier: ^8.5.9
version: 8.5.9(@types/react@19.2.14)(react@19.2.5) version: 8.5.9(@types/react@19.2.14)(react@19.2.5)
rehype-highlight:
specifier: ^7.0.2
version: 7.0.2
rehype-raw: rehype-raw:
specifier: ^7.0.0 specifier: ^7.0.0
version: 7.0.0 version: 7.0.0
@ -2433,6 +2439,9 @@ packages:
hast-util-from-parse5@8.0.3: hast-util-from-parse5@8.0.3:
resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
hast-util-is-element@3.0.0:
resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==}
hast-util-parse-selector@4.0.0: hast-util-parse-selector@4.0.0:
resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
@ -2448,6 +2457,9 @@ packages:
hast-util-to-parse5@8.0.1: hast-util-to-parse5@8.0.1:
resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
hast-util-to-text@4.0.2:
resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==}
hast-util-whitespace@3.0.0: hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
@ -2463,6 +2475,10 @@ packages:
hermes-parser@0.25.1: hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
highlight.js@11.11.1:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
hono@4.12.12: hono@4.12.12:
resolution: {integrity: sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==} resolution: {integrity: sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==}
engines: {node: '>=16.9.0'} engines: {node: '>=16.9.0'}
@ -2807,6 +2823,9 @@ packages:
longest-streak@3.1.0: longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
lowlight@3.3.0:
resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==}
lru-cache@5.1.1: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@ -3371,6 +3390,9 @@ packages:
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
rehype-highlight@7.0.2:
resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==}
rehype-raw@7.0.0: rehype-raw@7.0.0:
resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
@ -3686,6 +3708,9 @@ packages:
unified@11.0.5: unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
unist-util-find-after@5.0.0:
resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==}
unist-util-is@6.0.1: unist-util-is@6.0.1:
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
@ -6253,6 +6278,10 @@ snapshots:
vfile-location: 5.0.3 vfile-location: 5.0.3
web-namespaces: 2.0.1 web-namespaces: 2.0.1
hast-util-is-element@3.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-parse-selector@4.0.0: hast-util-parse-selector@4.0.0:
dependencies: dependencies:
'@types/hast': 3.0.4 '@types/hast': 3.0.4
@ -6309,6 +6338,13 @@ snapshots:
web-namespaces: 2.0.1 web-namespaces: 2.0.1
zwitch: 2.0.4 zwitch: 2.0.4
hast-util-to-text@4.0.2:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
hast-util-is-element: 3.0.0
unist-util-find-after: 5.0.0
hast-util-whitespace@3.0.0: hast-util-whitespace@3.0.0:
dependencies: dependencies:
'@types/hast': 3.0.4 '@types/hast': 3.0.4
@ -6329,6 +6365,8 @@ snapshots:
dependencies: dependencies:
hermes-estree: 0.25.1 hermes-estree: 0.25.1
highlight.js@11.11.1: {}
hono@4.12.12: {} hono@4.12.12: {}
html-parse-stringify@3.0.1: html-parse-stringify@3.0.1:
@ -6574,6 +6612,12 @@ snapshots:
longest-streak@3.1.0: {} longest-streak@3.1.0: {}
lowlight@3.3.0:
dependencies:
'@types/hast': 3.0.4
devlop: 1.1.0
highlight.js: 11.11.1
lru-cache@5.1.1: lru-cache@5.1.1:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
@ -7350,6 +7394,14 @@ snapshots:
tiny-invariant: 1.3.3 tiny-invariant: 1.3.3
tslib: 2.8.1 tslib: 2.8.1
rehype-highlight@7.0.2:
dependencies:
'@types/hast': 3.0.4
hast-util-to-text: 4.0.2
lowlight: 3.3.0
unist-util-visit: 5.1.0
vfile: 6.0.3
rehype-raw@7.0.0: rehype-raw@7.0.0:
dependencies: dependencies:
'@types/hast': 3.0.4 '@types/hast': 3.0.4
@ -7744,6 +7796,11 @@ snapshots:
trough: 2.2.0 trough: 2.2.0
vfile: 6.0.3 vfile: 6.0.3
unist-util-find-after@5.0.0:
dependencies:
'@types/unist': 3.0.3
unist-util-is: 6.0.1
unist-util-is@6.0.1: unist-util-is@6.0.1:
dependencies: dependencies:
'@types/unist': 3.0.3 '@types/unist': 3.0.3

View file

@ -0,0 +1,13 @@
import type { ReactNode } from "react"
import { useHighlightTheme } from "./hooks/use-highlight-theme"
interface AppProvidersProps {
children: ReactNode
}
export function AppProviders({ children }: AppProvidersProps) {
useHighlightTheme()
return <>{children}</>
}

View file

@ -7,6 +7,7 @@ import {
import type { ReactNode } from "react" import type { ReactNode } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import ReactMarkdown from "react-markdown" import ReactMarkdown from "react-markdown"
import rehypeHighlight from "rehype-highlight"
import rehypeRaw from "rehype-raw" import rehypeRaw from "rehype-raw"
import rehypeSanitize from "rehype-sanitize" import rehypeSanitize from "rehype-sanitize"
import remarkGfm from "remark-gfm" import remarkGfm from "remark-gfm"
@ -171,10 +172,10 @@ export function DetailSheet({
</div> </div>
{detailView === "preview" ? ( {detailView === "preview" ? (
<div className="prose prose-zinc dark:prose-invert prose-sm sm:prose-base prose-pre:rounded-xl prose-pre:border prose-pre:border-border/40 prose-pre:bg-zinc-950/90 prose-pre:shadow-sm prose-headings:tracking-tight prose-a:text-primary prose-a:no-underline hover:prose-a:underline max-w-none"> <div className="prose prose-zinc dark:prose-invert prose-sm sm:prose-base prose-pre:rounded-xl prose-pre:border prose-pre:border-border/40 prose-pre:bg-zinc-100 prose-pre:p-0 prose-pre:shadow-sm dark:prose-pre:bg-zinc-950/90 prose-headings:tracking-tight prose-a:text-primary prose-a:no-underline hover:prose-a:underline max-w-none">
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]} rehypePlugins={[rehypeRaw, rehypeSanitize, rehypeHighlight]}
> >
{selectedSkillDetail.content} {selectedSkillDetail.content}
</ReactMarkdown> </ReactMarkdown>

View file

@ -2,6 +2,7 @@ import { IconBrain, IconCheck, IconCopy } from "@tabler/icons-react"
import { useState } from "react" import { useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import ReactMarkdown from "react-markdown" import ReactMarkdown from "react-markdown"
import rehypeHighlight from "rehype-highlight"
import rehypeRaw from "rehype-raw" import rehypeRaw from "rehype-raw"
import rehypeSanitize from "rehype-sanitize" import rehypeSanitize from "rehype-sanitize"
import remarkGfm from "remark-gfm" import remarkGfm from "remark-gfm"
@ -63,7 +64,7 @@ export function AssistantMessage({
> >
<div <div
className={cn( className={cn(
"prose dark:prose-invert prose-pre:my-2 prose-pre:overflow-x-auto prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-950 prose-pre:p-3 max-w-none [overflow-wrap:anywhere] break-words", "prose dark:prose-invert prose-pre:my-2 prose-pre:overflow-x-auto prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-100 prose-pre:p-0 dark:prose-pre:bg-zinc-950 max-w-none [overflow-wrap:anywhere] break-words",
isThought isThought
? "prose-p:my-1.5 p-3 text-[13px] leading-relaxed opacity-90" ? "prose-p:my-1.5 p-3 text-[13px] leading-relaxed opacity-90"
: "prose-p:my-2 p-4 text-[15px] leading-relaxed", : "prose-p:my-2 p-4 text-[15px] leading-relaxed",
@ -71,7 +72,7 @@ export function AssistantMessage({
> >
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]} rehypePlugins={[rehypeRaw, rehypeSanitize, rehypeHighlight]}
> >
{content} {content}
</ReactMarkdown> </ReactMarkdown>

View file

@ -0,0 +1,70 @@
import { useEffect } from "react"
import githubDarkCss from "highlight.js/styles/github-dark.css?inline"
import githubLightCss from "highlight.js/styles/github.css?inline"
const THEME_STYLE_ID = "hljs-theme-style"
const THEME_STYLE_OWNER_ATTR = "data-picoclaw-highlight-theme"
const THEME_STYLE_OWNER_VALUE = "true"
const MANAGED_THEME_STYLE_SELECTOR = `style[${THEME_STYLE_OWNER_ATTR}="${THEME_STYLE_OWNER_VALUE}"]`
const ID_THEME_STYLE_SELECTOR = `style#${THEME_STYLE_ID}`
function getOrCreateThemeStyleElement(): HTMLStyleElement {
const managedStyleElement = document.head.querySelector<HTMLStyleElement>(
MANAGED_THEME_STYLE_SELECTOR,
)
if (managedStyleElement) {
return managedStyleElement
}
const existingStyleElement =
document.querySelector<HTMLStyleElement>(ID_THEME_STYLE_SELECTOR)
if (existingStyleElement) {
existingStyleElement.setAttribute(
THEME_STYLE_OWNER_ATTR,
THEME_STYLE_OWNER_VALUE,
)
return existingStyleElement
}
const conflictingElement = document.getElementById(THEME_STYLE_ID)
const styleElement = document.createElement("style")
if (!conflictingElement) {
styleElement.id = THEME_STYLE_ID
}
// Leave conflicting non-style nodes untouched and track the injected style explicitly.
styleElement.setAttribute(THEME_STYLE_OWNER_ATTR, THEME_STYLE_OWNER_VALUE)
document.head.appendChild(styleElement)
return styleElement
}
export function useHighlightTheme() {
useEffect(() => {
const root = document.documentElement
const styleElement = getOrCreateThemeStyleElement()
const applyTheme = () => {
const nextThemeCss = root.classList.contains("dark")
? githubDarkCss
: githubLightCss
styleElement.textContent = nextThemeCss
}
applyTheme()
const observer = new MutationObserver(() => {
applyTheme()
})
observer.observe(root, {
attributes: true,
attributeFilter: ["class"],
})
return () => {
observer.disconnect()
}
}, [])
}

View file

@ -3,6 +3,7 @@ import { RouterProvider, createRouter } from "@tanstack/react-router"
import { StrictMode } from "react" import { StrictMode } from "react"
import ReactDOM from "react-dom/client" import ReactDOM from "react-dom/client"
import { AppProviders } from "./app-providers"
import "./i18n" import "./i18n"
import "./index.css" import "./index.css"
import { routeTree } from "./routeTree.gen" import { routeTree } from "./routeTree.gen"
@ -27,9 +28,11 @@ if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement) const root = ReactDOM.createRoot(rootElement)
root.render( root.render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <AppProviders>
<RouterProvider router={router} /> <QueryClientProvider client={queryClient}>
</QueryClientProvider> <RouterProvider router={router} />
</QueryClientProvider>
</AppProviders>
</StrictMode>, </StrictMode>,
) )
} }