Initial commit: GovAI 政务AI平台
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
"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";
|
||||
|
||||
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 px-2 py-0.5 mx-0.5 text-xs font-medium rounded-md bg-blue-50 text-blue-700 border border-blue-200 align-middle">
|
||||
<BookOpen className="h-3 w-3 shrink-0" />
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (href === "#cite-ai") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 mx-0.5 text-xs font-medium rounded-md bg-amber-50 text-amber-700 border border-amber-200 align-middle whitespace-nowrap">
|
||||
<BrainCircuit className="h-3 w-3 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;
|
||||
}
|
||||
|
||||
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 组件拦截渲染
|
||||
*/
|
||||
function preprocessCitations(content: string): string {
|
||||
return content
|
||||
// 知识库引用:[[知识库:文献名称]] 或 [[知识库:文献名称:条款]]
|
||||
.replace(/\[\[(知识库:[^\]]+)\]\]/g, (_, label) => `[${label}](#cite-kb)`)
|
||||
// AI建议:标准格式 [[AI建议]]
|
||||
.replace(/\[\[AI建议\]\]/g, "[AI建议](#cite-ai)")
|
||||
// AI建议:来源说明块中的 **AI建议:** 或 **AI建议** 标题(仅匹配行首或 > 后)
|
||||
.replace(/^(\s*>?\s*)\*\*AI建议[::]\*\*/gm, "$1[AI建议](#cite-ai)")
|
||||
// AI建议:无加粗的 AI建议: 标题行(仅匹配行首或 > 后)
|
||||
.replace(/^(\s*>?\s*)AI建议[::]\s*$/gm, "$1[AI建议](#cite-ai)")
|
||||
// 推荐应用:[[推荐应用:应用名称:slug]] → 可点击跳转链接
|
||||
.replace(/\[\[推荐应用:([^:]+):([^\]]+)\]\]/g, (_, name, slug) => `[${name}](#cite-app:${slug})`);
|
||||
}
|
||||
|
||||
const GovMarkdown = memo(function GovMarkdown({ content, className }: 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));
|
||||
return (
|
||||
<div className={`gov-markdown ${className || ""}`}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
|
||||
{cleaned}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default GovMarkdown;
|
||||
Reference in New Issue
Block a user