feat(ai): AI 消息代码块外壳与思维卡流式计时 - shiki 双主题高亮/流式光标/复制工具条, 250ms 节流 mono 秒数 (DCE 工具卡清理)
This commit is contained in:
171
web/src/components/ai/ai-message-code-block.tsx
Normal file
171
web/src/components/ai/ai-message-code-block.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user