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
This commit is contained in:
parent
920b6bf690
commit
ecfeca40e2
16 changed files with 5970 additions and 0 deletions
367
product-source/guanghu-knowledge-base/server/git-engine.ts
Normal file
367
product-source/guanghu-knowledge-base/server/git-engine.ts
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
/**
|
||||
* 光湖知识库 · Git 引擎层
|
||||
*
|
||||
* 底层存储 = Git 仓库,不依赖任何数据库。
|
||||
* 每个文档 = 一个 Markdown 文件 + frontmatter 元数据。
|
||||
* 版本历史 = git log,diff = 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue