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>
|
<i class="fas fa-brain item-icon text-xs w-5 text-center"></i>
|
||||||
<span data-i18n="menu_memory">Memory</span>
|
<span data-i18n="menu_memory">Memory</span>
|
||||||
</a>
|
</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]"
|
<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">
|
data-view="channels">
|
||||||
<i class="fas fa-tower-broadcast item-icon text-xs w-5 text-center"></i>
|
<i class="fas fa-tower-broadcast item-icon text-xs w-5 text-center"></i>
|
||||||
@@ -558,6 +563,106 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- VIEW: Channels -->
|
||||||
<!-- ====================================================== -->
|
<!-- ====================================================== -->
|
||||||
@@ -670,6 +775,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
|
||||||
<script src="assets/js/console.js"></script>
|
<script src="assets/js/console.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -550,3 +550,142 @@
|
|||||||
.dark .slash-menu-item .desc {
|
.dark .slash-menu-item .desc {
|
||||||
color: #64748b;
|
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: '控制台',
|
console: '控制台',
|
||||||
nav_chat: '对话', nav_manage: '管理', nav_monitor: '监控',
|
nav_chat: '对话', nav_manage: '管理', nav_monitor: '监控',
|
||||||
menu_chat: '对话', menu_config: '配置', menu_skills: '技能',
|
menu_chat: '对话', menu_config: '配置', menu_skills: '技能',
|
||||||
menu_memory: '记忆', menu_channels: '通道', menu_tasks: '定时',
|
menu_memory: '记忆', menu_knowledge: '知识', menu_channels: '通道', menu_tasks: '定时',
|
||||||
menu_logs: '日志',
|
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>不断成长',
|
welcome_subtitle: '我可以帮你解答问题、管理计算机、创造和执行技能,并通过长期记忆<br>不断成长',
|
||||||
example_sys_title: '系统管理', example_sys_text: '帮我查看工作空间里有哪些文件',
|
example_sys_title: '系统管理', example_sys_text: '帮我查看工作空间里有哪些文件',
|
||||||
example_task_title: '技能系统', example_task_text: '查看所有支持的工具和技能',
|
example_task_title: '技能系统', example_task_text: '查看所有支持的工具和技能',
|
||||||
@@ -70,8 +76,14 @@ const I18N = {
|
|||||||
console: 'Console',
|
console: 'Console',
|
||||||
nav_chat: 'Chat', nav_manage: 'Management', nav_monitor: 'Monitor',
|
nav_chat: 'Chat', nav_manage: 'Management', nav_monitor: 'Monitor',
|
||||||
menu_chat: 'Chat', menu_config: 'Config', menu_skills: 'Skills',
|
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',
|
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.',
|
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_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',
|
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' },
|
config: { group: 'nav_manage', page: 'menu_config' },
|
||||||
skills: { group: 'nav_manage', page: 'menu_skills' },
|
skills: { group: 'nav_manage', page: 'menu_skills' },
|
||||||
memory: { group: 'nav_manage', page: 'menu_memory' },
|
memory: { group: 'nav_manage', page: 'menu_memory' },
|
||||||
|
knowledge:{ group: 'nav_manage', page: 'menu_knowledge' },
|
||||||
channels: { group: 'nav_manage', page: 'menu_channels' },
|
channels: { group: 'nav_manage', page: 'menu_channels' },
|
||||||
tasks: { group: 'nav_manage', page: 'menu_tasks' },
|
tasks: { group: 'nav_manage', page: 'menu_tasks' },
|
||||||
logs: { group: 'nav_monitor', page: 'menu_logs' },
|
logs: { group: 'nav_monitor', page: 'menu_logs' },
|
||||||
@@ -2888,11 +2901,334 @@ navigateTo = function(viewId) {
|
|||||||
document.getElementById('memory-panel-list').classList.remove('hidden');
|
document.getElementById('memory-panel-list').classList.remove('hidden');
|
||||||
loadMemoryView(1);
|
loadMemoryView(1);
|
||||||
}
|
}
|
||||||
|
else if (viewId === 'knowledge') loadKnowledgeView();
|
||||||
else if (viewId === 'channels') loadChannelsView();
|
else if (viewId === 'channels') loadChannelsView();
|
||||||
else if (viewId === 'tasks') loadTasksView();
|
else if (viewId === 'tasks') loadTasksView();
|
||||||
else if (viewId === 'logs') startLogStream();
|
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
|
// Initialization
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|||||||
@@ -445,6 +445,9 @@ class WebChannel(ChatChannel):
|
|||||||
'/api/skills', 'SkillsHandler',
|
'/api/skills', 'SkillsHandler',
|
||||||
'/api/memory', 'MemoryHandler',
|
'/api/memory', 'MemoryHandler',
|
||||||
'/api/memory/content', 'MemoryContentHandler',
|
'/api/memory/content', 'MemoryContentHandler',
|
||||||
|
'/api/knowledge/list', 'KnowledgeListHandler',
|
||||||
|
'/api/knowledge/read', 'KnowledgeReadHandler',
|
||||||
|
'/api/knowledge/graph', 'KnowledgeGraphHandler',
|
||||||
'/api/scheduler', 'SchedulerHandler',
|
'/api/scheduler', 'SchedulerHandler',
|
||||||
'/api/history', 'HistoryHandler',
|
'/api/history', 'HistoryHandler',
|
||||||
'/api/logs', 'LogsHandler',
|
'/api/logs', 'LogsHandler',
|
||||||
@@ -1531,6 +1534,143 @@ class AssetsHandler:
|
|||||||
raise web.notfound()
|
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:
|
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')
|
||||||
|
|||||||
Reference in New Issue
Block a user