Files
socaichat/langgraph/src/components/MessageBubble.tsx
T
gongzhiyongandClaude Sonnet 4.6 73a7175051
Deploy LangGraph Server to Azure Web App / build-and-deploy (push) Failing after 23s
Deploy LangGraph UI to Azure Static Web Apps / build-and-deploy (push) Failing after 53s
feat: cancelling status, ThreadSidebar lastActive time, MessageBubble smart summary
- 点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>
2026-04-12 15:44:58 +08:00

371 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)}
</>
)}
</>
);
}