Files
zk-data-agent/frontend/app/app/doc/[slug]/page.tsx
T
2026-05-20 15:53:06 +08:00

374 lines
8.9 KiB
TypeScript

import { readFile } from "node:fs/promises";
import path from "node:path";
import type { Metadata } from "next";
import Image from "next/image";
import { notFound } from "next/navigation";
import type { ReactNode } from "react";
const technicalDocs = {
"01-base-runtime": {
title: "基座 Runtime",
file: "01-base-runtime.md",
image: "/doc-assets/technical/01-base-runtime.png",
},
"02-agent-loop": {
title: "Agent Loop",
file: "02-agent-loop.md",
image: "/doc-assets/technical/02-agent-loop.png",
},
"03-tools": {
title: "工具体系",
file: "03-tools.md",
image: "/doc-assets/technical/03-tools.png",
},
"04-skills": {
title: "Skill 体系",
file: "04-skills.md",
image: "/doc-assets/technical/04-skills.png",
},
"05-workspace-memory-observability": {
title: "工作区、运行态和记忆",
file: "05-workspace-memory-observability.md",
image: "/doc-assets/technical/05-workspace-memory-observability.png",
},
"06-product-data": {
title: "product-data",
file: "06-product-data.md",
image: "/doc-assets/technical/06-product-data.png",
},
"07-online-mining-v2": {
title: "online-mining-v2",
file: "07-online-mining-v2.md",
image: "/doc-assets/technical/07-online-mining-v2.png",
},
"08-label-master": {
title: "标签大师",
file: "08-label-master.md",
image: "/doc-assets/technical/08-label-master.png",
},
"09-external-skills": {
title: "外部系统 Skill",
file: "09-external-skills.md",
image: "/doc-assets/technical/09-external-skills.png",
},
"10-memory-research": {
title: "Agent 记忆机制调研与对比",
file: "10-memory-research.md",
image: null,
},
} as const;
type TechnicalDocSlug = keyof typeof technicalDocs;
function getDoc(slug: string) {
if (slug in technicalDocs) {
return technicalDocs[slug as TechnicalDocSlug];
}
return null;
}
function markdownPath(file: string) {
return path.join(
process.cwd(),
"public",
"doc-assets",
"technical-docs",
file,
);
}
function normalizeMarkdown(markdown: string) {
return markdown
.replace(/^\s*#\s+.+\n+/, "")
.replaceAll("](assets/", "](/doc-assets/technical/");
}
function parseInline(text: string): ReactNode[] {
const nodes: ReactNode[] = [];
const pattern = /(`[^`]+`|\*\*[^*]+\*\*|\[([^\]]+)\]\(([^)]+)\))/g;
let cursor = 0;
while (true) {
const match = pattern.exec(text);
if (match === null) break;
if (match.index > cursor) {
nodes.push(text.slice(cursor, match.index));
}
const token = match[0];
const key = `${match.index}-${token}`;
if (token.startsWith("`")) {
nodes.push(<code key={key}>{token.slice(1, -1)}</code>);
} else if (token.startsWith("**")) {
nodes.push(<strong key={key}>{token.slice(2, -2)}</strong>);
} else {
const href = match[3] ?? "";
const isExternal = /^https?:\/\//.test(href);
nodes.push(
<a
href={href}
key={key}
rel={isExternal ? "noopener" : undefined}
target={isExternal ? "_blank" : undefined}
>
{match[2]}
</a>,
);
}
cursor = pattern.lastIndex;
}
if (cursor < text.length) {
nodes.push(text.slice(cursor));
}
return nodes;
}
function splitTableRow(line: string) {
return line
.trim()
.replace(/^\|/, "")
.replace(/\|$/, "")
.split("|")
.map((cell) => cell.trim());
}
function isTableDivider(line: string) {
return /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line);
}
function startsBlock(line: string, nextLine?: string) {
return (
line.startsWith("```") ||
/^#{1,6}\s+/.test(line) ||
/^[-*]\s+/.test(line) ||
/^\d+\.\s+/.test(line) ||
/^>\s?/.test(line) ||
/^!\[[^\]]*]\([^)]+\)\s*$/.test(line) ||
(line.includes("|") && nextLine !== undefined && isTableDivider(nextLine))
);
}
function renderHeading(depth: number, text: string, key: string) {
if (depth === 1) return <h1 key={key}>{parseInline(text)}</h1>;
if (depth === 2) return <h2 key={key}>{parseInline(text)}</h2>;
if (depth === 3) return <h3 key={key}>{parseInline(text)}</h3>;
if (depth === 4) return <h4 key={key}>{parseInline(text)}</h4>;
if (depth === 5) return <h5 key={key}>{parseInline(text)}</h5>;
return <h6 key={key}>{parseInline(text)}</h6>;
}
function renderMarkdown(markdown: string) {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const blocks: ReactNode[] = [];
let index = 0;
while (index < lines.length) {
const line = lines[index];
const trimmed = line.trim();
if (!trimmed) {
index += 1;
continue;
}
if (trimmed.startsWith("```")) {
const language = trimmed.slice(3).trim() || "text";
const codeLines: string[] = [];
index += 1;
while (index < lines.length && !lines[index].trim().startsWith("```")) {
codeLines.push(lines[index]);
index += 1;
}
if (index < lines.length) index += 1;
blocks.push(
<div className="project-doc-code-block" key={`code-${index}`}>
<div>{language}</div>
<pre>
<code>{codeLines.join("\n")}</code>
</pre>
</div>,
);
continue;
}
const heading = trimmed.match(/^(#{1,6})\s+(.*)$/);
if (heading) {
blocks.push(
renderHeading(heading[1].length, heading[2], `heading-${index}`),
);
index += 1;
continue;
}
const image = trimmed.match(/^!\[([^\]]*)]\(([^)]+)\)\s*$/);
if (image) {
blocks.push(
<Image
alt={image[1]}
height={900}
key={`image-${index}`}
src={image[2]}
width={1600}
/>,
);
index += 1;
continue;
}
if (
trimmed.includes("|") &&
index + 1 < lines.length &&
isTableDivider(lines[index + 1])
) {
const headers = splitTableRow(trimmed);
index += 2;
const rows: string[][] = [];
while (index < lines.length && lines[index].trim().includes("|")) {
rows.push(splitTableRow(lines[index]));
index += 1;
}
blocks.push(
<table key={`table-${index}`}>
<thead>
<tr>
{headers.map((cell) => (
<th key={cell}>{parseInline(cell)}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.join("|")}>
{row.map((cell) => (
<td key={`${row.join("|")}-${cell}`}>{parseInline(cell)}</td>
))}
</tr>
))}
</tbody>
</table>,
);
continue;
}
if (/^[-*]\s+/.test(trimmed)) {
const items: string[] = [];
while (index < lines.length && /^[-*]\s+/.test(lines[index].trim())) {
items.push(lines[index].trim().replace(/^[-*]\s+/, ""));
index += 1;
}
blocks.push(
<ul key={`ul-${index}`}>
{items.map((item) => (
<li key={item}>{parseInline(item)}</li>
))}
</ul>,
);
continue;
}
if (/^\d+\.\s+/.test(trimmed)) {
const items: string[] = [];
while (index < lines.length && /^\d+\.\s+/.test(lines[index].trim())) {
items.push(lines[index].trim().replace(/^\d+\.\s+/, ""));
index += 1;
}
blocks.push(
<ol key={`ol-${index}`}>
{items.map((item) => (
<li key={item}>{parseInline(item)}</li>
))}
</ol>,
);
continue;
}
if (/^>\s?/.test(trimmed)) {
const quoteLines: string[] = [];
while (index < lines.length && /^>\s?/.test(lines[index].trim())) {
quoteLines.push(lines[index].trim().replace(/^>\s?/, ""));
index += 1;
}
blocks.push(
<blockquote key={`quote-${index}`}>
{parseInline(quoteLines.join(" "))}
</blockquote>,
);
continue;
}
const paragraphLines = [trimmed];
index += 1;
while (
index < lines.length &&
lines[index].trim() &&
!startsBlock(lines[index].trim(), lines[index + 1]?.trim())
) {
paragraphLines.push(lines[index].trim());
index += 1;
}
blocks.push(
<p key={`p-${index}`}>{parseInline(paragraphLines.join(" "))}</p>,
);
}
return blocks;
}
export function generateStaticParams() {
return Object.keys(technicalDocs).map((slug) => ({ slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const doc = getDoc(slug);
if (!doc) return {};
return {
title: `${doc.title} - ZK Data Agent`,
};
}
export default async function TechnicalDocPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const doc = getDoc(slug);
if (!doc) notFound();
const rawMarkdown = await readFile(markdownPath(doc.file), "utf8");
const markdown = normalizeMarkdown(rawMarkdown);
return (
<main className="project-doc-page project-doc-detail-page">
<header className="project-doc-detail-hero">
<nav className="project-doc-nav">
<a href="/doc">ZK Data Agent</a>
<div>
<a href="/doc"></a>
</div>
</nav>
<div className="project-doc-detail-head">
<p className="project-doc-kicker"></p>
<h1>{doc.title}</h1>
<a className="project-doc-back-link" href="/doc">
</a>
</div>
</header>
<section className="project-doc-detail-shell">
<article className="project-doc-article">
{renderMarkdown(markdown)}
</article>
</section>
</main>
);
}