- 点5: cancelling 独立状态 UI,用轮询替换 300ms setTimeout,StreamStatusBar 加橙色视觉 - 点6: ThreadSidebar formatTime 支持毫秒时间戳,时间展示改用 lastActive - 点7: MessageBubble 智能摘要(关键词优先/标题次优/跳开场白),折叠阈值提升至25行/800字,展开后可收起 Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
371 lines
12 KiB
TypeScript
371 lines
12 KiB
TypeScript
import ReactMarkdown from "react-markdown";
|
||
import remarkGfm from "remark-gfm";
|
||
import remarkMath from "remark-math";
|
||
import rehypeKatex from "rehype-katex";
|
||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||
import {
|
||
oneDark,
|
||
oneLight,
|
||
} from "react-syntax-highlighter/dist/esm/styles/prism";
|
||
import { useState, type ReactNode } from "react";
|
||
import { Copy, Check } from "lucide-react";
|
||
import { cn } from "@/lib/utils";
|
||
import "katex/dist/katex.min.css";
|
||
|
||
// Fixed source tag definitions — only these 4 exact patterns are replaced (XSS-safe, no arbitrary HTML)
|
||
const SOURCE_TAGS: { pattern: string; label: string; className: string }[] = [
|
||
{ pattern: "[知识库]", label: "知识库", className: "inline-flex items-center text-[10px] px-1 py-0.5 rounded font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 mx-0.5" },
|
||
{ pattern: "[工单]", label: "工单", className: "inline-flex items-center text-[10px] px-1 py-0.5 rounded font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400 mx-0.5" },
|
||
{ pattern: "[网络]", label: "网络", className: "inline-flex items-center text-[10px] px-1 py-0.5 rounded font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 mx-0.5" },
|
||
{ pattern: "[推断]", label: "推断", className: "inline-flex items-center text-[10px] px-1 py-0.5 rounded font-medium bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-700 mx-0.5" },
|
||
];
|
||
|
||
// Splits a plain text string into React nodes, replacing:
|
||
// 1. Fixed source tags: [知识库] [工单] [网络] [推断] → colored badge spans
|
||
// 2. Citation numbers: [1] [2] [3] … → clickable blue chip buttons that dispatch soc:highlight-citation
|
||
function renderWithSourceBadges(text: string): ReactNode[] {
|
||
// Combined regex: named source tags OR citation numbers [N]
|
||
const sourceEscaped = SOURCE_TAGS.map((t) => t.pattern.replace(/[[\]]/g, "\\$&")).join("|");
|
||
// Citation pattern: [digits] only — must be a pure number to avoid colliding with markdown links
|
||
const combined = new RegExp(`(${sourceEscaped}|\\[\\d+\\])`, "g");
|
||
const parts = text.split(combined);
|
||
|
||
return parts.map((part, i) => {
|
||
// Fixed source tag?
|
||
const tag = SOURCE_TAGS.find((t) => t.pattern === part);
|
||
if (tag) {
|
||
return <span key={i} className={tag.className}>{tag.label}</span>;
|
||
}
|
||
// Citation chip? Match [N] exactly
|
||
const citMatch = /^\[(\d+)\]$/.exec(part);
|
||
if (citMatch) {
|
||
const num = Number(citMatch[1]);
|
||
return (
|
||
<button
|
||
key={i}
|
||
type="button"
|
||
onClick={() =>
|
||
window.dispatchEvent(
|
||
new CustomEvent("soc:highlight-citation", { detail: { index: num } }),
|
||
)
|
||
}
|
||
className="inline-flex items-center justify-center text-[10px] font-medium rounded px-1 py-0 min-w-[18px] bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 hover:bg-blue-200 dark:hover:bg-blue-800/50 transition-colors cursor-pointer mx-0.5 align-text-bottom"
|
||
title={`查看引用 ${num}`}
|
||
aria-label={`引用 ${num}`}
|
||
>
|
||
{num}
|
||
</button>
|
||
);
|
||
}
|
||
return part;
|
||
});
|
||
}
|
||
|
||
interface MessageBubbleProps {
|
||
content: string;
|
||
role: "human" | "ai";
|
||
}
|
||
|
||
const CODE_COLLAPSE_THRESHOLD = 20;
|
||
const CODE_PREVIEW_LINES = 5;
|
||
|
||
function CodeBlock({
|
||
language,
|
||
children,
|
||
}: {
|
||
language: string;
|
||
children: string;
|
||
}) {
|
||
const [copied, setCopied] = useState(false);
|
||
const lines = children.split("\n");
|
||
const isLongCode = lines.length > CODE_COLLAPSE_THRESHOLD;
|
||
const [codeExpanded, setCodeExpanded] = useState(!isLongCode);
|
||
|
||
const handleCopy = () => {
|
||
navigator.clipboard.writeText(children).then(() => {
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
});
|
||
};
|
||
|
||
// Detect dark mode via document class
|
||
const isDark =
|
||
typeof document !== "undefined" &&
|
||
document.documentElement.classList.contains("dark");
|
||
|
||
const displayedCode = codeExpanded
|
||
? children
|
||
: lines.slice(0, CODE_PREVIEW_LINES).join("\n");
|
||
|
||
return (
|
||
<div className="relative group my-2 rounded-lg overflow-hidden border border-border">
|
||
{/* Language label + copy button */}
|
||
<div className="flex items-center justify-between px-3 py-1.5 bg-muted border-b border-border">
|
||
<span className="text-xs font-mono text-muted-foreground">
|
||
{language || "text"}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={handleCopy}
|
||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||
>
|
||
{copied ? (
|
||
<Check className="size-3.5 text-green-500" />
|
||
) : (
|
||
<Copy className="size-3.5" />
|
||
)}
|
||
{copied ? "已复制" : "复制"}
|
||
</button>
|
||
</div>
|
||
<SyntaxHighlighter
|
||
language={language || "text"}
|
||
style={isDark ? oneDark : oneLight}
|
||
customStyle={{
|
||
margin: 0,
|
||
borderRadius: 0,
|
||
fontSize: "0.75rem",
|
||
background: "transparent",
|
||
}}
|
||
PreTag="div"
|
||
>
|
||
{displayedCode}
|
||
</SyntaxHighlighter>
|
||
{isLongCode && (
|
||
<div className="border-t border-border bg-muted px-3 py-1.5 flex items-center justify-between">
|
||
<span className="text-[10px] text-muted-foreground">
|
||
{codeExpanded ? `共 ${lines.length} 行` : `已折叠,共 ${lines.length} 行`}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setCodeExpanded((v) => !v)}
|
||
className="text-xs text-primary hover:underline"
|
||
>
|
||
{codeExpanded ? "折叠" : "展开全部"}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const CONCLUSION_KEYWORDS = /总结|结论|建议|综上|总体|总的来说|核心|要点|小结/;
|
||
const FILLER_PATTERN = /^(根据|针对|您好|你好|好的|感谢|当然|如您所述|以下是|下面是)/;
|
||
|
||
function extractSummary(text: string): string {
|
||
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
|
||
|
||
// 1. 优先:含总结关键词的段落首句
|
||
for (const line of lines) {
|
||
if (CONCLUSION_KEYWORDS.test(line) && line.length > 8 && !line.startsWith("#")) {
|
||
return line.replace(/^[*_#>\s-]+/, "").slice(0, 120);
|
||
}
|
||
}
|
||
|
||
// 2. 次优先:Markdown 标题(## / ###)之后的第一个非空行
|
||
for (let i = 0; i < lines.length; i++) {
|
||
if (/^#{2,3}\s/.test(lines[i]) && lines[i + 1]) {
|
||
const next = lines[i + 1].replace(/^[*_>\s-]+/, "");
|
||
if (next.length > 8) return next.slice(0, 120);
|
||
}
|
||
}
|
||
|
||
// 3. Fallback:跳过开场白,取第一个有实质内容的句子
|
||
const sentences = text.split(/(?<=[.。!!??])\s+/);
|
||
const meaningful = sentences.find((s) => s.length > 10 && !FILLER_PATTERN.test(s.trim()));
|
||
if (meaningful) return meaningful.trim().slice(0, 120);
|
||
|
||
// 4. 最终 fallback
|
||
return sentences.slice(0, 2).join(" ").trim();
|
||
}
|
||
|
||
export default function MessageBubble({ content }: MessageBubbleProps) {
|
||
const [summaryExpanded, setSummaryExpanded] = useState(false);
|
||
const contentLines = content.split("\n").length;
|
||
const isLong = contentLines > 25 || content.length > 800;
|
||
const summary = isLong ? extractSummary(content) : null;
|
||
|
||
return (
|
||
<>
|
||
{isLong && summary && !summaryExpanded && (
|
||
<div className="mb-2 pb-2 border-b border-border/40">
|
||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">{summary}…</p>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSummaryExpanded((v) => !v)}
|
||
className="mt-1 text-xs text-primary hover:underline"
|
||
>
|
||
展开全文
|
||
</button>
|
||
</div>
|
||
)}
|
||
{(!isLong || summaryExpanded) && (
|
||
<>
|
||
<ReactMarkdown
|
||
remarkPlugins={[remarkGfm, remarkMath]}
|
||
rehypePlugins={[rehypeKatex]}
|
||
components={{
|
||
// Code blocks
|
||
code({ className, children, ...props }) {
|
||
const match = /language-(\w+)/.exec(className ?? "");
|
||
const isInline = !match && !className;
|
||
const codeStr = String(children).replace(/\n$/, "");
|
||
|
||
if (isInline) {
|
||
return (
|
||
<code
|
||
className="bg-muted px-1 rounded text-xs font-mono"
|
||
{...props}
|
||
>
|
||
{children}
|
||
</code>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<CodeBlock language={match ? match[1] : ""}>{codeStr}</CodeBlock>
|
||
);
|
||
},
|
||
|
||
// Tables
|
||
table({ children }) {
|
||
return (
|
||
<div className="overflow-x-auto my-2">
|
||
<table className="min-w-full text-xs border-collapse border border-border">
|
||
{children}
|
||
</table>
|
||
</div>
|
||
);
|
||
},
|
||
th({ children }) {
|
||
return (
|
||
<th className="border border-border px-3 py-1.5 bg-muted text-left font-medium text-foreground">
|
||
{children}
|
||
</th>
|
||
);
|
||
},
|
||
td({ children }) {
|
||
return (
|
||
<td className="border border-border px-3 py-1.5 text-foreground">
|
||
{children}
|
||
</td>
|
||
);
|
||
},
|
||
|
||
// Headings
|
||
h1({ children }) {
|
||
return (
|
||
<h1 className="text-base font-bold mt-3 mb-1 text-foreground">
|
||
{children}
|
||
</h1>
|
||
);
|
||
},
|
||
h2({ children }) {
|
||
return (
|
||
<h2 className="text-sm font-semibold mt-3 mb-1 text-foreground">
|
||
{children}
|
||
</h2>
|
||
);
|
||
},
|
||
h3({ children }) {
|
||
return (
|
||
<h3 className="text-sm font-medium mt-2 mb-1 text-foreground">
|
||
{children}
|
||
</h3>
|
||
);
|
||
},
|
||
|
||
// Lists
|
||
ul({ children }) {
|
||
return (
|
||
<ul className="list-disc list-inside space-y-0.5 my-1 text-foreground">
|
||
{children}
|
||
</ul>
|
||
);
|
||
},
|
||
ol({ children }) {
|
||
return (
|
||
<ol className="list-decimal list-inside space-y-0.5 my-1 text-foreground">
|
||
{children}
|
||
</ol>
|
||
);
|
||
},
|
||
|
||
// Paragraphs — inline source badges for [知识库] [工单] [网络] [推断]
|
||
p({ children }) {
|
||
const processedChildren = Array.isArray(children)
|
||
? children.flatMap((child, idx) =>
|
||
typeof child === "string"
|
||
? renderWithSourceBadges(child).map((node, ni) =>
|
||
typeof node === "string" ? node : <span key={`${idx}-${ni}`}>{node}</span>
|
||
)
|
||
: [child]
|
||
)
|
||
: typeof children === "string"
|
||
? renderWithSourceBadges(children)
|
||
: children;
|
||
return <p className="my-1 leading-relaxed text-foreground">{processedChildren}</p>;
|
||
},
|
||
|
||
// Links
|
||
a({ href, children }) {
|
||
return (
|
||
<a
|
||
href={href}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className={cn(
|
||
"underline underline-offset-2 text-foreground",
|
||
"hover:opacity-80 transition-opacity",
|
||
)}
|
||
>
|
||
{children}
|
||
</a>
|
||
);
|
||
},
|
||
|
||
// Blockquote
|
||
blockquote({ children }) {
|
||
return (
|
||
<blockquote className="border-l-2 border-border pl-3 my-2 text-muted-foreground italic">
|
||
{children}
|
||
</blockquote>
|
||
);
|
||
},
|
||
|
||
// Horizontal rule
|
||
hr() {
|
||
return <hr className="my-3 border-border" />;
|
||
},
|
||
|
||
// Strong / em
|
||
strong({ children }) {
|
||
return (
|
||
<strong className="font-semibold text-foreground">{children}</strong>
|
||
);
|
||
},
|
||
em({ children }) {
|
||
return <em className="italic text-foreground">{children}</em>;
|
||
},
|
||
}}
|
||
>
|
||
{content}
|
||
</ReactMarkdown>
|
||
{summaryExpanded && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setSummaryExpanded(false);
|
||
}}
|
||
className="mt-2 text-xs text-primary hover:underline flex items-center gap-0.5 ml-auto"
|
||
>
|
||
收起 ▲
|
||
</button>
|
||
)}
|
||
</>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
|