feat(knowledge): support nested directories in knowledge base listing and display

This commit is contained in:
zhayujie
2026-04-16 12:28:18 +08:00
parent abd21335c4
commit 848430f062
4 changed files with 97 additions and 38 deletions

View File

@@ -34,7 +34,8 @@ class KnowledgeService:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def list_tree(self) -> dict: def list_tree(self) -> dict:
""" """
Return the knowledge directory tree grouped by category. Return the knowledge directory tree grouped by category,
supporting arbitrarily nested sub-directories.
Returns:: Returns::
@@ -44,10 +45,20 @@ class KnowledgeService:
"dir": "concepts", "dir": "concepts",
"files": [ "files": [
{"name": "moe.md", "title": "MoE", "size": 1234}, {"name": "moe.md", "title": "MoE", "size": 1234},
... ],
"children": []
},
{
"dir": "platform",
"files": [],
"children": [
{
"dir": "analysis",
"files": [{"name": "perf.md", ...}],
"children": []
}
] ]
}, },
...
], ],
"stats": {"pages": 15, "size": 32768}, "stats": {"pages": 15, "size": 32768},
"enabled": true "enabled": true
@@ -56,37 +67,43 @@ class KnowledgeService:
if not os.path.isdir(self.knowledge_dir): if not os.path.isdir(self.knowledge_dir):
return {"tree": [], "stats": {"pages": 0, "size": 0}, "enabled": conf().get("knowledge", True)} return {"tree": [], "stats": {"pages": 0, "size": 0}, "enabled": conf().get("knowledge", True)}
tree = [] stats = {"pages": 0, "size": 0}
total_files = 0 tree = self._scan_dir(self.knowledge_dir, stats)
total_bytes = 0
for name in sorted(os.listdir(self.knowledge_dir)):
full = os.path.join(self.knowledge_dir, name)
if not os.path.isdir(full) or name.startswith("."):
continue
files = []
for fname in sorted(os.listdir(full)):
if fname.endswith(".md") and not fname.startswith("."):
fpath = os.path.join(full, fname)
size = os.path.getsize(fpath)
total_files += 1
total_bytes += size
title = fname.replace(".md", "")
try:
with open(fpath, "r", encoding="utf-8") as f:
first_line = f.readline().strip()
if first_line.startswith("# "):
title = first_line[2:].strip()
except Exception:
pass
files.append({"name": fname, "title": title, "size": size})
tree.append({"dir": name, "files": files})
return { return {
"tree": tree, "tree": tree,
"stats": {"pages": total_files, "size": total_bytes}, "stats": stats,
"enabled": conf().get("knowledge", True), "enabled": conf().get("knowledge", True),
} }
def _scan_dir(self, dir_path: str, stats: dict) -> list:
"""Recursively scan a directory and return list of group nodes."""
result = []
for name in sorted(os.listdir(dir_path)):
if name.startswith("."):
continue
full = os.path.join(dir_path, name)
if os.path.isdir(full):
files = []
for fname in sorted(os.listdir(full)):
fpath = os.path.join(full, fname)
if os.path.isfile(fpath) and fname.endswith(".md") and not fname.startswith("."):
size = os.path.getsize(fpath)
stats["pages"] += 1
stats["size"] += size
title = fname.replace(".md", "")
try:
with open(fpath, "r", encoding="utf-8") as f:
first_line = f.readline().strip()
if first_line.startswith("# "):
title = first_line[2:].strip()
except Exception:
pass
files.append({"name": fname, "title": title, "size": size})
children = self._scan_dir(full, stats)
result.append({"dir": name, "files": files, "children": children})
return result
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# read — single file content # read — single file content
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View File

@@ -952,13 +952,13 @@
font-size: 8px; font-size: 8px;
transition: transform 0.15s; transition: transform 0.15s;
} }
.knowledge-tree-group.open .chevron { .knowledge-tree-group.open > .knowledge-tree-group-btn .chevron {
transform: rotate(90deg); transform: rotate(90deg);
} }
.knowledge-tree-group-items { .knowledge-tree-group-items {
display: none; display: none;
} }
.knowledge-tree-group.open .knowledge-tree-group-items { .knowledge-tree-group.open > .knowledge-tree-group-items {
display: block; display: block;
} }

View File

@@ -3620,19 +3620,28 @@ function renderKnowledgeTree(tree, filter) {
const container = document.getElementById('knowledge-tree'); const container = document.getElementById('knowledge-tree');
container.innerHTML = ''; container.innerHTML = '';
const lowerFilter = (filter || '').toLowerCase(); const lowerFilter = (filter || '').toLowerCase();
_renderKnowledgeGroups(container, tree, '', lowerFilter, 0);
}
tree.forEach(group => { function _renderKnowledgeGroups(container, groups, parentPath, lowerFilter, depth) {
const files = group.files.filter(f => const indent = depth * 12;
groups.forEach(group => {
const groupPath = parentPath ? parentPath + '/' + group.dir : group.dir;
const files = (group.files || []).filter(f =>
!lowerFilter || f.title.toLowerCase().includes(lowerFilter) || f.name.toLowerCase().includes(lowerFilter) !lowerFilter || f.title.toLowerCase().includes(lowerFilter) || f.name.toLowerCase().includes(lowerFilter)
); );
if (files.length === 0 && lowerFilter) return; const children = group.children || [];
const hasMatchingChildren = lowerFilter ? _hasFilterMatch(children, lowerFilter) : children.length > 0;
if (files.length === 0 && !hasMatchingChildren && lowerFilter) return;
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'knowledge-tree-group open'; div.className = 'knowledge-tree-group open';
const fileCount = _countFiles(group);
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.className = 'knowledge-tree-group-btn'; btn.className = 'knowledge-tree-group-btn';
btn.innerHTML = `<i class="fas fa-chevron-right chevron"></i><i class="fas fa-folder text-amber-400 text-[11px]"></i><span>${escapeHtml(group.dir)}</span><span class="ml-auto text-[10px] text-slate-400">${files.length}</span>`; btn.style.paddingLeft = (8 + indent) + 'px';
btn.innerHTML = `<i class="fas fa-chevron-right chevron"></i><i class="fas fa-folder text-amber-400 text-[11px]"></i><span>${escapeHtml(group.dir)}</span><span class="ml-auto text-[10px] text-slate-400">${fileCount}</span>`;
btn.onclick = () => div.classList.toggle('open'); btn.onclick = () => div.classList.toggle('open');
div.appendChild(btn); div.appendChild(btn);
@@ -3640,18 +3649,40 @@ function renderKnowledgeTree(tree, filter) {
items.className = 'knowledge-tree-group-items'; items.className = 'knowledge-tree-group-items';
files.forEach(f => { files.forEach(f => {
const fbtn = document.createElement('button'); const fbtn = document.createElement('button');
const fpath = group.dir + '/' + f.name; const fpath = groupPath + '/' + f.name;
fbtn.className = 'knowledge-tree-file' + (_knowledgeCurrentFile === fpath ? ' active' : ''); fbtn.className = 'knowledge-tree-file' + (_knowledgeCurrentFile === fpath ? ' active' : '');
fbtn.dataset.path = fpath; fbtn.dataset.path = fpath;
fbtn.style.paddingLeft = (24 + indent) + 'px';
fbtn.innerHTML = `<i class="fas fa-file-lines text-[10px] text-slate-400"></i><span class="truncate">${escapeHtml(f.title)}</span>`; fbtn.innerHTML = `<i class="fas fa-file-lines text-[10px] text-slate-400"></i><span class="truncate">${escapeHtml(f.title)}</span>`;
fbtn.onclick = () => openKnowledgeFile(fpath, f.title); fbtn.onclick = () => openKnowledgeFile(fpath, f.title);
items.appendChild(fbtn); items.appendChild(fbtn);
}); });
if (children.length > 0) {
_renderKnowledgeGroups(items, children, groupPath, lowerFilter, depth + 1);
}
div.appendChild(items); div.appendChild(items);
container.appendChild(div); container.appendChild(div);
}); });
} }
function _hasFilterMatch(groups, lowerFilter) {
for (const g of groups) {
for (const f of (g.files || [])) {
if (f.title.toLowerCase().includes(lowerFilter) || f.name.toLowerCase().includes(lowerFilter)) return true;
}
if (_hasFilterMatch(g.children || [], lowerFilter)) return true;
}
return false;
}
function _countFiles(group) {
let count = (group.files || []).length;
for (const child of (group.children || [])) {
count += _countFiles(child);
}
return count;
}
function filterKnowledgeTree(query) { function filterKnowledgeTree(query) {
renderKnowledgeTree(_knowledgeTreeData, query); renderKnowledgeTree(_knowledgeTreeData, query);
} }
@@ -3732,12 +3763,19 @@ function bindChatKnowledgeLinks(container) {
} }
function _findKnowledgeFileByName(filename) { function _findKnowledgeFileByName(filename) {
for (const group of _knowledgeTreeData) { return _searchFileInGroups(_knowledgeTreeData, '', filename);
for (const f of group.files) { }
function _searchFileInGroups(groups, parentPath, filename) {
for (const group of groups) {
const groupPath = parentPath ? parentPath + '/' + group.dir : group.dir;
for (const f of (group.files || [])) {
if (f.name === filename) { if (f.name === filename) {
return { path: group.dir + '/' + f.name, title: f.title }; return { path: groupPath + '/' + f.name, title: f.title };
} }
} }
const found = _searchFileInGroups(group.children || [], groupPath, filename);
if (found) return found;
} }
return null; return null;
} }

View File

@@ -40,6 +40,8 @@ Maintain a persistent, structured knowledge base in the `knowledge/` directory.
```markdown ```markdown
# Page Title # Page Title
> Source: <URL or description of the original material>
Content here. Cross-reference related pages with markdown links: Content here. Cross-reference related pages with markdown links:
[Related Page](../category/related-page.md) [Related Page](../category/related-page.md)
@@ -53,6 +55,8 @@ Content here. Cross-reference related pages with markdown links:
- [Page B](../category/page-b.md) — how it relates - [Page B](../category/page-b.md) — how it relates
``` ```
The `> Source:` line records where the knowledge came from (URL, document name, conversation, etc.). Always include it when the material originates from a specific source.
Cross-references build a knowledge graph. When creating or updating a page, link to related pages and update those pages to link back. **Only link to pages that already exist** — if a concept deserves its own page, create it first, then add the link. Cross-references build a knowledge graph. When creating or updating a page, link to related pages and update those pages to link back. **Only link to pages that already exist** — if a concept deserves its own page, create it first, then add the link.
## Index Format (`knowledge/index.md`) ## Index Format (`knowledge/index.md`)