From 6e3933be300c5be4feeebc7ab63ca01b3c3f259f Mon Sep 17 00:00:00 2001 From: yangziyu-hhh Date: Mon, 15 Jun 2026 13:15:07 +0800 Subject: [PATCH] feat(knowledge): add category and document management --- agent/knowledge/service.py | 217 +++++++++++++++++++++++++++-- agent/memory/storage.py | 3 +- channel/web/chat.html | 33 +++++ channel/web/static/css/console.css | 28 ++++ channel/web/static/js/console.js | 185 +++++++++++++++++++++++- channel/web/web_channel.py | 20 +++ tests/test_knowledge_service.py | 211 ++++++++++++++++++++++++++++ tests/test_knowledge_web.py | 61 ++++++++ 8 files changed, 740 insertions(+), 18 deletions(-) create mode 100644 tests/test_knowledge_service.py create mode 100644 tests/test_knowledge_web.py 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 4f527bb4..73719f71 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -840,6 +840,11 @@
+ +
+ + +