Просмотр исходного кода

seperate frontend and backend for knowhub

guantao 21 часов назад
Родитель
Сommit
2ee70186cb
9 измененных файлов с 84 добавлено и 1266 удалено
  1. 2 2
      agent/tools/builtin/search.py
  2. 12 949
      knowhub/server.py
  3. 6 6
      knowhub/static/app.js
  4. 64 31
      knowhub/static/index.html
  5. 0 171
      migrate_knowledge.py
  6. 0 21
      test_api.py
  7. 0 23
      test_api_output.json
  8. 0 25
      test_search.py
  9. 0 38
      test_xhs_search.py

+ 2 - 2
agent/tools/builtin/search.py

@@ -216,7 +216,7 @@ class SuggestSearchChannel(str, Enum):
             "name": "帖子搜索",
             "params": {
                 "keyword": "搜索关键词",
-                "channel": "搜索渠道",
+                "channel": "搜索渠道(xhs=小红书, gzh=公众号, sph=视频号, github, toutiao=头条, douyin=抖音, bili=B站, zhihu=知乎, weibo=微博)",
                 "cursor": "分页游标",
                 "max_count": "返回条数",
                 "content_type": "内容类型-视频/图文",
@@ -229,7 +229,7 @@ class SuggestSearchChannel(str, Enum):
             "name": "Search Posts",
             "params": {
                 "keyword": "Search keyword",
-                "channel": "Search channel",
+                "channel": "Search channel (xhs=XiaoHongShu, gzh=WeChat Official Account, sph=WeChat Channels, github, toutiao, douyin, bili, zhihu, weibo)",
                 "cursor": "Pagination cursor",
                 "max_count": "Max results",
                 "content_type": "content type-视频/图文",

+ 12 - 949
knowhub/server.py

@@ -19,7 +19,8 @@ from pathlib import Path
 from cryptography.hazmat.primitives.ciphers.aead import AESGCM
 
 from fastapi import FastAPI, HTTPException, Query, Header, Body, BackgroundTasks
-from fastapi.responses import HTMLResponse
+from fastapi.responses import HTMLResponse, FileResponse
+from fastapi.staticfiles import StaticFiles
 from pydantic import BaseModel, Field
 
 # 导入 LLM 调用(需要 agent 模块在 Python path 中)
@@ -808,6 +809,11 @@ async def lifespan(app: FastAPI):
 
 app = FastAPI(title=BRAND_NAME, lifespan=lifespan)
 
+# 挂载静态文件
+STATIC_DIR = Path(__file__).parent / "static"
+if STATIC_DIR.exists():
+    app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
+
 
 # --- Knowledge API ---
 
@@ -2021,956 +2027,13 @@ async def extract_knowledge_from_messages(extract_req: MessageExtractIn, backgro
         return {"status": "error", "error": str(e), "extracted_count": 0}
 
 
-@app.get("/", response_class=HTMLResponse)
+@app.get("/", response_class=FileResponse)
 def frontend():
     """KnowHub 管理前端"""
-    return """<!DOCTYPE html>
-<html lang="zh-CN">
-<head>
-    <meta charset="UTF-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0">
-    <title>KnowHub 管理</title>
-    <script src="https://cdn.tailwindcss.com"></script>
-    <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
-</head>
-<body class="bg-gray-50">
-    <div class="container mx-auto px-4 py-8 max-w-7xl">
-        <div class="flex justify-between items-center mb-8">
-            <h1 class="text-3xl font-bold text-gray-800">KnowHub 全局知识库</h1>
-            <div class="flex gap-3">
-                <button onclick="toggleSelectAll()" id="selectAllBtn" class="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-lg">
-                    全选
-                </button>
-                <button onclick="batchDelete()" id="batchDeleteBtn" class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed" disabled>
-                    删除选中 (<span id="selectedCount">0</span>)
-                </button>
-                <button onclick="batchVerify()" id="batchVerifyBtn" class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed" disabled>
-                    ✓ 批量验证通过 (<span id="verifyCount">0</span>)
-                </button>
-                <button onclick="openToolTableModal()" class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg">
-                    🔧 工具表
-                </button>
-                <button onclick="openAddModal()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg">
-                    + 新增知识
-                </button>
-            </div>
-        </div>
-
-        <!-- 搜索栏 -->
-        <div class="bg-white rounded-lg shadow p-6 mb-6">
-            <div class="flex gap-4">
-                <input type="text" id="searchInput" placeholder="输入任务描述进行语义搜索..."
-                       class="flex-1 border rounded px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
-                       onkeypress="if(event.key==='Enter') performSearch()">
-                <button onclick="performSearch()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded">
-                    搜索
-                </button>
-                <button onclick="clearSearch()" class="bg-gray-500 hover:bg-gray-600 text-white px-6 py-2 rounded">
-                    清除
-                </button>
-            </div>
-            <div id="searchStatus" class="mt-2 text-sm text-gray-600 hidden"></div>
-        </div>
-
-        <!-- 筛选栏 -->
-        <div class="bg-white rounded-lg shadow p-6 mb-6">
-            <div class="grid grid-cols-1 md:grid-cols-5 gap-4">
-                <div>
-                    <label class="block text-sm font-medium text-gray-700 mb-2">类型 (Types)</label>
-                    <div class="space-y-2">
-                        <label class="flex items-center"><input type="checkbox" value="strategy" class="mr-2 type-filter"> Strategy</label>
-                        <label class="flex items-center"><input type="checkbox" value="tool" class="mr-2 type-filter"> Tool</label>
-                        <label class="flex items-center"><input type="checkbox" value="user_profile" class="mr-2 type-filter"> User Profile</label>
-                        <label class="flex items-center"><input type="checkbox" value="usecase" class="mr-2 type-filter"> Usecase</label>
-                        <label class="flex items-center"><input type="checkbox" value="definition" class="mr-2 type-filter"> Definition</label>
-                        <label class="flex items-center"><input type="checkbox" value="plan" class="mr-2 type-filter"> Plan</label>
-                    </div>
-                </div>
-                <div>
-                    <label class="block text-sm font-medium text-gray-700 mb-2">Tags</label>
-                    <div id="tagsFilterContainer" class="space-y-2 max-h-40 overflow-y-auto">
-                        <p class="text-sm text-gray-500">加载中...</p>
-                    </div>
-                </div>
-                <div>
-                    <label class="block text-sm font-medium text-gray-700 mb-2">Owner</label>
-                    <input type="text" id="ownerFilter" placeholder="输入 owner" class="w-full border rounded px-3 py-2">
-                </div>
-                <div>
-                    <label class="block text-sm font-medium text-gray-700 mb-2">Scopes</label>
-                    <input type="text" id="scopesFilter" placeholder="输入 scope" class="w-full border rounded px-3 py-2">
-                </div>
-                <div>
-                    <label class="block text-sm font-medium text-gray-700 mb-2">Status</label>
-                    <div class="space-y-2">
-                        <label class="flex items-center"><input type="checkbox" value="approved" class="mr-2 status-filter" checked> Approved</label>
-                        <label class="flex items-center"><input type="checkbox" value="checked" class="mr-2 status-filter" checked> Checked</label>
-                        <label class="flex items-center"><input type="checkbox" value="rejected" class="mr-2 status-filter"> Rejected</label>
-                        <label class="flex items-center"><input type="checkbox" value="pending" class="mr-2 status-filter"> Pending</label>
-                    </div>
-                </div>
-            </div>
-            <button onclick="applyFilters()" class="mt-4 bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded">
-                应用筛选
-            </button>
-        </div>
-
-        <!-- 知识列表 -->
-        <div id="knowledgeList" class="space-y-4"></div>
-
-        <!-- 分页控件 -->
-        <div id="pagination" class="flex justify-center items-center gap-4 mt-6 hidden">
-            <button onclick="goToPage(currentPage - 1)" id="prevBtn" class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded disabled:opacity-50 disabled:cursor-not-allowed">
-                上一页
-            </button>
-            <span id="pageInfo" class="text-gray-700"></span>
-            <button onclick="goToPage(currentPage + 1)" id="nextBtn" class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded disabled:opacity-50 disabled:cursor-not-allowed">
-                下一页
-            </button>
-        </div>
-    </div>
-
-    <!-- 新增/编辑 Modal -->
-    <div id="modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4">
-        <div class="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto p-6">
-            <h2 id="modalTitle" class="text-2xl font-bold mb-4">新增知识</h2>
-            <form id="knowledgeForm" class="space-y-4">
-                <input type="hidden" id="editId">
-                <div>
-                    <label class="block text-sm font-medium mb-1">Task *</label>
-                    <input type="text" id="taskInput" required class="w-full border rounded px-3 py-2">
-                </div>
-                <div>
-                    <label class="block text-sm font-medium mb-1">Content *</label>
-                    <textarea id="contentInput" required rows="6" class="w-full border rounded px-3 py-2"></textarea>
-                </div>
-                <div>
-                    <label class="block text-sm font-medium mb-1">Types (多选)</label>
-                    <div class="space-y-1">
-                        <label class="flex items-center"><input type="checkbox" value="strategy" class="mr-2 type-checkbox"> Strategy</label>
-                        <label class="flex items-center"><input type="checkbox" value="tool" class="mr-2 type-checkbox"> Tool</label>
-                        <label class="flex items-center"><input type="checkbox" value="user_profile" class="mr-2 type-checkbox"> User Profile</label>
-                        <label class="flex items-center"><input type="checkbox" value="usecase" class="mr-2 type-checkbox"> Usecase</label>
-                        <label class="flex items-center"><input type="checkbox" value="definition" class="mr-2 type-checkbox"> Definition</label>
-                        <label class="flex items-center"><input type="checkbox" value="plan" class="mr-2 type-checkbox"> Plan</label>
-                    </div>
-                </div>
-                <div>
-                    <label class="block text-sm font-medium mb-1">Tags (JSON)</label>
-                    <textarea id="tagsInput" rows="2" placeholder='{"key": "value"}' class="w-full border rounded px-3 py-2"></textarea>
-                </div>
-                <div>
-                    <label class="block text-sm font-medium mb-1">Scopes (逗号分隔)</label>
-                    <input type="text" id="scopesInput" placeholder="org:cybertogether" class="w-full border rounded px-3 py-2">
-                </div>
-                <div>
-                    <label class="block text-sm font-medium mb-1">Owner</label>
-                    <input type="text" id="ownerInput" class="w-full border rounded px-3 py-2">
-                </div>
-                <div id="relationshipsSection" class="hidden">
-                    <label class="block text-sm font-medium text-gray-700 mb-2">关联知识</label>
-                    <div id="relationshipsList" class="space-y-1 text-sm bg-gray-50 rounded p-3"></div>
-                </div>
-                <div class="flex gap-2 pt-4">
-                    <button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded">保存</button>
-                    <button type="button" onclick="closeModal()" class="bg-gray-300 hover:bg-gray-400 px-6 py-2 rounded">取消</button>
-                </div>
-            </form>
-        </div>
-    </div>
-
-    <!-- 工具表 Modal -->
-    <div id="toolTableModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
-        <div class="bg-white rounded-xl shadow-xl w-full max-w-5xl max-h-[90vh] flex flex-col">
-            <div class="flex justify-between items-center p-6 border-b">
-                <h2 class="text-2xl font-bold">🔧 工具表</h2>
-                <button onclick="closeToolTableModal()" class="text-gray-400 hover:text-gray-600 text-2xl">✕</button>
-            </div>
-            <div id="toolCategoryTabs" class="flex gap-2 px-6 pt-4 flex-wrap border-b pb-4"></div>
-            <div class="flex flex-1 overflow-hidden">
-                <div id="toolList" class="w-[250px] border-r overflow-y-auto p-4 space-y-2 bg-gray-50 flex-shrink-0">
-                    <p class="text-sm text-gray-500 text-center mt-4">加载中...</p>
-                </div>
-                <div id="toolDetail" class="flex-1 overflow-y-auto p-6 bg-white">
-                    <div class="flex h-full items-center justify-center text-gray-400">
-                        ← 请在左侧选择要查看的工具
-                    </div>
-                </div>
-            </div>
-        </div>
-    </div>
-
-    <!-- 知识详情弹窗(只读)-->
-    <div id="knowledgeDetailModal" class="hidden fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center p-4 z-[60]">
-        <div class="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[85vh] flex flex-col">
-            <div class="flex justify-between items-center p-6 border-b">
-                <h2 class="text-xl font-bold text-gray-800">知识详情</h2>
-                <button onclick="closeKnowledgeDetailModal()" class="text-gray-400 hover:text-gray-600 text-2xl">✕</button>
-            </div>
-            <div id="knowledgeDetailContent" class="flex-1 overflow-y-auto p-6">
-                <p class="text-gray-400 text-center animate-pulse">加载中...</p>
-            </div>
-        </div>
-    </div>
-
-    <script>
-        let allKnowledge = [];
-        let availableTags = [];
-        let currentPage = 1;
-        let pageSize = 200;  // 每页显示200条
-        let totalPages = 1;
-        let totalCount = 0;
-        let isSearchMode = false;  // 标记是否在搜索模式
-        let selectedIds = new Set();  // 选中的知识ID集合
-
-        async function loadTags() {
-            const res = await fetch('/api/knowledge/meta/tags');
-            const data = await res.json();
-            availableTags = data.tags;
-            renderTagsFilter();
-        }
-
-        function renderTagsFilter() {
-            const container = document.getElementById('tagsFilterContainer');
-            if (availableTags.length === 0) {
-                container.innerHTML = '<p class="text-sm text-gray-500">暂无 tags</p>';
-                return;
-            }
-            container.innerHTML = availableTags.map(tag =>
-                `<label class="flex items-center"><input type="checkbox" value="${escapeHtml(tag)}" class="mr-2 tag-filter"> ${escapeHtml(tag)}</label>`
-            ).join('');
-        }
-
-        async function loadKnowledge(page = 1) {
-            const params = new URLSearchParams();
-            params.append('page', page);
-            params.append('page_size', pageSize);
-
-            const selectedTypes = Array.from(document.querySelectorAll('.type-filter:checked')).map(el => el.value);
-            if (selectedTypes.length > 0) {
-                params.append('types', selectedTypes.join(','));
-            }
-
-            const selectedTags = Array.from(document.querySelectorAll('.tag-filter:checked')).map(el => el.value);
-            if (selectedTags.length > 0) {
-                params.append('tags', selectedTags.join(','));
-            }
-
-            const ownerFilter = document.getElementById('ownerFilter').value.trim();
-            if (ownerFilter) {
-                params.append('owner', ownerFilter);
-            }
-
-            const scopesFilter = document.getElementById('scopesFilter').value.trim();
-            if (scopesFilter) {
-                params.append('scopes', scopesFilter);
-            }
-
-            const selectedStatus = Array.from(document.querySelectorAll('.status-filter:checked')).map(el => el.value);
-            if (selectedStatus.length > 0) {
-                params.append('status', selectedStatus.join(','));
-            }
-
-            try {
-                const res = await fetch(`/api/knowledge?${params.toString()}`);
-                if (!res.ok) {
-                    console.error('加载失败:', res.status, res.statusText);
-                    document.getElementById('knowledgeList').innerHTML = '<p class="text-red-500 text-center py-8">加载失败,请刷新页面重试</p>';
-                    return;
-                }
-                const data = await res.json();
-                allKnowledge = data.results || [];
-                currentPage = data.pagination.page;
-                totalPages = data.pagination.total_pages;
-                totalCount = data.pagination.total;
-                renderKnowledge(allKnowledge);
-                updatePagination();
-            } catch (error) {
-                console.error('加载错误:', error);
-                document.getElementById('knowledgeList').innerHTML = '<p class="text-red-500 text-center py-8">加载错误: ' + error.message + '</p>';
-            }
-        }
-
-        function applyFilters() {
-            currentPage = 1;  // 重置到第一页
-            loadKnowledge(currentPage);
-        }
-
-        function goToPage(page) {
-            if (page < 1 || page > totalPages) return;
-            loadKnowledge(page);
-        }
-
-        function updatePagination() {
-            const paginationDiv = document.getElementById('pagination');
-            const pageInfo = document.getElementById('pageInfo');
-            const prevBtn = document.getElementById('prevBtn');
-            const nextBtn = document.getElementById('nextBtn');
-
-            if (totalPages <= 1) {
-                paginationDiv.classList.add('hidden');
-            } else {
-                paginationDiv.classList.remove('hidden');
-                pageInfo.textContent = `第 ${currentPage} / ${totalPages} 页 (共 ${totalCount} 条)`;
-                prevBtn.disabled = currentPage === 1;
-                nextBtn.disabled = currentPage === totalPages;
-            }
-        }
-
-        async function performSearch() {
-            const query = document.getElementById('searchInput').value.trim();
-            if (!query) {
-                alert('请输入搜索内容');
-                return;
-            }
-
-            isSearchMode = true;
-            const statusDiv = document.getElementById('searchStatus');
-            statusDiv.textContent = '搜索中...';
-            statusDiv.classList.remove('hidden');
-
-            try {
-                const params = new URLSearchParams();
-                params.append('q', query);
-                params.append('top_k', '20');
-                params.append('min_score', '1');  // 搜索时降低最低分数要求
-
-                // 应用筛选条件
-                const selectedTypes = Array.from(document.querySelectorAll('.type-filter:checked')).map(el => el.value);
-                if (selectedTypes.length > 0) {
-                    params.append('types', selectedTypes.join(','));
-                }
-
-                const ownerFilter = document.getElementById('ownerFilter').value.trim();
-                if (ownerFilter) {
-                    params.append('owner', ownerFilter);
-                }
-
-                const res = await fetch(`/api/knowledge/search?${params.toString()}`);
-                if (!res.ok) {
-                    throw new Error(`搜索失败: ${res.status}`);
-                }
-
-                const data = await res.json();
-                allKnowledge = data.results || [];
-
-                statusDiv.textContent = `找到 ${allKnowledge.length} 条相关知识${data.reranked ? ' (已智能排序)' : ''}`;
-                renderKnowledge(allKnowledge);
-
-                // 搜索模式下隐藏分页
-                document.getElementById('pagination').classList.add('hidden');
-            } catch (error) {
-                console.error('搜索错误:', error);
-                statusDiv.textContent = '搜索失败: ' + error.message;
-                statusDiv.classList.add('text-red-500');
-            }
-        }
-
-        function clearSearch() {
-            document.getElementById('searchInput').value = '';
-            document.getElementById('searchStatus').classList.add('hidden');
-            document.getElementById('searchStatus').classList.remove('text-red-500');
-            isSearchMode = false;
-            currentPage = 1;
-            loadKnowledge(currentPage);
-        }
-
-        function renderKnowledge(list) {
-            const container = document.getElementById('knowledgeList');
-            if (list.length === 0) {
-                container.innerHTML = '<p class="text-gray-500 text-center py-8">暂无知识</p>';
-                return;
-            }
-
-            container.innerHTML = list.map(k => {
-                // 确保types是数组
-                let types = [];
-                if (Array.isArray(k.types)) {
-                    types = k.types;
-                } else if (typeof k.types === 'string') {
-                    // 如果是JSON字符串(以[开头),尝试解析
-                    if (k.types.startsWith('[')) {
-                        try {
-                            types = JSON.parse(k.types);
-                        } catch (e) {
-                            console.error('解析types失败:', k.types, e);
-                            types = [k.types];
-                        }
-                    } else {
-                        // 如果是普通字符串,包装成数组
-                        types = [k.types];
-                    }
-                }
-                const eval_data = k.eval || {};
-                const isChecked = selectedIds.has(k.id);
-                const statusColor = {
-                    'approved': 'bg-green-100 text-green-800',
-                    'checked':  'bg-blue-100 text-blue-800',
-                    'rejected': 'bg-red-100 text-red-800',
-                    'pending':  'bg-yellow-100 text-yellow-800',
-                    'processing': 'bg-orange-100 text-orange-800',
-                };
-                const statusClass = statusColor[k.status] || 'bg-gray-100 text-gray-800';
-                const statusLabel = k.status || 'approved';
-
-                // 工具 tag(来自 resource_ids 中 tools/ 前缀的条目)
-                const toolIds = (k.resource_ids || []).filter(id => id.startsWith('tools/'));
-                const toolTagsHtml = toolIds.length > 0
-                    ? `<div class="flex gap-1 flex-wrap mt-2">
-                           ${toolIds.map(tid => {
-                               const name = tid.split('/').pop();
-                               return `<span onclick="event.stopPropagation(); openToolTableModal('${tid}')"
-                                             class="text-[11px] px-2 py-0.5 bg-indigo-50 text-indigo-700 border border-indigo-200 rounded-full cursor-pointer hover:bg-indigo-100 transition">
-                                           🔧 ${name}
-                                       </span>`;
-                           }).join('')}
-                       </div>`
-                    : '';
-
-                return `
-                <div class="bg-white rounded-lg shadow p-6 hover:shadow-lg transition relative">
-                    <div class="absolute top-4 left-4">
-                        <input type="checkbox" class="knowledge-checkbox w-5 h-5 cursor-pointer"
-                               data-id="${k.id}" ${isChecked ? 'checked' : ''}
-                               onclick="event.stopPropagation(); toggleSelect('${k.id}')">
-                    </div>
-                    <div class="ml-10 cursor-pointer" onclick="openEditModal('${k.id}')">
-                        <div class="flex justify-between items-start mb-2">
-                            <div class="flex gap-2 flex-wrap">
-                                ${types.map(t => `<span class="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded">${t}</span>`).join('')}
-                            </div>
-                            <div class="flex items-center gap-2">
-                                <span class="text-xs px-2 py-1 rounded ${statusClass}">${statusLabel}</span>
-                                <span class="text-sm text-gray-500">${eval_data.score || 3}/5</span>
-                            </div>
-                        </div>
-                        <h3 class="text-lg font-semibold text-gray-800 mb-2">${escapeHtml(k.task)}</h3>
-                        <p class="text-sm text-gray-600 mb-2">${escapeHtml(k.content.substring(0, 150))}${k.content.length > 150 ? '...' : ''}</p>
-                        <div class="flex justify-between text-xs text-gray-500">
-                            <span>Owner: ${k.owner || 'N/A'}</span>
-                            <span>${new Date(k.created_at).toLocaleDateString()}</span>
-                        </div>
-                        ${toolTagsHtml}
-                    </div>
-                    <div class="flex gap-2 mt-3 pt-3 border-t border-gray-100">
-                        <button onclick="event.stopPropagation(); verifyKnowledge('${k.id}', 'approve', this)"
-                                class="${k.status === 'checked' ? 'bg-gray-300 hover:bg-gray-400 text-gray-700' : 'bg-green-400 hover:bg-green-500 text-white'} text-xs px-3 py-1 rounded transition-colors">
-                            ${k.status === 'checked' ? '取消验证' : '✓ 验证通过'}
-                        </button>
-                        <button onclick="event.stopPropagation(); verifyKnowledge('${k.id}', 'reject', this)"
-                                class="bg-red-400 hover:bg-red-500 text-white text-xs px-3 py-1 rounded transition-colors">
-                            ✗ 拒绝
-                        </button>
-                    </div>
-                </div>
-            `;
-            }).join('');
-        }
-
-        function toggleSelect(id) {
-            if (selectedIds.has(id)) {
-                selectedIds.delete(id);
-            } else {
-                selectedIds.add(id);
-            }
-            updateBatchDeleteButton();
-        }
-
-        function toggleSelectAll() {
-            if (selectedIds.size === allKnowledge.length) {
-                // 全部取消选中
-                selectedIds.clear();
-            } else {
-                // 全部选中
-                selectedIds.clear();
-                allKnowledge.forEach(k => selectedIds.add(k.id));
-            }
-            renderKnowledge(allKnowledge);
-            updateBatchDeleteButton();
-        }
-
-        function updateBatchDeleteButton() {
-            const count = selectedIds.size;
-            document.getElementById('selectedCount').textContent = count;
-            document.getElementById('verifyCount').textContent = count;
-            document.getElementById('batchDeleteBtn').disabled = count === 0;
-            document.getElementById('batchVerifyBtn').disabled = count === 0;
-            document.getElementById('selectAllBtn').textContent =
-                selectedIds.size === allKnowledge.length ? '取消全选' : '全选';
-        }
-
-        async function batchDelete() {
-            if (selectedIds.size === 0) return;
-
-            if (!confirm(`确定要删除选中的 ${selectedIds.size} 条知识吗?此操作不可恢复!`)) {
-                return;
-            }
-
-            try {
-                const ids = Array.from(selectedIds);
-                const res = await fetch('/api/knowledge/batch_delete', {
-                    method: 'POST',
-                    headers: {'Content-Type': 'application/json'},
-                    body: JSON.stringify(ids)
-                });
-
-                if (!res.ok) {
-                    throw new Error(`删除失败: ${res.status}`);
-                }
-
-                const data = await res.json();
-                alert(`成功删除 ${data.deleted_count} 条知识`);
-
-                // 清空选择并重新加载
-                selectedIds.clear();
-                updateBatchDeleteButton();
-
-                if (isSearchMode) {
-                    clearSearch();
-                } else {
-                    loadKnowledge(currentPage);
-                }
-            } catch (error) {
-                console.error('批量删除错误:', error);
-                alert('删除失败: ' + error.message);
-            }
-        }
-
-        function openAddModal() {
-            document.getElementById('modalTitle').textContent = '新增知识';
-            document.getElementById('knowledgeForm').reset();
-            document.getElementById('editId').value = '';
-            document.querySelectorAll('.type-checkbox').forEach(el => el.checked = false);
-            document.getElementById('modal').classList.remove('hidden');
-        }
-
-        async function openEditModal(id) {
-            let k = allKnowledge.find(item => item.id === id);
-            if (!k) {
-                // 当前列表中找不到(可能是 rejected/其他状态),通过 API 单独获取
-                try {
-                    const res = await fetch('/api/knowledge/' + encodeURIComponent(id));
-                    if (!res.ok) { alert('知识未找到: ' + id); return; }
-                    k = await res.json();
-                } catch (e) { alert('获取知识失败: ' + e.message); return; }
-            }
-
-            document.getElementById('modalTitle').textContent = '编辑知识';
-            document.getElementById('editId').value = k.id;
-            document.getElementById('taskInput').value = k.task || '';
-            document.getElementById('contentInput').value = k.content || '';
-            document.getElementById('tagsInput').value = JSON.stringify(k.tags || {});
-
-            // 防御性检查:确保 scopes 是数组
-            const scopes = Array.isArray(k.scopes) ? k.scopes : [];
-            document.getElementById('scopesInput').value = scopes.join(', ');
-
-            document.getElementById('ownerInput').value = k.owner || '';
-
-            // 防御性检查:确保 types 是数组
-            const types = Array.isArray(k.types) ? k.types : [];
-            document.querySelectorAll('.type-checkbox').forEach(el => {
-                el.checked = types.includes(el.value);
-            });
-
-            // 填充 relationships(可能是 JSON 字符串或数组)
-            let rels = [];
-            if (Array.isArray(k.relationships)) {
-                rels = k.relationships;
-            } else if (typeof k.relationships === 'string' && k.relationships.startsWith('[')) {
-                try { rels = JSON.parse(k.relationships); } catch(e) {}
-            }
-            const section = document.getElementById('relationshipsSection');
-            if (rels.length > 0) {
-                const typeColor = {
-                    superset: 'text-green-700', subset: 'text-orange-600',
-                    conflict: 'text-red-600', complement: 'text-blue-600',
-                    duplicate: 'text-gray-500'
-                };
-                document.getElementById('relationshipsList').innerHTML = rels.map(r =>
-                    `<div class="flex gap-2 items-center">
-                        <span class="font-medium ${typeColor[r.type] || 'text-gray-700'}">[${r.type}]</span>
-                        <span class="font-mono text-xs text-gray-500 cursor-pointer hover:underline"
-                              onclick="openEditModal('${r.target}')">${r.target}</span>
-                    </div>`
-                ).join('');
-                section.classList.remove('hidden');
-            } else {
-                section.classList.add('hidden');
-            }
-
-            document.getElementById('modal').classList.remove('hidden');
-        }
-
-        function closeModal() {
-            document.getElementById('modal').classList.add('hidden');
-        }
-
-        document.getElementById('knowledgeForm').addEventListener('submit', async (e) => {
-            e.preventDefault();
-
-            const editId = document.getElementById('editId').value;
-            const task = document.getElementById('taskInput').value;
-            const content = document.getElementById('contentInput').value;
-            const types = Array.from(document.querySelectorAll('.type-checkbox:checked')).map(el => el.value);
-            const tagsText = document.getElementById('tagsInput').value.trim();
-            const scopesText = document.getElementById('scopesInput').value.trim();
-            const owner = document.getElementById('ownerInput').value.trim();
-
-            let tags = {};
-            if (tagsText) {
-                try {
-                    tags = JSON.parse(tagsText);
-                } catch (e) {
-                    alert('Tags JSON 格式错误');
-                    return;
-                }
-            }
-
-            const scopes = scopesText ? scopesText.split(',').map(s => s.trim()).filter(s => s) : ['org:cybertogether'];
-
-            if (editId) {
-                // 编辑
-                const res = await fetch(`/api/knowledge/${editId}`, {
-                    method: 'PATCH',
-                    headers: {'Content-Type': 'application/json'},
-                    body: JSON.stringify({task, content, types, tags, scopes, owner})
-                });
-                if (!res.ok) {
-                    alert('更新失败');
-                    return;
-                }
-            } else {
-                // 新增
-                const res = await fetch('/api/knowledge', {
-                    method: 'POST',
-                    headers: {'Content-Type': 'application/json'},
-                    body: JSON.stringify({task, content, types, tags, scopes, owner})
-                });
-                if (!res.ok) {
-                    alert('新增失败');
-                    return;
-                }
-            }
-
-            closeModal();
-            await loadKnowledge();
-        });
-
-        async function verifyKnowledge(id, action, btn) {
-            if (btn) {
-                btn.disabled = true;
-                btn._origText = btn.textContent;
-                btn.textContent = '处理中...';
-            }
-            try {
-                const res = await fetch('/api/knowledge/' + id + '/verify', {
-                    method: 'POST',
-                    headers: {'Content-Type': 'application/json'},
-                    body: JSON.stringify({ action })
-                });
-                if (!res.ok) throw new Error('请求失败: ' + res.status);
-                if (isSearchMode) {
-                    clearSearch();
-                } else {
-                    loadKnowledge(currentPage);
-                }
-            } catch (error) {
-                console.error('验证错误:', error);
-                alert('操作失败: ' + error.message);
-                if (btn) {
-                    btn.disabled = false;
-                    btn.textContent = btn._origText;
-                }
-            }
-        }
-
-        async function batchVerify() {
-            if (selectedIds.size === 0) return;
-            if (!confirm(`确定要批量验证通过选中的 ${selectedIds.size} 条知识吗?`)) return;
-            const btn = document.getElementById('batchVerifyBtn');
-            if (btn) { btn.disabled = true; btn.textContent = `处理中...`; }
-            try {
-                const ids = Array.from(selectedIds);
-                const res = await fetch('/api/knowledge/batch_verify', {
-                    method: 'POST',
-                    headers: {'Content-Type': 'application/json'},
-                    body: JSON.stringify({ knowledge_ids: ids, action: 'approve', verified_by: 'user' })
-                });
-                if (!res.ok) throw new Error('请求失败: ' + res.status);
-                selectedIds.clear();
-                updateBatchDeleteButton();
-                if (isSearchMode) {
-                    clearSearch();
-                } else {
-                    loadKnowledge(currentPage);
-                }
-            } catch (error) {
-                console.error('批量验证错误:', error);
-                alert('验证失败: ' + error.message);
-                if (btn) { btn.disabled = false; updateBatchDeleteButton(); }
-            }
-        }
-
-        function escapeHtml(text) {
-            const div = document.createElement('div');
-            div.textContent = text;
-            return div.innerHTML;
-        }
-
-        loadTags();
-        loadKnowledge();
-
-        // --- 工具表逻辑 ---
-        let _allTools = [];
-        let _activeCategory = 'all';
-
-        async function openToolTableModal(targetToolId = null) {
-            document.getElementById('toolTableModal').classList.remove('hidden');
-            if (_allTools.length === 0) {
-                await loadToolList();
-            } else {
-                renderCategoryTabs();
-                renderToolList('all');
-            }
-            if (targetToolId) {
-                const targetTool = _allTools.find(t => t.id === targetToolId);
-                if (targetTool) {
-                    const cat = targetTool.metadata && targetTool.metadata.category ? targetTool.metadata.category : 'other';
-                    renderToolList(cat);
-                }
-                loadToolDetail(targetToolId);
-                setTimeout(() => {
-                    const el = document.querySelector(`.tool-item[data-id="${targetToolId}"]`);
-                    if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
-                }, 100);
-            }
-        }
-
-        function closeToolTableModal() {
-            document.getElementById('toolTableModal').classList.add('hidden');
-        }
-
-        async function loadToolList() {
-            try {
-                const res = await fetch('/api/resource?limit=1000');
-                const data = await res.json();
-                _allTools = (data.results || []).filter(r => r.id.startsWith('tools/'));
-                renderCategoryTabs();
-                renderToolList('all');
-            } catch (err) {
-                console.error('加载工具列表失败', err);
-                document.getElementById('toolList').innerHTML = '<p class="text-red-500 text-sm text-center">加载失败</p>';
-            }
-        }
-
-        function renderCategoryTabs() {
-            const cats = ['all', ...new Set(_allTools.map(t => t.metadata && t.metadata.category ? t.metadata.category : 'other'))];
-            document.getElementById('toolCategoryTabs').innerHTML = cats.map(cat => {
-                const isActive = cat === _activeCategory;
-                const activeClass = 'bg-indigo-600 text-white border-indigo-600';
-                const inactiveClass = 'bg-white text-gray-600 border-gray-300 hover:border-indigo-400';
-                return `<button onclick="renderToolList('${cat}')"
-                                id="tab_${cat}"
-                                class="tool-cat-tab px-4 py-1.5 rounded-full text-sm font-medium border transition ${isActive ? activeClass : inactiveClass}">
-                            ${cat === 'all' ? '全部' : cat}
-                        </button>`;
-            }).join('');
-        }
-
-        function renderToolList(category) {
-            _activeCategory = category;
-            document.querySelectorAll('.tool-cat-tab').forEach(btn => {
-                const isCurrent = btn.id === `tab_${category}`;
-                btn.className = `tool-cat-tab px-4 py-1.5 rounded-full text-sm font-medium border transition ${
-                    isCurrent ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-600 border-gray-300 hover:border-indigo-400'
-                }`;
-            });
-            const filtered = category === 'all'
-                ? _allTools
-                : _allTools.filter(t => (t.metadata && t.metadata.category ? t.metadata.category : 'other') === category);
-            const listHtml = filtered.length === 0
-                ? '<p class="text-sm text-gray-400 text-center mt-4">该分类下暂无工具</p>'
-                : filtered.map(t => `
-                    <div onclick="loadToolDetail('${t.id}')"
-                         class="tool-item p-3 rounded-lg border border-gray-200 cursor-pointer hover:border-indigo-400 hover:shadow-sm bg-white transition"
-                         data-id="${t.id}">
-                        <div class="font-bold text-gray-800 text-sm truncate" title="${escapeHtml(t.title || t.id)}">${escapeHtml(t.title || t.id.split('/').pop())}</div>
-                        <div class="mt-1 flex items-center justify-between">
-                            <span class="text-xs px-2 py-0.5 rounded truncate max-w-[100px] ${(t.metadata && t.metadata.status === '已接入') ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'}">${escapeHtml(t.metadata && t.metadata.status ? t.metadata.status : '未设置')}</span>
-                            <span class="text-[10px] text-gray-400">${escapeHtml(t.content_type || '')}</span>
-                        </div>
-                    </div>`).join('');
-            document.getElementById('toolList').innerHTML = listHtml;
-        }
-
-        async function loadToolDetail(id) {
-            document.querySelectorAll('.tool-item').forEach(el => {
-                if (el.dataset.id === id) {
-                    el.classList.add('border-indigo-500', 'ring-1', 'ring-indigo-500');
-                    el.classList.remove('border-gray-200');
-                } else {
-                    el.classList.remove('border-indigo-500', 'ring-1', 'ring-indigo-500');
-                    el.classList.add('border-gray-200');
-                }
-            });
-            const detailEl = document.getElementById('toolDetail');
-            detailEl.innerHTML = '<div class="flex h-full items-center justify-center"><p class="text-gray-400 animate-pulse">加载详情中...</p></div>';
-            try {
-                const res = await fetch('/api/resource/' + id);
-                const tool = await res.json();
-                const knowledgeIds = (tool.metadata && tool.metadata.knowledge_ids) ? tool.metadata.knowledge_ids : [];
-                const knowledgeHtml = knowledgeIds.length === 0
-                    ? '<span class="text-gray-400 text-xs">暂无</span>'
-                    : knowledgeIds.map(kid => `
-                        <span onclick="openKnowledgeDetailModal('${kid}')"
-                              class="text-xs px-2 py-1 bg-gray-100 hover:bg-gray-200 border rounded cursor-pointer text-gray-700 font-mono transition">
-                            ${kid.length > 24 ? kid.slice(0, 24) + '...' : kid}
-                        </span>`).join('');
-
-                const toolhubItems = (tool.metadata && tool.metadata.toolhub_items) ? tool.metadata.toolhub_items : [];
-                const toolhubHtml = toolhubItems.length === 0
-                    ? '<span class="text-gray-400 text-xs">暂无</span>'
-                    : toolhubItems.map(item => {
-                        const [id, desc] = Object.entries(item)[0];
-                        return `<span class="text-xs px-2 py-1 bg-blue-50 hover:bg-blue-100 border border-blue-200 rounded text-blue-700 font-mono transition">
-                            ${escapeHtml(id)}: ${escapeHtml(desc)}
-                        </span>`;
-                    }).join('');
-
-                const meta = tool.metadata || {};
-                const scenariosMd = Array.isArray(meta.scenarios) && meta.scenarios.length > 0
-                    ? meta.scenarios.map(s => `<li>${escapeHtml(s)}</li>`).join('')
-                    : '<li class="text-gray-400">暂无</li>';
-
-                detailEl.innerHTML = `
-                    <div class="mb-6 border-b pb-4">
-                        <h2 class="text-3xl font-black text-gray-900 mb-3">${escapeHtml(tool.title || id)}</h2>
-                        <div class="flex gap-2 flex-wrap text-sm mb-3">
-                            <span class="px-2.5 py-1 bg-indigo-50 text-indigo-700 rounded-md border border-indigo-100">
-                                📁 分类: ${escapeHtml(meta.category || '–')}
-                            </span>
-                            <span class="px-2.5 py-1 rounded-md border ${(meta.status === '已接入') ? 'bg-green-50 text-green-700 border-green-200' : 'bg-gray-50 text-gray-700 border-gray-200'}">
-                                🏷️ 状态: ${escapeHtml(meta.status || '–')}
-                            </span>
-                            <span class="px-2.5 py-1 bg-blue-50 text-blue-700 rounded-md border border-blue-100">
-                                📌 Slug: ${escapeHtml(meta.tool_slug || '–')}
-                            </span>
-                        </div>
-                        <div class="flex gap-1 flex-wrap items-center">
-                            <span class="text-xs text-gray-500 mr-1">🔗 关联知识:</span>
-                            ${knowledgeHtml}
-                        </div>
-                        <div class="flex gap-1 flex-wrap items-center mt-2">
-                            <span class="text-xs text-gray-500 mr-1">🔧 工具项:</span>
-                            ${toolhubHtml}
-                        </div>
-                    </div>
-
-                    <div class="text-gray-800 leading-relaxed max-w-none space-y-6">
-                        <div>
-                            <h3 class="text-lg font-bold border-b pb-2 mb-3">基础概览</h3>
-                            <div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
-                                <div><span class="text-gray-500 font-semibold">工具版本:</span> ${escapeHtml(meta.version || '–')}</div>
-                            </div>
-                            <div class="mt-3">
-                                <span class="text-gray-500 font-semibold text-sm block mb-1">功能介绍:</span>
-                                <div class="bg-gray-50 p-3 rounded-md text-sm border">${escapeHtml(meta.description || '暂无')}</div>
-                            </div>
-                        </div>
-
-                        <div>
-                            <h3 class="text-lg font-bold border-b pb-2 mb-3">使用指南</h3>
-                            <div class="mb-4">
-                                <span class="text-gray-500 font-semibold text-sm block mb-1">用法:</span>
-                                <div class="bg-gray-50 p-3 rounded-md text-sm border whitespace-pre-wrap">${escapeHtml(meta.usage || '暂无')}</div>
-                            </div>
-                            <div>
-                                <span class="text-gray-500 font-semibold text-sm block mb-1">应用场景:</span>
-                                <ul class="list-disc pl-5 space-y-1 text-sm">${scenariosMd}</ul>
-                            </div>
-                        </div>
-
-                        <div>
-                            <h3 class="text-lg font-bold border-b pb-2 mb-3">技术规格</h3>
-                            <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
-                                <div>
-                                    <span class="text-gray-500 font-semibold text-sm block mb-1">输入:</span>
-                                    <div class="bg-gray-50 p-3 rounded-md text-sm border h-full">${escapeHtml(meta.input || '暂无')}</div>
-                                </div>
-                                <div>
-                                    <span class="text-gray-500 font-semibold text-sm block mb-1">输出:</span>
-                                    <div class="bg-gray-50 p-3 rounded-md text-sm border h-full">${escapeHtml(meta.output || '暂无')}</div>
-                                </div>
-                            </div>
-                        </div>
-
-                        ${meta.source ? `
-                        <div>
-                            <h3 class="text-lg font-bold border-b pb-2 mb-3">消息信源</h3>
-                            <div class="text-sm overflow-hidden break-words text-blue-600 hover:underline">
-                                ${escapeHtml(meta.source)}
-                            </div>
-                        </div>` : ''}
-
-                        ${tool.body ? `
-                        <div class="pt-4 mt-6 border-t border-dashed">
-                            <h3 class="text-lg font-bold mb-3 text-gray-500">补充说明 (文档内容)</h3>
-                            <div class="markdown-body bg-gray-50 p-4 rounded-lg border text-sm">
-                                ${typeof marked !== 'undefined' ? marked.parse(tool.body) : escapeHtml(tool.body)}
-                            </div>
-                        </div>` : ''}
-                    </div>`;
-            } catch (err) {
-                detailEl.innerHTML = '<div class="text-red-500 flex h-full items-center justify-center">加载详情失败,请检查网络或日志</div>';
-                console.error(err);
-            }
-        }
-
-        async function openKnowledgeDetailModal(id) {
-            document.getElementById('knowledgeDetailModal').classList.remove('hidden');
-            const contentEl = document.getElementById('knowledgeDetailContent');
-            contentEl.innerHTML = '<p class="text-gray-400 text-center animate-pulse">加载中...</p>';
-            try {
-                const res = await fetch(`/api/knowledge/${encodeURIComponent(id)}`);
-                if (!res.ok) { contentEl.innerHTML = '<p class="text-red-500 text-center">知识未找到</p>'; return; }
-                const k = await res.json();
-                const statusColor = {
-                    'approved': 'bg-green-100 text-green-800',
-                    'checked':  'bg-blue-100 text-blue-800',
-                    'rejected': 'bg-red-100 text-red-800',
-                    'pending':  'bg-yellow-100 text-yellow-800',
-                };
-                const types = Array.isArray(k.types) ? k.types : [];
-                const tags = k.tags || {};
-                const tagKeys = Object.keys(tags);
-                contentEl.innerHTML = `
-                    <div class="flex gap-2 flex-wrap mb-4">
-                        ${types.map(t => `<span class="px-2 py-0.5 bg-blue-100 text-blue-700 rounded text-xs">${escapeHtml(t)}</span>`).join('')}
-                        <span class="px-2 py-0.5 rounded text-xs ${statusColor[k.status] || 'bg-gray-100 text-gray-700'}">${escapeHtml(k.status || '–')}</span>
-                    </div>
-                    <h3 class="text-lg font-bold text-gray-900 mb-3">${escapeHtml(k.task || '')}</h3>
-                    <div class="text-sm text-gray-700 bg-gray-50 rounded-lg p-4 mb-4 whitespace-pre-wrap leading-relaxed">
-                        ${escapeHtml(k.content || '')}
-                    </div>
-                    <div class="text-xs text-gray-500 space-y-1 border-t pt-3">
-                        <div>📌 ID:<span class="font-mono">${escapeHtml(k.id || '')}</span></div>
-                        <div>👤 Owner:${escapeHtml(k.owner || '–')}</div>
-                        <div>🕐 创建:${k.created_at ? new Date(k.created_at * 1000).toLocaleString() : '–'}</div>
-                        ${tagKeys.length > 0 ? `<div>🏷️ Tags:${tagKeys.map(t => `<span class="bg-gray-100 px-1 rounded">${escapeHtml(t)}</span>`).join(' ')}</div>` : ''}
-                    </div>`;
-            } catch (err) {
-                contentEl.innerHTML = '<p class="text-red-500 text-center">加载失败</p>';
-                console.error(err);
-            }
-        }
-
-        function closeKnowledgeDetailModal() {
-            document.getElementById('knowledgeDetailModal').classList.add('hidden');
-        }
-    </script>
-</body>
-</html>"""
+    index_file = STATIC_DIR / "index.html"
+    if not index_file.exists():
+        return HTMLResponse("<h1>KnowHub Frontend Not Found</h1><p>Please ensure knowhub/static/index.html exists.</p>", status_code=404)
+    return FileResponse(str(index_file))
 
 if __name__ == "__main__":
     import uvicorn

+ 6 - 6
knowhub/static/app.js

@@ -451,7 +451,7 @@ document.addEventListener('DOMContentLoaded', function() {
         const scopesText = document.getElementById('scopesInput').value.trim();
         const owner = document.getElementById('ownerInput').value.trim();
 
-        let tags = ;
+        let tags = {};
         if (tagsText) {
             try {
                 tags = JSON.parse(tagsText);
@@ -645,7 +645,7 @@ async function loadToolDetail(id) {
                     </div>
                     <div class="mt-3">
                         <span class="text-gray-500 font-semibold text-sm block mb-1">功能介绍:</span>
-                        <div class="bg-gray-50 p-3 rounded-md text-sm border">${escapeHtml(meta.description || '暂无')}</div>
+                        <div class="bg-gray-50 p-3 rounded-md text-sm border whitespace-pre-wrap">${escapeHtml(meta.description || '暂无')}</div>
                     </div>
                 </div>
 
@@ -664,13 +664,13 @@ async function loadToolDetail(id) {
                 <div>
                     <h3 class="text-lg font-bold border-b pb-2 mb-3">技术规格</h3>
                     <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
-                        <div>
+                        <div class="flex flex-col">
                             <span class="text-gray-500 font-semibold text-sm block mb-1">输入:</span>
-                            <div class="bg-gray-50 p-3 rounded-md text-sm border h-full">${escapeHtml(meta.input || '暂无')}</div>
+                            <div class="bg-gray-50 p-3 rounded-md text-sm border flex-1 whitespace-pre-wrap break-all">${escapeHtml(meta.input || '暂无')}</div>
                         </div>
-                        <div>
+                        <div class="flex flex-col">
                             <span class="text-gray-500 font-semibold text-sm block mb-1">输出:</span>
-                            <div class="bg-gray-50 p-3 rounded-md text-sm border h-full">${escapeHtml(meta.output || '暂无')}</div>
+                            <div class="bg-gray-50 p-3 rounded-md text-sm border flex-1 whitespace-pre-wrap break-all">${escapeHtml(meta.output || '暂无')}</div>
                         </div>
                     </div>
                 </div>

+ 64 - 31
knowhub/static/index.html

@@ -1,5 +1,6 @@
 <!DOCTYPE html>
 <html lang="zh-CN">
+
 <head>
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -7,21 +8,28 @@
     <script src="https://cdn.tailwindcss.com"></script>
     <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
 </head>
+
 <body class="bg-gray-50">
     <div class="container mx-auto px-4 py-8 max-w-7xl">
         <div class="flex justify-between items-center mb-8">
             <h1 class="text-3xl font-bold text-gray-800">KnowHub 全局知识库</h1>
             <div class="flex gap-3">
-                <button onclick="toggleSelectAll()" id="selectAllBtn" class="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-lg">
+                <button onclick="toggleSelectAll()" id="selectAllBtn"
+                    class="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-lg">
                     全选
                 </button>
-                <button onclick="batchDelete()" id="batchDeleteBtn" class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed" disabled>
+                <button onclick="batchDelete()" id="batchDeleteBtn"
+                    class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
+                    disabled>
                     删除选中 (<span id="selectedCount">0</span>)
                 </button>
-                <button onclick="batchVerify()" id="batchVerifyBtn" class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed" disabled>
+                <button onclick="batchVerify()" id="batchVerifyBtn"
+                    class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
+                    disabled>
                     ✓ 批量验证通过 (<span id="verifyCount">0</span>)
                 </button>
-                <button onclick="openToolTableModal()" class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg">
+                <button onclick="openToolTableModal()"
+                    class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg">
                     🔧 工具表
                 </button>
                 <button onclick="openAddModal()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg">
@@ -34,8 +42,8 @@
         <div class="bg-white rounded-lg shadow p-6 mb-6">
             <div class="flex gap-4">
                 <input type="text" id="searchInput" placeholder="输入任务描述进行语义搜索..."
-                       class="flex-1 border rounded px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
-                       onkeypress="if(event.key==='Enter') performSearch()">
+                    class="flex-1 border rounded px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
+                    onkeypress="if(event.key==='Enter') performSearch()">
                 <button onclick="performSearch()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded">
                     搜索
                 </button>
@@ -52,12 +60,18 @@
                 <div>
                     <label class="block text-sm font-medium text-gray-700 mb-2">类型 (Types)</label>
                     <div class="space-y-2">
-                        <label class="flex items-center"><input type="checkbox" value="strategy" class="mr-2 type-filter"> Strategy</label>
-                        <label class="flex items-center"><input type="checkbox" value="tool" class="mr-2 type-filter"> Tool</label>
-                        <label class="flex items-center"><input type="checkbox" value="user_profile" class="mr-2 type-filter"> User Profile</label>
-                        <label class="flex items-center"><input type="checkbox" value="usecase" class="mr-2 type-filter"> Usecase</label>
-                        <label class="flex items-center"><input type="checkbox" value="definition" class="mr-2 type-filter"> Definition</label>
-                        <label class="flex items-center"><input type="checkbox" value="plan" class="mr-2 type-filter"> Plan</label>
+                        <label class="flex items-center"><input type="checkbox" value="strategy"
+                                class="mr-2 type-filter"> Strategy</label>
+                        <label class="flex items-center"><input type="checkbox" value="tool" class="mr-2 type-filter">
+                            Tool</label>
+                        <label class="flex items-center"><input type="checkbox" value="user_profile"
+                                class="mr-2 type-filter"> User Profile</label>
+                        <label class="flex items-center"><input type="checkbox" value="usecase"
+                                class="mr-2 type-filter"> Usecase</label>
+                        <label class="flex items-center"><input type="checkbox" value="definition"
+                                class="mr-2 type-filter"> Definition</label>
+                        <label class="flex items-center"><input type="checkbox" value="plan" class="mr-2 type-filter">
+                            Plan</label>
                     </div>
                 </div>
                 <div>
@@ -77,10 +91,14 @@
                 <div>
                     <label class="block text-sm font-medium text-gray-700 mb-2">Status</label>
                     <div class="space-y-2">
-                        <label class="flex items-center"><input type="checkbox" value="approved" class="mr-2 status-filter" checked> Approved</label>
-                        <label class="flex items-center"><input type="checkbox" value="checked" class="mr-2 status-filter" checked> Checked</label>
-                        <label class="flex items-center"><input type="checkbox" value="rejected" class="mr-2 status-filter"> Rejected</label>
-                        <label class="flex items-center"><input type="checkbox" value="pending" class="mr-2 status-filter"> Pending</label>
+                        <label class="flex items-center"><input type="checkbox" value="approved"
+                                class="mr-2 status-filter" checked> Approved</label>
+                        <label class="flex items-center"><input type="checkbox" value="checked"
+                                class="mr-2 status-filter" checked> Checked</label>
+                        <label class="flex items-center"><input type="checkbox" value="rejected"
+                                class="mr-2 status-filter"> Rejected</label>
+                        <label class="flex items-center"><input type="checkbox" value="pending"
+                                class="mr-2 status-filter"> Pending</label>
                     </div>
                 </div>
             </div>
@@ -94,11 +112,13 @@
 
         <!-- 分页控件 -->
         <div id="pagination" class="flex justify-center items-center gap-4 mt-6 hidden">
-            <button onclick="goToPage(currentPage - 1)" id="prevBtn" class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded disabled:opacity-50 disabled:cursor-not-allowed">
+            <button onclick="goToPage(currentPage - 1)" id="prevBtn"
+                class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded disabled:opacity-50 disabled:cursor-not-allowed">
                 上一页
             </button>
             <span id="pageInfo" class="text-gray-700"></span>
-            <button onclick="goToPage(currentPage + 1)" id="nextBtn" class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded disabled:opacity-50 disabled:cursor-not-allowed">
+            <button onclick="goToPage(currentPage + 1)" id="nextBtn"
+                class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded disabled:opacity-50 disabled:cursor-not-allowed">
                 下一页
             </button>
         </div>
@@ -121,21 +141,29 @@
                 <div>
                     <label class="block text-sm font-medium mb-1">Types (多选)</label>
                     <div class="space-y-1">
-                        <label class="flex items-center"><input type="checkbox" value="strategy" class="mr-2 type-checkbox"> Strategy</label>
-                        <label class="flex items-center"><input type="checkbox" value="tool" class="mr-2 type-checkbox"> Tool</label>
-                        <label class="flex items-center"><input type="checkbox" value="user_profile" class="mr-2 type-checkbox"> User Profile</label>
-                        <label class="flex items-center"><input type="checkbox" value="usecase" class="mr-2 type-checkbox"> Usecase</label>
-                        <label class="flex items-center"><input type="checkbox" value="definition" class="mr-2 type-checkbox"> Definition</label>
-                        <label class="flex items-center"><input type="checkbox" value="plan" class="mr-2 type-checkbox"> Plan</label>
+                        <label class="flex items-center"><input type="checkbox" value="strategy"
+                                class="mr-2 type-checkbox"> Strategy</label>
+                        <label class="flex items-center"><input type="checkbox" value="tool" class="mr-2 type-checkbox">
+                            Tool</label>
+                        <label class="flex items-center"><input type="checkbox" value="user_profile"
+                                class="mr-2 type-checkbox"> User Profile</label>
+                        <label class="flex items-center"><input type="checkbox" value="usecase"
+                                class="mr-2 type-checkbox"> Usecase</label>
+                        <label class="flex items-center"><input type="checkbox" value="definition"
+                                class="mr-2 type-checkbox"> Definition</label>
+                        <label class="flex items-center"><input type="checkbox" value="plan" class="mr-2 type-checkbox">
+                            Plan</label>
                     </div>
                 </div>
                 <div>
                     <label class="block text-sm font-medium mb-1">Tags (JSON)</label>
-                    <textarea id="tagsInput" rows="2" placeholder='{"key": "value"}' class="w-full border rounded px-3 py-2"></textarea>
+                    <textarea id="tagsInput" rows="2" placeholder='{"key": "value"}'
+                        class="w-full border rounded px-3 py-2"></textarea>
                 </div>
                 <div>
                     <label class="block text-sm font-medium mb-1">Scopes (逗号分隔)</label>
-                    <input type="text" id="scopesInput" placeholder="org:cybertogether" class="w-full border rounded px-3 py-2">
+                    <input type="text" id="scopesInput" placeholder="org:cybertogether"
+                        class="w-full border rounded px-3 py-2">
                 </div>
                 <div>
                     <label class="block text-sm font-medium mb-1">Owner</label>
@@ -147,14 +175,16 @@
                 </div>
                 <div class="flex gap-2 pt-4">
                     <button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded">保存</button>
-                    <button type="button" onclick="closeModal()" class="bg-gray-300 hover:bg-gray-400 px-6 py-2 rounded">取消</button>
+                    <button type="button" onclick="closeModal()"
+                        class="bg-gray-300 hover:bg-gray-400 px-6 py-2 rounded">取消</button>
                 </div>
             </form>
         </div>
     </div>
 
     <!-- 工具表 Modal -->
-    <div id="toolTableModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
+    <div id="toolTableModal"
+        class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
         <div class="bg-white rounded-xl shadow-xl w-full max-w-5xl max-h-[90vh] flex flex-col">
             <div class="flex justify-between items-center p-6 border-b">
                 <h2 class="text-2xl font-bold">🔧 工具表</h2>
@@ -175,11 +205,13 @@
     </div>
 
     <!-- 知识详情弹窗(只读)-->
-    <div id="knowledgeDetailModal" class="hidden fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center p-4 z-[60]">
+    <div id="knowledgeDetailModal"
+        class="hidden fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center p-4 z-[60]">
         <div class="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[85vh] flex flex-col">
             <div class="flex justify-between items-center p-6 border-b">
                 <h2 class="text-xl font-bold text-gray-800">知识详情</h2>
-                <button onclick="closeKnowledgeDetailModal()" class="text-gray-400 hover:text-gray-600 text-2xl">✕</button>
+                <button onclick="closeKnowledgeDetailModal()"
+                    class="text-gray-400 hover:text-gray-600 text-2xl">✕</button>
             </div>
             <div id="knowledgeDetailContent" class="flex-1 overflow-y-auto p-6">
                 <p class="text-gray-400 text-center animate-pulse">加载中...</p>
@@ -189,4 +221,5 @@
 
     <script src="/static/app.js"></script>
 </body>
-</html>
+
+</html>

+ 0 - 171
migrate_knowledge.py

@@ -1,171 +0,0 @@
-#!/usr/bin/env python
-"""
-知识库迁移脚本: SQLite -> Milvus
-从旧的 SQLite 数据库迁移知识数据到新的 Milvus 向量数据库
-"""
-
-import sys
-import json
-import sqlite3
-import asyncio
-from pathlib import Path
-from datetime import datetime
-
-# 添加项目路径
-sys.path.insert(0, str(Path(__file__).parent))
-
-from knowhub.vector_store import MilvusStore
-from knowhub.embeddings import get_embeddings_batch
-
-
-async def migrate_knowledge():
-    """迁移知识数据"""
-
-    # 源数据库路径
-    source_db = Path("/root/knowhub_backup_20260309_204451.db")
-    if not source_db.exists():
-        print(f"❌ 源数据库不存在: {source_db}")
-        return
-
-    # 目标 Milvus 存储
-    milvus_data_dir = Path(__file__).parent / "knowhub/milvus_data"
-    target_store = MilvusStore(str(milvus_data_dir))
-
-    print(f"📂 源数据库: {source_db}")
-    print(f"📂 目标 Milvus: {milvus_data_dir}")
-    print(f"📊 当前 Milvus 中的知识数量: {target_store.count()}")
-
-    # 读取源数据
-    print("\n📖 正在读取源数据...")
-    conn = sqlite3.connect(str(source_db))
-    conn.row_factory = sqlite3.Row
-    cursor = conn.cursor()
-
-    cursor.execute("SELECT * FROM knowledge ORDER BY created_at")
-    rows = cursor.fetchall()
-    conn.close()
-
-    print(f"✅ 读取到 {len(rows)} 条知识数据")
-
-    if len(rows) == 0:
-        print("⚠️  没有数据需要迁移")
-        return
-
-    # 显示迁移信息
-    print(f"\n⚠️  即将迁移 {len(rows)} 条知识到 Milvus")
-    print(f"   当前 Milvus 中已有 {target_store.count()} 条知识")
-    print("   开始迁移...")
-
-    # 转换数据格式
-    print("\n🔄 正在转换数据格式...")
-    knowledge_list = []
-    tasks = []  # 用于批量生成 embedding
-
-    for row in rows:
-        try:
-            # 解析 JSON 字段
-            types = json.loads(row['types']) if row['types'] else ["strategy"]
-            tags = json.loads(row['tags']) if row['tags'] else {}
-            scopes = json.loads(row['scopes']) if row['scopes'] else ["org:cybertogether"]
-            source = json.loads(row['source']) if row['source'] else {}
-            eval_data = json.loads(row['eval']) if row['eval'] else {
-                "score": 3, "helpful": 1, "harmful": 0, "confidence": 0.5,
-                "helpful_history": [], "harmful_history": []
-            }
-            resource_ids = json.loads(row['resource_ids']) if row['resource_ids'] else []
-
-            # 解析时间戳
-            created_at = row['created_at']
-            updated_at = row['updated_at'] if row['updated_at'] else created_at
-
-            # 转换为时间戳(如果是 ISO 格式字符串)
-            if isinstance(created_at, str):
-                try:
-                    created_at = int(datetime.fromisoformat(created_at.replace('Z', '+00:00')).timestamp())
-                except:
-                    created_at = int(datetime.now().timestamp())
-
-            if isinstance(updated_at, str):
-                try:
-                    updated_at = int(datetime.fromisoformat(updated_at.replace('Z', '+00:00')).timestamp())
-                except:
-                    updated_at = created_at
-
-            knowledge_list.append({
-                "id": row['id'],
-                "message_id": row['message_id'] or "",
-                "task": row['task'],
-                "content": row['content'],
-                "types": types,
-                "tags": tags,
-                "tag_keys": list(tags.keys()) if isinstance(tags, dict) else [],
-                "scopes": scopes,
-                "owner": row['owner'] or "agent:unknown",
-                "resource_ids": resource_ids,
-                "source": source,
-                "eval": eval_data,
-                "created_at": created_at,
-                "updated_at": updated_at,
-                "status": "approved",
-                "relationships": [],
-            })
-
-            # 收集 task 用于生成 embedding(只基于 task)
-            tasks.append(row['task'])
-
-        except Exception as e:
-            print(f"⚠️  跳过无效数据 {row['id']}: {e}")
-            continue
-
-    print(f"✅ 成功转换 {len(knowledge_list)} 条知识")
-
-    # 批量生成 embeddings
-    print(f"\n🧮 正在生成 embeddings (只基于 task 字段)...")
-    batch_size = 100
-    all_embeddings = []
-
-    for i in range(0, len(tasks), batch_size):
-        batch_tasks = tasks[i:i+batch_size]
-        print(f"   处理 {i+1}-{min(i+batch_size, len(tasks))}/{len(tasks)}...")
-
-        try:
-            embeddings = await get_embeddings_batch(batch_tasks)
-            all_embeddings.extend(embeddings)
-        except Exception as e:
-            print(f"❌ 生成 embeddings 失败: {e}")
-            return
-
-    print(f"✅ 成功生成 {len(all_embeddings)} 个 embeddings")
-
-    # 添加 embeddings 到知识数据
-    for knowledge, embedding in zip(knowledge_list, all_embeddings):
-        knowledge["embedding"] = embedding
-
-    # 批量插入到 Milvus
-    print(f"\n💾 正在插入数据到 Milvus...")
-    batch_size = 100
-
-    for i in range(0, len(knowledge_list), batch_size):
-        batch = knowledge_list[i:i+batch_size]
-        try:
-            target_store.insert_batch(batch)
-            print(f"   已插入 {min(i+batch_size, len(knowledge_list))}/{len(knowledge_list)}")
-        except Exception as e:
-            print(f"❌ 插入失败: {e}")
-            print(f"   失败的批次: {i}-{i+batch_size}")
-            # 尝试逐条插入
-            for j, item in enumerate(batch):
-                try:
-                    target_store.insert(item)
-                except Exception as e2:
-                    print(f"   ⚠️  跳过 {item['id']}: {e2}")
-
-    # 验证
-    final_count = target_store.count()
-    print(f"\n✅ 迁移完成!")
-    print(f"   Milvus 中的知识总数: {final_count}")
-    print(f"   新增: {final_count - target_store.count() + len(knowledge_list)}")
-
-
-if __name__ == "__main__":
-    asyncio.run(migrate_knowledge())

+ 0 - 21
test_api.py

@@ -1,21 +0,0 @@
-import asyncio
-import httpx
-import json
-
-async def test():
-    async with httpx.AsyncClient(timeout=10) as client:
-        resp = await client.get(
-            "http://8.147.104.190:8001/api/agent/search",
-            params={
-                "q": "科幻",
-                "source_type": "实质",
-                "entity_type": "all",
-                "top_k": 2,
-                "use_description": "true",
-            }
-        )
-        data = resp.json()
-        with open("test_api_output.json", "w", encoding="utf-8") as f:
-            json.dump(data, f, ensure_ascii=False, indent=2)
-
-asyncio.run(test())

+ 0 - 23
test_api_output.json

@@ -1,23 +0,0 @@
-{
-  "success": true,
-  "query": "科幻",
-  "source_type": "实质",
-  "entity_type": "all",
-  "count": 2,
-  "results": [
-    {
-      "entity_type": "element",
-      "entity_id": 120420,
-      "name": "赛博民俗",
-      "description": "结合传统民俗与科幻赛博风格的文化概念",
-      "category_path": null
-    },
-    {
-      "entity_type": "element",
-      "entity_id": 127947,
-      "name": "赛博民俗",
-      "description": "结合传统民俗与科幻赛博风格的文化概念",
-      "category_path": null
-    }
-  ]
-}

+ 0 - 25
test_search.py

@@ -1,25 +0,0 @@
-import asyncio
-import sys
-import io
-
-# 设置 stdout 编码为 UTF-8
-sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
-sys.path.insert(0, '.')
-
-async def test():
-    from agent.tools.builtin.search import search_posts
-
-    print("Testing search_posts with gzh channel...")
-    result = await search_posts(keyword='Midjourney v8', channel='gzh', max_count=20)
-
-    print(f'Title: {result.title}')
-    print(f'Has error: {result.error is not None}')
-
-    if result.error:
-        print(f'Error message: {result.error}')
-    else:
-        print(f'Output length: {len(result.output)} chars')
-        print(f'Has images: {len(result.images) > 0}')
-
-if __name__ == '__main__':
-    asyncio.run(test())

+ 0 - 38
test_xhs_search.py

@@ -1,38 +0,0 @@
-"""
-测试小红书渠道的 search_posts 接口
-"""
-import asyncio
-import sys
-sys.path.insert(0, '/root/Agent')
-
-from agent.tools.builtin.search import search_posts
-
-
-async def test_xhs_search():
-    """测试小红书搜索"""
-    print("开始测试小红书渠道搜索...")
-    print("-" * 50)
-
-    # 测试搜索
-    result = await search_posts(
-        keyword="Python编程",
-        channel="xhs",
-        cursor="0",
-        max_count=5
-    )
-
-    print(f"标题: {result.title}")
-    print(f"是否有错误: {result.error is not None}")
-    if result.error:
-        print(f"错误信息: {result.error}")
-    else:
-        print(f"输出长度: {len(result.output)} 字符")
-        print(f"图片数量: {len(result.images) if result.images else 0}")
-        print("\n搜索结果:")
-        print(result.output[:500])  # 只打印前500字符
-
-    return result
-
-
-if __name__ == "__main__":
-    result = asyncio.run(test_xhs_search())