96b944ac8d
- 更新 ESLint 配置添加 react-hooks 规则 - 运行 Prettier 格式化所有前端文件 - 为 users、apps、audit、quotas 页面添加 Skeleton 加载骨架屏 - 修复动态组件渲染问题(CategoryIcon/TypeIcon 使用 React.createElement) - 修复 useRef 渲染期间访问问题(改用 useMemo) - 修复 effect 中 setState 同步调用问题 - 新增 global-not-found.tsx 404 页面 - 修复 knowledge 页面引号转义问题
224 lines
8.5 KiB
TypeScript
224 lines
8.5 KiB
TypeScript
"use client";
|
||
|
||
import { memo } from "react";
|
||
import ReactMarkdown from "react-markdown";
|
||
import remarkGfm from "remark-gfm";
|
||
import type { Components } from "react-markdown";
|
||
import { BookOpen, BrainCircuit, ArrowRight } from "lucide-react";
|
||
import { useRouter } from "next/navigation";
|
||
import type { Chunk } from "@/lib/types";
|
||
|
||
const mdComponents: Components = {
|
||
h1: ({ children }) => (
|
||
<h1 className="text-lg font-bold text-primary border-b-2 border-primary/20 pb-2 mb-3 mt-4 first:mt-0">
|
||
{children}
|
||
</h1>
|
||
),
|
||
h2: ({ children }) => (
|
||
<h2 className="text-base font-bold text-primary/90 border-l-3 border-primary pl-3 mb-2 mt-4">
|
||
{children}
|
||
</h2>
|
||
),
|
||
h3: ({ children }) => (
|
||
<h3 className="text-sm font-bold text-foreground mb-1.5 mt-3 flex items-center gap-1.5">
|
||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-primary shrink-0" />
|
||
{children}
|
||
</h3>
|
||
),
|
||
p: ({ children }) => <p className="text-sm leading-7 text-foreground/90 my-2">{children}</p>,
|
||
ul: ({ children }) => <ul className="my-2 ml-1 space-y-1">{children}</ul>,
|
||
ol: ({ children }) => (
|
||
<ol className="my-2 ml-1 space-y-1 list-decimal list-inside">{children}</ol>
|
||
),
|
||
li: ({ children }) => (
|
||
<li className="text-sm leading-6 text-foreground/90 flex items-start gap-1.5">
|
||
<span className="inline-block w-1 h-1 rounded-full bg-primary/60 mt-2.5 shrink-0" />
|
||
<span className="flex-1">{children}</span>
|
||
</li>
|
||
),
|
||
blockquote: ({ children }) => (
|
||
<blockquote className="my-3 border-l-3 border-amber-400 bg-amber-50/60 dark:bg-amber-950/20 px-4 py-2 rounded-r-lg text-sm">
|
||
{children}
|
||
</blockquote>
|
||
),
|
||
table: ({ children }) => (
|
||
<div className="my-3 overflow-x-auto rounded-lg border">
|
||
<table className="w-full text-sm">{children}</table>
|
||
</div>
|
||
),
|
||
thead: ({ children }) => <thead className="bg-primary/5 border-b">{children}</thead>,
|
||
th: ({ children }) => (
|
||
<th className="px-3 py-2 text-left text-xs font-semibold text-primary/80 uppercase tracking-wider">
|
||
{children}
|
||
</th>
|
||
),
|
||
td: ({ children }) => <td className="px-3 py-2 text-sm border-b border-muted">{children}</td>,
|
||
hr: () => <hr className="my-4 border-t-2 border-dashed border-primary/10" />,
|
||
strong: ({ children }) => <strong className="font-semibold text-foreground">{children}</strong>,
|
||
em: ({ children }) => <em className="text-primary/80 not-italic font-medium">{children}</em>,
|
||
a: ({ href, children }) => {
|
||
if (href === "#cite-kb") {
|
||
const label = String(children).replace(/^知识库:/, "");
|
||
return (
|
||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 mx-0.5 text-xs font-semibold rounded bg-blue-100 text-blue-700 border border-blue-300 align-middle hover:bg-blue-200 transition-colors cursor-pointer">
|
||
<BookOpen className="h-3.5 w-3.5 shrink-0" />
|
||
<span>{label}</span>
|
||
</span>
|
||
);
|
||
}
|
||
if (href === "#cite-ai") {
|
||
return (
|
||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 mx-0.5 text-xs font-semibold rounded bg-orange-100 text-orange-700 border border-orange-300 align-middle whitespace-nowrap hover:bg-orange-200 transition-colors cursor-pointer">
|
||
<BrainCircuit className="h-3.5 w-3.5 shrink-0" />
|
||
<span>AI建议</span>
|
||
</span>
|
||
);
|
||
}
|
||
// 推荐应用:渲染为可点击的应用跳转卡片
|
||
if (href?.startsWith("#cite-app:")) {
|
||
const slug = href.replace("#cite-app:", "");
|
||
const appName = String(children);
|
||
return <AppLinkBadge slug={slug} name={appName} />;
|
||
}
|
||
return (
|
||
<a
|
||
href={href}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-primary underline underline-offset-2 hover:text-primary/80"
|
||
>
|
||
{children}
|
||
</a>
|
||
);
|
||
},
|
||
code: ({ className, children }) => {
|
||
const lang = className?.replace("language-", "") || "";
|
||
const isBlock = className?.includes("language-");
|
||
if (isBlock && (lang === "markdown" || lang === "md")) {
|
||
const text = String(children).replace(/\n$/, "");
|
||
return (
|
||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
|
||
{text}
|
||
</ReactMarkdown>
|
||
);
|
||
}
|
||
if (isBlock) {
|
||
return (
|
||
<div className="my-3 rounded-lg overflow-hidden border bg-slate-50 dark:bg-slate-900">
|
||
<div className="px-3 py-1.5 bg-slate-100 dark:bg-slate-800 text-xs text-muted-foreground border-b">
|
||
{lang || "代码"}
|
||
</div>
|
||
<pre className="p-3 overflow-x-auto text-xs leading-5">
|
||
<code>{children}</code>
|
||
</pre>
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<code className="text-xs font-mono bg-primary/5 text-primary px-1.5 py-0.5 rounded border border-primary/10">
|
||
{children}
|
||
</code>
|
||
);
|
||
},
|
||
pre: ({ children }) => <>{children}</>,
|
||
};
|
||
|
||
/**
|
||
* 推荐应用跳转徽章组件:点击后跳转到同机构内的其他应用
|
||
*/
|
||
function AppLinkBadge({ slug, name }: { slug: string; name: string }) {
|
||
const router = useRouter();
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => router.push(`/chat/${slug}`)}
|
||
className="inline-flex items-center gap-1 px-2.5 py-1 mx-0.5 text-xs font-medium rounded-lg bg-emerald-50 text-emerald-700 border border-emerald-200 align-middle cursor-pointer hover:bg-emerald-100 hover:border-emerald-300 transition-colors whitespace-nowrap"
|
||
>
|
||
<ArrowRight className="h-3 w-3 shrink-0" />
|
||
<span>{name}</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
interface GovMarkdownProps {
|
||
content: string;
|
||
className?: string;
|
||
chunks?: Chunk[];
|
||
}
|
||
|
||
function stripOuterCodeFence(text: string): string {
|
||
const trimmed = text.trim();
|
||
const match = trimmed.match(/^```[\w]*\s*\n([\s\S]*?)```\s*$/);
|
||
if (match) return match[1].trim();
|
||
return text;
|
||
}
|
||
|
||
/**
|
||
* 预处理 markdown 内容:将来源标注转为特殊链接格式,由 ReactMarkdown 的 a 组件拦截渲染
|
||
*
|
||
* 支持的标注格式:
|
||
* - [[chunk:N]] → 知识库引用(使用 chunks 映射表解析为文档名)
|
||
* - [[知识库:文档名]] → 知识库引用徽章
|
||
* - [[AI建议]] → AI建议徽章
|
||
* - [[推荐应用:名称:slug]] → 可点击跳转链接
|
||
*/
|
||
function preprocessCitations(content: string, chunks?: Chunk[]): string {
|
||
let result = content;
|
||
console.log("[preprocessCitations] chunks:", chunks);
|
||
|
||
// 先处理 chunk 编号引用 [[chunk:N]]
|
||
result = result.replace(/\[\[chunk:(\d+)\]\]/g, (_, idx) => {
|
||
const i = parseInt(idx, 10);
|
||
if (chunks && chunks[i]) {
|
||
// 使用 chunks 映射表,转换为带文档名的知识库引用
|
||
const docName = chunks[i].doc_name || chunks[i].content?.slice(0, 20) || "知识库片段";
|
||
console.log(`[preprocessCitations] chunk[${i}]:`, docName);
|
||
return `[知识库:${docName}](#cite-kb)`;
|
||
}
|
||
// 无映射时显示后备文本
|
||
console.log(`[preprocessCitations] chunk[${i}] not found, chunks length:`, chunks?.length);
|
||
return `[知识库片段](#cite-kb)`;
|
||
});
|
||
|
||
// 知识库引用:[[知识库:文献名称]] 或 [[知识库:文献名称:条款]]
|
||
result = result.replace(/\[\[(知识库:[^\]]+)\]\]/g, (_, label) => `[${label}](#cite-kb)`);
|
||
|
||
// AI建议:标准格式 [[AI建议]]
|
||
result = result.replace(/\[\[AI建议\]\]/g, "[AI建议](#cite-ai)");
|
||
|
||
// AI建议:来源说明块中的 **AI建议:** 或 **AI建议** 标题(仅匹配行首或 > 后)
|
||
result = result.replace(/^(\s*\>?\s*)\*\*AI建议[::]\*\*/gm, "$1[AI建议](#cite-ai)");
|
||
|
||
// AI建议:无加粗的 AI建议: 标题行(仅匹配行首或 > 后)
|
||
result = result.replace(/^(\s*\>?\s*)AI建议[::]\s*$/gm, "$1[AI建议](#cite-ai)");
|
||
|
||
// 推荐应用:[[推荐应用:应用名称:slug]] → 可点击跳转链接
|
||
result = result.replace(
|
||
/\[\[推荐应用:([^:]+):([^\]]+)\]\]/g,
|
||
(_, name, slug) => `[${name}](#cite-app:${slug})`,
|
||
);
|
||
|
||
return result;
|
||
}
|
||
|
||
const GovMarkdown = memo(function GovMarkdown({ content, className, chunks }: GovMarkdownProps) {
|
||
if (!content) {
|
||
return (
|
||
<div className="flex items-center gap-2 text-sm text-muted-foreground py-1">
|
||
<span className="inline-block w-2 h-2 rounded-full bg-primary/60 animate-pulse" />
|
||
正在检索知识库并生成回复...
|
||
</div>
|
||
);
|
||
}
|
||
const cleaned = preprocessCitations(stripOuterCodeFence(content), chunks);
|
||
return (
|
||
<div className={`gov-markdown ${className || ""}`}>
|
||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
|
||
{cleaned}
|
||
</ReactMarkdown>
|
||
</div>
|
||
);
|
||
});
|
||
|
||
export default GovMarkdown;
|