feat(knowledge): add category and document management

This commit is contained in:
yangziyu-hhh
2026-06-15 13:15:07 +08:00
parent 9387980e74
commit 6e3933be30
8 changed files with 740 additions and 18 deletions

View File

@@ -12,11 +12,16 @@ Knowledge file layout (under workspace_root):
import os
import re
import asyncio
import shutil
import threading
from pathlib import Path
from typing import Optional
from typing import Optional, Iterable
from common.log import logger
from config import conf
from agent.memory.config import MemoryConfig
from agent.memory.manager import MemoryManager
class KnowledgeService:
@@ -25,9 +30,189 @@ class KnowledgeService:
Operates directly on the filesystem.
"""
def __init__(self, workspace_root: str):
self.workspace_root = workspace_root
self.knowledge_dir = os.path.join(workspace_root, "knowledge")
PROTECTED_FILES = {"index.md", "log.md"}
INVALID_NAME_RE = re.compile(r'[<>:"|?*\x00-\x1f]')
def __init__(self, workspace_root: str, memory_manager=None):
self.workspace_root = os.path.abspath(workspace_root)
self.knowledge_dir = os.path.join(self.workspace_root, "knowledge")
self._memory_manager = memory_manager
def _resolve_path(self, rel_path: str, *, kind: Optional[str] = None,
allow_missing: bool = True) -> tuple:
if not isinstance(rel_path, str) or not rel_path.strip():
raise ValueError("path is required")
rel_path = rel_path.replace("\\", "/").strip("/")
parts = rel_path.split("/")
if any(not p or p in (".", "..") or self.INVALID_NAME_RE.search(p) for p in parts):
raise ValueError("invalid path")
if kind == "document" and not rel_path.lower().endswith(".md"):
raise ValueError("document path must end with .md")
root = Path(self.knowledge_dir).resolve()
candidate = root.joinpath(*parts)
# Resolve the nearest existing ancestor so a symlink cannot be used
# to escape when the final destination does not exist yet.
ancestor = candidate
while not ancestor.exists() and ancestor != root:
ancestor = ancestor.parent
try:
ancestor.resolve().relative_to(root)
except ValueError:
raise ValueError("path outside knowledge dir")
if candidate.exists():
try:
candidate.resolve().relative_to(root)
except ValueError:
raise ValueError("path outside knowledge dir")
elif not allow_missing:
raise FileNotFoundError(f"path not found: {rel_path}")
return rel_path, candidate
def _ensure_not_protected(self, rel_path: str):
if rel_path in self.PROTECTED_FILES:
raise ValueError(f"protected knowledge file: {rel_path}")
def _manager(self):
if self._memory_manager is None:
self._memory_manager = MemoryManager(MemoryConfig(workspace_root=self.workspace_root))
return self._memory_manager
@staticmethod
def _run_sync(coro):
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(coro)
result = []
error = []
def runner():
try:
result.append(asyncio.run(coro))
except Exception as exc:
error.append(exc)
thread = threading.Thread(target=runner)
thread.start()
thread.join()
if error:
raise error[0]
return result[0] if result else None
def _sync_index(self, old_paths: Iterable[str]):
old_paths = sorted(set(old_paths))
if not old_paths:
return
manager = self._manager()
for rel_path in old_paths:
manager.storage.delete_by_path(f"knowledge/{rel_path}")
manager.mark_dirty()
self._run_sync(manager.sync())
def create_category(self, path: str) -> dict:
rel_path, full_path = self._resolve_path(path, kind="category")
if full_path.exists():
return {"path": rel_path, "created": False, "reason": "already_exists"}
full_path.mkdir(parents=True)
return {"path": rel_path, "created": True}
def rename_category(self, path: str, new_path: str) -> dict:
old_rel, old_full = self._resolve_path(path, kind="category", allow_missing=False)
new_rel, new_full = self._resolve_path(new_path, kind="category")
if not old_full.is_dir():
raise ValueError(f"not a category: {old_rel}")
if new_full.exists():
raise FileExistsError(f"target already exists: {new_rel}")
old_documents = [str(p.relative_to(old_full)).replace(os.sep, "/")
for p in old_full.rglob("*.md") if p.is_file()]
new_full.parent.mkdir(parents=True, exist_ok=True)
try:
old_full.rename(new_full)
except FileNotFoundError:
return {"old_path": old_rel, "path": new_rel, "moved": False, "reason": "not_found"}
except FileExistsError:
raise FileExistsError(f"target already exists: {new_rel}")
old_paths = [f"{old_rel}/{p}" for p in old_documents]
self._sync_index(old_paths)
return {"old_path": old_rel, "path": new_rel, "moved_documents": len(old_documents)}
def delete_category(self, path: str, confirm: bool = False) -> dict:
rel_path, full_path = self._resolve_path(path, kind="category")
if not full_path.exists():
return {"path": rel_path, "deleted": False, "reason": "not_found"}
if not full_path.is_dir():
raise ValueError(f"not a category: {rel_path}")
knowledge_root = Path(self.knowledge_dir).resolve()
documents = [str(p.relative_to(knowledge_root)).replace(os.sep, "/")
for p in full_path.rglob("*.md") if p.is_file()]
if any(p in self.PROTECTED_FILES for p in documents):
raise ValueError("category contains protected knowledge files")
if any(full_path.iterdir()) and not confirm:
raise ValueError("category is not empty; confirmation is required")
try:
shutil.rmtree(full_path)
except FileNotFoundError:
return {"path": rel_path, "deleted": False, "reason": "not_found"}
self._sync_index(documents)
return {"path": rel_path, "deleted": True, "deleted_documents": len(documents)}
def delete_documents(self, paths: Iterable[str]) -> dict:
if not isinstance(paths, list):
raise ValueError("paths must be a list")
results = []
deleted = []
for path in paths:
rel_path, full_path = self._resolve_path(path, kind="document")
self._ensure_not_protected(rel_path)
if not full_path.exists():
deleted.append(rel_path)
results.append({"path": rel_path, "deleted": False, "reason": "not_found"})
continue
if not full_path.is_file():
raise ValueError(f"not a document: {rel_path}")
try:
full_path.unlink()
deleted.append(rel_path)
results.append({"path": rel_path, "deleted": True})
except FileNotFoundError:
deleted.append(rel_path)
results.append({"path": rel_path, "deleted": False, "reason": "not_found"})
self._sync_index(deleted)
return {"results": results, "deleted": sum(1 for item in results if item["deleted"])}
def move_documents(self, paths: Iterable[str], target_category: str) -> dict:
if not isinstance(paths, list):
raise ValueError("paths must be a list")
target_rel, target_full = self._resolve_path(target_category, kind="category")
if not target_full.is_dir():
raise FileNotFoundError(f"category not found: {target_rel}")
results = []
moved_old_paths = []
for path in paths:
rel_path, full_path = self._resolve_path(path, kind="document")
self._ensure_not_protected(rel_path)
if not full_path.exists():
results.append({"path": rel_path, "moved": False, "reason": "not_found"})
continue
destination = target_full / full_path.name
new_rel = str(destination.relative_to(Path(self.knowledge_dir).resolve())).replace(os.sep, "/")
if destination.exists():
results.append({"path": rel_path, "moved": False, "reason": "target_exists",
"target": new_rel})
continue
try:
os.link(full_path, destination)
full_path.unlink()
moved_old_paths.append(rel_path)
results.append({"path": rel_path, "moved": True, "target": new_rel})
except FileExistsError:
results.append({"path": rel_path, "moved": False, "reason": "target_exists",
"target": new_rel})
except FileNotFoundError:
results.append({"path": rel_path, "moved": False, "reason": "not_found"})
self._sync_index(moved_old_paths)
return {"results": results, "moved": len(moved_old_paths)}
# ------------------------------------------------------------------
# list — directory tree with stats
@@ -121,15 +306,8 @@ class KnowledgeService:
:raises ValueError: if path is invalid or escapes knowledge dir
:raises FileNotFoundError: if file does not exist
"""
if not rel_path or ".." in rel_path:
raise ValueError("invalid path")
full_path = os.path.normpath(os.path.join(self.knowledge_dir, rel_path))
allowed = os.path.normpath(self.knowledge_dir)
if not full_path.startswith(allowed + os.sep) and full_path != allowed:
raise ValueError("path outside knowledge dir")
if not os.path.isfile(full_path):
rel_path, full_path = self._resolve_path(rel_path, kind="document")
if not full_path.is_file():
raise FileNotFoundError(f"file not found: {rel_path}")
with open(full_path, "r", encoding="utf-8") as f:
@@ -228,13 +406,26 @@ class KnowledgeService:
result = self.build_graph()
return {"action": action, "code": 200, "message": "success", "payload": result}
elif action == "create_category":
result = self.create_category(payload.get("path"))
elif action == "rename_category":
result = self.rename_category(payload.get("path"), payload.get("new_path"))
elif action == "delete_category":
result = self.delete_category(payload.get("path"), payload.get("confirm", False))
elif action == "delete_documents":
result = self.delete_documents(payload.get("paths") or [])
elif action == "move_documents":
result = self.move_documents(payload.get("paths") or [], payload.get("target_category"))
else:
return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None}
return {"action": action, "code": 200, "message": "success", "payload": result}
except ValueError as e:
return {"action": action, "code": 403, "message": str(e), "payload": None}
except FileNotFoundError as e:
return {"action": action, "code": 404, "message": str(e), "payload": None}
except FileExistsError as e:
return {"action": action, "code": 409, "message": str(e), "payload": None}
except Exception as e:
logger.error(f"[KnowledgeService] dispatch error: action={action}, error={e}")
return {"action": action, "code": 500, "message": str(e), "payload": None}

View File

@@ -825,9 +825,10 @@ class MemoryStorage:
return []
def delete_by_path(self, path: str):
"""Delete all chunks from a file"""
"""Delete all chunks and file metadata for a path."""
with self._lock:
self.conn.execute("DELETE FROM chunks WHERE path = ?", (path,))
self.conn.execute("DELETE FROM files WHERE path = ?", (path,))
self.conn.commit()
def get_file_hash(self, path: str) -> Optional[str]:

View File

@@ -840,6 +840,11 @@
</div>
<div class="flex items-center gap-2">
<span id="knowledge-stats" class="text-xs text-slate-400 dark:text-slate-500 hidden sm:inline"></span>
<span id="knowledge-action-status" class="text-xs opacity-0 transition-opacity duration-200"></span>
<button onclick="createKnowledgeCategory()"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-xs font-medium cursor-pointer transition-colors">
<i class="fas fa-folder-plus"></i><span data-i18n="knowledge_new_category">新建分类</span>
</button>
<div class="flex items-center bg-slate-100 dark:bg-white/10 rounded-lg p-0.5">
<button id="knowledge-tab-docs" onclick="switchKnowledgeTab('docs')"
class="knowledge-tab px-3 py-1.5 rounded-md text-xs font-medium cursor-pointer transition-colors duration-150 active">
@@ -1068,6 +1073,34 @@
</div><!-- /main-content -->
</div><!-- /app -->
<!-- Knowledge Action Dialog -->
<div id="knowledge-dialog-overlay" class="fixed inset-0 bg-black/50 z-[200] hidden flex items-center justify-center">
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl w-full max-w-md mx-4 overflow-hidden">
<div class="p-6">
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-xl bg-emerald-50 dark:bg-emerald-900/20 flex items-center justify-center">
<i id="knowledge-dialog-icon" class="fas fa-folder text-emerald-500"></i>
</div>
<div>
<h3 id="knowledge-dialog-title" class="font-semibold text-slate-800 dark:text-slate-100"></h3>
<p id="knowledge-dialog-subtitle" class="text-xs text-slate-400 dark:text-slate-500 mt-0.5"></p>
</div>
</div>
<label id="knowledge-dialog-label" class="block text-sm font-medium text-slate-600 dark:text-slate-300 mb-1.5"></label>
<input id="knowledge-dialog-input" type="text"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500">
<select id="knowledge-dialog-select"
class="hidden w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-[#222] text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500"></select>
<p id="knowledge-dialog-hint" class="mt-2 text-xs text-slate-400 dark:text-slate-500"></p>
<p id="knowledge-dialog-error" class="mt-2 text-xs text-red-500 hidden"></p>
</div>
<div class="flex justify-end gap-3 px-6 py-4 border-t border-slate-100 dark:border-white/5">
<button id="knowledge-dialog-cancel" class="px-4 py-2 rounded-lg border border-slate-200 dark:border-white/10 text-slate-600 dark:text-slate-300 text-sm hover:bg-slate-50 dark:hover:bg-white/5">取消</button>
<button id="knowledge-dialog-submit" class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium disabled:opacity-50">确定</button>
</div>
</div>
</div>
<!-- Confirm Dialog -->
<div id="confirm-dialog-overlay" class="fixed inset-0 bg-black/50 z-[200] hidden flex items-center justify-center">
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl

View File

@@ -1203,6 +1203,34 @@
background: #EDFDF3;
color: #228547;
}
.knowledge-actions {
display: flex;
gap: 2px;
margin-left: auto;
opacity: 0;
transition: opacity 0.15s;
}
.knowledge-tree-file:hover .knowledge-actions,
.knowledge-tree-group-btn:hover .knowledge-actions,
.knowledge-tree-file:focus-within .knowledge-actions,
.knowledge-tree-group-btn:focus-within .knowledge-actions {
opacity: 1;
}
.knowledge-action {
padding: 3px 5px;
border-radius: 5px;
color: #94a3b8;
font-size: 9px;
}
.knowledge-action:hover {
color: #228547;
background: rgba(34, 133, 71, 0.08);
}
.knowledge-action.danger:hover {
color: #ef4444;
background: rgba(239, 68, 68, 0.08);
}
.dark .knowledge-tree-file:hover {
background: rgba(255,255,255,0.06);
color: #e2e8f0;

View File

@@ -84,6 +84,7 @@ const I18N = {
knowledge_select_hint: '选择一个文档查看', knowledge_empty_hint: '暂无知识页面',
knowledge_empty_guide: '在对话中发送文档、链接或主题给 Agent它会自动整理到你的知识库中。',
knowledge_go_chat: '开始对话',
knowledge_new_category: '新建分类',
welcome_subtitle: '我可以帮你解答问题、管理计算机、创造和执行技能,并通过<br>长期记忆和知识库不断成长',
example_sys_title: '系统管理', example_sys_text: '查看工作空间里有哪些文件',
example_task_title: '定时任务', example_task_text: '1分钟后提醒我检查服务器',
@@ -290,6 +291,7 @@ const I18N = {
knowledge_select_hint: 'Select a document to view', knowledge_empty_hint: 'No knowledge pages yet',
knowledge_empty_guide: 'Send documents, links or topics to the agent in chat, and it will automatically organize them into your knowledge base.',
knowledge_go_chat: 'Start a conversation',
knowledge_new_category: 'New category',
welcome_subtitle: 'I can help you answer questions, manage your computer, create and execute skills, and keep growing through <br> long-term memory and a personal knowledge base.',
example_sys_title: 'System', example_sys_text: 'Show me the files in the workspace',
example_task_title: 'Scheduler', example_task_text: 'Remind me to check the server in 5 minutes',
@@ -7351,7 +7353,7 @@ function loadKnowledgeView() {
statsEl.textContent = totalPages + ' pages · ' + sizeStr;
if (totalPages === 0) {
if (totalPages === 0 && tree.length === 0 && rootFiles.length === 0) {
emptyEl.querySelector('p').textContent = t('knowledge_empty_hint');
const guideEl = document.getElementById('knowledge-empty-guide');
if (guideEl) guideEl.classList.remove('hidden');
@@ -7397,7 +7399,7 @@ function renderKnowledgeTree(tree, rootFilesOrFilter, filter) {
const fbtn = document.createElement('button');
fbtn.className = 'knowledge-tree-file' + (_knowledgeCurrentFile === f.name ? ' active' : '');
fbtn.dataset.path = f.name;
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>${_knowledgeFileActions(f.name)}`;
fbtn.onclick = () => openKnowledgeFile(f.name, f.title);
container.appendChild(fbtn);
});
@@ -7422,7 +7424,7 @@ function _renderKnowledgeGroups(container, groups, parentPath, lowerFilter, dept
const btn = document.createElement('button');
btn.className = 'knowledge-tree-group-btn';
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.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>${_knowledgeCategoryActions(groupPath)}`;
btn.onclick = () => div.classList.toggle('open');
div.appendChild(btn);
@@ -7434,7 +7436,7 @@ function _renderKnowledgeGroups(container, groups, parentPath, lowerFilter, dept
fbtn.className = 'knowledge-tree-file' + (_knowledgeCurrentFile === fpath ? ' active' : '');
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>${_knowledgeFileActions(fpath)}`;
fbtn.onclick = () => openKnowledgeFile(fpath, f.title);
items.appendChild(fbtn);
});
@@ -7446,6 +7448,181 @@ function _renderKnowledgeGroups(container, groups, parentPath, lowerFilter, dept
});
}
function _knowledgeActionButton(icon, title, handler) {
const danger = icon === 'fa-trash' ? ' danger' : '';
return `<span role="button" tabindex="0" title="${escapeHtml(title)}" onclick="event.stopPropagation();${handler}" class="knowledge-action${danger}"><i class="fas ${icon}"></i></span>`;
}
function _knowledgeFileActions(path) {
if (path === 'index.md' || path === 'log.md') return '';
const value = JSON.stringify(path).replace(/"/g, '&quot;');
return `<span class="knowledge-actions">${_knowledgeActionButton('fa-arrow-right-arrow-left', '移动', `moveKnowledgeDocument(${value})`)}${_knowledgeActionButton('fa-trash', '删除', `deleteKnowledgeDocument(${value})`)}</span>`;
}
function _knowledgeCategoryActions(path) {
const value = JSON.stringify(path).replace(/"/g, '&quot;');
return `<span class="knowledge-actions">${_knowledgeActionButton('fa-pen', '重命名', `renameKnowledgeCategory(${value})`)}${_knowledgeActionButton('fa-trash', '删除', `deleteKnowledgeCategory(${value})`)}</span>`;
}
async function dispatchKnowledgeAction(action, payload) {
_setKnowledgeStatus(currentLang === 'zh' ? '处理中...' : 'Working...', false, true);
try {
const response = await fetch('/api/knowledge/action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action, payload}),
});
const result = await response.json();
if (result.status !== 'success') {
_setKnowledgeStatus(result.message || (currentLang === 'zh' ? '操作失败' : 'Operation failed'), true);
loadKnowledgeView();
return null;
}
_setKnowledgeStatus(_knowledgeResultMessage(action, result.payload), false);
loadKnowledgeView();
return result.payload;
} catch (error) {
_setKnowledgeStatus(currentLang === 'zh' ? '请求失败,请稍后重试' : 'Request failed, please try again', true);
return null;
}
}
function _setKnowledgeStatus(message, isError, persistent) {
const el = document.getElementById('knowledge-action-status');
el.textContent = message;
el.className = `text-xs transition-opacity duration-200 ${isError ? 'text-red-500' : 'text-primary-500'}`;
el.classList.remove('opacity-0');
clearTimeout(el._hideTimer);
if (!persistent) el._hideTimer = setTimeout(() => el.classList.add('opacity-0'), 3500);
}
function _knowledgeResultMessage(action, payload) {
if (currentLang !== 'zh') {
return action === 'create_category' ? 'Category created' :
action === 'rename_category' ? 'Category renamed' :
action === 'delete_category' ? 'Category deleted' :
action === 'move_documents' ? `${payload?.moved || 0} document moved` :
`${payload?.deleted || 0} document deleted`;
}
return action === 'create_category' ? '分类已创建' :
action === 'rename_category' ? '分类已重命名' :
action === 'delete_category' ? '分类已删除' :
action === 'move_documents' ? `已移动 ${payload?.moved || 0} 个文档` :
`已删除 ${payload?.deleted || 0} 个文档`;
}
function _knowledgeCategoryPaths(groups, parent = '') {
const paths = [];
for (const group of groups || []) {
const path = parent ? `${parent}/${group.dir}` : group.dir;
paths.push(path, ..._knowledgeCategoryPaths(group.children || [], path));
}
return paths;
}
function openKnowledgeDialog(options) {
const overlay = document.getElementById('knowledge-dialog-overlay');
const input = document.getElementById('knowledge-dialog-input');
const select = document.getElementById('knowledge-dialog-select');
const submit = document.getElementById('knowledge-dialog-submit');
const cancel = document.getElementById('knowledge-dialog-cancel');
document.getElementById('knowledge-dialog-title').textContent = options.title;
document.getElementById('knowledge-dialog-subtitle').textContent = options.subtitle || '';
document.getElementById('knowledge-dialog-label').textContent = options.label;
document.getElementById('knowledge-dialog-hint').textContent = options.hint || '';
document.getElementById('knowledge-dialog-error').classList.add('hidden');
document.getElementById('knowledge-dialog-icon').className = `fas ${options.icon || 'fa-folder'} text-emerald-500`;
input.classList.toggle('hidden', options.type === 'select');
select.classList.toggle('hidden', options.type !== 'select');
input.value = options.value || '';
if (options.type === 'select') {
select.innerHTML = (options.choices || []).map(value => `<option value="${escapeHtml(value)}">${escapeHtml(value)}</option>`).join('');
}
submit.textContent = currentLang === 'zh' ? '确定' : 'Confirm';
cancel.textContent = currentLang === 'zh' ? '取消' : 'Cancel';
submit.disabled = options.type === 'select' && !(options.choices || []).length;
const close = () => overlay.classList.add('hidden');
const submitAction = async () => {
const value = (options.type === 'select' ? select.value : input.value).trim();
const error = options.validate ? options.validate(value) : (!value ? (currentLang === 'zh' ? '此项不能为空' : 'This field is required') : '');
if (error) {
const errorEl = document.getElementById('knowledge-dialog-error');
errorEl.textContent = error;
errorEl.classList.remove('hidden');
return;
}
submit.disabled = true;
const ok = await options.onSubmit(value);
submit.disabled = false;
if (ok !== null) close();
};
submit.onclick = submitAction;
cancel.onclick = close;
overlay.onclick = event => { if (event.target === overlay) close(); };
input.onkeydown = event => { if (event.key === 'Enter') submitAction(); };
overlay.classList.remove('hidden');
setTimeout(() => (options.type === 'select' ? select : input).focus(), 0);
}
function createKnowledgeCategory() {
openKnowledgeDialog({
title: currentLang === 'zh' ? '新建分类' : 'New category',
subtitle: currentLang === 'zh' ? '分类会创建为 knowledge/ 下的目录' : 'Creates a directory under knowledge/',
label: currentLang === 'zh' ? '分类路径' : 'Category path',
hint: currentLang === 'zh' ? '支持嵌套路径,例如 research/ai' : 'Nested paths are supported, e.g. research/ai',
icon: 'fa-folder-plus',
onSubmit: path => dispatchKnowledgeAction('create_category', {path}),
});
}
function renameKnowledgeCategory(path) {
openKnowledgeDialog({
title: currentLang === 'zh' ? '重命名分类' : 'Rename category',
subtitle: path,
label: currentLang === 'zh' ? '新的分类路径' : 'New category path',
value: path,
icon: 'fa-pen',
validate: value => value === path ? (currentLang === 'zh' ? '请输入不同的分类路径' : 'Enter a different category path') : '',
onSubmit: newPath => dispatchKnowledgeAction('rename_category', {path, new_path: newPath}),
});
}
function deleteKnowledgeCategory(path) {
showConfirmDialog({
title: '删除分类',
message: `确认删除“${path}”及其中全部文档?`,
okText: t('confirm_yes'),
cancelText: t('confirm_cancel'),
onConfirm: () => dispatchKnowledgeAction('delete_category', {path, confirm: true}),
});
}
function deleteKnowledgeDocument(path) {
showConfirmDialog({
title: '删除文档',
message: `确认删除“${path}”?`,
okText: t('confirm_yes'),
cancelText: t('confirm_cancel'),
onConfirm: () => dispatchKnowledgeAction('delete_documents', {paths: [path]}),
});
}
function moveKnowledgeDocument(path) {
const currentCategory = path.includes('/') ? path.split('/').slice(0, -1).join('/') : '';
const choices = _knowledgeCategoryPaths(_knowledgeTreeData).filter(value => value !== currentCategory);
openKnowledgeDialog({
title: currentLang === 'zh' ? '移动文档' : 'Move document',
subtitle: path,
label: currentLang === 'zh' ? '目标分类' : 'Destination category',
hint: choices.length ? '' : (currentLang === 'zh' ? '请先创建其他分类' : 'Create another category first'),
type: 'select',
choices,
icon: 'fa-arrow-right-arrow-left',
onSubmit: target => dispatchKnowledgeAction('move_documents', {paths: [path], target_category: target}),
});
}
function _hasFilterMatch(groups, lowerFilter) {
for (const g of groups) {
for (const f of (g.files || [])) {

View File

@@ -1154,6 +1154,7 @@ class WebChannel(ChatChannel):
'/api/knowledge/list', 'KnowledgeListHandler',
'/api/knowledge/read', 'KnowledgeReadHandler',
'/api/knowledge/graph', 'KnowledgeGraphHandler',
'/api/knowledge/action', 'KnowledgeActionHandler',
'/api/scheduler', 'SchedulerHandler',
'/api/sessions', 'SessionsHandler',
'/api/sessions/(.*)/generate_title', 'SessionTitleHandler',
@@ -4164,6 +4165,25 @@ class KnowledgeGraphHandler:
return json.dumps({"nodes": [], "links": []})
class KnowledgeActionHandler:
def POST(self):
_require_auth()
web.header('Content-Type', 'application/json; charset=utf-8')
try:
body = json.loads(web.data() or b"{}")
action = body.get("action", "")
payload = body.get("payload") or {}
from agent.knowledge.service import KnowledgeService
result = KnowledgeService(_get_workspace_root()).dispatch(action, payload)
return json.dumps({
"status": "success" if result["code"] < 300 else "error",
**result,
}, ensure_ascii=False)
except Exception as e:
logger.error(f"[WebChannel] Knowledge action error: {e}")
return json.dumps({"status": "error", "code": 500, "message": str(e), "payload": None})
class VersionHandler:
def GET(self):
web.header('Content-Type', 'application/json; charset=utf-8')

View File

@@ -0,0 +1,211 @@
import asyncio
import os
import sqlite3
from pathlib import Path
from unittest.mock import patch
from agent.memory.storage import MemoryChunk, MemoryStorage
from agent.knowledge.service import KnowledgeService
class FakeStorage:
def __init__(self):
self.deleted = []
def delete_by_path(self, path):
self.deleted.append(path)
class FakeMemoryManager:
def __init__(self):
self.storage = FakeStorage()
self.dirty = 0
self.synced = 0
def mark_dirty(self):
self.dirty += 1
async def sync(self):
self.synced += 1
def service(tmp_path):
(tmp_path / "knowledge").mkdir()
manager = FakeMemoryManager()
return KnowledgeService(str(tmp_path), manager), manager
def test_category_lifecycle_and_confirmation(tmp_path):
svc, manager = service(tmp_path)
assert svc.dispatch("create_category", {"path": "notes"})["payload"]["created"]
(tmp_path / "knowledge/notes/a.md").write_text("# A", encoding="utf-8")
denied = svc.dispatch("delete_category", {"path": "notes"})
assert denied["code"] == 403
result = svc.dispatch("delete_category", {"path": "notes", "confirm": True})
assert result["payload"]["deleted_documents"] == 1
assert manager.storage.deleted == ["knowledge/notes/a.md"]
assert manager.synced == 1
def test_rename_category_reindexes_documents(tmp_path):
svc, manager = service(tmp_path)
(tmp_path / "knowledge/old/sub").mkdir(parents=True)
(tmp_path / "knowledge/old/a.md").write_text("a", encoding="utf-8")
(tmp_path / "knowledge/old/sub/b.md").write_text("b", encoding="utf-8")
result = svc.dispatch("rename_category", {"path": "old", "new_path": "new"})
assert result["code"] == 200
assert sorted(manager.storage.deleted) == [
"knowledge/old/a.md", "knowledge/old/sub/b.md"
]
assert manager.synced == 1
assert (tmp_path / "knowledge/new/sub/b.md").exists()
def test_delete_documents_is_idempotent_and_protects_metadata(tmp_path):
svc, manager = service(tmp_path)
(tmp_path / "knowledge/index.md").write_text("index", encoding="utf-8")
(tmp_path / "knowledge/a.md").write_text("a", encoding="utf-8")
protected = svc.dispatch("delete_documents", {"paths": ["index.md"]})
assert protected["code"] == 403
first = svc.dispatch("delete_documents", {"paths": ["a.md", "missing.md"]})
assert first["payload"]["deleted"] == 1
second = svc.dispatch("delete_documents", {"paths": ["a.md"]})
assert second["payload"]["deleted"] == 0
assert manager.storage.deleted == ["knowledge/a.md", "knowledge/missing.md", "knowledge/a.md"]
assert manager.synced == 2
def test_move_documents_rejects_overwrite_and_syncs(tmp_path):
svc, manager = service(tmp_path)
(tmp_path / "knowledge/source").mkdir()
(tmp_path / "knowledge/target").mkdir()
(tmp_path / "knowledge/source/a.md").write_text("a", encoding="utf-8")
(tmp_path / "knowledge/source/b.md").write_text("b", encoding="utf-8")
(tmp_path / "knowledge/target/b.md").write_text("existing", encoding="utf-8")
result = svc.dispatch("move_documents", {
"paths": ["source/a.md", "source/b.md"], "target_category": "target"
})
assert result["payload"]["moved"] == 1
assert result["payload"]["results"][1]["reason"] == "target_exists"
assert manager.storage.deleted == ["knowledge/source/a.md"]
assert manager.synced == 1
def test_path_traversal_and_symlink_escape_are_rejected(tmp_path):
svc, _ = service(tmp_path)
outside = tmp_path / "outside"
outside.mkdir()
(tmp_path / "knowledge/link").symlink_to(outside, target_is_directory=True)
assert svc.dispatch("create_category", {"path": "../bad"})["code"] == 403
assert svc.dispatch("create_category", {"path": "link/bad"})["code"] == 403
def test_dispatch_sync_works_inside_running_event_loop(tmp_path):
svc, manager = service(tmp_path)
(tmp_path / "knowledge/source").mkdir()
(tmp_path / "knowledge/target").mkdir()
(tmp_path / "knowledge/source/a.md").write_text("a", encoding="utf-8")
async def run():
return svc.dispatch("move_documents", {
"paths": ["source/a.md"], "target_category": "target"
})
assert asyncio.run(run())["code"] == 200
assert manager.synced == 1
def test_real_storage_delete_by_path_removes_chunks_and_file_metadata(tmp_path):
storage = MemoryStorage(tmp_path / "index.db")
path = "knowledge/category/a.md"
storage.save_chunks_batch([MemoryChunk(
id="chunk-1", user_id=None, scope="shared", source="knowledge",
path=path, start_line=1, end_line=1, text="unique content",
embedding=None, hash="hash-1",
)])
storage.update_file_metadata(path, "knowledge", "file-hash", 1, 14)
storage.delete_by_path(path)
assert storage.conn.execute("SELECT COUNT(*) FROM chunks WHERE path = ?", (path,)).fetchone()[0] == 0
assert storage.conn.execute("SELECT COUNT(*) FROM files WHERE path = ?", (path,)).fetchone()[0] == 0
storage.close()
def test_missing_document_still_cleans_stale_index(tmp_path):
svc, manager = service(tmp_path)
result = svc.dispatch("delete_documents", {"paths": ["removed-by-agent.md"]})
assert result["code"] == 200
assert result["payload"]["results"][0]["reason"] == "not_found"
assert manager.storage.deleted == ["knowledge/removed-by-agent.md"]
assert manager.dirty == 1
assert manager.synced == 1
def test_category_rename_handles_concurrent_disappearance(tmp_path):
svc, manager = service(tmp_path)
category = tmp_path / "knowledge/source"
category.mkdir()
(category / "a.md").write_text("a", encoding="utf-8")
def disappear_then_rename(path, target):
(category / "a.md").unlink()
category.rmdir()
raise FileNotFoundError(path)
with patch.object(Path, "rename", disappear_then_rename):
result = svc.dispatch("rename_category", {"path": "source", "new_path": "target"})
assert result["code"] == 200
assert result["payload"]["reason"] == "not_found"
assert manager.storage.deleted == []
def test_category_delete_handles_concurrent_disappearance(tmp_path):
svc, manager = service(tmp_path)
category = tmp_path / "knowledge/source"
category.mkdir()
(category / "a.md").write_text("a", encoding="utf-8")
def disappear_then_delete(path):
(category / "a.md").unlink()
category.rmdir()
raise FileNotFoundError(path)
with patch("agent.knowledge.service.shutil.rmtree", side_effect=disappear_then_delete):
result = svc.dispatch("delete_category", {"path": "source", "confirm": True})
assert result["code"] == 200
assert result["payload"]["reason"] == "not_found"
assert manager.storage.deleted == []
def test_move_does_not_overwrite_target_created_concurrently(tmp_path):
svc, manager = service(tmp_path)
(tmp_path / "knowledge/source").mkdir()
(tmp_path / "knowledge/target").mkdir()
source = tmp_path / "knowledge/source/a.md"
target = tmp_path / "knowledge/target/a.md"
source.write_text("source", encoding="utf-8")
real_link = os.link
def create_target_then_link(src, dst):
target.write_text("concurrent", encoding="utf-8")
return real_link(src, dst)
with patch("agent.knowledge.service.os.link", side_effect=create_target_then_link):
result = svc.dispatch("move_documents", {
"paths": ["source/a.md"], "target_category": "target",
})
assert result["payload"]["results"][0]["reason"] == "target_exists"
assert source.read_text(encoding="utf-8") == "source"
assert target.read_text(encoding="utf-8") == "concurrent"
assert manager.storage.deleted == []

View File

@@ -0,0 +1,61 @@
import json
from pathlib import Path
from unittest.mock import patch
def test_knowledge_action_handler_delegates_to_dispatch(tmp_path):
from channel.web.web_channel import KnowledgeActionHandler
request = {"action": "create_category", "payload": {"path": "research"}}
dispatched = {"action": "create_category", "code": 200, "message": "success",
"payload": {"path": "research", "created": True}}
with patch("channel.web.web_channel._require_auth"), \
patch("channel.web.web_channel.web.header"), \
patch("channel.web.web_channel.web.data", return_value=json.dumps(request).encode()), \
patch("channel.web.web_channel._get_workspace_root", return_value=str(tmp_path)), \
patch("agent.knowledge.service.KnowledgeService.dispatch", return_value=dispatched) as dispatch:
response = json.loads(KnowledgeActionHandler().POST())
dispatch.assert_called_once_with("create_category", {"path": "research"})
assert response["status"] == "success"
assert response["payload"]["created"] is True
def test_knowledge_action_handler_preserves_dispatch_error(tmp_path):
from channel.web.web_channel import KnowledgeActionHandler
dispatched = {"action": "delete_documents", "code": 403,
"message": "protected knowledge file: index.md", "payload": None}
request = {"action": "delete_documents", "payload": {"paths": ["index.md"]}}
with patch("channel.web.web_channel._require_auth"), \
patch("channel.web.web_channel.web.header"), \
patch("channel.web.web_channel.web.data", return_value=json.dumps(request).encode()), \
patch("channel.web.web_channel._get_workspace_root", return_value=str(tmp_path)), \
patch("agent.knowledge.service.KnowledgeService.dispatch", return_value=dispatched):
response = json.loads(KnowledgeActionHandler().POST())
assert response["status"] == "error"
assert response["code"] == 403
assert response["message"] == "protected knowledge file: index.md"
def test_knowledge_frontend_management_contract():
root = Path(__file__).parents[1]
html = (root / "channel/web/chat.html").read_text(encoding="utf-8")
js = (root / "channel/web/static/js/console.js").read_text(encoding="utf-8")
assert 'id="knowledge-dialog-overlay"' in html
assert "function openKnowledgeDialog(" in js
assert "function _knowledgeCategoryPaths(" in js
assert "dispatchKnowledgeAction('create_category'" in js
assert "dispatchKnowledgeAction('rename_category'" in js
assert "dispatchKnowledgeAction('delete_category'" in js
assert "dispatchKnowledgeAction('delete_documents'" in js
assert "dispatchKnowledgeAction('move_documents'" in js
knowledge_section = js[js.index("// Knowledge View"):js.index("function _hasFilterMatch")]
assert "prompt(" not in knowledge_section
assert "alert(" not in knowledge_section
assert "if (path === 'index.md' || path === 'log.md') return '';" in knowledge_section