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,87 @@
import { useState } from 'react';
import { DocTreeNode } from '../api';
interface Props {
nodes: DocTreeNode[];
currentPath: string;
onSelect: (path: string) => void;
onCreate: (parentPath: string) => void;
depth?: number;
}
export function DocTree({ nodes, currentPath, onSelect, onCreate, depth = 0 }: Props) {
return (
<div className="kb-tree" style={{ paddingLeft: depth > 0 ? 16 : 0 }}>
{nodes.map(node => (
<TreeNode
key={node.path}
node={node}
currentPath={currentPath}
onSelect={onSelect}
onCreate={onCreate}
depth={depth}
/>
))}
</div>
);
}
function TreeNode({
node,
currentPath,
onSelect,
onCreate,
depth,
}: {
node: DocTreeNode;
currentPath: string;
onSelect: (path: string) => void;
onCreate: (parentPath: string) => void;
depth: number;
}) {
const [expanded, setExpanded] = useState(depth < 2);
const isActive = node.path === currentPath;
if (node.type === 'folder') {
return (
<div className="kb-tree-folder">
<div
className={`kb-tree-item kb-tree-folder-header ${expanded ? 'expanded' : ''}`}
onClick={() => setExpanded(!expanded)}
>
<span className="kb-tree-icon">{expanded ? '📂' : '📁'}</span>
<span className="kb-tree-name">{node.name}</span>
<button
className="kb-tree-add"
onClick={e => {
e.stopPropagation();
onCreate(node.path);
}}
title="在此文件夹下新建文档"
>
+
</button>
</div>
{expanded && node.children && (
<DocTree
nodes={node.children}
currentPath={currentPath}
onSelect={onSelect}
onCreate={onCreate}
depth={depth + 1}
/>
)}
</div>
);
}
return (
<div
className={`kb-tree-item kb-tree-doc ${isActive ? 'active' : ''}`}
onClick={() => onSelect(node.path)}
>
<span className="kb-tree-icon">📄</span>
<span className="kb-tree-name">{node.name}</span>
</div>
);
}

View file

@ -0,0 +1,105 @@
import { useState, useEffect, useMemo } from 'react';
import { DocContent } from '../api';
import { marked } from 'marked';
interface Props {
doc: DocContent;
onSave: (title: string, body: string) => void;
}
export function Editor({ doc, onSave }: Props) {
const [editing, setEditing] = useState(false);
const [title, setTitle] = useState(doc.meta.title);
const [body, setBody] = useState(doc.body);
// 切换文档时重置
useEffect(() => {
setTitle(doc.meta.title);
setBody(doc.body);
setEditing(false);
}, [doc.meta.id]);
const rendered = useMemo(() => {
try {
return marked(body, { async: false }) as string;
} catch {
return '<p>渲染失败</p>';
}
}, [body]);
const handleSave = () => {
onSave(title, body);
setEditing(false);
};
const handleCancel = () => {
setTitle(doc.meta.title);
setBody(doc.body);
setEditing(false);
};
return (
<div className="kb-editor">
{/* 元信息栏 */}
<div className="kb-editor-meta">
<span className="kb-editor-path">{doc.meta.id}</span>
<span className="kb-editor-date">
{new Date(doc.meta.updatedAt).toLocaleString('zh-CN')}
</span>
<span className="kb-editor-author">by {doc.meta.author}</span>
</div>
{/* 标题 */}
{editing ? (
<input
className="kb-editor-title-input"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="文档标题"
/>
) : (
<h1 className="kb-editor-title" onClick={() => setEditing(true)}>
{doc.meta.title}
</h1>
)}
{/* 操作栏 */}
<div className="kb-editor-toolbar">
{editing ? (
<>
<button className="kb-btn-primary" onClick={handleSave}></button>
<button className="kb-btn-secondary" onClick={handleCancel}></button>
</>
) : (
<button className="kb-btn-primary" onClick={() => setEditing(true)}></button>
)}
</div>
{/* 内容区 */}
{editing ? (
<div className="kb-editor-edit-pane">
<textarea
className="kb-editor-textarea"
value={body}
onChange={e => setBody(e.target.value)}
placeholder="用 Markdown 写作..."
spellCheck={false}
/>
<div className="kb-editor-preview-pane">
<div
className="kb-markdown-render"
dangerouslySetInnerHTML={{ __html: rendered }}
/>
</div>
</div>
) : (
<div className="kb-editor-read-pane">
<div
className="kb-markdown-render"
dangerouslySetInnerHTML={{ __html: rendered }}
/>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,99 @@
import { useState, useRef, useEffect } from 'react';
import { api, SearchResult } from '../api';
interface Props {
onSelect: (path: string) => void;
}
export function SearchBar({ onSelect }: Props) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
const containerRef = useRef<HTMLDivElement>(null);
// 防抖搜索
useEffect(() => {
if (!query.trim()) {
setResults([]);
setOpen(false);
return;
}
clearTimeout(timerRef.current);
timerRef.current = setTimeout(async () => {
setLoading(true);
try {
const r = await api.search(query);
setResults(r);
setOpen(true);
} catch {
setResults([]);
} finally {
setLoading(false);
}
}, 300);
return () => clearTimeout(timerRef.current);
}, [query]);
// 点击外部关闭
useEffect(() => {
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
return (
<div className="kb-search" ref={containerRef}>
<div className="kb-search-input-wrap">
<span className="kb-search-icon">🔍</span>
<input
className="kb-search-input"
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="搜索文档..."
onFocus={() => results.length > 0 && setOpen(true)}
/>
{query && (
<button className="kb-search-clear" onClick={() => { setQuery(''); setResults([]); setOpen(false); }}>
</button>
)}
{loading && <span className="kb-search-loading">...</span>}
</div>
{open && results.length > 0 && (
<div className="kb-search-dropdown">
{results.map((r, i) => (
<div
key={`${r.path}-${i}`}
className="kb-search-result"
onClick={() => {
onSelect(r.path);
setOpen(false);
setQuery('');
}}
>
<div className="kb-search-result-title">{r.title}</div>
<div className="kb-search-result-path">{r.path}</div>
<div className="kb-search-result-snippet">{r.snippet}</div>
</div>
))}
</div>
)}
{open && results.length === 0 && !loading && query && (
<div className="kb-search-dropdown">
<div className="kb-search-empty"></div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,154 @@
import { useState, useEffect } from 'react';
import { api, VersionEntry, DiffResult } from '../api';
interface Props {
docPath: string;
}
export function VersionHistory({ docPath }: Props) {
const [history, setHistory] = useState<VersionEntry[]>([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<[string, string] | null>(null);
const [diff, setDiff] = useState<DiffResult | null>(null);
const [versionContent, setVersionContent] = useState<string | null>(null);
const [viewingHash, setViewingHash] = useState<string | null>(null);
useEffect(() => {
loadHistory();
setSelected(null);
setDiff(null);
setVersionContent(null);
setViewingHash(null);
}, [docPath]);
const loadHistory = async () => {
setLoading(true);
try {
const h = await api.getHistory(docPath);
setHistory(h);
} catch {
setHistory([]);
} finally {
setLoading(false);
}
};
const handleSelectForDiff = (hash: string) => {
if (!selected) {
setSelected([hash, hash]);
} else if (selected[0] === selected[1]) {
// 选第二个点
const sorted = [selected[0], hash].sort((a, b) => {
const ai = history.findIndex(h => h.hash === a);
const bi = history.findIndex(h => h.hash === b);
return ai - bi;
});
setSelected([sorted[1], sorted[0]]); // [newer, older]
loadDiff(sorted[1], sorted[0]);
} else {
// 重新开始选择
setSelected([hash, hash]);
setDiff(null);
}
};
const loadDiff = async (from: string, to: string) => {
try {
const d = await api.diffVersions(docPath, from, to);
setDiff(d);
} catch {
setDiff(null);
}
};
const viewVersion = async (hash: string) => {
setViewingHash(hash);
try {
const content = await api.getDocAtVersion(docPath, hash);
setVersionContent(content);
} catch {
setVersionContent('加载失败');
}
};
if (loading) return <div className="kb-history-loading">...</div>;
return (
<div className="kb-history">
<h2 className="kb-history-title"></h2>
<p className="kb-history-hint">
</p>
<div className="kb-history-list">
{history.map(entry => {
const isSelected = selected && (selected[0] === entry.hash || selected[1] === entry.hash);
return (
<div
key={entry.hash}
className={`kb-history-entry ${isSelected ? 'selected' : ''}`}
>
<div className="kb-history-entry-main" onClick={() => handleSelectForDiff(entry.hash)}>
<span className="kb-history-hash">{entry.shortHash}</span>
<span className="kb-history-message">{entry.message}</span>
<span className="kb-history-date">
{new Date(entry.date).toLocaleString('zh-CN')}
</span>
<span className="kb-history-author">{entry.author}</span>
</div>
<button
className="kb-btn-small"
onClick={() => viewVersion(entry.hash)}
>
</button>
</div>
);
})}
</div>
{/* 版本内容预览 */}
{viewingHash && versionContent !== null && (
<div className="kb-history-preview">
<div className="kb-history-preview-header">
<span> {viewingHash.substring(0, 7)}</span>
<button onClick={() => { setViewingHash(null); setVersionContent(null); }}></button>
</div>
<pre className="kb-history-preview-content">{versionContent}</pre>
</div>
)}
{/* Diff 视图 */}
{diff && selected && (
<div className="kb-history-diff">
<div className="kb-history-diff-header">
<span>
: {selected[1].substring(0, 7)} {selected[0].substring(0, 7)}
</span>
<span className="kb-history-diff-stats">
+{diff.additions} / -{diff.deletions}
</span>
<button onClick={() => { setSelected(null); setDiff(null); }}></button>
</div>
<div className="kb-history-diff-body">
{diff.hunks.map((hunk, i) => (
<div key={i} className="kb-diff-hunk">
{hunk.lines.map((line, j) => (
<div
key={j}
className={`kb-diff-line ${
line.startsWith('+') ? 'add' :
line.startsWith('-') ? 'del' : 'ctx'
}`}
>
{line}
</div>
))}
</div>
))}
</div>
</div>
)}
</div>
);
}