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>
);
}

View File

@@ -1,4 +1,5 @@
import { Streamdown, type Components } from "streamdown";
import { AiMessageCodeBlock } from "./ai-message-code-block";
import "streamdown/styles.css";
@@ -8,7 +9,8 @@ type AIMessageMarkdownProps = {
className?: string;
};
const components: Components = {
function buildComponents(isStreaming: boolean): Components {
return {
h1: ({ children, ...props }) => <h2 {...props} className="ai-message-markdown-heading ai-message-markdown-heading-1">{children}</h2>,
h2: ({ children, ...props }) => <h3 {...props} className="ai-message-markdown-heading ai-message-markdown-heading-2">{children}</h3>,
h3: ({ children, ...props }) => <h4 {...props} className="ai-message-markdown-heading ai-message-markdown-heading-3">{children}</h4>,
@@ -18,7 +20,7 @@ const components: Components = {
ol: ({ children, ...props }) => <ol {...props} className="ai-message-markdown-list ai-message-markdown-list-ordered">{children}</ol>,
li: ({ children, ...props }) => <li {...props} className="ai-message-markdown-list-item">{children}</li>,
blockquote: ({ children, ...props }) => <blockquote {...props} className="ai-message-markdown-blockquote">{children}</blockquote>,
pre: ({ children, ...props }) => <pre {...props} className="ai-message-markdown-pre">{children}</pre>,
pre: ({ children, ...props }) => <AiMessageCodeBlock isStreaming={isStreaming} {...props}>{children}</AiMessageCodeBlock>,
code: ({ children, className, ...props }) => <code {...props} className={`ai-message-markdown-code ${className || ""}`.trim()}>{children}</code>,
a: ({ children, ...props }) => <a {...props} className="ai-message-markdown-link" target="_blank" rel="noreferrer">{children}</a>,
hr: (props) => <hr {...props} className="ai-message-markdown-rule" />,
@@ -26,7 +28,9 @@ const components: Components = {
th: ({ children, ...props }) => <th {...props} className="ai-message-markdown-table-cell ai-message-markdown-table-header">{children}</th>,
td: ({ children, ...props }) => <td {...props} className="ai-message-markdown-table-cell">{children}</td>,
input: ({ ...props }) => <input {...props} className="ai-message-markdown-task" disabled />,
};
};
}
export function AIMessageMarkdown({ children, isStreaming = false, className = "" }: AIMessageMarkdownProps) {
if (!children.trim()) return null;
@@ -40,7 +44,7 @@ export function AIMessageMarkdown({ children, isStreaming = false, className = "
parseIncompleteMarkdown
skipHtml
lineNumbers={false}
components={components}
components={buildComponents(isStreaming)}
>
{children}
</Streamdown>

View File

@@ -3,7 +3,7 @@ import { ChevronDown } from "lucide-react";
export type GenerationToolStatus = "running" | "completed" | "error" | "cancelled";
export function GenerationToolCard({ status, isBulk = false, heading, children }: { status: GenerationToolStatus; isBulk?: boolean; heading: ReactNode; children: ReactNode }) {
export function GenerationToolCard({ status, heading, children }: { status: GenerationToolStatus; heading: ReactNode; children: ReactNode }) {
const [open, setOpen] = useState(true);
useEffect(() => {

View File

@@ -3,6 +3,15 @@ import { ChevronDown, Sparkles } from "lucide-react";
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
function LiveReasoningCount() {
const [elapsedSeconds, setElapsedSeconds] = useState(0);
useEffect(() => {
const startedAt = Date.now();
const timer = window.setInterval(() => setElapsedSeconds((Date.now() - startedAt) / 1000), 250);
return () => window.clearInterval(timer);
}, []);
return <span className="ml-0.5 font-mono text-[11px] tabular-nums opacity-80">{elapsedSeconds.toFixed(1)}s</span>;
}
export function MessageReasoning({ reasoning, isStreaming }: { reasoning: string; isStreaming: boolean }) {
const [open, setOpen] = useState(isStreaming);
const [durationSeconds, setDurationSeconds] = useState<number>();
@@ -33,7 +42,7 @@ export function MessageReasoning({ reasoning, isStreaming }: { reasoning: string
return <div className="mb-2 text-xs text-foreground/55">
<button type="button" className="inline-flex min-h-8 items-center gap-1.5 rounded-md px-2 transition-colors hover:bg-surface-hover hover:text-foreground" aria-expanded={open} onClick={() => setOpen((value) => !value)}>
<Sparkles className="size-3.5" />
<span>{isStreaming ? "正在思考" : durationSeconds ? `已思考 ${durationSeconds} 秒` : "思考过程"}</span>
<span>{isStreaming ? <>正在思考<LiveReasoningCount /></> : durationSeconds ? <>已思考 <span className="font-mono tabular-nums">{durationSeconds}</span> 秒</> : "思考过程"}</span>
<ChevronDown className={`size-3.5 transition-transform${open ? " rotate-180" : ""}`} />
</button>
{open ? <div className="mt-2 rounded-md bg-surface-active p-3 text-foreground/70"><AIMessageMarkdown isStreaming={isStreaming}>{reasoning}</AIMessageMarkdown></div> : null}

View File

@@ -63,17 +63,7 @@ export function WorkingDots({
* work. Disabled under prefers-reduced-motion. Caller positions it: wrap the
* control in a `relative` container and pass the matching border radius.
*/
export function WorkingGlow({
active,
color,
radius = 0,
className,
}: {
active: boolean;
color: string;
radius?: number | string;
className?: string;
}) {
export function WorkingGlow({ active, color, radius = 0, className }: { active: boolean; color: string; radius?: number | string; className?: string }) {
const reducedMotion = useReducedMotion();
if (!active || reducedMotion) return null;
const soft = `color-mix(in srgb, ${color} 55%, transparent)`;

View File

@@ -15264,21 +15264,79 @@ html:not(.dark) .creation-history-drawer-root {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.88em;
}
.ai-message-markdown-pre {
.ai-message-codeblock {
margin: 0.8em 0;
padding: 0.8em 0.9em;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--foreground) 11%, transparent);
border-radius: var(--r-md);
background: color-mix(in srgb, var(--foreground) 5%, var(--background));
}
.ai-message-codeblock-header {
display: flex;
height: 34px;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding: 0 var(--space-2) 0 12px;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
}
.ai-message-codeblock-lang {
overflow: hidden;
color: color-mix(in srgb, currentColor 56%, transparent);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: var(--fs-micro);
font-weight: 600;
letter-spacing: 0.05em;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.ai-message-codeblock-copy {
display: inline-flex;
height: 24px;
flex: 0 0 auto;
align-items: center;
gap: 5px;
border: 0;
border-radius: 6px;
background: transparent;
padding: 0 var(--space-2);
color: color-mix(in srgb, currentColor 62%, transparent);
font: inherit;
font-size: var(--fs-micro);
transition: background-color 150ms ease, color 150ms ease;
}
.ai-message-codeblock-copy svg { width: 13px; height: 13px; }
.ai-message-codeblock-copy:hover { background: color-mix(in srgb, currentColor 10%, transparent); color: inherit; }
.ai-message-codeblock-copy:focus-visible { outline: 2px solid color-mix(in srgb, var(--primary) 60%, transparent); outline-offset: -1px; }
.ai-message-codeblock-pre {
margin: 0;
padding: 10px 12px;
overflow-x: auto;
border-radius: var(--r-sm);
background: color-mix(in srgb, var(--foreground) 7%, var(--background));
background: transparent;
color: color-mix(in srgb, var(--foreground) 86%, transparent);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.84em;
line-height: 1.55;
tab-size: 2;
}
.ai-message-markdown-pre .ai-message-markdown-code {
.ai-message-codeblock-pre .ai-message-markdown-code {
padding: 0;
background: transparent;
color: inherit;
font-size: inherit;
}
@media (prefers-reduced-motion: reduce) {
.ai-message-codeblock-copy { transition: none; }
}
/* --- ai-message 代码块语法高亮(shiki 双主题 CSS 变量:--shiki-light/--shiki-dark)--- */
.ai-message-codeblock-pre .shiki { margin: 0; padding: 0; background: transparent !important; }
.ai-message-codeblock-pre .shiki code { font-family: inherit; font-size: inherit; color: inherit; }
html:not(.dark) .ai-message-codeblock-pre .shiki,
html:not(.dark) .ai-message-codeblock-pre .shiki span { color: var(--shiki-light) !important; }
html.dark .ai-message-codeblock-pre .shiki,
html.dark .ai-message-codeblock-pre .shiki span { color: var(--shiki-dark) !important; }
.ai-message-markdown-link {
color: var(--primary);
text-decoration: underline;