374 lines
8.9 KiB
TypeScript
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>
|
|
);
|
|
}
|