feat(ai): AI 消息代码块外壳与思维卡流式计时 - shiki 双主题高亮/流式光标/复制工具条, 250ms 节流 mono 秒数 (DCE 工具卡清理)
This commit is contained in:
1 parent
97a0b4d111
commit
fd5ebb6893
6 files changed
+254
-22
No files matched your search
@@ -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 { Streamdown, type Components } from "streamdown";
|
||||||
|
import { AiMessageCodeBlock } from "./ai-message-code-block";
|
||||||
|
|
||||||
import "streamdown/styles.css";
|
import "streamdown/styles.css";
|
||||||
|
|
||||||
@@ -8,7 +9,8 @@ type AIMessageMarkdownProps = {
|
|||||||
className?: string;
|
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>,
|
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>,
|
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>,
|
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>,
|
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>,
|
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>,
|
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>,
|
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>,
|
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" />,
|
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>,
|
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>,
|
td: ({ children, ...props }) => <td {...props} className="ai-message-markdown-table-cell">{children}</td>,
|
||||||
input: ({ ...props }) => <input {...props} className="ai-message-markdown-task" disabled />,
|
input: ({ ...props }) => <input {...props} className="ai-message-markdown-task" disabled />,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export function AIMessageMarkdown({ children, isStreaming = false, className = "" }: AIMessageMarkdownProps) {
|
export function AIMessageMarkdown({ children, isStreaming = false, className = "" }: AIMessageMarkdownProps) {
|
||||||
if (!children.trim()) return null;
|
if (!children.trim()) return null;
|
||||||
@@ -40,7 +44,7 @@ export function AIMessageMarkdown({ children, isStreaming = false, className = "
|
|||||||
parseIncompleteMarkdown
|
parseIncompleteMarkdown
|
||||||
skipHtml
|
skipHtml
|
||||||
lineNumbers={false}
|
lineNumbers={false}
|
||||||
components={components}
|
components={buildComponents(isStreaming)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Streamdown>
|
</Streamdown>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ChevronDown } from "lucide-react";
|
|||||||
|
|
||||||
export type GenerationToolStatus = "running" | "completed" | "error" | "cancelled";
|
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);
|
const [open, setOpen] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -3,6 +3,15 @@ import { ChevronDown, Sparkles } from "lucide-react";
|
|||||||
|
|
||||||
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
|
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 }) {
|
export function MessageReasoning({ reasoning, isStreaming }: { reasoning: string; isStreaming: boolean }) {
|
||||||
const [open, setOpen] = useState(isStreaming);
|
const [open, setOpen] = useState(isStreaming);
|
||||||
const [durationSeconds, setDurationSeconds] = useState<number>();
|
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">
|
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)}>
|
<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" />
|
<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" : ""}`} />
|
<ChevronDown className={`size-3.5 transition-transform${open ? " rotate-180" : ""}`} />
|
||||||
</button>
|
</button>
|
||||||
{open ? <div className="mt-2 rounded-md bg-surface-active p-3 text-foreground/70"><AIMessageMarkdown isStreaming={isStreaming}>{reasoning}</AIMessageMarkdown></div> : null}
|
{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
|
* work. Disabled under prefers-reduced-motion. Caller positions it: wrap the
|
||||||
* control in a `relative` container and pass the matching border radius.
|
* control in a `relative` container and pass the matching border radius.
|
||||||
*/
|
*/
|
||||||
export function WorkingGlow({
|
export function WorkingGlow({ active, color, radius = 0, className }: { active: boolean; color: string; radius?: number | string; className?: string }) {
|
||||||
active,
|
|
||||||
color,
|
|
||||||
radius = 0,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
active: boolean;
|
|
||||||
color: string;
|
|
||||||
radius?: number | string;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const reducedMotion = useReducedMotion();
|
const reducedMotion = useReducedMotion();
|
||||||
if (!active || reducedMotion) return null;
|
if (!active || reducedMotion) return null;
|
||||||
const soft = `color-mix(in srgb, ${color} 55%, transparent)`;
|
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-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
font-size: 0.88em;
|
font-size: 0.88em;
|
||||||
}
|
}
|
||||||
.ai-message-markdown-pre {
|
.ai-message-codeblock {
|
||||||
margin: 0.8em 0;
|
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;
|
overflow-x: auto;
|
||||||
border-radius: var(--r-sm);
|
background: transparent;
|
||||||
background: color-mix(in srgb, var(--foreground) 7%, var(--background));
|
|
||||||
color: color-mix(in srgb, var(--foreground) 86%, transparent);
|
color: color-mix(in srgb, var(--foreground) 86%, transparent);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
font-size: 0.84em;
|
font-size: 0.84em;
|
||||||
line-height: 1.55;
|
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;
|
padding: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
font-size: 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 {
|
.ai-message-markdown-link {
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
|||||||
Reference in new issue
Block a user