mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-06-02 00:57:41 +08:00
feat(knowledge): add file list and graph in web channel
This commit is contained in:
@@ -110,6 +110,11 @@
|
||||
<i class="fas fa-brain item-icon text-xs w-5 text-center"></i>
|
||||
<span data-i18n="menu_memory">Memory</span>
|
||||
</a>
|
||||
<a class="sidebar-item flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 hover:bg-white/5 hover:text-neutral-200 text-[14px]"
|
||||
data-view="knowledge">
|
||||
<i class="fas fa-book item-icon text-xs w-5 text-center"></i>
|
||||
<span data-i18n="menu_knowledge">Knowledge</span>
|
||||
</a>
|
||||
<a class="sidebar-item flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 hover:bg-white/5 hover:text-neutral-200 text-[14px]"
|
||||
data-view="channels">
|
||||
<i class="fas fa-tower-broadcast item-icon text-xs w-5 text-center"></i>
|
||||
@@ -558,6 +563,106 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====================================================== -->
|
||||
<!-- VIEW: Knowledge -->
|
||||
<!-- ====================================================== -->
|
||||
<div id="view-knowledge" class="view">
|
||||
<div class="flex-1 overflow-y-auto p-4 md:p-8 lg:p-10">
|
||||
<div class="w-full max-w-[1600px] mx-auto">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4 md:mb-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="knowledge_title">Knowledge</h2>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="knowledge_desc">Browse and explore your knowledge base</p>
|
||||
</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>
|
||||
<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">
|
||||
<i class="fas fa-folder-tree mr-1.5"></i><span data-i18n="knowledge_tab_docs">Documents</span>
|
||||
</button>
|
||||
<button id="knowledge-tab-graph" onclick="switchKnowledgeTab('graph')"
|
||||
class="knowledge-tab px-3 py-1.5 rounded-md text-xs font-medium cursor-pointer transition-colors duration-150">
|
||||
<i class="fas fa-diagram-project mr-1.5"></i><span data-i18n="knowledge_tab_graph">Graph</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div id="knowledge-empty" class="flex flex-col items-center justify-center py-20">
|
||||
<div class="w-16 h-16 rounded-2xl bg-emerald-50 dark:bg-emerald-900/20 flex items-center justify-center mb-4">
|
||||
<i class="fas fa-book text-emerald-400 text-xl"></i>
|
||||
</div>
|
||||
<p class="text-slate-500 dark:text-slate-400 font-medium" data-i18n="knowledge_loading">Loading knowledge base...</p>
|
||||
<p class="text-sm text-slate-400 dark:text-slate-500 mt-1" data-i18n="knowledge_loading_desc">Knowledge pages will be displayed here</p>
|
||||
<div id="knowledge-empty-guide" class="hidden mt-6 max-w-sm text-center">
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mb-4" data-i18n="knowledge_empty_guide">Send documents, links or topics to the agent in chat, and it will automatically organize them into your knowledge base.</p>
|
||||
<button onclick="navigateTo('chat')"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600
|
||||
text-white text-sm font-medium cursor-pointer transition-colors duration-150">
|
||||
<i class="fas fa-message text-xs"></i>
|
||||
<span data-i18n="knowledge_go_chat">Start a conversation</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Documents panel -->
|
||||
<div id="knowledge-panel-docs" class="hidden">
|
||||
<div class="flex flex-col md:flex-row gap-4 md:gap-6" style="min-height: calc(100vh - 220px)">
|
||||
<!-- File tree -->
|
||||
<div id="knowledge-sidebar" class="w-full md:w-72 lg:w-80 flex-shrink-0">
|
||||
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-slate-200 dark:border-white/10">
|
||||
<div class="relative">
|
||||
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-xs"></i>
|
||||
<input id="knowledge-search" type="text" placeholder="Search..."
|
||||
class="w-full pl-8 pr-3 py-1.5 text-xs bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-lg text-slate-700 dark:text-slate-200 placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-primary-400/50"
|
||||
oninput="filterKnowledgeTree(this.value)">
|
||||
</div>
|
||||
</div>
|
||||
<div id="knowledge-tree" class="p-2 overflow-y-auto max-h-[50vh] md:max-h-[calc(100vh-300px)]"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Content viewer -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div id="knowledge-content-placeholder"
|
||||
class="flex flex-col items-center justify-center py-20 text-slate-400 dark:text-slate-500"
|
||||
<i class="fas fa-file-lines text-3xl mb-3 opacity-40"></i>
|
||||
<p class="text-sm" data-i18n="knowledge_select_hint">Select a document to view</p>
|
||||
</div>
|
||||
<div id="knowledge-content-viewer" class="hidden">
|
||||
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 overflow-hidden">
|
||||
<div class="flex items-center gap-3 px-4 md:px-5 py-3 border-b border-slate-200 dark:border-white/10">
|
||||
<button onclick="knowledgeMobileBack()" class="md:hidden p-1 -ml-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 cursor-pointer">
|
||||
<i class="fas fa-arrow-left text-xs"></i>
|
||||
</button>
|
||||
<i class="fas fa-file-lines text-slate-400 text-sm hidden md:inline"></i>
|
||||
<span id="knowledge-viewer-title" class="text-sm font-medium text-slate-700 dark:text-slate-200 truncate"></span>
|
||||
<span id="knowledge-viewer-path" class="text-xs text-slate-400 dark:text-slate-500 ml-auto font-mono truncate hidden md:inline"></span>
|
||||
</div>
|
||||
<div id="knowledge-viewer-body"
|
||||
class="p-4 md:p-5 overflow-y-auto text-sm msg-content text-slate-700 dark:text-slate-200"
|
||||
style="max-height: calc(100vh - 280px)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Graph panel -->
|
||||
<div id="knowledge-panel-graph" class="hidden">
|
||||
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 overflow-hidden">
|
||||
<div id="knowledge-graph-container" class="w-full h-[60vh] md:h-[calc(100vh-220px)]"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====================================================== -->
|
||||
<!-- VIEW: Channels -->
|
||||
<!-- ====================================================== -->
|
||||
@@ -670,6 +775,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
|
||||
<script src="assets/js/console.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -550,3 +550,142 @@
|
||||
.dark .slash-menu-item .desc {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Knowledge View
|
||||
============================================================ */
|
||||
|
||||
/* Tab toggle */
|
||||
.knowledge-tab {
|
||||
color: #64748b;
|
||||
}
|
||||
.knowledge-tab.active {
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
}
|
||||
.dark .knowledge-tab.active {
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* File tree */
|
||||
.knowledge-tree-group {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.knowledge-tree-group-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.knowledge-tree-group-btn:hover {
|
||||
background: rgba(0,0,0,0.04);
|
||||
color: #334155;
|
||||
}
|
||||
.dark .knowledge-tree-group-btn:hover {
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.knowledge-tree-group-btn i.chevron {
|
||||
font-size: 8px;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.knowledge-tree-group.open .chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.knowledge-tree-group-items {
|
||||
display: none;
|
||||
}
|
||||
.knowledge-tree-group.open .knowledge-tree-group-items {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.knowledge-tree-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 8px 5px 24px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.knowledge-tree-file:hover {
|
||||
background: rgba(0,0,0,0.04);
|
||||
color: #334155;
|
||||
}
|
||||
.knowledge-tree-file.active {
|
||||
background: #EDFDF3;
|
||||
color: #228547;
|
||||
}
|
||||
.dark .knowledge-tree-file:hover {
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.dark .knowledge-tree-file.active {
|
||||
background: rgba(74, 190, 110, 0.1);
|
||||
color: #4ABE6E;
|
||||
}
|
||||
|
||||
/* Graph legend */
|
||||
.knowledge-graph-legend {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
z-index: 10;
|
||||
}
|
||||
.knowledge-graph-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.knowledge-graph-legend-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Graph tooltip */
|
||||
.knowledge-graph-tooltip {
|
||||
position: absolute;
|
||||
padding: 6px 10px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
z-index: 20;
|
||||
}
|
||||
.dark .knowledge-graph-tooltip {
|
||||
background: #1A1A1A;
|
||||
border-color: rgba(255,255,255,0.1);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
@@ -15,8 +15,14 @@ const I18N = {
|
||||
console: '控制台',
|
||||
nav_chat: '对话', nav_manage: '管理', nav_monitor: '监控',
|
||||
menu_chat: '对话', menu_config: '配置', menu_skills: '技能',
|
||||
menu_memory: '记忆', menu_channels: '通道', menu_tasks: '定时',
|
||||
menu_memory: '记忆', menu_knowledge: '知识', menu_channels: '通道', menu_tasks: '定时',
|
||||
menu_logs: '日志',
|
||||
knowledge_title: '知识库', knowledge_desc: '浏览和探索你的知识库',
|
||||
knowledge_tab_docs: '文档', knowledge_tab_graph: '图谱',
|
||||
knowledge_loading: '加载知识库中...', knowledge_loading_desc: '知识页面将显示在这里',
|
||||
knowledge_select_hint: '选择一个文档查看', knowledge_empty_hint: '暂无知识页面',
|
||||
knowledge_empty_guide: '在对话中发送文档、链接或主题给 Agent,它会自动整理到你的知识库中。',
|
||||
knowledge_go_chat: '开始对话',
|
||||
welcome_subtitle: '我可以帮你解答问题、管理计算机、创造和执行技能,并通过长期记忆<br>不断成长',
|
||||
example_sys_title: '系统管理', example_sys_text: '帮我查看工作空间里有哪些文件',
|
||||
example_task_title: '技能系统', example_task_text: '查看所有支持的工具和技能',
|
||||
@@ -70,8 +76,14 @@ const I18N = {
|
||||
console: 'Console',
|
||||
nav_chat: 'Chat', nav_manage: 'Management', nav_monitor: 'Monitor',
|
||||
menu_chat: 'Chat', menu_config: 'Config', menu_skills: 'Skills',
|
||||
menu_memory: 'Memory', menu_channels: 'Channels', menu_tasks: 'Tasks',
|
||||
menu_memory: 'Memory', menu_knowledge: 'Knowledge', menu_channels: 'Channels', menu_tasks: 'Tasks',
|
||||
menu_logs: 'Logs',
|
||||
knowledge_title: 'Knowledge', knowledge_desc: 'Browse and explore your knowledge base',
|
||||
knowledge_tab_docs: 'Documents', knowledge_tab_graph: 'Graph',
|
||||
knowledge_loading: 'Loading knowledge base...', knowledge_loading_desc: 'Knowledge pages will be displayed here',
|
||||
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',
|
||||
welcome_subtitle: 'I can help you answer questions, manage your computer, create and execute skills, and keep growing through <br> long-term memory.',
|
||||
example_sys_title: 'System', example_sys_text: 'Show me the files in the workspace',
|
||||
example_task_title: 'Skills', example_task_text: 'Show current tools and skills',
|
||||
@@ -182,6 +194,7 @@ const VIEW_META = {
|
||||
config: { group: 'nav_manage', page: 'menu_config' },
|
||||
skills: { group: 'nav_manage', page: 'menu_skills' },
|
||||
memory: { group: 'nav_manage', page: 'menu_memory' },
|
||||
knowledge:{ group: 'nav_manage', page: 'menu_knowledge' },
|
||||
channels: { group: 'nav_manage', page: 'menu_channels' },
|
||||
tasks: { group: 'nav_manage', page: 'menu_tasks' },
|
||||
logs: { group: 'nav_monitor', page: 'menu_logs' },
|
||||
@@ -2888,11 +2901,334 @@ navigateTo = function(viewId) {
|
||||
document.getElementById('memory-panel-list').classList.remove('hidden');
|
||||
loadMemoryView(1);
|
||||
}
|
||||
else if (viewId === 'knowledge') loadKnowledgeView();
|
||||
else if (viewId === 'channels') loadChannelsView();
|
||||
else if (viewId === 'tasks') loadTasksView();
|
||||
else if (viewId === 'logs') startLogStream();
|
||||
};
|
||||
|
||||
// =====================================================================
|
||||
// Knowledge View
|
||||
// =====================================================================
|
||||
let _knowledgeTreeData = [];
|
||||
let _knowledgeCurrentFile = null;
|
||||
let _knowledgeGraphLoaded = false;
|
||||
|
||||
function loadKnowledgeView() {
|
||||
// Reset to docs tab
|
||||
switchKnowledgeTab('docs');
|
||||
_knowledgeGraphLoaded = false;
|
||||
_knowledgeCurrentFile = null;
|
||||
|
||||
fetch('/api/knowledge/list').then(r => r.json()).then(data => {
|
||||
if (data.status !== 'success') return;
|
||||
|
||||
const emptyEl = document.getElementById('knowledge-empty');
|
||||
const docsPanel = document.getElementById('knowledge-panel-docs');
|
||||
const statsEl = document.getElementById('knowledge-stats');
|
||||
|
||||
const tree = data.tree || [];
|
||||
_knowledgeTreeData = tree;
|
||||
const stats = data.stats || {};
|
||||
const totalPages = stats.pages || 0;
|
||||
const sizeStr = stats.size < 1024 ? stats.size + ' B' : (stats.size / 1024).toFixed(1) + ' KB';
|
||||
|
||||
statsEl.textContent = totalPages + ' pages · ' + sizeStr;
|
||||
|
||||
if (totalPages === 0) {
|
||||
emptyEl.querySelector('p').textContent = t('knowledge_empty_hint');
|
||||
const guideEl = document.getElementById('knowledge-empty-guide');
|
||||
if (guideEl) guideEl.classList.remove('hidden');
|
||||
emptyEl.classList.remove('hidden');
|
||||
docsPanel.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
emptyEl.classList.add('hidden');
|
||||
docsPanel.classList.remove('hidden');
|
||||
|
||||
renderKnowledgeTree(tree);
|
||||
|
||||
// Auto-select the first file (desktop only)
|
||||
if (window.innerWidth >= 768) {
|
||||
const firstGroup = tree.find(g => g.files && g.files.length > 0);
|
||||
if (firstGroup) {
|
||||
const firstFile = firstGroup.files[0];
|
||||
openKnowledgeFile(firstGroup.dir + '/' + firstFile.name, firstFile.title);
|
||||
}
|
||||
} else {
|
||||
document.getElementById('knowledge-content-placeholder').classList.add('hidden');
|
||||
document.getElementById('knowledge-content-viewer').classList.add('hidden');
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function renderKnowledgeTree(tree, filter) {
|
||||
const container = document.getElementById('knowledge-tree');
|
||||
container.innerHTML = '';
|
||||
const lowerFilter = (filter || '').toLowerCase();
|
||||
|
||||
tree.forEach(group => {
|
||||
const files = group.files.filter(f =>
|
||||
!lowerFilter || f.title.toLowerCase().includes(lowerFilter) || f.name.toLowerCase().includes(lowerFilter)
|
||||
);
|
||||
if (files.length === 0 && lowerFilter) return;
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'knowledge-tree-group open';
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'knowledge-tree-group-btn';
|
||||
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">${files.length}</span>`;
|
||||
btn.onclick = () => div.classList.toggle('open');
|
||||
div.appendChild(btn);
|
||||
|
||||
const items = document.createElement('div');
|
||||
items.className = 'knowledge-tree-group-items';
|
||||
files.forEach(f => {
|
||||
const fbtn = document.createElement('button');
|
||||
const fpath = group.dir + '/' + f.name;
|
||||
fbtn.className = 'knowledge-tree-file' + (_knowledgeCurrentFile === fpath ? ' active' : '');
|
||||
fbtn.dataset.path = fpath;
|
||||
fbtn.innerHTML = `<i class="fas fa-file-lines text-[10px] text-slate-400"></i><span class="truncate">${escapeHtml(f.title)}</span>`;
|
||||
fbtn.onclick = () => openKnowledgeFile(fpath, f.title);
|
||||
items.appendChild(fbtn);
|
||||
});
|
||||
div.appendChild(items);
|
||||
container.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function filterKnowledgeTree(query) {
|
||||
renderKnowledgeTree(_knowledgeTreeData, query);
|
||||
}
|
||||
|
||||
function openKnowledgeFile(path, title) {
|
||||
_knowledgeCurrentFile = path;
|
||||
// Update active state in tree via data-path
|
||||
document.querySelectorAll('.knowledge-tree-file').forEach(el => {
|
||||
el.classList.toggle('active', el.dataset.path === path);
|
||||
});
|
||||
|
||||
// Immediately hide placeholder
|
||||
document.getElementById('knowledge-content-placeholder').classList.add('hidden');
|
||||
|
||||
fetch(`/api/knowledge/read?path=${encodeURIComponent(path)}`).then(r => r.json()).then(data => {
|
||||
if (data.status !== 'success') return;
|
||||
const viewer = document.getElementById('knowledge-content-viewer');
|
||||
document.getElementById('knowledge-viewer-title').textContent = title;
|
||||
document.getElementById('knowledge-viewer-path').textContent = path;
|
||||
document.getElementById('knowledge-viewer-body').innerHTML = renderMarkdown(data.content || '');
|
||||
viewer.classList.remove('hidden');
|
||||
applyHighlighting(viewer);
|
||||
|
||||
// Mobile: hide sidebar, show content
|
||||
if (window.innerWidth < 768) {
|
||||
document.getElementById('knowledge-sidebar').classList.add('hidden');
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function knowledgeMobileBack() {
|
||||
document.getElementById('knowledge-sidebar').classList.remove('hidden');
|
||||
document.getElementById('knowledge-content-viewer').classList.add('hidden');
|
||||
}
|
||||
|
||||
function switchKnowledgeTab(tab) {
|
||||
document.querySelectorAll('.knowledge-tab').forEach(el => el.classList.remove('active'));
|
||||
document.getElementById('knowledge-tab-' + tab).classList.add('active');
|
||||
|
||||
const docsPanel = document.getElementById('knowledge-panel-docs');
|
||||
const graphPanel = document.getElementById('knowledge-panel-graph');
|
||||
|
||||
if (tab === 'docs') {
|
||||
docsPanel.classList.remove('hidden');
|
||||
graphPanel.classList.add('hidden');
|
||||
} else {
|
||||
docsPanel.classList.add('hidden');
|
||||
graphPanel.classList.remove('hidden');
|
||||
if (!_knowledgeGraphLoaded) {
|
||||
loadKnowledgeGraph();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadKnowledgeGraph() {
|
||||
_knowledgeGraphLoaded = true;
|
||||
const container = document.getElementById('knowledge-graph-container');
|
||||
container.innerHTML = '';
|
||||
|
||||
fetch('/api/knowledge/graph').then(r => r.json()).then(data => {
|
||||
const nodes = data.nodes || [];
|
||||
const links = data.links || [];
|
||||
if (nodes.length === 0) {
|
||||
container.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-slate-400"><i class="fas fa-diagram-project text-3xl mb-3 opacity-40"></i><p class="text-sm">${t('knowledge_empty_hint')}</p></div>`;
|
||||
return;
|
||||
}
|
||||
renderKnowledgeGraph(container, nodes, links);
|
||||
}).catch(() => {
|
||||
container.innerHTML = '<div class="flex items-center justify-center h-full text-slate-400 text-sm">Failed to load graph</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderKnowledgeGraph(container, nodes, links) {
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight || 600;
|
||||
|
||||
const categories = [...new Set(nodes.map(n => n.category))];
|
||||
const colorScale = d3.scaleOrdinal(d3.schemeTableau10).domain(categories);
|
||||
|
||||
// Connection count for sizing
|
||||
const connCount = {};
|
||||
nodes.forEach(n => connCount[n.id] = 0);
|
||||
links.forEach(l => {
|
||||
connCount[l.source] = (connCount[l.source] || 0) + 1;
|
||||
connCount[l.target] = (connCount[l.target] || 0) + 1;
|
||||
});
|
||||
|
||||
const svg = d3.select(container)
|
||||
.append('svg')
|
||||
.attr('width', width)
|
||||
.attr('height', height);
|
||||
|
||||
const g = svg.append('g');
|
||||
|
||||
// Zoom with adaptive label visibility
|
||||
let currentZoomScale = 1;
|
||||
const zoom = d3.zoom()
|
||||
.scaleExtent([0.2, 5])
|
||||
.on('zoom', (event) => {
|
||||
g.attr('transform', event.transform);
|
||||
currentZoomScale = event.transform.k;
|
||||
updateLabelVisibility();
|
||||
});
|
||||
svg.call(zoom);
|
||||
|
||||
function updateLabelVisibility() {
|
||||
if (!label) return;
|
||||
if (currentZoomScale < 0.8) {
|
||||
label.attr('opacity', 0);
|
||||
} else {
|
||||
const baseFontSize = Math.min(12, 10 / Math.max(currentZoomScale * 0.7, 0.5));
|
||||
label.attr('opacity', 1).attr('font-size', baseFontSize);
|
||||
}
|
||||
}
|
||||
|
||||
const simulation = d3.forceSimulation(nodes)
|
||||
.force('link', d3.forceLink(links).id(d => d.id).distance(90))
|
||||
.force('charge', d3.forceManyBody().strength(-180))
|
||||
.force('center', d3.forceCenter(width / 2, height / 2))
|
||||
.force('x', d3.forceX(width / 2).strength(0.06))
|
||||
.force('y', d3.forceY(height / 2).strength(0.06))
|
||||
.force('collision', d3.forceCollide().radius(d => getNodeRadius(d) + 30));
|
||||
|
||||
function getNodeRadius(d) {
|
||||
return Math.max(5, Math.min(16, 5 + (connCount[d.id] || 0) * 2));
|
||||
}
|
||||
|
||||
const link = g.append('g')
|
||||
.selectAll('line')
|
||||
.data(links)
|
||||
.join('line')
|
||||
.attr('stroke', '#94a3b8')
|
||||
.attr('stroke-opacity', 0.3)
|
||||
.attr('stroke-width', 1);
|
||||
|
||||
const node = g.append('g')
|
||||
.selectAll('circle')
|
||||
.data(nodes)
|
||||
.join('circle')
|
||||
.attr('r', d => getNodeRadius(d))
|
||||
.attr('fill', d => colorScale(d.category))
|
||||
.attr('stroke', '#fff')
|
||||
.attr('stroke-width', 1.5)
|
||||
.style('cursor', 'pointer')
|
||||
.call(d3.drag()
|
||||
.on('start', (event, d) => { if (!event.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
|
||||
.on('drag', (event, d) => { d.fx = event.x; d.fy = event.y; })
|
||||
.on('end', (event, d) => { if (!event.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; })
|
||||
);
|
||||
|
||||
const label = g.append('g')
|
||||
.selectAll('text')
|
||||
.data(nodes)
|
||||
.join('text')
|
||||
.text(d => d.label.length > 15 ? d.label.slice(0, 14) + '…' : d.label)
|
||||
.attr('font-size', 9)
|
||||
.attr('dx', d => getNodeRadius(d) + 4)
|
||||
.attr('dy', 3)
|
||||
.attr('fill', '#64748b')
|
||||
.style('pointer-events', 'none');
|
||||
|
||||
// Tooltip
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.className = 'knowledge-graph-tooltip';
|
||||
container.style.position = 'relative';
|
||||
container.appendChild(tooltip);
|
||||
|
||||
node.on('mouseover', (event, d) => {
|
||||
tooltip.textContent = d.label + ' (' + d.category + ')';
|
||||
tooltip.style.opacity = '1';
|
||||
tooltip.style.left = (event.offsetX + 12) + 'px';
|
||||
tooltip.style.top = (event.offsetY - 8) + 'px';
|
||||
// Highlight connections
|
||||
link.attr('stroke-opacity', l => (l.source.id === d.id || l.target.id === d.id) ? 0.8 : 0.1);
|
||||
node.attr('opacity', n => n.id === d.id || links.some(l => (l.source.id === d.id && l.target.id === n.id) || (l.target.id === d.id && l.source.id === n.id)) ? 1 : 0.2);
|
||||
label.attr('opacity', n => n.id === d.id || links.some(l => (l.source.id === d.id && l.target.id === n.id) || (l.target.id === d.id && l.source.id === n.id)) ? 1 : 0.1);
|
||||
}).on('mousemove', (event) => {
|
||||
tooltip.style.left = (event.offsetX + 12) + 'px';
|
||||
tooltip.style.top = (event.offsetY - 8) + 'px';
|
||||
}).on('mouseout', () => {
|
||||
tooltip.style.opacity = '0';
|
||||
link.attr('stroke-opacity', 0.3);
|
||||
node.attr('opacity', 1);
|
||||
label.attr('opacity', 1);
|
||||
}).on('click', (event, d) => {
|
||||
// Switch to docs tab and open the file
|
||||
switchKnowledgeTab('docs');
|
||||
openKnowledgeFile(d.id, d.label);
|
||||
});
|
||||
|
||||
simulation.on('tick', () => {
|
||||
link.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
|
||||
.attr('x2', d => d.target.x).attr('y2', d => d.target.y);
|
||||
node.attr('cx', d => d.x).attr('cy', d => d.y);
|
||||
label.attr('x', d => d.x).attr('y', d => d.y);
|
||||
});
|
||||
|
||||
// Auto fit-to-view when simulation settles
|
||||
simulation.on('end', () => {
|
||||
const pad = 16;
|
||||
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
||||
nodes.forEach(n => {
|
||||
if (n.x < x0) x0 = n.x;
|
||||
if (n.y < y0) y0 = n.y;
|
||||
if (n.x > x1) x1 = n.x;
|
||||
if (n.y > y1) y1 = n.y;
|
||||
});
|
||||
const bw = x1 - x0 + pad * 2;
|
||||
const bh = y1 - y0 + pad * 2;
|
||||
if (bw > 0 && bh > 0) {
|
||||
const scale = Math.min(width / bw, height / bh, 4);
|
||||
const tx = width / 2 - (x0 + x1) / 2 * scale;
|
||||
const ty = height / 2 - (y0 + y1) / 2 * scale;
|
||||
svg.transition().duration(500).call(
|
||||
zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Legend
|
||||
const legendDiv = document.createElement('div');
|
||||
legendDiv.className = 'knowledge-graph-legend';
|
||||
categories.forEach(cat => {
|
||||
const item = document.createElement('span');
|
||||
item.className = 'knowledge-graph-legend-item';
|
||||
item.innerHTML = `<span class="knowledge-graph-legend-dot" style="background:${colorScale(cat)}"></span>${escapeHtml(cat)}`;
|
||||
legendDiv.appendChild(item);
|
||||
});
|
||||
container.appendChild(legendDiv);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Initialization
|
||||
// =====================================================================
|
||||
|
||||
@@ -445,6 +445,9 @@ class WebChannel(ChatChannel):
|
||||
'/api/skills', 'SkillsHandler',
|
||||
'/api/memory', 'MemoryHandler',
|
||||
'/api/memory/content', 'MemoryContentHandler',
|
||||
'/api/knowledge/list', 'KnowledgeListHandler',
|
||||
'/api/knowledge/read', 'KnowledgeReadHandler',
|
||||
'/api/knowledge/graph', 'KnowledgeGraphHandler',
|
||||
'/api/scheduler', 'SchedulerHandler',
|
||||
'/api/history', 'HistoryHandler',
|
||||
'/api/logs', 'LogsHandler',
|
||||
@@ -1531,6 +1534,143 @@ class AssetsHandler:
|
||||
raise web.notfound()
|
||||
|
||||
|
||||
class KnowledgeListHandler:
|
||||
"""Return the knowledge directory tree as JSON."""
|
||||
|
||||
def GET(self):
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
try:
|
||||
workspace_root = _get_workspace_root()
|
||||
knowledge_dir = os.path.join(workspace_root, "knowledge")
|
||||
if not os.path.isdir(knowledge_dir):
|
||||
return json.dumps({"status": "success", "tree": [], "stats": {"pages": 0, "size": 0}})
|
||||
|
||||
tree = []
|
||||
total_files = 0
|
||||
total_bytes = 0
|
||||
for name in sorted(os.listdir(knowledge_dir)):
|
||||
full = os.path.join(knowledge_dir, name)
|
||||
if not os.path.isdir(full) or name.startswith("."):
|
||||
continue
|
||||
files = []
|
||||
for fname in sorted(os.listdir(full)):
|
||||
if fname.endswith(".md") and not fname.startswith("."):
|
||||
fpath = os.path.join(full, fname)
|
||||
size = os.path.getsize(fpath)
|
||||
total_files += 1
|
||||
total_bytes += size
|
||||
title = fname.replace(".md", "")
|
||||
try:
|
||||
with open(fpath, "r", encoding="utf-8") as f:
|
||||
first_line = f.readline().strip()
|
||||
if first_line.startswith("# "):
|
||||
title = first_line[2:].strip()
|
||||
except Exception:
|
||||
pass
|
||||
files.append({"name": fname, "title": title, "size": size})
|
||||
tree.append({"dir": name, "files": files})
|
||||
|
||||
return json.dumps({
|
||||
"status": "success",
|
||||
"tree": tree,
|
||||
"stats": {"pages": total_files, "size": total_bytes},
|
||||
"enabled": conf().get("knowledge", True),
|
||||
}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"[WebChannel] Knowledge list error: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
class KnowledgeReadHandler:
|
||||
"""Read a single knowledge markdown file."""
|
||||
|
||||
def GET(self):
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
try:
|
||||
params = web.input(path='')
|
||||
rel_path = params.path
|
||||
if not rel_path or ".." in rel_path:
|
||||
return json.dumps({"status": "error", "message": "invalid path"})
|
||||
|
||||
workspace_root = _get_workspace_root()
|
||||
full_path = os.path.join(workspace_root, "knowledge", rel_path)
|
||||
full_path = os.path.normpath(full_path)
|
||||
knowledge_dir = os.path.normpath(os.path.join(workspace_root, "knowledge"))
|
||||
if not full_path.startswith(knowledge_dir):
|
||||
return json.dumps({"status": "error", "message": "path outside knowledge dir"})
|
||||
|
||||
if not os.path.isfile(full_path):
|
||||
return json.dumps({"status": "error", "message": "file not found"})
|
||||
|
||||
with open(full_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
return json.dumps({"status": "success", "content": content, "path": rel_path}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"[WebChannel] Knowledge read error: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
class KnowledgeGraphHandler:
|
||||
"""Return nodes and links for the knowledge graph visualization."""
|
||||
|
||||
def GET(self):
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
workspace_root = _get_workspace_root()
|
||||
knowledge_dir = Path(workspace_root) / "knowledge"
|
||||
if not knowledge_dir.is_dir():
|
||||
return json.dumps({"nodes": [], "links": []})
|
||||
|
||||
nodes = {}
|
||||
links = []
|
||||
link_re = re.compile(r'\[([^\]]*)\]\(([^)]+\.md)\)')
|
||||
|
||||
for md_file in knowledge_dir.rglob("*.md"):
|
||||
rel = str(md_file.relative_to(knowledge_dir))
|
||||
if rel in ("index.md", "log.md"):
|
||||
continue
|
||||
parts = rel.split("/")
|
||||
category = parts[0] if len(parts) > 1 else "root"
|
||||
title = md_file.stem.replace("-", " ").title()
|
||||
try:
|
||||
content = md_file.read_text(encoding="utf-8")
|
||||
first_line = content.strip().split("\n")[0]
|
||||
if first_line.startswith("# "):
|
||||
title = first_line[2:].strip()
|
||||
except Exception:
|
||||
pass
|
||||
nodes[rel] = {"id": rel, "label": title, "category": category}
|
||||
try:
|
||||
content = md_file.read_text(encoding="utf-8")
|
||||
for _, link_target in link_re.findall(content):
|
||||
resolved = (md_file.parent / link_target).resolve()
|
||||
try:
|
||||
target_rel = str(resolved.relative_to(knowledge_dir))
|
||||
except ValueError:
|
||||
continue
|
||||
if target_rel != rel:
|
||||
links.append({"source": rel, "target": target_rel})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
valid_ids = set(nodes.keys())
|
||||
links = [l for l in links if l["source"] in valid_ids and l["target"] in valid_ids]
|
||||
seen = set()
|
||||
deduped = []
|
||||
for l in links:
|
||||
key = tuple(sorted([l["source"], l["target"]]))
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
deduped.append(l)
|
||||
|
||||
return json.dumps({
|
||||
"nodes": list(nodes.values()),
|
||||
"links": deduped,
|
||||
}, ensure_ascii=False)
|
||||
|
||||
|
||||
class VersionHandler:
|
||||
def GET(self):
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
|
||||
Reference in New Issue
Block a user