@@ -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">
+
+
diff --git a/channel/web/static/css/console.css b/channel/web/static/css/console.css
index a618265d..c24b3dd5 100644
--- a/channel/web/static/css/console.css
+++ b/channel/web/static/css/console.css
@@ -1247,6 +1247,18 @@
background: rgba(74, 190, 110, 0.1);
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 */
.knowledge-graph-legend {
diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js
index 09f09e4d..cf8a93ef 100644
--- a/channel/web/static/js/console.js
+++ b/channel/web/static/js/console.js
@@ -94,6 +94,8 @@ const I18N = {
knowledge_empty_guide: '在对话中发送文档、链接或主题给 Agent,它会自动整理到你的知识库中。',
knowledge_go_chat: '开始对话',
knowledge_new_category: '新建分类',
+ knowledge_new_document: '新建文档',
+ knowledge_import_documents: '导入文档',
welcome_subtitle: '我可以帮你解答问题、管理计算机、创造和执行技能,并通过
长期记忆和知识库不断成长',
example_sys_title: '系统管理', example_sys_text: '查看工作空间里有哪些文件',
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_go_chat: 'Start a conversation',
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
long-term memory and a personal knowledge base.',
example_sys_title: 'System', example_sys_text: 'Show me the files in the workspace',
example_task_title: 'Scheduler', example_task_text: 'Remind me to check the server in 5 minutes',
@@ -7541,6 +7545,9 @@ let _knowledgeTreeData = [];
let _knowledgeRootFiles = [];
let _knowledgeCurrentFile = null;
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() {
// Reset to docs tab
@@ -7550,6 +7557,7 @@ function loadKnowledgeView() {
fetch('/api/knowledge/list').then(r => r.json()).then(data => {
if (data.status !== 'success') return;
+ initKnowledgeImportDropZone();
const emptyEl = document.getElementById('knowledge-empty');
const docsPanel = document.getElementById('knowledge-panel-docs');
@@ -7713,12 +7721,14 @@ function _knowledgeResultMessage(action, payload) {
return action === 'create_category' ? 'Category created' :
action === 'rename_category' ? 'Category renamed' :
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` :
`${payload?.deleted || 0} document deleted`;
}
return action === 'create_category' ? '分类已创建' :
action === 'rename_category' ? '分类已重命名' :
action === 'delete_category' ? '分类已删除' :
+ action === 'import_documents' ? `导入 ${payload?.imported || 0} 个,跳过 ${payload?.skipped || 0} 个,失败 ${payload?.failed || 0} 个` :
action === 'move_documents' ? `已移动 ${payload?.moved || 0} 个文档` :
`已删除 ${payload?.deleted || 0} 个文档`;
}
@@ -7734,8 +7744,15 @@ function _knowledgeCategoryPaths(groups, parent = '') {
function openKnowledgeDialog(options) {
const overlay = document.getElementById('knowledge-dialog-overlay');
+ const card = document.getElementById('knowledge-dialog-card');
const input = document.getElementById('knowledge-dialog-input');
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 cancel = document.getElementById('knowledge-dialog-cancel');
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-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');
+ 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');
+ textarea.classList.toggle('hidden', options.type !== 'textarea');
+ documentForm.classList.toggle('hidden', options.type !== 'document');
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') {
select.innerHTML = (options.choices || []).map(value => `
`).join('');
}
@@ -7756,7 +7793,13 @@ function openKnowledgeDialog(options) {
const close = () => overlay.classList.add('hidden');
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') : '');
if (error) {
const errorEl = document.getElementById('knowledge-dialog-error');
@@ -7774,7 +7817,7 @@ function openKnowledgeDialog(options) {
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);
+ setTimeout(() => (options.type === 'select' ? select : (options.type === 'textarea' ? textarea : (options.type === 'document' ? documentFilename : input))).focus(), 0);
}
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 和 TXT,TXT 会转成 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) {
openKnowledgeDialog({
title: currentLang === 'zh' ? '重命名分类' : 'Rename category',
diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py
index 1d07b0e7..04a0bf70 100644
--- a/channel/web/web_channel.py
+++ b/channel/web/web_channel.py
@@ -181,6 +181,29 @@ def _read_uploaded_file_bytes(file_obj) -> bytes:
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():
"""Return unprocessed multipart form data when web.py exposes rawinput."""
rawinput = getattr(getattr(web, "webapi", None), "rawinput", None)
@@ -1155,6 +1178,7 @@ class WebChannel(ChatChannel):
'/api/knowledge/read', 'KnowledgeReadHandler',
'/api/knowledge/graph', 'KnowledgeGraphHandler',
'/api/knowledge/action', 'KnowledgeActionHandler',
+ '/api/knowledge/import', 'KnowledgeImportHandler',
'/api/scheduler', 'SchedulerHandler',
'/api/sessions', 'SessionsHandler',
'/api/sessions/(.*)/generate_title', 'SessionTitleHandler',
@@ -4509,6 +4533,71 @@ class KnowledgeActionHandler:
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:
def GET(self):
web.header('Content-Type', 'application/json; charset=utf-8')
diff --git a/tests/test_knowledge_service.py b/tests/test_knowledge_service.py
index 96ef447b..d1bc9a94 100644
--- a/tests/test_knowledge_service.py
+++ b/tests/test_knowledge_service.py
@@ -209,3 +209,89 @@ def test_move_does_not_overwrite_target_created_concurrently(tmp_path):
assert source.read_text(encoding="utf-8") == "source"
assert target.read_text(encoding="utf-8") == "concurrent"
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
diff --git a/tests/test_knowledge_web.py b/tests/test_knowledge_web.py
index 92991b98..3dd26092 100644
--- a/tests/test_knowledge_web.py
+++ b/tests/test_knowledge_web.py
@@ -47,15 +47,80 @@ def test_knowledge_frontend_management_contract():
js = (root / "channel/web/static/js/console.js").read_text(encoding="utf-8")
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 _knowledgeCategoryPaths(" in js
assert "dispatchKnowledgeAction('create_category'" in js
+ assert "dispatchKnowledgeAction('create_document'" 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
+ 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")]
assert "prompt(" not in knowledge_section
assert "alert(" not 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"