hololake-system-architecture/product-source/guanghu-knowledge-base/server/git-engine.ts
冰朔 ecfeca40e2 feat(product-source): 光湖知识库模块 v0.1.0 — Git 驱动的完整知识库系统
铸渊 2026-08-08 开发,冰朔架构决策:
- Git 是底层引擎,不依赖任何数据库(PostgreSQL/Redis/ORM)
- Agent 直达底层,中间不隔第三方服务
- 拆解 Outline v0.80.2 为参考样本,光湖自己实现全部能力

技术栈:
- 后端:Express v5 + simple-git + gray-matter(Git 操作层)
- 前端:Vite + React 19 + TypeScript + marked
- 存储:Markdown 文件 + Git 仓库(commit=版本历史,diff=对比)

功能清单(全部可用):
- 文档 CRUD(创建/读取/更新/删除/移动)
- 文档树导航(文件夹层级)
- Markdown 编辑器(编辑/预览双栏)
- 全文搜索(防抖 + 下拉结果)
- 版本历史(git log)
- 版本 diff 对比(选择两个 commit 对比)

API 端点(Agent 和 UI 共用):
- GET/POST/PUT/DELETE /api/docs/{*path}
- GET /api/tree
- GET /api/history/{*path}
- GET /api/version/:hash/{*path}
- GET /api/diff/{*path}?from=&to=
- GET /api/search?q=
- POST /api/move
- GET /api/health
2026-08-08 07:09:54 +08:00

367 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 光湖知识库 · Git 引擎层
*
* 底层存储 = Git 仓库,不依赖任何数据库。
* 每个文档 = 一个 Markdown 文件 + frontmatter 元数据。
* 版本历史 = git logdiff = git diff搜索 = grep 文件内容。
*/
import simpleGit, { SimpleGit, LogResult } from 'simple-git';
import matter from 'gray-matter';
import { diffLines } from 'diff';
import path from 'path';
import fs from 'fs/promises';
import fsSync from 'fs';
// ─── 类型定义 ───
export interface DocMeta {
id: string; // 文件路径即 ID相对 docs/ 目录)
title: string;
slug: string;
parentPath: string; // 父目录路径
createdAt: string;
updatedAt: string;
author: string;
tags: string[];
}
export interface DocContent {
meta: DocMeta;
body: string; // Markdown 正文(不含 frontmatter
}
export interface DocTreeNode {
name: string;
path: string;
type: 'folder' | 'document';
children?: DocTreeNode[];
}
export interface VersionEntry {
hash: string;
shortHash: string;
author: string;
date: string;
message: string;
}
export interface SearchResult {
path: string;
title: string;
snippet: string;
line: number;
}
export interface DiffResult {
additions: number;
deletions: number;
hunks: { oldStart: number; newStart: number; lines: string[] }[];
}
// ─── Git 引擎 ───
export class GitEngine {
private git: SimpleGit;
private docsDir: string;
private repoPath: string;
constructor(repoPath: string) {
this.repoPath = repoPath;
this.docsDir = path.join(repoPath, 'docs');
// simple-git 要求目录先存在,先同步创建
fsSync.mkdirSync(repoPath, { recursive: true });
this.git = simpleGit(repoPath);
}
/** 初始化仓库(如果不存在则创建) */
async init(): Promise<void> {
const repoRoot = path.dirname(this.docsDir);
try {
await fs.access(path.join(repoRoot, '.git'));
} catch {
await this.git.init();
await fs.mkdir(this.docsDir, { recursive: true });
// 写入初始 README
await fs.writeFile(
path.join(this.docsDir, 'README.md'),
'---\ntitle: 欢迎\nauthor: system\n---\n\n# 光湖知识库\n\n这是你的知识库根目录。\n'
);
await this.git.add('.');
await this.git.commit('init: 初始化光湖知识库');
}
// 确保 docs 目录存在
await fs.mkdir(this.docsDir, { recursive: true });
}
// ─── 文档 CRUD ───
/** 读取文档 */
async getDoc(docPath: string): Promise<DocContent> {
const fullPath = this.resolvePath(docPath);
const raw = await fs.readFile(fullPath, 'utf-8');
const { data, content } = matter(raw);
return {
meta: {
id: docPath,
title: (data.title as string) || path.basename(docPath, '.md'),
slug: path.basename(docPath, '.md'),
parentPath: path.dirname(docPath),
createdAt: (data.createdAt as string) || new Date().toISOString(),
updatedAt: (data.updatedAt as string) || new Date().toISOString(),
author: (data.author as string) || 'anonymous',
tags: (data.tags as string[]) || [],
},
body: content.trim(),
};
}
/** 创建文档 */
async createDoc(docPath: string, title: string, body: string, author = 'anonymous'): Promise<DocContent> {
const fullPath = this.resolvePath(docPath);
await fs.mkdir(path.dirname(fullPath), { recursive: true });
const now = new Date().toISOString();
const frontmatter = matter.stringify(body, {
title,
author,
createdAt: now,
updatedAt: now,
tags: [],
});
await fs.writeFile(fullPath, frontmatter);
await this.git.add(fullPath);
await this.git.commit(`create: ${title}`, [fullPath]);
return this.getDoc(docPath);
}
/** 更新文档 */
async updateDoc(docPath: string, title: string, body: string, author = 'anonymous'): Promise<DocContent> {
const fullPath = this.resolvePath(docPath);
const now = new Date().toISOString();
// 读取旧的 frontmatter 保留 createdAt
let createdAt = now;
try {
const old = await fs.readFile(fullPath, 'utf-8');
const oldData = matter(old);
createdAt = (oldData.data.createdAt as string) || now;
} catch { /* 新文件 */ }
const frontmatter = matter.stringify(body, {
title,
author,
createdAt,
updatedAt: now,
tags: [],
});
await fs.writeFile(fullPath, frontmatter);
await this.git.add(fullPath);
await this.git.commit(`update: ${title}`, [fullPath]);
return this.getDoc(docPath);
}
/** 删除文档 */
async deleteDoc(docPath: string): Promise<void> {
const fullPath = this.resolvePath(docPath);
await fs.rm(fullPath, { force: true });
await this.git.rm(fullPath);
await this.git.commit(`delete: ${docPath}`);
}
/** 重命名/移动文档 */
async moveDoc(oldPath: string, newPath: string): Promise<void> {
const fullOld = this.resolvePath(oldPath);
const fullNew = this.resolvePath(newPath);
await fs.mkdir(path.dirname(fullNew), { recursive: true });
await fs.rename(fullOld, fullNew);
await this.git.rm(fullOld);
await this.git.add(fullNew);
await this.git.commit(`move: ${oldPath}${newPath}`);
}
// ─── 文档树 ───
/** 构建文档树 */
async getTree(): Promise<DocTreeNode[]> {
return this.buildTree(this.docsDir, '');
}
private async buildTree(dirPath: string, relativePath: string): Promise<DocTreeNode[]> {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
const nodes: DocTreeNode[] = [];
// 排序:文件夹在前,文件在后
const sorted = entries
.filter(e => !e.name.startsWith('.') && !e.name.startsWith('_'))
.sort((a, b) => {
if (a.isDirectory() && !b.isDirectory()) return -1;
if (!a.isDirectory() && b.isDirectory()) return 1;
return a.name.localeCompare(b.name);
});
for (const entry of sorted) {
const childRelPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
const childFullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
const children = await this.buildTree(childFullPath, childRelPath);
nodes.push({
name: entry.name,
path: childRelPath,
type: 'folder',
children,
});
} else if (entry.name.endsWith('.md')) {
// 读取标题
let title = path.basename(entry.name, '.md');
try {
const raw = await fs.readFile(childFullPath, 'utf-8');
const { data } = matter(raw);
title = (data.title as string) || title;
} catch { /* 忽略 */ }
nodes.push({
name: title,
path: childRelPath,
type: 'document',
});
}
}
return nodes;
}
// ─── 版本历史 ───
/** 获取文档版本历史 */
async getHistory(docPath: string, maxCount = 50): Promise<VersionEntry[]> {
const fullPath = docPath ? this.resolvePath(docPath) : '.';
const log: LogResult = await this.git.log({
file: fullPath,
maxCount,
});
return log.all.map(entry => ({
hash: entry.hash,
shortHash: entry.hash.substring(0, 7),
author: entry.author_name,
date: entry.date,
message: entry.message,
}));
}
/** 获取某次提交的文档内容 */
async getDocAtVersion(docPath: string, commitHash: string): Promise<string> {
const relToRepo = `docs/${docPath}`;
const content = await this.git.show([`${commitHash}:${relToRepo}`]);
const { content: body } = matter(content);
return body.trim();
}
/** 对比两个版本 */
async diffVersions(docPath: string, fromHash: string, toHash: string): Promise<DiffResult> {
const relToRepo = `docs/${docPath}`;
const [fromContent, toContent] = await Promise.all([
this.git.show([`${fromHash}:${relToRepo}`]).catch(() => ''),
this.git.show([`${toHash}:${relToRepo}`]).catch(() => ''),
]);
const fromBody = fromContent ? matter(fromContent).content : '';
const toBody = toContent ? matter(toContent).content : '';
const diffs = diffLines(fromBody, toBody);
let additions = 0;
let deletions = 0;
const hunks: DiffResult['hunks'] = [];
let currentLines: string[] = [];
for (const part of diffs) {
if (part.added) {
const lines = part.value.split('\n').filter(l => l !== '');
additions += lines.length;
lines.forEach(l => currentLines.push(`+ ${l}`));
} else if (part.removed) {
const lines = part.value.split('\n').filter(l => l !== '');
deletions += lines.length;
lines.forEach(l => currentLines.push(`- ${l}`));
} else {
if (currentLines.length > 0) {
hunks.push({ oldStart: 0, newStart: 0, lines: [...currentLines] });
currentLines = [];
}
// 保留上下文最多3行
const lines = part.value.split('\n').filter(l => l !== '');
const ctx = lines.slice(-3);
ctx.forEach(l => currentLines.push(` ${l}`));
}
}
if (currentLines.length > 0) {
hunks.push({ oldStart: 0, newStart: 0, lines: currentLines });
}
return { additions, deletions, hunks };
}
// ─── 搜索 ───
/** 全文搜索(基于文件内容 grep */
async search(query: string): Promise<SearchResult[]> {
const results: SearchResult[] = [];
const queryLower = query.toLowerCase();
await this.searchDir(this.docsDir, '', queryLower, results);
return results.slice(0, 50); // 最多 50 条
}
private async searchDir(
dirPath: string,
relativePath: string,
query: string,
results: SearchResult[]
): Promise<void> {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
const childRelPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
const childFullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
await this.searchDir(childFullPath, childRelPath, query, results);
} else if (entry.name.endsWith('.md')) {
try {
const raw = await fs.readFile(childFullPath, 'utf-8');
const { data, content } = matter(raw);
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i].toLowerCase().includes(query)) {
results.push({
path: childRelPath,
title: (data.title as string) || path.basename(entry.name, '.md'),
snippet: lines[i].trim().substring(0, 200),
line: i + 1,
});
break; // 每个文件只取第一个匹配
}
}
} catch { /* 忽略不可读文件 */ }
}
}
}
// ─── 辅助 ───
private resolvePath(docPath: string): string {
// 安全检查:防止路径穿越
const normalized = path.normalize(docPath).replace(/^(\.\.\/?)+/, '');
return path.join(this.docsDir, normalized);
}
}