feat(ai): AI 消息代码块外壳与思维卡流式计时 - shiki 双主题高亮/流式光标/复制工具条, 250ms 节流 mono 秒数 (DCE 工具卡清理)

This commit is contained in:
maomaochong-ai
2026-09-06 20:29:39 +08:00
parent 1190a1b310
commit ab9d2422f9
6 changed files with 254 additions and 22 deletions

View File

@@ -0,0 +1,171 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Check, Copy } from "lucide-react";
type AiMessageCodeBlockProps = {
children?: ReactNode;
className?: string;
isStreaming?: boolean;
};
type ShikiHighlighter = Awaited<ReturnType<typeof import("shiki").createHighlighter>>;
let highlighterPromise: Promise<ShikiHighlighter> | null = null;
const SHIKI_LANGS = [
"javascript",
"typescript",
"jsx",
"tsx",
"json",
"html",
"css",
"scss",
"bash",
"shellscript",
"python",
"sql",
"markdown",
"yaml",
"diff",
"xml",
"java",
"go",
"rust",
"c",
"cpp",
"csharp",
"ruby",
"php",
"kotlin",
"swift",
"docker",
"text",
] as const;
/** 用户常见语言写法 → shiki 语言名;未知语言回退纯文本(无高亮但外壳不变)。 */
const LANGUAGE_ALIASES: Record<string, string> = {
sh: "shellscript",
shell: "shellscript",
zsh: "shellscript",
js: "javascript",
mjs: "javascript",
cjs: "javascript",
ts: "typescript",
mts: "typescript",
py: "python",
yml: "yaml",
md: "markdown",
mdx: "markdown",
rb: "ruby",
rs: "rust",
go: "go",
"c++": "cpp",
"c#": "csharp",
cs: "csharp",
kt: "kotlin",
dockerfile: "docker",
text: "text",
};
function loadHighlighter(): Promise<ShikiHighlighter> {
if (!highlighterPromise) {
highlighterPromise = import("shiki").then(({ createHighlighter }) =>
createHighlighter({
langs: [...SHIKI_LANGS],
themes: ["github-light", "github-dark"],
}),
);
}
return highlighterPromise;
}
function collectText(node: ReactNode): string {
if (node == null || typeof node === "boolean") return "";
if (typeof node === "string" || typeof node === "number") return String(node);
if (Array.isArray(node)) return node.map(collectText).join("");
if (typeof node === "object" && "props" in node) {
return collectText((node.props as { children?: ReactNode }).children);
}
return "";
}
function detectLanguage(children: ReactNode, ownClassName?: string): string {
const pattern = /language-([^\s]+)/;
const own = typeof ownClassName === "string" ? ownClassName.match(pattern) : null;
if (own) return own[1];
const first = Array.isArray(children) ? children[0] : children;
if (first && typeof first === "object" && "props" in first) {
const className = (first.props as { className?: unknown }).className;
const match = typeof className === "string" ? className.match(pattern) : null;
if (match) return match[1];
}
return "";
}
/**
* 自研代码块:语言标签 + 复制按钮 + 统一圆角/边框/底色;
* 消息结束后用 shiki 对代码上色(懒加载,github-light/dark 双主题 CSS 变量,
* 由 .dark 类切换),流式期间保持单色避免逐帧闪烁。
*/
export function AiMessageCodeBlock({ children, className, isStreaming = false, ...props }: AiMessageCodeBlockProps) {
const [copied, setCopied] = useState(false);
const [highlightHtml, setHighlightHtml] = useState<string | null>(null);
const timerRef = useRef<number | undefined>(undefined);
const codeText = collectText(children);
const language = detectLanguage(children, className).toLowerCase();
useEffect(() => {
if (isStreaming || !codeText.trim()) {
setHighlightHtml(null);
return;
}
let cancelled = false;
const timer = window.setTimeout(() => {
void loadHighlighter()
.then(async (highlighter) => {
if (cancelled) return;
const aliased = LANGUAGE_ALIASES[language];
const lang = aliased ?? ((SHIKI_LANGS as readonly string[]).includes(language) ? language : "text");
const html = await highlighter.codeToHtml(codeText.trimEnd(), {
lang,
themes: { light: "github-light", dark: "github-dark" },
defaultColor: false,
cssVariablePrefix: "--shiki-",
});
if (!cancelled) setHighlightHtml(html);
})
.catch(() => {});
}, 120);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [codeText, language, isStreaming]);
const handleCopy = () => {
const payload = codeText.trimEnd();
void navigator.clipboard?.writeText(payload).catch(() => {});
setCopied(true);
window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => setCopied(false), 1600);
};
return (
<div className="ai-message-codeblock">
<div className="ai-message-codeblock-header">
<span className="ai-message-codeblock-lang">{language || "code"}</span>
<button type="button" className="ai-message-codeblock-copy" aria-label={copied ? "已复制" : "复制代码"} onClick={handleCopy}>
{copied ? <Check /> : <Copy />}
<span>{copied ? "已复制" : "复制"}</span>
</button>
</div>
{highlightHtml ? (
<div className="ai-message-codeblock-pre" dangerouslySetInnerHTML={{ __html: highlightHtml }} />
) : (
<pre {...props} className="ai-message-codeblock-pre">
{children}
</pre>
)}
</div>
);
}