Jelajahi Sumber

feat: knowhub extract api

Talegorithm 3 hari lalu
induk
melakukan
ea23824c5b
3 mengubah file dengan 604 tambahan dan 545 penghapusan
  1. 52 0
      agent/core/runner.py
  2. 4 0
      agent/tools/builtin/knowledge.py
  3. 548 545
      knowhub/server.py

+ 52 - 0
agent/core/runner.py

@@ -72,6 +72,13 @@ class KnowledgeConfig:
     # 知识注入(agent切换当前工作的goal时,自动注入相关知识)
     # 知识注入(agent切换当前工作的goal时,自动注入相关知识)
     enable_injection: bool = True          # 是否在 focus goal 时自动注入相关知识
     enable_injection: bool = True          # 是否在 focus goal 时自动注入相关知识
 
 
+    # 默认字段(保存/搜索时自动注入)
+    owner: str = ""                            # 所有者(空则尝试从 git config user.email 获取,再空则用 agent:{agent_id})
+    default_tags: Optional[Dict[str, str]] = None      # 默认 tags(会与工具调用参数合并)
+    default_scopes: Optional[List[str]] = None         # 默认 scopes(空则用 ["org:cybertogether"])
+    default_search_types: Optional[List[str]] = None   # 默认搜索类型过滤
+    default_search_owner: str = ""                     # 默认搜索 owner 过滤(空则不过滤)
+
     def get_reflect_prompt(self) -> str:
     def get_reflect_prompt(self) -> str:
         """压缩时反思 prompt"""
         """压缩时反思 prompt"""
         return self.reflect_prompt if self.reflect_prompt else build_reflect_prompt()
         return self.reflect_prompt if self.reflect_prompt else build_reflect_prompt()
@@ -80,6 +87,27 @@ class KnowledgeConfig:
         """任务完成后复盘 prompt"""
         """任务完成后复盘 prompt"""
         return self.completion_reflect_prompt if self.completion_reflect_prompt else COMPLETION_REFLECT_PROMPT
         return self.completion_reflect_prompt if self.completion_reflect_prompt else COMPLETION_REFLECT_PROMPT
 
 
+    def get_owner(self, agent_id: str = "agent") -> str:
+        """获取 owner(优先级:配置 > git email > agent:{agent_id})"""
+        if self.owner:
+            return self.owner
+
+        # 尝试从 git config 获取
+        try:
+            import subprocess
+            result = subprocess.run(
+                ["git", "config", "user.email"],
+                capture_output=True,
+                text=True,
+                timeout=2,
+            )
+            if result.returncode == 0 and result.stdout.strip():
+                return result.stdout.strip()
+        except Exception:
+            pass
+
+        return f"agent:{agent_id}"
+
 
 
 @dataclass
 @dataclass
 class ContextUsage:
 class ContextUsage:
@@ -953,6 +981,21 @@ class AgentRunner:
                     elif tool_args is None:
                     elif tool_args is None:
                         tool_args = {}
                         tool_args = {}
 
 
+                    # 注入知识管理工具的默认字段
+                    if tool_name == "knowledge_save":
+                        tool_args.setdefault("owner", config.knowledge.get_owner(config.agent_id))
+                        if config.knowledge.default_tags:
+                            existing_tags = tool_args.get("tags") or {}
+                            merged_tags = {**config.knowledge.default_tags, **existing_tags}
+                            tool_args["tags"] = merged_tags
+                        if config.knowledge.default_scopes:
+                            tool_args.setdefault("scopes", config.knowledge.default_scopes)
+                    elif tool_name == "knowledge_search":
+                        if config.knowledge.default_search_types and "types" not in tool_args:
+                            tool_args["types"] = config.knowledge.default_search_types
+                        if config.knowledge.default_search_owner and "owner" not in tool_args:
+                            tool_args["owner"] = config.knowledge.default_search_owner
+
                     tool_result = await self.tools.execute(
                     tool_result = await self.tools.execute(
                         tool_name,
                         tool_name,
                         tool_args,
                         tool_args,
@@ -1250,6 +1293,15 @@ class AgentRunner:
                 tool_args.setdefault("source_category", "exp")
                 tool_args.setdefault("source_category", "exp")
                 tool_args.setdefault("message_id", trace_id)
                 tool_args.setdefault("message_id", trace_id)
 
 
+                # 注入知识管理默认字段
+                tool_args.setdefault("owner", config.knowledge.get_owner(config.agent_id))
+                if config.knowledge.default_tags:
+                    existing_tags = tool_args.get("tags") or {}
+                    merged_tags = {**config.knowledge.default_tags, **existing_tags}
+                    tool_args["tags"] = merged_tags
+                if config.knowledge.default_scopes:
+                    tool_args.setdefault("scopes", config.knowledge.default_scopes)
+
                 try:
                 try:
                     await self.tools.execute(
                     await self.tools.execute(
                         "knowledge_save",
                         "knowledge_save",

+ 4 - 0
agent/tools/builtin/knowledge.py

@@ -22,6 +22,7 @@ async def knowledge_search(
     top_k: int = 5,
     top_k: int = 5,
     min_score: int = 3,
     min_score: int = 3,
     types: Optional[List[str]] = None,
     types: Optional[List[str]] = None,
+    owner: Optional[str] = None,
     context: Optional[ToolContext] = None,
     context: Optional[ToolContext] = None,
 ) -> ToolResult:
 ) -> ToolResult:
     """
     """
@@ -32,6 +33,7 @@ async def knowledge_search(
         top_k: 返回数量(默认 5)
         top_k: 返回数量(默认 5)
         min_score: 最低评分过滤(默认 3)
         min_score: 最低评分过滤(默认 3)
         types: 按类型过滤(user_profile/strategy/tool/usecase/definition/plan)
         types: 按类型过滤(user_profile/strategy/tool/usecase/definition/plan)
+        owner: 按所有者过滤(可选)
         context: 工具上下文
         context: 工具上下文
 
 
     Returns:
     Returns:
@@ -45,6 +47,8 @@ async def knowledge_search(
         }
         }
         if types:
         if types:
             params["types"] = ",".join(types)
             params["types"] = ",".join(types)
+        if owner:
+            params["owner"] = owner
 
 
         async with httpx.AsyncClient(timeout=60.0) as client:
         async with httpx.AsyncClient(timeout=60.0) as client:
             response = await client.get(f"{KNOWHUB_API}/api/knowledge/search", params=params)
             response = await client.get(f"{KNOWHUB_API}/api/knowledge/search", params=params)

+ 548 - 545
knowhub/server.py

@@ -101,49 +101,6 @@ def init_db():
 
 
 # --- Models ---
 # --- Models ---
 
 
-class ExperienceIn(BaseModel):
-    name: str
-    url: str = ""
-    category: str = ""
-    task: str
-    score: int = Field(ge=1, le=5)
-    outcome: str = ""
-    tips: str = ""
-    content_id: str = ""
-    submitted_by: str = ""
-
-
-class ExperienceOut(BaseModel):
-    task: str
-    score: int
-    outcome: str
-    tips: str
-    content_id: str
-    submitted_by: str
-    created_at: str
-
-
-class ResourceResult(BaseModel):
-    name: str
-    url: str
-    relevant_experiences: list[ExperienceOut]
-    avg_score: float
-    experience_count: int
-
-
-class SearchResponse(BaseModel):
-    results: list[ResourceResult]
-
-
-class ResourceDetailResponse(BaseModel):
-    name: str
-    url: str
-    category: str
-    avg_score: float
-    experience_count: int
-    experiences: list[ExperienceOut]
-
-
 class ContentIn(BaseModel):
 class ContentIn(BaseModel):
     id: str
     id: str
     title: str = ""
     title: str = ""
@@ -197,6 +154,14 @@ class KnowledgePatchIn(BaseModel):
     owner: Optional[str] = None
     owner: Optional[str] = None
 
 
 
 
+class MessageExtractIn(BaseModel):
+    """POST /api/extract 请求体(消息历史提取)"""
+    messages: list[dict]  # [{role: str, content: str}, ...]
+    agent_id: str = "unknown"
+    submitted_by: str  # 必填,作为 owner
+    session_key: str = ""
+
+
 class KnowledgeBatchUpdateIn(BaseModel):
 class KnowledgeBatchUpdateIn(BaseModel):
     feedback_list: list[dict]
     feedback_list: list[dict]
 
 
@@ -232,521 +197,110 @@ async def lifespan(app: FastAPI):
 app = FastAPI(title=BRAND_NAME, lifespan=lifespan)
 app = FastAPI(title=BRAND_NAME, lifespan=lifespan)
 
 
 
 
-@app.get("/", response_class=HTMLResponse)
-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>
-</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>
-            <button onclick="openAddModal()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg">
-                + 新增知识
-            </button>
-        </div>
+# --- Knowledge API ---
 
 
-        <!-- 筛选栏 -->
-        <div class="bg-white rounded-lg shadow p-6 mb-6">
-            <div class="grid grid-cols-1 md:grid-cols-4 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>
-            <button onclick="applyFilters()" class="mt-4 bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded">
-                应用筛选
-            </button>
-        </div>
+@app.post("/api/content", status_code=201)
+def submit_content(content: ContentIn):
+    conn = get_db()
+    try:
+        now = datetime.now(timezone.utc).isoformat()
+        conn.execute(
+            "INSERT OR REPLACE INTO contents"
+            "(id, title, body, sort_order, submitted_by, created_at)"
+            " VALUES (?, ?, ?, ?, ?, ?)",
+            (content.id, content.title, content.body, content.sort_order, content.submitted_by, now),
+        )
+        conn.commit()
+        return {"status": "ok"}
+    finally:
+        conn.close()
 
 
-        <!-- 知识列表 -->
-        <div id="knowledgeList" class="space-y-4"></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 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>
+@app.get("/api/content/{content_id:path}", response_model=ContentOut)
+def get_content(content_id: str):
+    conn = get_db()
+    try:
+        row = conn.execute(
+            "SELECT id, title, body, sort_order FROM contents WHERE id = ?",
+            (content_id,),
+        ).fetchone()
+        if not row:
+            raise HTTPException(status_code=404, detail=f"Content not found: {content_id}")
 
 
-    <script>
-        let allKnowledge = [];
-        let availableTags = [];
+        # 计算导航上下文
+        root_id = content_id.split("/")[0] if "/" in content_id else content_id
 
 
-        async function loadTags() {
-            const res = await fetch('/api/knowledge/meta/tags');
-            const data = await res.json();
-            availableTags = data.tags;
-            renderTagsFilter();
-        }
+        # TOC (根节点)
+        toc = None
+        if "/" in content_id:
+            toc_row = conn.execute(
+                "SELECT id, title FROM contents WHERE id = ?",
+                (root_id,),
+            ).fetchone()
+            if toc_row:
+                toc = ContentNode(id=toc_row["id"], title=toc_row["title"])
 
 
-        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('');
-        }
+        # Children (子节点)
+        children = []
+        children_rows = conn.execute(
+            "SELECT id, title FROM contents WHERE id LIKE ? AND id != ? ORDER BY sort_order",
+            (f"{content_id}/%", content_id),
+        ).fetchall()
+        children = [ContentNode(id=r["id"], title=r["title"]) for r in children_rows]
 
 
-        async function loadKnowledge() {
-            const params = new URLSearchParams();
-            params.append('limit', '1000');
+        # Prev/Next (同级节点)
+        prev_node = None
+        next_node = None
+        if "/" in content_id:
+            siblings = conn.execute(
+                "SELECT id, title, sort_order FROM contents WHERE id LIKE ? AND id NOT LIKE ? ORDER BY sort_order",
+                (f"{root_id}/%", f"{root_id}/%/%"),
+            ).fetchall()
+            for i, sib in enumerate(siblings):
+                if sib["id"] == content_id:
+                    if i > 0:
+                        prev_node = ContentNode(id=siblings[i-1]["id"], title=siblings[i-1]["title"])
+                    if i < len(siblings) - 1:
+                        next_node = ContentNode(id=siblings[i+1]["id"], title=siblings[i+1]["title"])
+                    break
 
 
-            const selectedTypes = Array.from(document.querySelectorAll('.type-filter:checked')).map(el => el.value);
-            if (selectedTypes.length > 0) {
-                params.append('types', selectedTypes.join(','));
-            }
+        return ContentOut(
+            id=row["id"],
+            title=row["title"],
+            body=row["body"],
+            toc=toc,
+            children=children,
+            prev=prev_node,
+            next=next_node,
+        )
+    finally:
+        conn.close()
 
 
-            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);
-            }
+# ===== Knowledge API =====
 
 
-            const scopesFilter = document.getElementById('scopesFilter').value.trim();
-            if (scopesFilter) {
-                params.append('scopes', scopesFilter);
-            }
+# 两阶段检索逻辑
+async def _route_knowledge_by_llm(query_text: str, metadata_list: list[dict], k: int = 5) -> list[str]:
+    """
+    第一阶段:语义路由。
+    让 LLM 挑选出 2*k 个语义相关的 ID。
+    """
+    if not metadata_list:
+        return []
 
 
-            const res = await fetch(`/api/knowledge?${params.toString()}`);
-            const data = await res.json();
-            allKnowledge = data.results;
-            renderKnowledge(allKnowledge);
-        }
+    routing_k = k * 2
 
 
-        function applyFilters() {
-            loadKnowledge();
-        }
+    routing_data = [
+        {
+            "id": m["id"],
+            "types": m["types"],
+            "task": m["task"][:100]
+        } for m in metadata_list
+    ]
 
 
-        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 => `
-                <div class="bg-white rounded-lg shadow p-6 hover:shadow-lg transition cursor-pointer" onclick="openEditModal('${k.id}')">
-                    <div class="flex justify-between items-start mb-2">
-                        <div class="flex gap-2 flex-wrap">
-                            ${k.types.map(t => `<span class="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded">${t}</span>`).join('')}
-                        </div>
-                        <span class="text-sm text-gray-500">${k.eval.score || 3}/5</span>
-                    </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>
-                </div>
-            `).join('');
-        }
-
-        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) {
-            const k = allKnowledge.find(item => item.id === id);
-            if (!k) 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);
-            document.getElementById('scopesInput').value = k.scopes.join(', ');
-            document.getElementById('ownerInput').value = k.owner;
-
-            document.querySelectorAll('.type-checkbox').forEach(el => {
-                el.checked = k.types.includes(el.value);
-            });
-
-            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();
-        });
-
-        function escapeHtml(text) {
-            const div = document.createElement('div');
-            div.textContent = text;
-            return div.innerHTML;
-        }
-
-        loadTags();
-        loadKnowledge();
-    </script>
-</body>
-</html>"""
-
-
-def _search_rows(conn: sqlite3.Connection, q: str, category: Optional[str]) -> list[sqlite3.Row]:
-    """LIKE 搜索,拆词后 AND 连接,匹配 task + tips + outcome + name"""
-    terms = q.split()
-    if not terms:
-        return []
-
-    conditions = []
-    params: list[str] = []
-    for term in terms:
-        like = f"%{term}%"
-        conditions.append(
-            "(task LIKE ? OR tips LIKE ? OR outcome LIKE ? OR name LIKE ?)"
-        )
-        params.extend([like, like, like, like])
-
-    if category:
-        conditions.append("category = ?")
-        params.append(category)
-
-    sql = (
-        "SELECT name, url, category, task, score, outcome, tips, content_id, "
-        "submitted_by, created_at FROM experiences WHERE "
-        + " AND ".join(conditions)
-        + " ORDER BY created_at DESC"
-    )
-    return conn.execute(sql, params).fetchall()
-
-
-def _group_by_resource(rows: list[sqlite3.Row], limit: int) -> list[ResourceResult]:
-    """按 name 分组并聚合"""
-    groups: dict[str, list[sqlite3.Row]] = {}
-    for row in rows:
-        name = row["name"]
-        if name not in groups:
-            groups[name] = []
-        groups[name].append(row)
-
-    results = []
-    for resource_name, resource_rows in groups.items():
-        scores = [r["score"] for r in resource_rows]
-        avg = sum(scores) / len(scores)
-        results.append(ResourceResult(
-            name=resource_name,
-            url=resource_rows[0]["url"],
-            relevant_experiences=[
-                ExperienceOut(
-                    task=r["task"],
-                    score=r["score"],
-                    outcome=r["outcome"],
-                    tips=r["tips"],
-                    content_id=r["content_id"],
-                    submitted_by=r["submitted_by"],
-                    created_at=r["created_at"],
-                )
-                for r in resource_rows
-            ],
-            avg_score=round(avg, 1),
-            experience_count=len(resource_rows),
-        ))
-
-    results.sort(key=lambda r: r.avg_score * r.experience_count, reverse=True)
-    return results[:limit]
-
-
-@app.get("/api/search", response_model=SearchResponse)
-def search_experiences(
-    q: str = Query(..., min_length=1),
-    category: Optional[str] = None,
-    limit: int = Query(default=10, ge=1, le=50),
-):
-    conn = get_db()
-    try:
-        rows = _search_rows(conn, q, category)
-        return SearchResponse(results=_group_by_resource(rows, limit))
-    finally:
-        conn.close()
-
-
-@app.post("/api/experience", status_code=201)
-def submit_experience(exp: ExperienceIn):
-    conn = get_db()
-    try:
-        now = datetime.now(timezone.utc).isoformat()
-        conn.execute(
-            "INSERT INTO experiences"
-            "(name, url, category, task, score, outcome, tips, content_id, submitted_by, created_at)"
-            " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
-            (exp.name, exp.url, exp.category, exp.task,
-             exp.score, exp.outcome, exp.tips, exp.content_id, exp.submitted_by, now),
-        )
-        conn.commit()
-        return {"status": "ok"}
-    finally:
-        conn.close()
-
-
-@app.get("/api/resource/{name}", response_model=ResourceDetailResponse)
-def get_resource_experiences(name: str):
-    conn = get_db()
-    try:
-        rows = conn.execute(
-            "SELECT name, url, category, task, score, outcome, tips, content_id, "
-            "submitted_by, created_at FROM experiences "
-            "WHERE name = ? ORDER BY created_at DESC",
-            (name,),
-        ).fetchall()
-        if not rows:
-            raise HTTPException(status_code=404, detail=f"No experiences found for resource: {name}")
-
-        scores = [r["score"] for r in rows]
-        avg = sum(scores) / len(scores)
-        return ResourceDetailResponse(
-            name=name,
-            url=rows[0]["url"],
-            category=rows[0]["category"],
-            avg_score=round(avg, 1),
-            experience_count=len(rows),
-            experiences=[
-                ExperienceOut(
-                    task=r["task"],
-                    score=r["score"],
-                    outcome=r["outcome"],
-                    tips=r["tips"],
-                    content_id=r["content_id"],
-                    submitted_by=r["submitted_by"],
-                    created_at=r["created_at"],
-                )
-                for r in rows
-            ],
-        )
-    finally:
-        conn.close()
-
-
-@app.post("/api/content", status_code=201)
-def submit_content(content: ContentIn):
-    conn = get_db()
-    try:
-        now = datetime.now(timezone.utc).isoformat()
-        conn.execute(
-            "INSERT OR REPLACE INTO contents"
-            "(id, title, body, sort_order, submitted_by, created_at)"
-            " VALUES (?, ?, ?, ?, ?, ?)",
-            (content.id, content.title, content.body, content.sort_order, content.submitted_by, now),
-        )
-        conn.commit()
-        return {"status": "ok"}
-    finally:
-        conn.close()
-
-
-@app.get("/api/content/{content_id:path}", response_model=ContentOut)
-def get_content(content_id: str):
-    conn = get_db()
-    try:
-        row = conn.execute(
-            "SELECT id, title, body, sort_order FROM contents WHERE id = ?",
-            (content_id,),
-        ).fetchone()
-        if not row:
-            raise HTTPException(status_code=404, detail=f"Content not found: {content_id}")
-
-        # 计算导航上下文
-        root_id = content_id.split("/")[0] if "/" in content_id else content_id
-
-        # TOC (根节点)
-        toc = None
-        if "/" in content_id:
-            toc_row = conn.execute(
-                "SELECT id, title FROM contents WHERE id = ?",
-                (root_id,),
-            ).fetchone()
-            if toc_row:
-                toc = ContentNode(id=toc_row["id"], title=toc_row["title"])
-
-        # Children (子节点)
-        children = []
-        children_rows = conn.execute(
-            "SELECT id, title FROM contents WHERE id LIKE ? AND id != ? ORDER BY sort_order",
-            (f"{content_id}/%", content_id),
-        ).fetchall()
-        children = [ContentNode(id=r["id"], title=r["title"]) for r in children_rows]
-
-        # Prev/Next (同级节点)
-        prev_node = None
-        next_node = None
-        if "/" in content_id:
-            siblings = conn.execute(
-                "SELECT id, title, sort_order FROM contents WHERE id LIKE ? AND id NOT LIKE ? ORDER BY sort_order",
-                (f"{root_id}/%", f"{root_id}/%/%"),
-            ).fetchall()
-            for i, sib in enumerate(siblings):
-                if sib["id"] == content_id:
-                    if i > 0:
-                        prev_node = ContentNode(id=siblings[i-1]["id"], title=siblings[i-1]["title"])
-                    if i < len(siblings) - 1:
-                        next_node = ContentNode(id=siblings[i+1]["id"], title=siblings[i+1]["title"])
-                    break
-
-        return ContentOut(
-            id=row["id"],
-            title=row["title"],
-            body=row["body"],
-            toc=toc,
-            children=children,
-            prev=prev_node,
-            next=next_node,
-        )
-    finally:
-        conn.close()
-
-
-# ===== Knowledge API =====
-
-# 两阶段检索逻辑
-async def _route_knowledge_by_llm(query_text: str, metadata_list: list[dict], k: int = 5) -> list[str]:
-    """
-    第一阶段:语义路由。
-    让 LLM 挑选出 2*k 个语义相关的 ID。
-    """
-    if not metadata_list:
-        return []
-
-    routing_k = k * 2
-
-    routing_data = [
-        {
-            "id": m["id"],
-            "types": m["types"],
-            "task": m["task"][:100]
-        } for m in metadata_list
-    ]
-
-    prompt = f"""
-你是一个知识检索专家。根据用户的当前任务需求,从下列原子知识元数据中挑选出最相关的最多 {routing_k} 个知识 ID。
-任务需求:"{query_text}"
+    prompt = f"""
+你是一个知识检索专家。根据用户的当前任务需求,从下列原子知识元数据中挑选出最相关的最多 {routing_k} 个知识 ID。
+任务需求:"{query_text}"
 
 
 可选知识列表:
 可选知识列表:
 {json.dumps(routing_data, ensure_ascii=False, indent=1)}
 {json.dumps(routing_data, ensure_ascii=False, indent=1)}
@@ -777,6 +331,7 @@ async def _search_knowledge_two_stage(
     top_k: int = 5,
     top_k: int = 5,
     min_score: int = 3,
     min_score: int = 3,
     types_filter: Optional[list[str]] = None,
     types_filter: Optional[list[str]] = None,
+    owner_filter: Optional[str] = None,
     conn: sqlite3.Connection = None
     conn: sqlite3.Connection = None
 ) -> list[dict]:
 ) -> list[dict]:
     """
     """
@@ -808,6 +363,10 @@ async def _search_knowledge_two_stage(
                 if not any(t in types for t in types_filter):
                 if not any(t in types for t in types_filter):
                     continue
                     continue
 
 
+            # owner 过滤
+            if owner_filter and row["owner"] != owner_filter:
+                continue
+
             task = row["task"]
             task = row["task"]
             content_text = row["content"]
             content_text = row["content"]
             eval_data = json.loads(row["eval"])
             eval_data = json.loads(row["eval"])
@@ -900,7 +459,8 @@ async def search_knowledge_api(
     q: str = Query(..., description="查询文本"),
     q: str = Query(..., description="查询文本"),
     top_k: int = Query(default=5, ge=1, le=20),
     top_k: int = Query(default=5, ge=1, le=20),
     min_score: int = Query(default=3, ge=1, le=5),
     min_score: int = Query(default=3, ge=1, le=5),
-    types: Optional[str] = None
+    types: Optional[str] = None,
+    owner: Optional[str] = None
 ):
 ):
     """检索知识(两阶段:语义路由 + 质量精排)"""
     """检索知识(两阶段:语义路由 + 质量精排)"""
     conn = get_db()
     conn = get_db()
@@ -912,6 +472,7 @@ async def search_knowledge_api(
             top_k=top_k,
             top_k=top_k,
             min_score=min_score,
             min_score=min_score,
             types_filter=types_filter,
             types_filter=types_filter,
+            owner_filter=owner,
             conn=conn
             conn=conn
         )
         )
 
 
@@ -1462,6 +1023,448 @@ REPORT: 原有 X 条,合并后 Y 条,精简了 Z 条。
         conn.close()
         conn.close()
 
 
 
 
+@app.post("/api/extract")
+async def extract_knowledge_from_messages(extract_req: MessageExtractIn):
+    """从消息历史中提取知识(LLM 分析)"""
+    if not extract_req.submitted_by:
+        raise HTTPException(status_code=400, detail="submitted_by is required")
+
+    messages = extract_req.messages
+    if not messages or len(messages) == 0:
+        return {"status": "ok", "extracted_count": 0, "knowledge_ids": []}
+
+    # 构造消息历史文本
+    messages_text = ""
+    for msg in messages:
+        role = msg.get("role", "unknown")
+        content = msg.get("content", "")
+        messages_text += f"[{role}]: {content}\n\n"
+
+    # LLM 提取知识
+    prompt = f"""你是一个知识提取专家。请从以下 Agent 对话历史中提取有价值的知识。
+
+【对话历史】:
+{messages_text}
+
+【提取要求】:
+1. 识别对话中的关键知识点(工具使用经验、问题解决方案、最佳实践、踩坑经验等)
+2. 每条知识必须包含:
+   - task: 任务场景描述(在什么情况下,要完成什么目标)
+   - content: 核心知识内容(具体可操作的方法、注意事项)
+   - types: 知识类型(从 strategy/tool/user_profile/usecase/definition/plan 中选择)
+   - score: 评分 1-5(根据知识的价值和可操作性)
+3. 只提取有实际价值的知识,不要提取泛泛而谈的内容
+4. 如果没有值得提取的知识,返回空列表
+
+【输出格式】:
+严格按以下 JSON 格式输出,每条知识之间用逗号分隔:
+[
+  {{
+    "task": "任务场景描述",
+    "content": "核心知识内容",
+    "types": ["strategy"],
+    "score": 4
+  }},
+  {{
+    "task": "另一个任务场景",
+    "content": "另一个知识内容",
+    "types": ["tool"],
+    "score": 5
+  }}
+]
+
+如果没有知识,输出: []
+
+禁止输出任何解释或额外文本,只输出 JSON 数组。"""
+
+    try:
+        print(f"\n[Extract] 正在从 {len(messages)} 条消息中提取知识...")
+
+        response = await openrouter_llm_call(
+            messages=[{"role": "user", "content": prompt}],
+            model="google/gemini-2.0-flash-001"
+        )
+
+        content = response.get("content", "").strip()
+
+        # 尝试解析 JSON
+        # 移除可能的 markdown 代码块标记
+        if content.startswith("```json"):
+            content = content[7:]
+        if content.startswith("```"):
+            content = content[3:]
+        if content.endswith("```"):
+            content = content[:-3]
+        content = content.strip()
+
+        extracted_knowledge = json.loads(content)
+
+        if not isinstance(extracted_knowledge, list):
+            raise ValueError("LLM output is not a list")
+
+        # 保存提取的知识
+        conn = get_db()
+        knowledge_ids = []
+        now = datetime.now(timezone.utc).isoformat()
+
+        try:
+            for item in extracted_knowledge:
+                task = item.get("task", "")
+                knowledge_content = item.get("content", "")
+                types = item.get("types", ["strategy"])
+                score = item.get("score", 3)
+
+                if not task or not knowledge_content:
+                    continue
+
+                # 生成 ID
+                import uuid
+                timestamp = datetime.now().strftime('%Y%m%d-%H%M%S')
+                random_suffix = uuid.uuid4().hex[:4]
+                knowledge_id = f"knowledge-{timestamp}-{random_suffix}"
+
+                # 准备数据
+                source = {
+                    "name": "message_extraction",
+                    "category": "exp",
+                    "urls": [],
+                    "agent_id": extract_req.agent_id,
+                    "submitted_by": extract_req.submitted_by,
+                    "timestamp": now,
+                    "session_key": extract_req.session_key
+                }
+
+                eval_data = {
+                    "score": score,
+                    "helpful": 1,
+                    "harmful": 0,
+                    "confidence": 0.7,
+                    "helpful_history": [],
+                    "harmful_history": []
+                }
+
+                # 插入数据库
+                conn.execute(
+                    """INSERT INTO knowledge
+                    (id, message_id, types, task, tags, scopes, owner, content,
+                     source, eval, created_at, updated_at)
+                    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+                    (
+                        knowledge_id,
+                        "",
+                        json.dumps(types),
+                        task,
+                        json.dumps({}),
+                        json.dumps(["org:cybertogether"]),
+                        extract_req.submitted_by,
+                        knowledge_content,
+                        json.dumps(source, ensure_ascii=False),
+                        json.dumps(eval_data, ensure_ascii=False),
+                        now,
+                        now,
+                    ),
+                )
+                knowledge_ids.append(knowledge_id)
+
+            conn.commit()
+            print(f"[Extract] 成功提取并保存 {len(knowledge_ids)} 条知识")
+
+            return {
+                "status": "ok",
+                "extracted_count": len(knowledge_ids),
+                "knowledge_ids": knowledge_ids
+            }
+
+        finally:
+            conn.close()
+
+    except json.JSONDecodeError as e:
+        print(f"[Extract] JSON 解析失败: {e}")
+        print(f"[Extract] LLM 输出: {content[:500]}")
+        return {"status": "error", "error": "Failed to parse LLM output", "extracted_count": 0}
+    except Exception as e:
+        print(f"[Extract] 提取失败: {e}")
+        return {"status": "error", "error": str(e), "extracted_count": 0}
+
+
+@app.get("/", response_class=HTMLResponse)
+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>
+</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>
+            <button onclick="openAddModal()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg">
+                + 新增知识
+            </button>
+        </div>
+
+        <!-- 筛选栏 -->
+        <div class="bg-white rounded-lg shadow p-6 mb-6">
+            <div class="grid grid-cols-1 md:grid-cols-4 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>
+            <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>
+
+    <!-- 新增/编辑 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 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>
+
+    <script>
+        let allKnowledge = [];
+        let availableTags = [];
+
+        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() {
+            const params = new URLSearchParams();
+            params.append('limit', '1000');
+
+            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 res = await fetch(`/api/knowledge?${params.toString()}`);
+            const data = await res.json();
+            allKnowledge = data.results;
+            renderKnowledge(allKnowledge);
+        }
+
+        function applyFilters() {
+            loadKnowledge();
+        }
+
+        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 => `
+                <div class="bg-white rounded-lg shadow p-6 hover:shadow-lg transition cursor-pointer" onclick="openEditModal('${k.id}')">
+                    <div class="flex justify-between items-start mb-2">
+                        <div class="flex gap-2 flex-wrap">
+                            ${k.types.map(t => `<span class="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded">${t}</span>`).join('')}
+                        </div>
+                        <span class="text-sm text-gray-500">${k.eval.score || 3}/5</span>
+                    </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>
+                </div>
+            `).join('');
+        }
+
+        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) {
+            const k = allKnowledge.find(item => item.id === id);
+            if (!k) 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);
+            document.getElementById('scopesInput').value = k.scopes.join(', ');
+            document.getElementById('ownerInput').value = k.owner;
+
+            document.querySelectorAll('.type-checkbox').forEach(el => {
+                el.checked = k.types.includes(el.value);
+            });
+
+            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();
+        });
+
+        function escapeHtml(text) {
+            const div = document.createElement('div');
+            div.textContent = text;
+            return div.innerHTML;
+        }
+
+        loadTags();
+        loadKnowledge();
+    </script>
+</body>
+</html>"""
+
 if __name__ == "__main__":
 if __name__ == "__main__":
     import uvicorn
     import uvicorn
     uvicorn.run(app, host="0.0.0.0", port=9999)
     uvicorn.run(app, host="0.0.0.0", port=9999)