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:
冰朔 2026-08-08 07:09:54 +08:00
commit ecfeca40e2
16 changed files with 5970 additions and 0 deletions

View file

@ -0,0 +1,367 @@
/**
* · 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);
}
}