mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-19 12:47:25 +08:00
Merge pull request #2893 from yangziyu-hhh/master
feat(knowledge): add category and document management
This commit is contained in:
@@ -12,11 +12,16 @@ Knowledge file layout (under workspace_root):
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import asyncio
|
||||||
|
import shutil
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional, Iterable
|
||||||
|
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
from config import conf
|
from config import conf
|
||||||
|
from agent.memory.config import MemoryConfig
|
||||||
|
from agent.memory.manager import MemoryManager
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeService:
|
class KnowledgeService:
|
||||||
@@ -25,9 +30,189 @@ class KnowledgeService:
|
|||||||
Operates directly on the filesystem.
|
Operates directly on the filesystem.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, workspace_root: str):
|
PROTECTED_FILES = {"index.md", "log.md"}
|
||||||
self.workspace_root = workspace_root
|
INVALID_NAME_RE = re.compile(r'[<>:"|?*\x00-\x1f]')
|
||||||
self.knowledge_dir = os.path.join(workspace_root, "knowledge")
|
|
||||||
|
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
|
# list — directory tree with stats
|
||||||
@@ -121,15 +306,8 @@ class KnowledgeService:
|
|||||||
:raises ValueError: if path is invalid or escapes knowledge dir
|
:raises ValueError: if path is invalid or escapes knowledge dir
|
||||||
:raises FileNotFoundError: if file does not exist
|
:raises FileNotFoundError: if file does not exist
|
||||||
"""
|
"""
|
||||||
if not rel_path or ".." in rel_path:
|
rel_path, full_path = self._resolve_path(rel_path, kind="document")
|
||||||
raise ValueError("invalid path")
|
if not full_path.is_file():
|
||||||
|
|
||||||
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):
|
|
||||||
raise FileNotFoundError(f"file not found: {rel_path}")
|
raise FileNotFoundError(f"file not found: {rel_path}")
|
||||||
|
|
||||||
with open(full_path, "r", encoding="utf-8") as f:
|
with open(full_path, "r", encoding="utf-8") as f:
|
||||||
@@ -228,13 +406,26 @@ class KnowledgeService:
|
|||||||
result = self.build_graph()
|
result = self.build_graph()
|
||||||
return {"action": action, "code": 200, "message": "success", "payload": result}
|
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:
|
else:
|
||||||
return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None}
|
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:
|
except ValueError as e:
|
||||||
return {"action": action, "code": 403, "message": str(e), "payload": None}
|
return {"action": action, "code": 403, "message": str(e), "payload": None}
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
return {"action": action, "code": 404, "message": str(e), "payload": None}
|
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:
|
except Exception as e:
|
||||||
logger.error(f"[KnowledgeService] dispatch error: action={action}, error={e}")
|
logger.error(f"[KnowledgeService] dispatch error: action={action}, error={e}")
|
||||||
return {"action": action, "code": 500, "message": str(e), "payload": None}
|
return {"action": action, "code": 500, "message": str(e), "payload": None}
|
||||||
|
|||||||
@@ -825,9 +825,10 @@ class MemoryStorage:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
def delete_by_path(self, path: str):
|
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:
|
with self._lock:
|
||||||
self.conn.execute("DELETE FROM chunks WHERE path = ?", (path,))
|
self.conn.execute("DELETE FROM chunks WHERE path = ?", (path,))
|
||||||
|
self.conn.execute("DELETE FROM files WHERE path = ?", (path,))
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
def get_file_hash(self, path: str) -> Optional[str]:
|
def get_file_hash(self, path: str) -> Optional[str]:
|
||||||
|
|||||||
@@ -840,6 +840,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<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-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">
|
<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')"
|
<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">
|
class="knowledge-tab px-3 py-1.5 rounded-md text-xs font-medium cursor-pointer transition-colors duration-150 active">
|
||||||
@@ -1076,6 +1081,34 @@
|
|||||||
</div><!-- /main-content -->
|
</div><!-- /main-content -->
|
||||||
</div><!-- /app -->
|
</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 -->
|
<!-- Confirm Dialog -->
|
||||||
<div id="confirm-dialog-overlay" class="fixed inset-0 bg-black/50 z-[200] hidden flex items-center justify-center">
|
<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
|
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl
|
||||||
|
|||||||
@@ -1211,6 +1211,34 @@
|
|||||||
background: #EDFDF3;
|
background: #EDFDF3;
|
||||||
color: #228547;
|
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 {
|
.dark .knowledge-tree-file:hover {
|
||||||
background: rgba(255,255,255,0.06);
|
background: rgba(255,255,255,0.06);
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ const I18N = {
|
|||||||
knowledge_select_hint: '选择一个文档查看', knowledge_empty_hint: '暂无知识页面',
|
knowledge_select_hint: '选择一个文档查看', knowledge_empty_hint: '暂无知识页面',
|
||||||
knowledge_empty_guide: '在对话中发送文档、链接或主题给 Agent,它会自动整理到你的知识库中。',
|
knowledge_empty_guide: '在对话中发送文档、链接或主题给 Agent,它会自动整理到你的知识库中。',
|
||||||
knowledge_go_chat: '开始对话',
|
knowledge_go_chat: '开始对话',
|
||||||
|
knowledge_new_category: '新建分类',
|
||||||
welcome_subtitle: '我可以帮你解答问题、管理计算机、创造和执行技能,并通过<br>长期记忆和知识库不断成长',
|
welcome_subtitle: '我可以帮你解答问题、管理计算机、创造和执行技能,并通过<br>长期记忆和知识库不断成长',
|
||||||
example_sys_title: '系统管理', example_sys_text: '查看工作空间里有哪些文件',
|
example_sys_title: '系统管理', example_sys_text: '查看工作空间里有哪些文件',
|
||||||
example_task_title: '定时任务', example_task_text: '1分钟后提醒我检查服务器',
|
example_task_title: '定时任务', example_task_text: '1分钟后提醒我检查服务器',
|
||||||
@@ -332,6 +333,7 @@ const I18N = {
|
|||||||
knowledge_select_hint: 'Select a document to view', knowledge_empty_hint: 'No knowledge pages yet',
|
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_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_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.',
|
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_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',
|
example_task_title: 'Scheduler', example_task_text: 'Remind me to check the server in 5 minutes',
|
||||||
@@ -7691,7 +7693,7 @@ function loadKnowledgeView() {
|
|||||||
|
|
||||||
statsEl.textContent = totalPages + ' pages · ' + sizeStr;
|
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');
|
emptyEl.querySelector('p').textContent = t('knowledge_empty_hint');
|
||||||
const guideEl = document.getElementById('knowledge-empty-guide');
|
const guideEl = document.getElementById('knowledge-empty-guide');
|
||||||
if (guideEl) guideEl.classList.remove('hidden');
|
if (guideEl) guideEl.classList.remove('hidden');
|
||||||
@@ -7737,7 +7739,7 @@ function renderKnowledgeTree(tree, rootFilesOrFilter, filter) {
|
|||||||
const fbtn = document.createElement('button');
|
const fbtn = document.createElement('button');
|
||||||
fbtn.className = 'knowledge-tree-file' + (_knowledgeCurrentFile === f.name ? ' active' : '');
|
fbtn.className = 'knowledge-tree-file' + (_knowledgeCurrentFile === f.name ? ' active' : '');
|
||||||
fbtn.dataset.path = f.name;
|
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);
|
fbtn.onclick = () => openKnowledgeFile(f.name, f.title);
|
||||||
container.appendChild(fbtn);
|
container.appendChild(fbtn);
|
||||||
});
|
});
|
||||||
@@ -7762,7 +7764,7 @@ function _renderKnowledgeGroups(container, groups, parentPath, lowerFilter, dept
|
|||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.className = 'knowledge-tree-group-btn';
|
btn.className = 'knowledge-tree-group-btn';
|
||||||
btn.style.paddingLeft = (8 + indent) + 'px';
|
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');
|
btn.onclick = () => div.classList.toggle('open');
|
||||||
div.appendChild(btn);
|
div.appendChild(btn);
|
||||||
|
|
||||||
@@ -7774,7 +7776,7 @@ function _renderKnowledgeGroups(container, groups, parentPath, lowerFilter, dept
|
|||||||
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.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);
|
fbtn.onclick = () => openKnowledgeFile(fpath, f.title);
|
||||||
items.appendChild(fbtn);
|
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 `<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, '"');
|
||||||
|
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, '"');
|
||||||
|
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) {
|
function _hasFilterMatch(groups, lowerFilter) {
|
||||||
for (const g of groups) {
|
for (const g of groups) {
|
||||||
for (const f of (g.files || [])) {
|
for (const f of (g.files || [])) {
|
||||||
|
|||||||
@@ -1154,6 +1154,7 @@ class WebChannel(ChatChannel):
|
|||||||
'/api/knowledge/list', 'KnowledgeListHandler',
|
'/api/knowledge/list', 'KnowledgeListHandler',
|
||||||
'/api/knowledge/read', 'KnowledgeReadHandler',
|
'/api/knowledge/read', 'KnowledgeReadHandler',
|
||||||
'/api/knowledge/graph', 'KnowledgeGraphHandler',
|
'/api/knowledge/graph', 'KnowledgeGraphHandler',
|
||||||
|
'/api/knowledge/action', 'KnowledgeActionHandler',
|
||||||
'/api/scheduler', 'SchedulerHandler',
|
'/api/scheduler', 'SchedulerHandler',
|
||||||
'/api/scheduler/toggle', 'SchedulerToggleHandler',
|
'/api/scheduler/toggle', 'SchedulerToggleHandler',
|
||||||
'/api/scheduler/update', 'SchedulerUpdateHandler',
|
'/api/scheduler/update', 'SchedulerUpdateHandler',
|
||||||
@@ -4627,6 +4628,25 @@ class KnowledgeGraphHandler:
|
|||||||
return json.dumps({"nodes": [], "links": []})
|
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:
|
class VersionHandler:
|
||||||
def GET(self):
|
def GET(self):
|
||||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
|||||||
211
tests/test_knowledge_service.py
Normal file
211
tests/test_knowledge_service.py
Normal 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 == []
|
||||||
61
tests/test_knowledge_web.py
Normal file
61
tests/test_knowledge_web.py
Normal 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
|
||||||
Reference in New Issue
Block a user