Files
UHD_Media_Tool/apps/web/app/components/agentui/code-block.tsx
T

157 lines
5.5 KiB
TypeScript

// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/code-block.tsx + agent-code.tsx
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:不引 shiki(包体大,且高亮主题带十六进制颜色),纯文本按行渲染;JSON 输入先尝试缩进美化;
// 状态色:streaming 用中性色 + 转圈,complete 用 success;高亮行用 warning 洗层;行号/字号/圆角换 token;
// 去掉 motion 按压;复制失败静默。
import { Check, Copy, FileCode2, LoaderCircle } from "lucide-react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { cn } from "~/lib/utils.ts";
export type CodeLanguage = "text" | "json" | "bash" | "diff";
/** JSON 文本尝试缩进美化;不是合法 JSON 原样返回(摘要可能被截断) */
export function prettyMaybeJson(s: string): { text: string; language: CodeLanguage } {
const t = s.trim();
if ((t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]"))) {
try {
return { text: JSON.stringify(JSON.parse(t), null, 2), language: "json" };
} catch {
return { text: s, language: "json" };
}
}
return { text: s, language: "text" };
}
export interface CodeBlockProps {
code: string;
language?: CodeLanguage;
filename?: ReactNode;
status?: "streaming" | "complete";
showLineNumbers?: boolean;
highlightLines?: number[];
/** 最大高度(Tailwind 类,如 max-h-56) */
maxHeightClassName?: string;
wrap?: boolean;
copyable?: boolean;
/** 不显示顶栏(嵌在别的卡片里时) */
bare?: boolean;
tone?: "default" | "destructive";
className?: string;
}
export function CodeBlock({
code,
language = "text",
filename,
status = "complete",
showLineNumbers = false,
highlightLines = [],
maxHeightClassName = "max-h-56",
wrap = true,
copyable = true,
bare = false,
tone = "default",
className,
}: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const t = useRef<number | undefined>(undefined);
useEffect(
() => () => {
if (t.current) window.clearTimeout(t.current);
},
[],
);
const streaming = status === "streaming";
const hl = new Set(highlightLines);
const lines = code.split("\n");
const copy = async () => {
try {
await navigator.clipboard?.writeText(code);
setCopied(true);
if (t.current) window.clearTimeout(t.current);
t.current = window.setTimeout(() => setCopied(false), 1600);
} catch {
// 非安全上下文没有剪贴板
}
};
return (
<div
data-state={status}
aria-busy={streaming}
className={cn(
"w-full min-w-0 overflow-hidden rounded-lg border border-border/60 bg-foreground/[0.03] text-sm",
tone === "destructive" && "border-destructive/30",
className,
)}
>
{bare ? null : (
<div className="flex h-8 items-center gap-2 border-b border-border/50 px-2.5">
<FileCode2 aria-hidden className="size-3.5 shrink-0 text-muted-foreground/70" />
{filename ? <span className="min-w-0 truncate text-meta text-foreground/80">{filename}</span> : null}
<span className="text-micro font-medium tracking-wide text-muted-foreground uppercase">{language}</span>
<span
className={cn(
"ml-auto inline-flex shrink-0 items-center gap-1 text-micro font-medium",
streaming ? "text-muted-foreground" : "text-success",
)}
>
{streaming ? (
<LoaderCircle aria-hidden className="size-3 animate-spin motion-reduce:animate-none" />
) : (
<Check aria-hidden className="size-3" />
)}
{streaming ? "输出中" : "已完成"}
</span>
{copyable ? (
<button
type="button"
aria-label={copied ? "已复制" : "复制"}
title={copied ? "已复制" : "复制"}
onClick={copy}
className="grid size-6 shrink-0 cursor-pointer place-items-center rounded-full text-muted-foreground outline-none transition-colors duration-micro hover:bg-foreground/[0.06] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</button>
) : null}
</div>
)}
<div className={cn("overflow-auto py-1.5", maxHeightClassName)}>
<pre
className={cn(
"m-0 font-mono text-meta leading-5 text-foreground/85",
tone === "destructive" && "text-destructive",
!wrap && "min-w-max",
)}
>
<code>
{lines.map((line, i) => (
<span
key={i}
className={cn(
"grid min-h-5",
showLineNumbers ? "grid-cols-[2.25rem_minmax(0,1fr)]" : "grid-cols-1",
hl.has(i + 1) && "bg-warning/10",
)}
>
{showLineNumbers ? (
<span className="pr-2 text-right text-muted-foreground/50 tabular-nums select-none">{i + 1}</span>
) : null}
<span
className={cn(
"pr-3",
showLineNumbers ? "pl-1" : "pl-3",
wrap ? "break-all whitespace-pre-wrap" : "whitespace-pre",
)}
>
{line || " "}
</span>
</span>
))}
</code>
</pre>
</div>
</div>
);
}