Merge pull request #2893 from yangziyu-hhh/master

feat(knowledge): add category and document management
This commit is contained in:
zhayujie
2026-06-16 18:52:43 +08:00
committed by GitHub
8 changed files with 740 additions and 18 deletions

View File

@@ -840,6 +840,11 @@
</div>
<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-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">
<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">
@@ -1076,6 +1081,34 @@
</div><!-- /main-content -->
</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 -->
<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

View File

@@ -1211,6 +1211,34 @@
background: #EDFDF3;
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 {
background: rgba(255,255,255,0.06);
color: #e2e8f0;

View File

@@ -93,6 +93,7 @@ const I18N = {
knowledge_select_hint: '选择一个文档查看', knowledge_empty_hint: '暂无知识页面',
knowledge_empty_guide: '在对话中发送文档、链接或主题给 Agent它会自动整理到你的知识库中。',
knowledge_go_chat: '开始对话',
knowledge_new_category: '新建分类',
welcome_subtitle: '我可以帮你解答问题、管理计算机、创造和执行技能,并通过<br>长期记忆和知识库不断成长',
example_sys_title: '系统管理', example_sys_text: '查看工作空间里有哪些文件',
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_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',
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_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;
if (totalPages === 0) {
if (totalPages === 0 && tree.length === 0 && rootFiles.length === 0) {
emptyEl.querySelector('p').textContent = t('knowledge_empty_hint');
const guideEl = document.getElementById('knowledge-empty-guide');
if (guideEl) guideEl.classList.remove('hidden');
@@ -7737,7 +7739,7 @@ function renderKnowledgeTree(tree, rootFilesOrFilter, filter) {
const fbtn = document.createElement('button');
fbtn.className = 'knowledge-tree-file' + (_knowledgeCurrentFile === f.name ? ' active' : '');
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);
container.appendChild(fbtn);
});
@@ -7762,7 +7764,7 @@ function _renderKnowledgeGroups(container, groups, parentPath, lowerFilter, dept
const btn = document.createElement('button');
btn.className = 'knowledge-tree-group-btn';
btn.style.paddingLeft = (8 + indent) + 'px';
btn.innerHTML = `<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');
div.appendChild(btn);
@@ -7774,7 +7776,7 @@ function _renderKnowledgeGroups(container, groups, parentPath, lowerFilter, dept
fbtn.className = 'knowledge-tree-file' + (_knowledgeCurrentFile === fpath ? ' active' : '');
fbtn.dataset.path = fpath;
fbtn.style.paddingLeft = (24 + indent) + 'px';
fbtn.innerHTML = `<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);
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, '&quot;');
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, '&quot;');
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) {
for (const g of groups) {
for (const f of (g.files || [])) {

View File

@@ -1154,6 +1154,7 @@ class WebChannel(ChatChannel):
'/api/knowledge/list', 'KnowledgeListHandler',
'/api/knowledge/read', 'KnowledgeReadHandler',
'/api/knowledge/graph', 'KnowledgeGraphHandler',
'/api/knowledge/action', 'KnowledgeActionHandler',
'/api/scheduler', 'SchedulerHandler',
'/api/scheduler/toggle', 'SchedulerToggleHandler',
'/api/scheduler/update', 'SchedulerUpdateHandler',
@@ -4627,6 +4628,25 @@ class KnowledgeGraphHandler:
return json.dumps({"nodes": [], "links": []})
class KnowledgeActionHandler:
def POST(self):
_require_auth()
web.header('Content-Type', 'application/json; charset=utf-8')
try:
body = json.loads(web.data() or b"{}")
action = body.get("action", "")
payload = body.get("payload") or {}
from agent.knowledge.service import KnowledgeService
result = KnowledgeService(_get_workspace_root()).dispatch(action, payload)
return json.dumps({
"status": "success" if result["code"] < 300 else "error",
**result,
}, ensure_ascii=False)
except Exception as e:
logger.error(f"[WebChannel] Knowledge action error: {e}")
return json.dumps({"status": "error", "code": 500, "message": str(e), "payload": None})
class VersionHandler:
def GET(self):
web.header('Content-Type', 'application/json; charset=utf-8')