feat(knowledge): add document creation and import

This commit is contained in:
yangziyu-hhh
2026-06-18 16:06:41 +08:00
parent e7a069b060
commit 84d6848e67
7 changed files with 611 additions and 6 deletions

View File

@@ -32,6 +32,10 @@ class KnowledgeService:
PROTECTED_FILES = {"index.md", "log.md"} PROTECTED_FILES = {"index.md", "log.md"}
INVALID_NAME_RE = re.compile(r'[<>:"|?*\x00-\x1f]') INVALID_NAME_RE = re.compile(r'[<>:"|?*\x00-\x1f]')
IMPORT_EXTENSIONS = {".md", ".txt"}
MAX_IMPORT_FILES = 100
MAX_IMPORT_FILE_SIZE = 10 * 1024 * 1024
MAX_IMPORT_TOTAL_SIZE = 50 * 1024 * 1024
def __init__(self, workspace_root: str, memory_manager=None): def __init__(self, workspace_root: str, memory_manager=None):
self.workspace_root = os.path.abspath(workspace_root) self.workspace_root = os.path.abspath(workspace_root)
@@ -100,9 +104,9 @@ class KnowledgeService:
raise error[0] raise error[0]
return result[0] if result else None return result[0] if result else None
def _sync_index(self, old_paths: Iterable[str]): def _sync_index(self, old_paths: Iterable[str], force: bool = False):
old_paths = sorted(set(old_paths)) old_paths = sorted(set(old_paths))
if not old_paths: if not old_paths and not force:
return return
manager = self._manager() manager = self._manager()
for rel_path in old_paths: for rel_path in old_paths:
@@ -110,6 +114,113 @@ class KnowledgeService:
manager.mark_dirty() manager.mark_dirty()
self._run_sync(manager.sync()) self._run_sync(manager.sync())
def _sanitize_document_name(self, filename: str) -> str:
name = os.path.basename((filename or "").replace("\\", "/")).strip()
if not name:
raise ValueError("filename is required")
stem, ext = os.path.splitext(name)
if ext.lower() not in self.IMPORT_EXTENSIONS:
raise ValueError(f"unsupported file type: {ext or name}")
if not stem or stem in (".", "..") or self.INVALID_NAME_RE.search(stem):
raise ValueError("invalid filename")
safe_name = f"{stem}.md"
self._ensure_not_protected(safe_name)
return safe_name
@staticmethod
def _decode_document_content(content) -> str:
if isinstance(content, str):
return content
if not isinstance(content, (bytes, bytearray)):
raise ValueError("document content is required")
return bytes(content).decode("utf-8-sig", errors="replace")
def _resolve_import_destination(self, target_category: str, filename: str,
conflict_strategy: str) -> tuple:
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}")
safe_name = self._sanitize_document_name(filename)
destination = target_full / safe_name
rel_path = f"{target_rel}/{safe_name}"
if destination.exists():
if conflict_strategy == "skip":
return rel_path, destination, "skip"
if conflict_strategy == "rename":
stem = destination.stem
suffix = destination.suffix
for index in range(1, 1000):
candidate = target_full / f"{stem}-{index}{suffix}"
if not candidate.exists():
candidate_rel = f"{target_rel}/{candidate.name}"
return candidate_rel, candidate, "write"
raise FileExistsError(f"target already exists: {rel_path}")
if conflict_strategy != "overwrite":
raise ValueError("invalid conflict strategy")
return rel_path, destination, "write"
def create_document(self, path: str, content: str = "", overwrite: bool = False) -> dict:
rel_path, full_path = self._resolve_path(path, kind="document")
self._ensure_not_protected(rel_path)
if len((content or "").encode("utf-8")) > self.MAX_IMPORT_FILE_SIZE:
raise ValueError("file too large")
if full_path.exists() and not overwrite:
raise FileExistsError(f"target already exists: {rel_path}")
old_paths = [rel_path] if full_path.exists() else []
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content or "", encoding="utf-8")
self._sync_index(old_paths, force=True)
return {"path": rel_path, "created": True, "overwritten": bool(old_paths)}
def import_documents(self, target_category: str, files: Iterable[dict],
conflict_strategy: str = "skip") -> dict:
if not isinstance(files, list):
raise ValueError("files must be a list")
if len(files) > self.MAX_IMPORT_FILES:
raise ValueError(f"too many files: max {self.MAX_IMPORT_FILES}")
results = []
old_paths = []
imported = skipped = failed = 0
total_size = 0
for item in files:
filename = item.get("filename") if isinstance(item, dict) else None
try:
content_bytes = item.get("content") if isinstance(item, dict) else None
size = len(content_bytes.encode("utf-8")) if isinstance(content_bytes, str) else len(content_bytes or b"")
total_size += size
if total_size > self.MAX_IMPORT_TOTAL_SIZE:
raise ValueError("import batch too large")
if size > self.MAX_IMPORT_FILE_SIZE:
raise ValueError("file too large")
rel_path, destination, mode = self._resolve_import_destination(
target_category, filename, conflict_strategy
)
if mode == "skip":
skipped += 1
results.append({"filename": filename, "path": rel_path, "status": "skipped",
"reason": "target_exists"})
continue
old_exists = destination.exists()
content = self._decode_document_content(content_bytes)
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(content, encoding="utf-8")
if old_exists:
old_paths.append(rel_path)
imported += 1
results.append({"filename": filename, "path": rel_path, "status": "imported",
"overwritten": old_exists})
except Exception as exc:
failed += 1
results.append({"filename": filename or "", "status": "failed", "reason": str(exc)})
if imported:
self._sync_index(old_paths, force=True)
return {"results": results, "imported": imported, "skipped": skipped, "failed": failed}
def create_category(self, path: str) -> dict: def create_category(self, path: str) -> dict:
rel_path, full_path = self._resolve_path(path, kind="category") rel_path, full_path = self._resolve_path(path, kind="category")
if full_path.exists(): if full_path.exists():
@@ -416,6 +527,15 @@ class KnowledgeService:
result = self.delete_documents(payload.get("paths") or []) result = self.delete_documents(payload.get("paths") or [])
elif action == "move_documents": elif action == "move_documents":
result = self.move_documents(payload.get("paths") or [], payload.get("target_category")) result = self.move_documents(payload.get("paths") or [], payload.get("target_category"))
elif action == "create_document":
result = self.create_document(payload.get("path"), payload.get("content", ""),
payload.get("overwrite", False))
elif action == "import_documents":
result = self.import_documents(
payload.get("target_category"),
payload.get("files") or [],
payload.get("conflict_strategy", "skip"),
)
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} return {"action": action, "code": 200, "message": "success", "payload": result}

View File

@@ -845,6 +845,15 @@
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"> 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> <i class="fas fa-folder-plus"></i><span data-i18n="knowledge_new_category">新建分类</span>
</button> </button>
<button onclick="createKnowledgeDocument()"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-slate-200 dark:border-white/10 text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5 text-xs font-medium cursor-pointer transition-colors">
<i class="fas fa-file-circle-plus"></i><span data-i18n="knowledge_new_document">新建文档</span>
</button>
<button onclick="selectKnowledgeImportFiles()"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-slate-200 dark:border-white/10 text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5 text-xs font-medium cursor-pointer transition-colors">
<i class="fas fa-file-arrow-up"></i><span data-i18n="knowledge_import_documents">导入文档</span>
</button>
<input id="knowledge-import-input" type="file" class="hidden" multiple accept=".md,.txt,text/markdown,text/plain">
<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">
@@ -1075,7 +1084,7 @@
<!-- Knowledge Action Dialog --> <!-- Knowledge Action Dialog -->
<div id="knowledge-dialog-overlay" class="fixed inset-0 bg-black/50 z-[200] hidden flex items-center justify-center"> <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 id="knowledge-dialog-card" 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="p-6">
<div class="flex items-center gap-3 mb-5"> <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"> <div class="w-10 h-10 rounded-xl bg-emerald-50 dark:bg-emerald-900/20 flex items-center justify-center">
@@ -1091,6 +1100,29 @@
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"> 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" <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> 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>
<textarea id="knowledge-dialog-textarea" rows="8"
class="hidden 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 font-mono resize-y"></textarea>
<div id="knowledge-document-form" class="hidden space-y-3">
<div class="rounded-lg bg-emerald-50 dark:bg-emerald-900/15 border border-emerald-100 dark:border-emerald-800/40 px-3 py-2">
<div id="knowledge-document-category-label" class="text-[11px] text-emerald-600 dark:text-emerald-400 mb-0.5"></div>
<div id="knowledge-document-path-preview" class="text-xs font-mono text-emerald-700 dark:text-emerald-300 break-all"></div>
</div>
<div>
<label id="knowledge-document-filename-label" class="block text-sm font-medium text-slate-600 dark:text-slate-300 mb-1.5"></label>
<input id="knowledge-document-filename" 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"
placeholder="note.md">
</div>
<div>
<div class="flex items-center justify-between mb-1.5">
<label id="knowledge-document-content-label" class="block text-sm font-medium text-slate-600 dark:text-slate-300"></label>
<button id="knowledge-document-template" type="button" class="text-xs text-primary-500 hover:text-primary-600"></button>
</div>
<textarea id="knowledge-document-content" rows="14"
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 font-mono resize-y"
placeholder="# Title&#10;&#10;Write your notes here..."></textarea>
</div>
</div>
<p id="knowledge-dialog-hint" class="mt-2 text-xs text-slate-400 dark:text-slate-500"></p> <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> <p id="knowledge-dialog-error" class="mt-2 text-xs text-red-500 hidden"></p>
</div> </div>

View File

@@ -1247,6 +1247,18 @@
background: rgba(74, 190, 110, 0.1); background: rgba(74, 190, 110, 0.1);
color: #4ABE6E; color: #4ABE6E;
} }
.knowledge-import-drag-over {
outline: 2px dashed rgba(74, 190, 110, 0.55);
outline-offset: 4px;
border-radius: 14px;
}
#knowledge-dialog-card.knowledge-document-dialog {
max-width: 760px;
}
#knowledge-document-content {
min-height: 320px;
line-height: 1.55;
}
/* Graph legend */ /* Graph legend */
.knowledge-graph-legend { .knowledge-graph-legend {

View File

@@ -94,6 +94,8 @@ const I18N = {
knowledge_empty_guide: '在对话中发送文档、链接或主题给 Agent它会自动整理到你的知识库中。', knowledge_empty_guide: '在对话中发送文档、链接或主题给 Agent它会自动整理到你的知识库中。',
knowledge_go_chat: '开始对话', knowledge_go_chat: '开始对话',
knowledge_new_category: '新建分类', knowledge_new_category: '新建分类',
knowledge_new_document: '新建文档',
knowledge_import_documents: '导入文档',
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分钟后提醒我检查服务器',
@@ -310,6 +312,8 @@ const I18N = {
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', knowledge_new_category: 'New category',
knowledge_new_document: 'New document',
knowledge_import_documents: 'Import documents',
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',
@@ -7541,6 +7545,9 @@ let _knowledgeTreeData = [];
let _knowledgeRootFiles = []; let _knowledgeRootFiles = [];
let _knowledgeCurrentFile = null; let _knowledgeCurrentFile = null;
let _knowledgeGraphLoaded = false; let _knowledgeGraphLoaded = false;
const KNOWLEDGE_IMPORT_MAX_FILES = 100;
const KNOWLEDGE_IMPORT_MAX_FILE_SIZE = 10 * 1024 * 1024;
const KNOWLEDGE_IMPORT_MAX_TOTAL_SIZE = 50 * 1024 * 1024;
function loadKnowledgeView() { function loadKnowledgeView() {
// Reset to docs tab // Reset to docs tab
@@ -7550,6 +7557,7 @@ function loadKnowledgeView() {
fetch('/api/knowledge/list').then(r => r.json()).then(data => { fetch('/api/knowledge/list').then(r => r.json()).then(data => {
if (data.status !== 'success') return; if (data.status !== 'success') return;
initKnowledgeImportDropZone();
const emptyEl = document.getElementById('knowledge-empty'); const emptyEl = document.getElementById('knowledge-empty');
const docsPanel = document.getElementById('knowledge-panel-docs'); const docsPanel = document.getElementById('knowledge-panel-docs');
@@ -7713,12 +7721,14 @@ function _knowledgeResultMessage(action, payload) {
return action === 'create_category' ? 'Category created' : return action === 'create_category' ? 'Category created' :
action === 'rename_category' ? 'Category renamed' : action === 'rename_category' ? 'Category renamed' :
action === 'delete_category' ? 'Category deleted' : action === 'delete_category' ? 'Category deleted' :
action === 'import_documents' ? `${payload?.imported || 0} imported · ${payload?.skipped || 0} skipped · ${payload?.failed || 0} failed` :
action === 'move_documents' ? `${payload?.moved || 0} document moved` : action === 'move_documents' ? `${payload?.moved || 0} document moved` :
`${payload?.deleted || 0} document deleted`; `${payload?.deleted || 0} document deleted`;
} }
return action === 'create_category' ? '分类已创建' : return action === 'create_category' ? '分类已创建' :
action === 'rename_category' ? '分类已重命名' : action === 'rename_category' ? '分类已重命名' :
action === 'delete_category' ? '分类已删除' : action === 'delete_category' ? '分类已删除' :
action === 'import_documents' ? `导入 ${payload?.imported || 0} 个,跳过 ${payload?.skipped || 0} 个,失败 ${payload?.failed || 0}` :
action === 'move_documents' ? `已移动 ${payload?.moved || 0} 个文档` : action === 'move_documents' ? `已移动 ${payload?.moved || 0} 个文档` :
`已删除 ${payload?.deleted || 0} 个文档`; `已删除 ${payload?.deleted || 0} 个文档`;
} }
@@ -7734,8 +7744,15 @@ function _knowledgeCategoryPaths(groups, parent = '') {
function openKnowledgeDialog(options) { function openKnowledgeDialog(options) {
const overlay = document.getElementById('knowledge-dialog-overlay'); const overlay = document.getElementById('knowledge-dialog-overlay');
const card = document.getElementById('knowledge-dialog-card');
const input = document.getElementById('knowledge-dialog-input'); const input = document.getElementById('knowledge-dialog-input');
const select = document.getElementById('knowledge-dialog-select'); const select = document.getElementById('knowledge-dialog-select');
const textarea = document.getElementById('knowledge-dialog-textarea');
const documentForm = document.getElementById('knowledge-document-form');
const documentFilename = document.getElementById('knowledge-document-filename');
const documentContent = document.getElementById('knowledge-document-content');
const templateBtn = document.getElementById('knowledge-document-template');
const documentPathPreview = document.getElementById('knowledge-document-path-preview');
const submit = document.getElementById('knowledge-dialog-submit'); const submit = document.getElementById('knowledge-dialog-submit');
const cancel = document.getElementById('knowledge-dialog-cancel'); const cancel = document.getElementById('knowledge-dialog-cancel');
document.getElementById('knowledge-dialog-title').textContent = options.title; document.getElementById('knowledge-dialog-title').textContent = options.title;
@@ -7744,9 +7761,29 @@ function openKnowledgeDialog(options) {
document.getElementById('knowledge-dialog-hint').textContent = options.hint || ''; document.getElementById('knowledge-dialog-hint').textContent = options.hint || '';
document.getElementById('knowledge-dialog-error').classList.add('hidden'); document.getElementById('knowledge-dialog-error').classList.add('hidden');
document.getElementById('knowledge-dialog-icon').className = `fas ${options.icon || 'fa-folder'} text-emerald-500`; document.getElementById('knowledge-dialog-icon').className = `fas ${options.icon || 'fa-folder'} text-emerald-500`;
input.classList.toggle('hidden', options.type === 'select'); card.classList.toggle('knowledge-document-dialog', options.type === 'document');
input.classList.toggle('hidden', options.type === 'select' || options.type === 'textarea' || options.type === 'document');
select.classList.toggle('hidden', options.type !== 'select'); select.classList.toggle('hidden', options.type !== 'select');
textarea.classList.toggle('hidden', options.type !== 'textarea');
documentForm.classList.toggle('hidden', options.type !== 'document');
input.value = options.value || ''; input.value = options.value || '';
textarea.value = options.value || '';
documentFilename.value = options.filename || '';
documentContent.value = options.content || '';
document.getElementById('knowledge-document-category-label').textContent = currentLang === 'zh' ? '目标分类' : 'Destination category';
documentPathPreview.textContent = options.category
? `knowledge/${options.category}/`
: 'knowledge/';
documentFilename.oninput = null;
document.getElementById('knowledge-document-filename-label').textContent = currentLang === 'zh' ? '文件名' : 'Filename';
document.getElementById('knowledge-document-content-label').textContent = currentLang === 'zh' ? 'Markdown 内容' : 'Markdown content';
templateBtn.textContent = currentLang === 'zh' ? '插入模板' : 'Insert template';
templateBtn.onclick = () => {
if (documentContent.value.trim()) return;
const title = (documentFilename.value || 'untitled').replace(/\.md$/i, '');
documentContent.value = `# ${title}\n\n## 摘要\n\n\n## 关键点\n\n- \n\n## 参考\n\n`;
documentContent.focus();
};
if (options.type === 'select') { if (options.type === 'select') {
select.innerHTML = (options.choices || []).map(value => `<option value="${escapeHtml(value)}">${escapeHtml(value)}</option>`).join(''); select.innerHTML = (options.choices || []).map(value => `<option value="${escapeHtml(value)}">${escapeHtml(value)}</option>`).join('');
} }
@@ -7756,7 +7793,13 @@ function openKnowledgeDialog(options) {
const close = () => overlay.classList.add('hidden'); const close = () => overlay.classList.add('hidden');
const submitAction = async () => { const submitAction = async () => {
const value = (options.type === 'select' ? select.value : input.value).trim(); const rawValue = options.type === 'select' ? select.value :
(options.type === 'textarea' ? textarea.value :
(options.type === 'document' ? {
filename: documentFilename.value.trim(),
content: documentContent.value,
} : input.value));
const value = options.type === 'textarea' || options.type === 'document' ? rawValue : rawValue.trim();
const error = options.validate ? options.validate(value) : (!value ? (currentLang === 'zh' ? '此项不能为空' : 'This field is required') : ''); const error = options.validate ? options.validate(value) : (!value ? (currentLang === 'zh' ? '此项不能为空' : 'This field is required') : '');
if (error) { if (error) {
const errorEl = document.getElementById('knowledge-dialog-error'); const errorEl = document.getElementById('knowledge-dialog-error');
@@ -7774,7 +7817,7 @@ function openKnowledgeDialog(options) {
overlay.onclick = event => { if (event.target === overlay) close(); }; overlay.onclick = event => { if (event.target === overlay) close(); };
input.onkeydown = event => { if (event.key === 'Enter') submitAction(); }; input.onkeydown = event => { if (event.key === 'Enter') submitAction(); };
overlay.classList.remove('hidden'); overlay.classList.remove('hidden');
setTimeout(() => (options.type === 'select' ? select : input).focus(), 0); setTimeout(() => (options.type === 'select' ? select : (options.type === 'textarea' ? textarea : (options.type === 'document' ? documentFilename : input))).focus(), 0);
} }
function createKnowledgeCategory() { function createKnowledgeCategory() {
@@ -7788,6 +7831,164 @@ function createKnowledgeCategory() {
}); });
} }
function createKnowledgeDocument() {
const categories = _knowledgeCategoryPaths(_knowledgeTreeData);
if (!categories.length) {
_setKnowledgeStatus(currentLang === 'zh' ? '请先创建分类' : 'Create a category first', true);
return;
}
openKnowledgeDialog({
title: currentLang === 'zh' ? '新建文档' : 'New document',
subtitle: currentLang === 'zh' ? '先选择分类,然后输入文件名' : 'Choose a category, then enter a filename',
label: currentLang === 'zh' ? '目标分类' : 'Destination category',
type: 'select',
choices: categories,
icon: 'fa-file-circle-plus',
onSubmit: category => {
openKnowledgeDocumentEditor(category);
return null;
},
});
}
function openKnowledgeDocumentEditor(category) {
openKnowledgeDialog({
title: currentLang === 'zh' ? '新建文档' : 'New document',
subtitle: currentLang === 'zh' ? `保存到 ${category}` : `Save to ${category}`,
label: '',
hint: currentLang === 'zh' ? '文件名可省略 .md 后缀;保存后会自动同步索引。' : 'The .md suffix is optional. Index sync runs after saving.',
type: 'document',
category,
filename: '',
content: '',
icon: 'fa-file-circle-plus',
validate: value => {
if (!value.filename) return currentLang === 'zh' ? '文件名不能为空' : 'Filename is required';
if (/\.[^.]+$/i.test(value.filename) && !/\.md$/i.test(value.filename)) {
return currentLang === 'zh' ? '新建文档仅支持 .md 文件名' : 'New documents must be .md files';
}
if (!value.content.trim()) return currentLang === 'zh' ? '内容不能为空' : 'Content is required';
if (new Blob([value.content]).size > KNOWLEDGE_IMPORT_MAX_FILE_SIZE) {
return currentLang === 'zh' ? '内容不能超过 10MB' : 'Content cannot exceed 10MB';
}
return '';
},
onSubmit: value => {
const safeName = value.filename.endsWith('.md') ? value.filename : `${value.filename}.md`;
return dispatchKnowledgeAction('create_document', {
path: `${category}/${safeName}`,
content: value.content,
overwrite: false,
});
},
});
}
function selectKnowledgeImportFiles() {
const input = document.getElementById('knowledge-import-input');
input.value = '';
input.onchange = () => {
if (input.files && input.files.length) openKnowledgeImportDialog(Array.from(input.files));
};
input.click();
}
function openKnowledgeImportDialog(files) {
const validationError = validateKnowledgeImportFiles(files);
if (validationError) {
_setKnowledgeStatus(validationError, true);
return;
}
const choices = _knowledgeCategoryPaths(_knowledgeTreeData);
openKnowledgeDialog({
title: currentLang === 'zh' ? '导入文档' : 'Import documents',
subtitle: currentLang === 'zh' ? `已选择 ${files.length} 个文件` : `${files.length} file(s) selected`,
label: currentLang === 'zh' ? '目标分类' : 'Destination category',
hint: choices.length ? (currentLang === 'zh' ? '支持 Markdown 和 TXTTXT 会转成 Markdown 文档' : 'Markdown and TXT are supported. TXT is converted to Markdown.') :
(currentLang === 'zh' ? '请先创建一个分类' : 'Create a category first'),
type: 'select',
choices,
icon: 'fa-file-arrow-up',
onSubmit: target => importKnowledgeDocuments(files, target),
});
}
async function importKnowledgeDocuments(files, targetCategory) {
const validationError = validateKnowledgeImportFiles(files);
if (validationError) {
_setKnowledgeStatus(validationError, true);
return null;
}
const supported = files.filter(file => /\.(md|txt)$/i.test(file.name || ''));
if (!supported.length) {
_setKnowledgeStatus(currentLang === 'zh' ? '请选择 .md 或 .txt 文件' : 'Choose .md or .txt files', true);
return null;
}
const formData = new FormData();
formData.append('target_category', targetCategory);
formData.append('conflict_strategy', 'rename');
supported.forEach(file => formData.append('files', file, file.name));
_setKnowledgeStatus(currentLang === 'zh' ? '正在导入...' : 'Importing...', false, true);
try {
const response = await fetch('/api/knowledge/import', { method: 'POST', body: formData });
const result = await response.json();
if (result.status !== 'success') {
_setKnowledgeStatus(result.message || (currentLang === 'zh' ? '导入失败' : 'Import failed'), true);
loadKnowledgeView();
return null;
}
_setKnowledgeStatus(_knowledgeResultMessage('import_documents', result.payload), false);
loadKnowledgeView();
return result.payload;
} catch (error) {
_setKnowledgeStatus(currentLang === 'zh' ? '导入请求失败' : 'Import request failed', true);
return null;
}
}
function validateKnowledgeImportFiles(files) {
if (!files || !files.length) return currentLang === 'zh' ? '请选择文件' : 'Choose files';
if (files.length > KNOWLEDGE_IMPORT_MAX_FILES) {
return currentLang === 'zh' ? `一次最多导入 ${KNOWLEDGE_IMPORT_MAX_FILES} 个文件` : `Import at most ${KNOWLEDGE_IMPORT_MAX_FILES} files at a time`;
}
let total = 0;
for (const file of files) {
total += file.size || 0;
if ((file.size || 0) > KNOWLEDGE_IMPORT_MAX_FILE_SIZE) {
return currentLang === 'zh' ? `${file.name} 超过 10MB` : `${file.name} exceeds 10MB`;
}
}
if (total > KNOWLEDGE_IMPORT_MAX_TOTAL_SIZE) {
return currentLang === 'zh' ? '单次导入总大小不能超过 50MB' : 'Total import size cannot exceed 50MB';
}
return '';
}
let _knowledgeImportDropReady = false;
function initKnowledgeImportDropZone() {
if (_knowledgeImportDropReady) return;
const panel = document.getElementById('knowledge-panel-docs');
if (!panel) return;
_knowledgeImportDropReady = true;
['dragenter', 'dragover'].forEach(name => {
panel.addEventListener(name, event => {
if (!event.dataTransfer || !event.dataTransfer.types.includes('Files')) return;
event.preventDefault();
panel.classList.add('knowledge-import-drag-over');
});
});
['dragleave', 'drop'].forEach(name => {
panel.addEventListener(name, event => {
if (event.type === 'drop') {
event.preventDefault();
const files = Array.from(event.dataTransfer?.files || []);
if (files.length) openKnowledgeImportDialog(files);
}
panel.classList.remove('knowledge-import-drag-over');
});
});
}
function renameKnowledgeCategory(path) { function renameKnowledgeCategory(path) {
openKnowledgeDialog({ openKnowledgeDialog({
title: currentLang === 'zh' ? '重命名分类' : 'Rename category', title: currentLang === 'zh' ? '重命名分类' : 'Rename category',

View File

@@ -181,6 +181,29 @@ def _read_uploaded_file_bytes(file_obj) -> bytes:
raise TypeError(f"Unsupported uploaded content type: {type(content).__name__}") raise TypeError(f"Unsupported uploaded content type: {type(content).__name__}")
def _read_uploaded_file_bytes_limited(file_obj, max_bytes: int) -> bytes:
"""Read uploaded content and fail once it exceeds max_bytes."""
if isinstance(file_obj, bytes):
content = file_obj
elif isinstance(file_obj, str):
content = file_obj.encode("utf-8")
elif hasattr(file_obj, "file") and hasattr(file_obj.file, "read"):
content = file_obj.file.read(max_bytes + 1)
elif hasattr(file_obj, "read"):
content = file_obj.read(max_bytes + 1)
elif hasattr(file_obj, "value"):
content = file_obj.value
else:
raise ValueError("Unable to read uploaded file content")
if isinstance(content, str):
content = content.encode("utf-8")
if not isinstance(content, bytes):
raise TypeError(f"Unsupported uploaded content type: {type(content).__name__}")
if len(content) > max_bytes:
raise ValueError("file too large")
return content
def _raw_web_input(): def _raw_web_input():
"""Return unprocessed multipart form data when web.py exposes rawinput.""" """Return unprocessed multipart form data when web.py exposes rawinput."""
rawinput = getattr(getattr(web, "webapi", None), "rawinput", None) rawinput = getattr(getattr(web, "webapi", None), "rawinput", None)
@@ -1155,6 +1178,7 @@ class WebChannel(ChatChannel):
'/api/knowledge/read', 'KnowledgeReadHandler', '/api/knowledge/read', 'KnowledgeReadHandler',
'/api/knowledge/graph', 'KnowledgeGraphHandler', '/api/knowledge/graph', 'KnowledgeGraphHandler',
'/api/knowledge/action', 'KnowledgeActionHandler', '/api/knowledge/action', 'KnowledgeActionHandler',
'/api/knowledge/import', 'KnowledgeImportHandler',
'/api/scheduler', 'SchedulerHandler', '/api/scheduler', 'SchedulerHandler',
'/api/sessions', 'SessionsHandler', '/api/sessions', 'SessionsHandler',
'/api/sessions/(.*)/generate_title', 'SessionTitleHandler', '/api/sessions/(.*)/generate_title', 'SessionTitleHandler',
@@ -4509,6 +4533,71 @@ class KnowledgeActionHandler:
return json.dumps({"status": "error", "code": 500, "message": str(e), "payload": None}) return json.dumps({"status": "error", "code": 500, "message": str(e), "payload": None})
class KnowledgeImportHandler:
def POST(self):
_require_auth()
web.header('Content-Type', 'application/json; charset=utf-8')
try:
from agent.knowledge.service import KnowledgeService
content_length = int(getattr(web.ctx, "env", {}).get("CONTENT_LENGTH") or 0)
if content_length > KnowledgeService.MAX_IMPORT_TOTAL_SIZE:
return json.dumps({
"status": "error",
"code": 413,
"message": "import batch too large",
"payload": None,
})
params = _raw_web_input()
target_category = params.get("target_category", "")
conflict_strategy = params.get("conflict_strategy", "skip")
uploaded = _ensure_list(params.get("files"))
single = params.get("file")
if single is not None:
uploaded.append(single)
if not uploaded:
return json.dumps({"status": "error", "code": 400, "message": "No files uploaded", "payload": None})
if len(uploaded) > KnowledgeService.MAX_IMPORT_FILES:
return json.dumps({
"status": "error",
"code": 400,
"message": f"too many files: max {KnowledgeService.MAX_IMPORT_FILES}",
"payload": None,
})
files = []
total_size = 0
for file_obj in uploaded:
if file_obj is None:
continue
filename = getattr(file_obj, "filename", "") or getattr(file_obj, "name", "")
content = _read_uploaded_file_bytes_limited(file_obj, KnowledgeService.MAX_IMPORT_FILE_SIZE)
total_size += len(content)
if total_size > KnowledgeService.MAX_IMPORT_TOTAL_SIZE:
return json.dumps({
"status": "error",
"code": 413,
"message": "import batch too large",
"payload": None,
})
files.append({
"filename": filename,
"content": content,
})
result = KnowledgeService(_get_workspace_root()).dispatch("import_documents", {
"target_category": target_category,
"conflict_strategy": conflict_strategy,
"files": files,
})
return json.dumps({
"status": "success" if result["code"] < 300 else "error",
**result,
}, ensure_ascii=False)
except Exception as e:
logger.error(f"[WebChannel] Knowledge import error: {e}", exc_info=True)
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')

View File

@@ -209,3 +209,89 @@ def test_move_does_not_overwrite_target_created_concurrently(tmp_path):
assert source.read_text(encoding="utf-8") == "source" assert source.read_text(encoding="utf-8") == "source"
assert target.read_text(encoding="utf-8") == "concurrent" assert target.read_text(encoding="utf-8") == "concurrent"
assert manager.storage.deleted == [] assert manager.storage.deleted == []
def test_create_document_writes_and_syncs(tmp_path):
svc, manager = service(tmp_path)
(tmp_path / "knowledge/notes").mkdir()
result = svc.dispatch("create_document", {
"path": "notes/new.md", "content": "# New\nBody",
})
assert result["code"] == 200
assert (tmp_path / "knowledge/notes/new.md").read_text(encoding="utf-8") == "# New\nBody"
assert manager.dirty == 1
assert manager.synced == 1
def test_import_documents_supports_md_txt_and_rename_conflicts(tmp_path):
svc, manager = service(tmp_path)
(tmp_path / "knowledge/notes").mkdir()
(tmp_path / "knowledge/notes/a.md").write_text("existing", encoding="utf-8")
result = svc.dispatch("import_documents", {
"target_category": "notes",
"conflict_strategy": "rename",
"files": [
{"filename": "a.md", "content": b"# A"},
{"filename": "plain.txt", "content": "plain text"},
],
})
assert result["code"] == 200
assert result["payload"]["imported"] == 2
assert (tmp_path / "knowledge/notes/a-1.md").read_text(encoding="utf-8") == "# A"
assert (tmp_path / "knowledge/notes/plain.md").read_text(encoding="utf-8") == "plain text"
assert manager.storage.deleted == []
assert manager.synced == 1
def test_import_documents_skip_overwrite_and_failures(tmp_path):
svc, manager = service(tmp_path)
(tmp_path / "knowledge/notes").mkdir()
existing = tmp_path / "knowledge/notes/a.md"
existing.write_text("old", encoding="utf-8")
skipped = svc.dispatch("import_documents", {
"target_category": "notes",
"conflict_strategy": "skip",
"files": [{"filename": "a.md", "content": b"new"}],
})
assert skipped["payload"]["skipped"] == 1
assert existing.read_text(encoding="utf-8") == "old"
assert manager.synced == 0
overwritten = svc.dispatch("import_documents", {
"target_category": "notes",
"conflict_strategy": "overwrite",
"files": [
{"filename": "a.md", "content": b"new"},
{"filename": "bad.pdf", "content": b"%PDF"},
],
})
assert overwritten["payload"]["imported"] == 1
assert overwritten["payload"]["failed"] == 1
assert existing.read_text(encoding="utf-8") == "new"
assert manager.storage.deleted == ["knowledge/notes/a.md"]
assert manager.synced == 1
def test_import_documents_rejects_large_files_and_batches(tmp_path):
svc, manager = service(tmp_path)
(tmp_path / "knowledge/notes").mkdir()
too_large = svc.dispatch("import_documents", {
"target_category": "notes",
"files": [{"filename": "big.md", "content": b"x" * (svc.MAX_IMPORT_FILE_SIZE + 1)}],
})
assert too_large["payload"]["failed"] == 1
assert too_large["payload"]["results"][0]["reason"] == "file too large"
too_many = svc.dispatch("import_documents", {
"target_category": "notes",
"files": [{"filename": f"{i}.md", "content": b"x"} for i in range(svc.MAX_IMPORT_FILES + 1)],
})
assert too_many["code"] == 403
assert "too many files" in too_many["message"]
assert manager.synced == 0

View File

@@ -47,15 +47,80 @@ def test_knowledge_frontend_management_contract():
js = (root / "channel/web/static/js/console.js").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 'id="knowledge-dialog-overlay"' in html
assert 'id="knowledge-dialog-textarea"' in html
assert 'id="knowledge-document-form"' in html
assert 'id="knowledge-document-path-preview"' in html
assert "function openKnowledgeDialog(" in js assert "function openKnowledgeDialog(" in js
assert "function _knowledgeCategoryPaths(" in js assert "function _knowledgeCategoryPaths(" in js
assert "dispatchKnowledgeAction('create_category'" in js assert "dispatchKnowledgeAction('create_category'" in js
assert "dispatchKnowledgeAction('create_document'" in js
assert "dispatchKnowledgeAction('rename_category'" in js assert "dispatchKnowledgeAction('rename_category'" in js
assert "dispatchKnowledgeAction('delete_category'" in js assert "dispatchKnowledgeAction('delete_category'" in js
assert "dispatchKnowledgeAction('delete_documents'" in js assert "dispatchKnowledgeAction('delete_documents'" in js
assert "dispatchKnowledgeAction('move_documents'" in js assert "dispatchKnowledgeAction('move_documents'" in js
assert 'id="knowledge-import-input"' in html
assert "function createKnowledgeDocument(" in js
assert "function openKnowledgeDocumentEditor(" in js
assert "documentPathPreview.textContent = options.category" in js
assert "options.type === 'document'" in js
assert "input.classList.toggle('hidden', options.type === 'select' || options.type === 'textarea' || options.type === 'document')" in js
assert "function selectKnowledgeImportFiles(" in js
assert "function importKnowledgeDocuments(" in js
assert "function validateKnowledgeImportFiles(" in js
assert "KNOWLEDGE_IMPORT_MAX_FILE_SIZE" in js
assert "fetch('/api/knowledge/import'" in js
assert "initKnowledgeImportDropZone()" in js
knowledge_section = js[js.index("// Knowledge View"):js.index("function _hasFilterMatch")] knowledge_section = js[js.index("// Knowledge View"):js.index("function _hasFilterMatch")]
assert "prompt(" not in knowledge_section assert "prompt(" not in knowledge_section
assert "alert(" not in knowledge_section assert "alert(" not in knowledge_section
assert "if (path === 'index.md' || path === 'log.md') return '';" in knowledge_section assert "if (path === 'index.md' || path === 'log.md') return '';" in knowledge_section
class UploadedFile:
def __init__(self, filename, content):
self.filename = filename
self.value = content
def test_knowledge_import_handler_delegates_to_dispatch(tmp_path):
from channel.web.web_channel import KnowledgeImportHandler
dispatched = {"action": "import_documents", "code": 200, "message": "success",
"payload": {"imported": 2, "skipped": 0, "failed": 0}}
params = {
"target_category": "notes",
"conflict_strategy": "rename",
"files": [UploadedFile("a.md", b"# A"), UploadedFile("b.txt", b"B")],
}
with patch("channel.web.web_channel._require_auth"), \
patch("channel.web.web_channel.web.header"), \
patch("channel.web.web_channel._raw_web_input", return_value=params), \
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(KnowledgeImportHandler().POST())
dispatch.assert_called_once()
action, payload = dispatch.call_args.args
assert action == "import_documents"
assert payload["target_category"] == "notes"
assert payload["conflict_strategy"] == "rename"
assert [f["filename"] for f in payload["files"]] == ["a.md", "b.txt"]
assert response["status"] == "success"
assert response["payload"]["imported"] == 2
def test_knowledge_import_handler_rejects_large_content_length(tmp_path):
from channel.web.web_channel import KnowledgeImportHandler
from agent.knowledge.service import KnowledgeService
with patch("channel.web.web_channel._require_auth"), \
patch("channel.web.web_channel.web.header"), \
patch("channel.web.web_channel.web.ctx") as ctx:
ctx.env = {"CONTENT_LENGTH": str(KnowledgeService.MAX_IMPORT_TOTAL_SIZE + 1)}
response = json.loads(KnowledgeImportHandler().POST())
assert response["status"] == "error"
assert response["code"] == 413
assert response["message"] == "import batch too large"