forked from Gitlink/gitlink_help_center
157 lines
4.8 KiB
JavaScript
157 lines
4.8 KiB
JavaScript
/**
|
||
* 帮助中心 AI 助手 —— 知识库构建脚本
|
||
*
|
||
* 作用:扫描 docs/ 下所有 Markdown 文档,去掉 frontmatter 与图片/代码噪声,
|
||
* 按标题(# / ## / ###)切分成可检索的段落(chunk),输出为 knowledge.json,
|
||
* 供前端 AI 助手做“客户端检索(RAG)”使用。
|
||
*
|
||
* 用法:node scripts/build-knowledge.js
|
||
* (文档有更新后重新运行一次即可)
|
||
*/
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const DOCS_DIR = path.resolve(__dirname, '..', 'docs');
|
||
const OUT_FILE = path.resolve(__dirname, '..', 'src', 'components', 'AIAssistant', 'knowledge.json');
|
||
|
||
/** 收集所有 .md 文件(相对路径) */
|
||
function walkMd(dir, base = '') {
|
||
const out = [];
|
||
for (const name of fs.readdirSync(dir)) {
|
||
const full = path.join(dir, name);
|
||
const rel = base ? `${base}/${name}` : name;
|
||
const stat = fs.statSync(full);
|
||
if (stat.isDirectory()) out.push(...walkMd(full, rel));
|
||
else if (name.endsWith('.md')) out.push({ abs: full, rel });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** 去掉 frontmatter(--- ... ---) */
|
||
function stripFrontmatter(text) {
|
||
if (text.startsWith('---')) {
|
||
const end = text.indexOf('\n---', 3);
|
||
if (end !== -1) return text.slice(end + 4).replace(/^\s*\n/, '');
|
||
}
|
||
return text;
|
||
}
|
||
|
||
/** 取 frontmatter 里的 sidebar_label / label,作为页面友好标题 */
|
||
function frontTitle(text, fileRel) {
|
||
const m = text.match(/^---([\s\S]*?)---/);
|
||
let label = '';
|
||
if (m) {
|
||
const lm = m[1].match(/sidebar_label:\s*['"]?([^'"\n]+)['"]?/);
|
||
const km = m[1].match(/label:\s*['"]?([^'"\n]+)['"]?/);
|
||
label = (lm && lm[1].trim()) || (km && km[1].trim()) || '';
|
||
}
|
||
if (!label) {
|
||
label = path.basename(fileRel, '.md');
|
||
}
|
||
return label.trim();
|
||
}
|
||
|
||
/** 计算页面在站点中的访问路径,与 Docusaurus routeBasePath:'/' 对齐 */
|
||
function pageRoute(fileRel, text) {
|
||
let slug = '';
|
||
const m = text.match(/^---([\s\S]*?)---/);
|
||
if (m) {
|
||
const sm = m[1].match(/slug:\s*['"]?([^'"\n]+)['"]?/);
|
||
if (sm) slug = sm[1].trim();
|
||
}
|
||
if (slug && slug !== '/') {
|
||
// 以 slug 里的内容作为路径(去掉前导斜杠)
|
||
return slug.replace(/^\/+/, '');
|
||
}
|
||
// 默认:目录/文件名(去掉 .md)。intro.md 的 slug 是 / 单独处理。
|
||
const noExt = fileRel.replace(/\.md$/, '');
|
||
if (noExt === 'intro') return '';
|
||
return noExt;
|
||
}
|
||
|
||
/** 清洗一段文本:去掉图片、HTML、链接保留文字、多余空行 */
|
||
function cleanText(text) {
|
||
return text
|
||
.replace(/!\[[^\]]*\]\([^)]*\)/g, '') // 图片
|
||
.replace(/<[^>]+>/g, ' ') // HTML 标签
|
||
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // 链接保留文字
|
||
.replace(/`{1,3}/g, '') // 代码反引号
|
||
.replace(/^\s{0,3}>\s?/gm, '') // 引用符
|
||
.replace(/\n{3,}/g, '\n\n')
|
||
.trim();
|
||
}
|
||
|
||
/** 按标题把一页切成多个 chunk */
|
||
function chunkPage(text, pageLabel, route, fileName) {
|
||
const lines = text.split('\n');
|
||
const chunks = [];
|
||
let curSection = pageLabel; // 当前小节标题
|
||
let buf = [];
|
||
|
||
const flush = () => {
|
||
const body = cleanText(buf.join('\n'));
|
||
if (body && body.length > 8) {
|
||
chunks.push({
|
||
title: pageLabel,
|
||
section: curSection,
|
||
path: '/' + route,
|
||
source: fileName,
|
||
text: body,
|
||
});
|
||
}
|
||
buf = [];
|
||
};
|
||
|
||
for (const raw of lines) {
|
||
const hm = raw.match(/^(#{1,4})\s+(.*)/);
|
||
if (hm) {
|
||
flush();
|
||
const level = hm[1].length;
|
||
const headText = cleanText(hm[2]);
|
||
// 用一级标题作为页面标题候选,二三级作为小节
|
||
if (level === 1) {
|
||
if (headText) { /* 一级标题作为页面正文一部分,不单独覆盖 pageLabel */ }
|
||
}
|
||
curSection = headText || pageLabel;
|
||
buf.push(raw); // 保留标题文字进正文
|
||
} else {
|
||
buf.push(raw);
|
||
}
|
||
}
|
||
flush();
|
||
return chunks;
|
||
}
|
||
|
||
function main() {
|
||
if (!fs.existsSync(DOCS_DIR)) {
|
||
console.error('[build-knowledge] docs 目录不存在:', DOCS_DIR);
|
||
process.exit(1);
|
||
}
|
||
const files = walkMd(DOCS_DIR);
|
||
const all = [];
|
||
let pageCount = 0;
|
||
for (const f of files) {
|
||
let raw;
|
||
try {
|
||
raw = fs.readFileSync(f.abs, 'utf8');
|
||
} catch (e) {
|
||
console.warn('[build-knowledge] 读取失败:', f.rel, e.message);
|
||
continue;
|
||
}
|
||
const label = frontTitle(raw, f.rel);
|
||
const route = pageRoute(f.rel, raw);
|
||
const body = stripFrontmatter(raw);
|
||
const chunks = chunkPage(body, label, route, f.rel);
|
||
if (chunks.length) {
|
||
all.push(...chunks);
|
||
pageCount += 1;
|
||
}
|
||
}
|
||
|
||
fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
|
||
fs.writeFileSync(OUT_FILE, JSON.stringify(all, null, 0), 'utf8');
|
||
console.log(`[build-knowledge] 完成:${pageCount} 个文档,${all.length} 个段落 -> ${path.relative(process.cwd(), OUT_FILE)}`);
|
||
}
|
||
|
||
main();
|