Talegorithm 3 дней назад
Родитель
Сommit
48cb1b3a8b
4 измененных файлов с 449 добавлено и 75 удалено
  1. 66 45
      agent/core/runner.py
  2. 0 23
      knowhub/config.py
  3. 2 2
      knowhub/docs/knowledge-management.md
  4. 381 5
      knowhub/server.py

+ 66 - 45
agent/core/runner.py

@@ -81,6 +81,17 @@ class KnowledgeConfig:
         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
 
 
 
 
+@dataclass
+class ContextUsage:
+    """Context 使用情况"""
+    trace_id: str
+    message_count: int
+    token_count: int
+    max_tokens: int
+    usage_percent: float
+    image_count: int = 0
+
+
 # ===== 运行配置 =====
 # ===== 运行配置 =====
 
 
 @dataclass
 @dataclass
@@ -249,8 +260,16 @@ class AgentRunner:
         # 知识保存跟踪(每个 trace 独立)
         # 知识保存跟踪(每个 trace 独立)
         self._saved_knowledge_ids: Dict[str, List[str]] = {}  # trace_id → [knowledge_ids]
         self._saved_knowledge_ids: Dict[str, List[str]] = {}  # trace_id → [knowledge_ids]
 
 
+        # Context 使用跟踪
+        self._context_warned: Dict[str, set] = {}  # trace_id → {30, 50, 80} 已警告过的阈值
+        self._context_usage: Dict[str, ContextUsage] = {}  # trace_id → 当前用量快照
+
     # ===== 核心公开方法 =====
     # ===== 核心公开方法 =====
 
 
+    def get_context_usage(self, trace_id: str) -> Optional[ContextUsage]:
+        """获取指定 trace 的 context 使用情况"""
+        return self._context_usage.get(trace_id)
+
     async def run(
     async def run(
         self,
         self,
         messages: List[Dict],
         messages: List[Dict],
@@ -681,7 +700,7 @@ class AgentRunner:
             token_count = estimate_tokens(history)
             token_count = estimate_tokens(history)
             max_tokens = compression_config.get_max_tokens(config.model)
             max_tokens = compression_config.get_max_tokens(config.model)
 
 
-            # 压缩评估日志
+            # 计算使用率
             progress_pct = (token_count / max_tokens * 100) if max_tokens > 0 else 0
             progress_pct = (token_count / max_tokens * 100) if max_tokens > 0 else 0
             msg_count = len(history)
             msg_count = len(history)
             img_count = sum(
             img_count = sum(
@@ -691,6 +710,27 @@ class AgentRunner:
                 if isinstance(part, dict) and part.get("type") in ("image", "image_url")
                 if isinstance(part, dict) and part.get("type") in ("image", "image_url")
             )
             )
 
 
+            # 更新 context usage 快照
+            self._context_usage[trace_id] = ContextUsage(
+                trace_id=trace_id,
+                message_count=msg_count,
+                token_count=token_count,
+                max_tokens=max_tokens,
+                usage_percent=progress_pct,
+                image_count=img_count,
+            )
+
+            # 阈值警告(30%, 50%, 80%)
+            if trace_id not in self._context_warned:
+                self._context_warned[trace_id] = set()
+
+            for threshold in [30, 50, 80]:
+                if progress_pct >= threshold and threshold not in self._context_warned[trace_id]:
+                    self._context_warned[trace_id].add(threshold)
+                    logger.warning(
+                        f"Context 使用率达到 {threshold}%: {token_count:,} / {max_tokens:,} tokens ({msg_count} 条消息)"
+                    )
+
             # 检查是否需要压缩(token 或消息数量超限)
             # 检查是否需要压缩(token 或消息数量超限)
             needs_compression_by_tokens = token_count > max_tokens
             needs_compression_by_tokens = token_count > max_tokens
             needs_compression_by_count = (
             needs_compression_by_count = (
@@ -699,16 +739,6 @@ class AgentRunner:
             )
             )
             needs_compression = needs_compression_by_tokens or needs_compression_by_count
             needs_compression = needs_compression_by_tokens or needs_compression_by_count
 
 
-            print(f"\n[压缩评估] 消息数: {msg_count} / {compression_config.max_messages} | 图片数: {img_count} | Token: {token_count:,} / {max_tokens:,} ({progress_pct:.1f}%)")
-
-            if needs_compression:
-                if needs_compression_by_count:
-                    print(f"[压缩评估] ⚠️  消息数超过阈值 ({msg_count} > {compression_config.max_messages}),触发压缩流程")
-                if needs_compression_by_tokens:
-                    print(f"[压缩评估] ⚠️  Token 数超过阈值,触发压缩流程")
-            else:
-                print(f"[压缩评估] ✅ 未超阈值,无需压缩")
-
             # 知识提取:在任何压缩发生前,用完整 history 做反思
             # 知识提取:在任何压缩发生前,用完整 history 做反思
             if needs_compression and config.knowledge.enable_extraction:
             if needs_compression and config.knowledge.enable_extraction:
                 await self._run_reflect(
                 await self._run_reflect(
@@ -717,40 +747,28 @@ class AgentRunner:
                     source_name="compression_reflection",
                     source_name="compression_reflection",
                 )
                 )
 
 
+            # Level 1 压缩:GoalTree 过滤
             if needs_compression and self.trace_store and goal_tree:
             if needs_compression and self.trace_store and goal_tree:
-                # 使用本地 head_seq(store 中的 head_sequence 在 loop 期间未更新,是过时的)
                 if head_seq > 0:
                 if head_seq > 0:
                     main_path_msgs = await self.trace_store.get_main_path_messages(
                     main_path_msgs = await self.trace_store.get_main_path_messages(
                         trace_id, head_seq
                         trace_id, head_seq
                     )
                     )
                     filtered_msgs = filter_by_goal_status(main_path_msgs, goal_tree)
                     filtered_msgs = filter_by_goal_status(main_path_msgs, goal_tree)
                     if len(filtered_msgs) < len(main_path_msgs):
                     if len(filtered_msgs) < len(main_path_msgs):
-                        filtered_tokens = estimate_tokens([msg.to_llm_dict() for msg in filtered_msgs])
-                        print(
-                            f"[Level 1 压缩] 消息: {len(main_path_msgs)} → {len(filtered_msgs)} 条 | "
-                            f"Token: {token_count:,} → ~{filtered_tokens:,}"
-                        )
                         logger.info(
                         logger.info(
-                            "Level 1 压缩: %d -> %d 条消息 (tokens ~%d, 阈值 %d)",
-                            len(main_path_msgs), len(filtered_msgs), token_count, max_tokens,
+                            "Level 1 压缩: %d -> %d 条消息",
+                            len(main_path_msgs), len(filtered_msgs),
                         )
                         )
                         history = [msg.to_llm_dict() for msg in filtered_msgs]
                         history = [msg.to_llm_dict() for msg in filtered_msgs]
                     else:
                     else:
-                        print(
-                            f"[Level 1 压缩] 无可过滤消息 ({len(main_path_msgs)} 条全部保留, "
-                            f"completed/abandoned goals={sum(1 for g in goal_tree.goals if g.status in ('completed', 'abandoned'))})"
-                        )
                         logger.info(
                         logger.info(
-                            "Level 1 压缩: 无可过滤消息 (%d 条全部保留, completed/abandoned goals=%d)",
+                            "Level 1 压缩: 无可过滤消息 (%d 条全部保留)",
                             len(main_path_msgs),
                             len(main_path_msgs),
-                            sum(1 for g in goal_tree.goals
-                                if g.status in ("completed", "abandoned")),
                         )
                         )
             elif needs_compression:
             elif needs_compression:
-                print("[压缩评估] ⚠️  无法执行 Level 1 压缩(缺少 store 或 goal_tree)")
                 logger.warning(
                 logger.warning(
-                    "消息数 (%d) 或 token 数 (%d) 超过阈值 (max_messages=%d, max_tokens=%d),但无法执行 Level 1 压缩(缺少 store 或 goal_tree)",
-                    msg_count, token_count, compression_config.max_messages, max_tokens,
+                    "消息数 (%d) 或 token 数 (%d) 超过阈值,但无法执行 Level 1 压缩(缺少 store 或 goal_tree)",
+                    msg_count, token_count,
                 )
                 )
 
 
             # Level 2 压缩:LLM 总结(Level 1 后仍超阈值时触发)
             # Level 2 压缩:LLM 总结(Level 1 后仍超阈值时触发)
@@ -764,16 +782,6 @@ class AgentRunner:
             needs_level2 = needs_level2_by_tokens or needs_level2_by_count
             needs_level2 = needs_level2_by_tokens or needs_level2_by_count
 
 
             if needs_level2:
             if needs_level2:
-                progress_pct_after = (token_count_after / max_tokens * 100) if max_tokens > 0 else 0
-                reason = []
-                if needs_level2_by_count:
-                    reason.append(f"消息数 {msg_count_after} > {compression_config.max_messages}")
-                if needs_level2_by_tokens:
-                    reason.append(f"Token {token_count_after:,} > {max_tokens:,} ({progress_pct_after:.1f}%)")
-                print(
-                    f"[Level 2 压缩] Level 1 后仍超阈值: {' | '.join(reason)} "
-                    f"→ 触发 LLM 总结"
-                )
                 logger.info(
                 logger.info(
                     "Level 1 后仍超阈值 (消息数=%d/%d, token=%d/%d),触发 Level 2 压缩",
                     "Level 1 后仍超阈值 (消息数=%d/%d, token=%d/%d),触发 Level 2 压缩",
                     msg_count_after, compression_config.max_messages, token_count_after, max_tokens,
                     msg_count_after, compression_config.max_messages, token_count_after, max_tokens,
@@ -781,12 +789,20 @@ class AgentRunner:
                 history, head_seq, sequence = await self._compress_history(
                 history, head_seq, sequence = await self._compress_history(
                     trace_id, history, goal_tree, config, sequence, head_seq,
                     trace_id, history, goal_tree, config, sequence, head_seq,
                 )
                 )
-                final_tokens = estimate_tokens(history)
-                print(f"[Level 2 压缩] 完成: Token {token_count_after:,} → {final_tokens:,}")
-            elif needs_compression:
-                # Level 1 压缩成功,未触发 Level 2
-                print(f"[压缩评估] ✅ Level 1 压缩后达标: 消息数 {msg_count_after} | Token {token_count_after:,} / {max_tokens:,}")
-            print()  # 空行分隔
+
+            # 压缩完成后,输出最终发给模型的消息列表
+            if needs_compression:
+                logger.info("压缩完成,发送给模型的消息列表:")
+                for idx, msg in enumerate(history):
+                    role = msg.get("role", "unknown")
+                    content = msg.get("content", "")
+                    if isinstance(content, str):
+                        preview = content[:100] + ("..." if len(content) > 100 else "")
+                    elif isinstance(content, list):
+                        preview = f"[{len(content)} blocks]"
+                    else:
+                        preview = str(content)[:100]
+                    logger.info(f"  [{idx}] {role}: {preview}")
 
 
             # 构建 LLM messages(注入上下文)
             # 构建 LLM messages(注入上下文)
             llm_messages = list(history)
             llm_messages = list(history)
@@ -1045,6 +1061,11 @@ class AgentRunner:
         if config.knowledge.enable_completion_extraction:
         if config.knowledge.enable_completion_extraction:
             await self._extract_knowledge_on_completion(trace_id, history, config)
             await self._extract_knowledge_on_completion(trace_id, history, config)
 
 
+        # 清理 trace 相关的跟踪数据
+        self._context_warned.pop(trace_id, None)
+        self._context_usage.pop(trace_id, None)
+        self._saved_knowledge_ids.pop(trace_id, None)
+
         # 更新 head_sequence 并完成 Trace
         # 更新 head_sequence 并完成 Trace
         if self.trace_store:
         if self.trace_store:
             await self.trace_store.update_trace(
             await self.trace_store.update_trace(

+ 0 - 23
knowhub/config.py

@@ -1,23 +0,0 @@
-"""
-KnowHub 统一配置
-
-所有 KnowHub 相关代码应该从这里获取配置,而不是硬编码。
-"""
-
-import os
-
-# KnowHub Server 默认地址
-DEFAULT_API_URL = "http://43.106.118.91:9999"
-
-# 从环境变量获取,如果没有则使用默认值
-KNOWHUB_API_URL = os.getenv("KNOWHUB_API", DEFAULT_API_URL)
-
-
-def get_api_url() -> str:
-    """获取 KnowHub API 地址
-
-    优先级:
-    1. 环境变量 KNOWHUB_API
-    2. 默认值 DEFAULT_API_URL
-    """
-    return KNOWHUB_API_URL

+ 2 - 2
knowhub/docs/knowledge-management.md

@@ -74,8 +74,8 @@ Agent                           KnowHub Server
   - `plan`: 流程步骤、决策点、方法论
   - `plan`: 流程步骤、决策点、方法论
 - **task**: 任务描述,什么场景、在做什么
 - **task**: 任务描述,什么场景、在做什么
 - **tags**: 业务标签(JSON 对象),如 `{"category": "preference", "domain": "coding_style"}`
 - **tags**: 业务标签(JSON 对象),如 `{"category": "preference", "domain": "coding_style"}`
-- **scopes**: 可见范围数组,如 `["org:cybertogether"]`
-- **owner**: 所有者,格式 `agent:{agent_id}`
+- **scopes**: 可见范围数组,默认 `["user:owner"]`
+- **owner**: 所有者,默认取用户的git email
 - **content**: 核心知识内容
 - **content**: 核心知识内容
 - **source**: 来源信息(嵌套对象)
 - **source**: 来源信息(嵌套对象)
   - **name**: 资源名称
   - **name**: 资源名称

+ 381 - 5
knowhub/server.py

@@ -16,6 +16,7 @@ from typing import Optional
 from pathlib import Path
 from pathlib import Path
 
 
 from fastapi import FastAPI, HTTPException, Query
 from fastapi import FastAPI, HTTPException, Query
+from fastapi.responses import HTMLResponse
 from pydantic import BaseModel, Field
 from pydantic import BaseModel, Field
 
 
 # 导入 LLM 调用(需要 agent 模块在 Python path 中)
 # 导入 LLM 调用(需要 agent 模块在 Python path 中)
@@ -186,6 +187,16 @@ class KnowledgeUpdateIn(BaseModel):
     evolve_feedback: Optional[str] = None
     evolve_feedback: Optional[str] = None
 
 
 
 
+class KnowledgePatchIn(BaseModel):
+    """PATCH /api/knowledge/{id} 请求体(直接字段编辑)"""
+    task: Optional[str] = None
+    content: Optional[str] = None
+    types: Optional[list[str]] = None
+    tags: Optional[dict] = None
+    scopes: Optional[list[str]] = None
+    owner: Optional[str] = None
+
+
 class KnowledgeBatchUpdateIn(BaseModel):
 class KnowledgeBatchUpdateIn(BaseModel):
     feedback_list: list[dict]
     feedback_list: list[dict]
 
 
@@ -221,6 +232,285 @@ 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>
+
+        <!-- 筛选栏 -->
+        <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>"""
+
+
 def _search_rows(conn: sqlite3.Connection, q: str, category: Optional[str]) -> list[sqlite3.Row]:
 def _search_rows(conn: sqlite3.Connection, q: str, category: Optional[str]) -> list[sqlite3.Row]:
     """LIKE 搜索,拆词后 AND 连接,匹配 task + tips + outcome + name"""
     """LIKE 搜索,拆词后 AND 连接,匹配 task + tips + outcome + name"""
     terms = q.split()
     terms = q.split()
@@ -695,25 +985,47 @@ def save_knowledge(knowledge: KnowledgeIn):
 
 
 @app.get("/api/knowledge")
 @app.get("/api/knowledge")
 def list_knowledge(
 def list_knowledge(
-    limit: int = Query(default=10, ge=1, le=100),
+    limit: int = Query(default=100, ge=1, le=1000),
     types: Optional[str] = None,
     types: Optional[str] = None,
-    scopes: Optional[str] = None
+    scopes: Optional[str] = None,
+    owner: Optional[str] = None,
+    tags: Optional[str] = None
 ):
 ):
-    """列出知识"""
+    """列出知识(支持后端筛选)"""
     conn = get_db()
     conn = get_db()
     try:
     try:
         query = "SELECT * FROM knowledge"
         query = "SELECT * FROM knowledge"
         params = []
         params = []
         conditions = []
         conditions = []
 
 
+        # types 支持多个,用 OR 连接
         if types:
         if types:
-            conditions.append("types LIKE ?")
-            params.append(f"%{types}%")
+            type_list = [t.strip() for t in types.split(',') if t.strip()]
+            if type_list:
+                type_conditions = []
+                for t in type_list:
+                    type_conditions.append("types LIKE ?")
+                    params.append(f"%{t}%")
+                conditions.append(f"({' OR '.join(type_conditions)})")
 
 
         if scopes:
         if scopes:
             conditions.append("scopes LIKE ?")
             conditions.append("scopes LIKE ?")
             params.append(f"%{scopes}%")
             params.append(f"%{scopes}%")
 
 
+        if owner:
+            conditions.append("owner LIKE ?")
+            params.append(f"%{owner}%")
+
+        # tags 支持多个,用 OR 连接
+        if tags:
+            tag_list = [t.strip() for t in tags.split(',') if t.strip()]
+            if tag_list:
+                tag_conditions = []
+                for t in tag_list:
+                    tag_conditions.append("tags LIKE ?")
+                    params.append(f"%{t}%")
+                conditions.append(f"({' OR '.join(tag_conditions)})")
+
         if conditions:
         if conditions:
             query += " WHERE " + " AND ".join(conditions)
             query += " WHERE " + " AND ".join(conditions)
 
 
@@ -744,6 +1056,22 @@ def list_knowledge(
         conn.close()
         conn.close()
 
 
 
 
+@app.get("/api/knowledge/meta/tags")
+def get_all_tags():
+    """获取所有已有的 tags"""
+    conn = get_db()
+    try:
+        rows = conn.execute("SELECT tags FROM knowledge").fetchall()
+        all_tags = set()
+        for row in rows:
+            tags_dict = json.loads(row["tags"])
+            for key in tags_dict.keys():
+                all_tags.add(key)
+        return {"tags": sorted(list(all_tags))}
+    finally:
+        conn.close()
+
+
 @app.get("/api/knowledge/{knowledge_id}")
 @app.get("/api/knowledge/{knowledge_id}")
 def get_knowledge(knowledge_id: str):
 def get_knowledge(knowledge_id: str):
     """获取单条知识"""
     """获取单条知识"""
@@ -853,6 +1181,54 @@ async def update_knowledge(knowledge_id: str, update: KnowledgeUpdateIn):
         conn.close()
         conn.close()
 
 
 
 
+@app.patch("/api/knowledge/{knowledge_id}")
+def patch_knowledge(knowledge_id: str, patch: KnowledgePatchIn):
+    """直接编辑知识字段"""
+    conn = get_db()
+    try:
+        row = conn.execute("SELECT * FROM knowledge WHERE id = ?", (knowledge_id,)).fetchone()
+        if not row:
+            raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
+
+        updates = []
+        params = []
+
+        if patch.task is not None:
+            updates.append("task = ?")
+            params.append(patch.task)
+        if patch.content is not None:
+            updates.append("content = ?")
+            params.append(patch.content)
+        if patch.types is not None:
+            updates.append("types = ?")
+            params.append(json.dumps(patch.types, ensure_ascii=False))
+        if patch.tags is not None:
+            updates.append("tags = ?")
+            params.append(json.dumps(patch.tags, ensure_ascii=False))
+        if patch.scopes is not None:
+            updates.append("scopes = ?")
+            params.append(json.dumps(patch.scopes, ensure_ascii=False))
+        if patch.owner is not None:
+            updates.append("owner = ?")
+            params.append(patch.owner)
+
+        if not updates:
+            return {"status": "ok", "knowledge_id": knowledge_id}
+
+        now = datetime.now(timezone.utc).isoformat()
+        updates.append("updated_at = ?")
+        params.append(now)
+        params.append(knowledge_id)
+
+        query = f"UPDATE knowledge SET {', '.join(updates)} WHERE id = ?"
+        conn.execute(query, params)
+        conn.commit()
+
+        return {"status": "ok", "knowledge_id": knowledge_id}
+    finally:
+        conn.close()
+
+
 @app.post("/api/knowledge/batch_update")
 @app.post("/api/knowledge/batch_update")
 async def batch_update_knowledge(batch: KnowledgeBatchUpdateIn):
 async def batch_update_knowledge(batch: KnowledgeBatchUpdateIn):
     """批量反馈知识有效性"""
     """批量反馈知识有效性"""