diff --git a/agent/knowledge/service.py b/agent/knowledge/service.py
index fb564afc..80fc6748 100644
--- a/agent/knowledge/service.py
+++ b/agent/knowledge/service.py
@@ -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}
diff --git a/agent/memory/storage.py b/agent/memory/storage.py
index 683b083f..240ff9de 100644
--- a/agent/memory/storage.py
+++ b/agent/memory/storage.py
@@ -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]:
diff --git a/channel/web/chat.html b/channel/web/chat.html
index af0b9aab..a3638216 100644
--- a/channel/web/chat.html
+++ b/channel/web/chat.html
@@ -840,6 +840,11 @@
+
+
+
+
+
${escapeHtml(f.title)}`;
+ fbtn.innerHTML = `${escapeHtml(f.title)}${_knowledgeFileActions(f.name)}`;
fbtn.onclick = () => openKnowledgeFile(f.name, f.title);
container.appendChild(fbtn);
});
@@ -7762,7 +7764,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 = `${escapeHtml(group.dir)}${fileCount}`;
+ btn.innerHTML = `${escapeHtml(group.dir)}${fileCount}${_knowledgeCategoryActions(groupPath)}`;
btn.onclick = () => div.classList.toggle('open');
div.appendChild(btn);
@@ -7774,7 +7776,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 = `${escapeHtml(f.title)}`;
+ fbtn.innerHTML = `${escapeHtml(f.title)}${_knowledgeFileActions(fpath)}`;
fbtn.onclick = () => openKnowledgeFile(fpath, f.title);
items.appendChild(fbtn);
});
@@ -7786,6 +7788,181 @@ function _renderKnowledgeGroups(container, groups, parentPath, lowerFilter, dept
});
}
+function _knowledgeActionButton(icon, title, handler) {
+ const danger = icon === 'fa-trash' ? ' danger' : '';
+ return ``;
+}
+
+function _knowledgeFileActions(path) {
+ if (path === 'index.md' || path === 'log.md') return '';
+ const value = JSON.stringify(path).replace(/"/g, '"');
+ return `${_knowledgeActionButton('fa-arrow-right-arrow-left', '移动', `moveKnowledgeDocument(${value})`)}${_knowledgeActionButton('fa-trash', '删除', `deleteKnowledgeDocument(${value})`)}`;
+}
+
+function _knowledgeCategoryActions(path) {
+ const value = JSON.stringify(path).replace(/"/g, '"');
+ return `${_knowledgeActionButton('fa-pen', '重命名', `renameKnowledgeCategory(${value})`)}${_knowledgeActionButton('fa-trash', '删除', `deleteKnowledgeCategory(${value})`)}`;
+}
+
+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 => ``).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 || [])) {
diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py
index bd72005b..9bcc2be4 100644
--- a/channel/web/web_channel.py
+++ b/channel/web/web_channel.py
@@ -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/scheduler/toggle', 'SchedulerToggleHandler',
'/api/scheduler/update', 'SchedulerUpdateHandler',
@@ -4627,6 +4628,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')
diff --git a/tests/test_knowledge_service.py b/tests/test_knowledge_service.py
new file mode 100644
index 00000000..96ef447b
--- /dev/null
+++ b/tests/test_knowledge_service.py
@@ -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 == []
diff --git a/tests/test_knowledge_web.py b/tests/test_knowledge_web.py
new file mode 100644
index 00000000..92991b98
--- /dev/null
+++ b/tests/test_knowledge_web.py
@@ -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