Parcourir la source

reconstruct knowhub and database

guantao il y a 1 jour
Parent
commit
5239a3767a

+ 40 - 14
agent/tools/builtin/search.py

@@ -219,7 +219,10 @@ class SuggestSearchChannel(str, Enum):
                 "channel": "搜索渠道",
                 "cursor": "分页游标",
                 "max_count": "返回条数",
-                "content_type": "内容类型-视频/图文"
+                "content_type": "内容类型-视频/图文",
+                "sort_type": "排序方式(xhs专用)",
+                "publish_time": "发布时间筛选(xhs专用)",
+                "filter_note_range": "笔记时长筛选(xhs专用)"
             }
         },
         "en": {
@@ -229,7 +232,10 @@ class SuggestSearchChannel(str, Enum):
                 "channel": "Search channel",
                 "cursor": "Pagination cursor",
                 "max_count": "Max results",
-                "content_type": "content type-视频/图文"
+                "content_type": "content type-视频/图文",
+                "sort_type": "Sort type (xhs only)",
+                "publish_time": "Publish time filter (xhs only)",
+                "filter_note_range": "Note duration filter (xhs only)"
             }
         }
     }
@@ -237,9 +243,12 @@ class SuggestSearchChannel(str, Enum):
 async def search_posts(
     keyword: str,
     channel: str = "xhs",
-    cursor: str = "0",
+    cursor: str = "",
     max_count: int = 20,
-    content_type: str = ""
+    content_type: str = "",
+    sort_type: str = "综合排序",
+    publish_time: str = "不限",
+    filter_note_range: str = "不限"
 ) -> ToolResult:
     """
     帖子搜索(浏览模式)
@@ -259,9 +268,14 @@ async def search_posts(
             - bili: B站
             - zhihu: 知乎
             - weibo: 微博
-        cursor: 分页游标,默认为 "0"(第一页)
+        cursor: 分页游标,首次请求为空字符串,后续使用上次返回的 cursor
         max_count: 返回的最大条数,默认为 20
-        content_type:内容类型-视频/图文,默认不传为不限制类型
+        content_type: 内容类型筛选,默认不限;
+            xhs 可选值:'不限' | '图文' | '视频' | '文章';
+            其他渠道可选值:'视频' | '图文'
+        sort_type: 排序方式(仅 xhs 有效),可选值:'综合排序' | '最新发布' | '最多点赞',默认'综合排序'
+        publish_time: 发布时间筛选(仅 xhs 有效),可选值:'不限' | '近30天' | '近7天' | '近1天',默认'不限'
+        filter_note_range: 笔记时长筛选,视频内容有效(仅 xhs 有效),可选值:'不限' | '1分钟以内' | '1-5分钟' | '5分钟以上',默认'不限'
 
     Returns:
         ToolResult 包含搜索结果摘要列表(封面图+标题+内容截断),
@@ -272,13 +286,24 @@ async def search_posts(
         channel_value = channel.value if isinstance(channel, PostSearchChannel) else channel
 
         url = f"{BASE_URL}/data"
-        payload = {
-            "type": channel_value,
-            "keyword": keyword,
-            "cursor": cursor,
-            "max_count": max_count,
-            "content_type": content_type
-        }
+        if channel_value == "xhs":
+            payload = {
+                "type": channel_value,
+                "keyword": keyword,
+                "cursor": cursor,
+                "content_type": content_type if content_type else "不限",
+                "sort_type": sort_type,
+                "publish_time": publish_time,
+                "filter_note_range": filter_note_range,
+            }
+        else:
+            payload = {
+                "type": channel_value,
+                "keyword": keyword,
+                "cursor": cursor if cursor else "0",
+                "max_count": max_count,
+                "content_type": content_type,
+            }
 
         async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
             response = await client.post(
@@ -300,10 +325,11 @@ async def search_posts(
         summary_list = []
         for idx, post in enumerate(posts):
             body = post.get("body_text", "") or ""
+            title = post.get("title") or body[:20] or ""
             summary_list.append({
                 "index": idx + 1,
                 "channel_content_id": post.get("channel_content_id"),
-                "title": post.get("title"),
+                "title": title,
                 "body_text": body[:100] + ("..." if len(body) > 100 else ""),
                 "like_count": post.get("like_count"),
                 "collect_count": post.get("collect_count"),

+ 30 - 0
knowhub/knowhub_db/check_extensions.py

@@ -0,0 +1,30 @@
+#!/usr/bin/env python3
+import os
+import psycopg2
+from dotenv import load_dotenv
+
+load_dotenv()
+
+conn = psycopg2.connect(
+    host=os.getenv('KNOWHUB_DB'),
+    port=int(os.getenv('KNOWHUB_PORT', 5432)),
+    user=os.getenv('KNOWHUB_USER'),
+    password=os.getenv('KNOWHUB_PASSWORD'),
+    database=os.getenv('KNOWHUB_DB_NAME')
+)
+cursor = conn.cursor()
+
+# 查看所有可用扩展
+cursor.execute("SELECT * FROM pg_available_extensions WHERE name LIKE '%vector%';")
+print("可用的vector扩展:")
+for row in cursor.fetchall():
+    print(row)
+
+# 查看已安装扩展
+cursor.execute("SELECT * FROM pg_extension;")
+print("\n已安装的扩展:")
+for row in cursor.fetchall():
+    print(row)
+
+cursor.close()
+conn.close()

+ 28 - 0
knowhub/knowhub_db/check_fastann.py

@@ -0,0 +1,28 @@
+#!/usr/bin/env python3
+import os
+import psycopg2
+from dotenv import load_dotenv
+
+load_dotenv()
+
+conn = psycopg2.connect(
+    host=os.getenv('KNOWHUB_DB'),
+    port=int(os.getenv('KNOWHUB_PORT', 5432)),
+    user=os.getenv('KNOWHUB_USER'),
+    password=os.getenv('KNOWHUB_PASSWORD'),
+    database=os.getenv('KNOWHUB_DB_NAME')
+)
+cursor = conn.cursor()
+
+# 查看fastann的函数和操作符
+cursor.execute("""
+    SELECT proname, prosrc FROM pg_proc
+    WHERE proname LIKE '%fastann%' OR proname LIKE '%ann%'
+    LIMIT 20;
+""")
+print("fastann相关函数:")
+for row in cursor.fetchall():
+    print(f"  {row[0]}")
+
+cursor.close()
+conn.close()

+ 61 - 0
knowhub/knowhub_db/clean_invalid_knowledge_refs.py

@@ -0,0 +1,61 @@
+#!/usr/bin/env python3
+"""
+清理 tool_table 中无效的 knowledge 引用
+"""
+
+import os
+import json
+import psycopg2
+from psycopg2.extras import RealDictCursor
+from dotenv import load_dotenv
+
+load_dotenv()
+
+def clean_invalid_refs():
+    conn = psycopg2.connect(
+        host=os.getenv('KNOWHUB_DB'),
+        port=int(os.getenv('KNOWHUB_PORT', 5432)),
+        user=os.getenv('KNOWHUB_USER'),
+        password=os.getenv('KNOWHUB_PASSWORD'),
+        database=os.getenv('KNOWHUB_DB_NAME')
+    )
+    conn.autocommit = False
+    cur = conn.cursor(cursor_factory=RealDictCursor)
+
+    # 查所有工具
+    cur.execute("SELECT id, name, knowledge FROM tool_table WHERE knowledge IS NOT NULL AND knowledge::text != '[]'")
+    tools = cur.fetchall()
+
+    print(f"检查 {len(tools)} 个工具...")
+
+    cleaned_count = 0
+    total_removed = 0
+
+    for tool in tools:
+        knowledge_ids = tool['knowledge'] if isinstance(tool['knowledge'], list) else json.loads(tool['knowledge'] or '[]')
+        if not knowledge_ids:
+            continue
+
+        # 检查哪些存在
+        cur.execute('SELECT id FROM knowledge WHERE id = ANY(%s)', (knowledge_ids,))
+        existing = {r['id'] for r in cur.fetchall()}
+
+        # 过滤出有效的
+        valid_ids = [kid for kid in knowledge_ids if kid in existing]
+
+        if len(valid_ids) < len(knowledge_ids):
+            removed = len(knowledge_ids) - len(valid_ids)
+            print(f"✅ {tool['name']}: 移除 {removed} 个无效引用,保留 {len(valid_ids)} 个")
+            cur.execute(
+                "UPDATE tool_table SET knowledge = %s WHERE id = %s",
+                (json.dumps(valid_ids), tool['id'])
+            )
+            cleaned_count += 1
+            total_removed += removed
+
+    conn.commit()
+    print(f"\n完成:清理了 {cleaned_count} 个工具,共移除 {total_removed} 个无效引用")
+    conn.close()
+
+if __name__ == "__main__":
+    clean_invalid_refs()

+ 65 - 0
knowhub/knowhub_db/clean_resource_knowledge_refs.py

@@ -0,0 +1,65 @@
+#!/usr/bin/env python3
+"""
+清理 resources 表中 metadata.knowledge_ids 的无效引用
+"""
+
+import os
+import json
+import psycopg2
+from psycopg2.extras import RealDictCursor
+from dotenv import load_dotenv
+
+load_dotenv()
+
+def clean_resource_knowledge_refs():
+    conn = psycopg2.connect(
+        host=os.getenv('KNOWHUB_DB'),
+        port=int(os.getenv('KNOWHUB_PORT', 5432)),
+        user=os.getenv('KNOWHUB_USER'),
+        password=os.getenv('KNOWHUB_PASSWORD'),
+        database=os.getenv('KNOWHUB_DB_NAME')
+    )
+    conn.autocommit = False
+    cur = conn.cursor(cursor_factory=RealDictCursor)
+
+    # 查所有有 knowledge_ids 的资源
+    cur.execute("SELECT id, title, metadata FROM resources WHERE metadata IS NOT NULL")
+    resources = cur.fetchall()
+
+    print(f"检查 {len(resources)} 个资源...")
+
+    cleaned_count = 0
+    total_removed = 0
+
+    for res in resources:
+        metadata = res['metadata'] if isinstance(res['metadata'], dict) else json.loads(res['metadata'] or '{}')
+        knowledge_ids = metadata.get('knowledge_ids', [])
+
+        if not knowledge_ids:
+            continue
+
+        # 检查哪些存在
+        cur.execute('SELECT id FROM knowledge WHERE id = ANY(%s)', (knowledge_ids,))
+        existing = {r['id'] for r in cur.fetchall()}
+
+        # 过滤出有效的
+        valid_ids = [kid for kid in knowledge_ids if kid in existing]
+
+        if len(valid_ids) < len(knowledge_ids):
+            removed = len(knowledge_ids) - len(valid_ids)
+            print(f"✅ {res['title']}: 移除 {removed} 个无效引用,保留 {len(valid_ids)} 个")
+
+            metadata['knowledge_ids'] = valid_ids
+            cur.execute(
+                "UPDATE resources SET metadata = %s WHERE id = %s",
+                (json.dumps(metadata), res['id'])
+            )
+            cleaned_count += 1
+            total_removed += removed
+
+    conn.commit()
+    print(f"\n完成:清理了 {cleaned_count} 个资源,共移除 {total_removed} 个无效引用")
+    conn.close()
+
+if __name__ == "__main__":
+    clean_resource_knowledge_refs()

+ 116 - 0
knowhub/knowhub_db/create_tables.py

@@ -0,0 +1,116 @@
+#!/usr/bin/env python3
+"""
+创建 tool_table, skill_table, requirement_table 三张新表
+"""
+
+import os
+import psycopg2
+from psycopg2.extras import RealDictCursor
+from dotenv import load_dotenv
+
+load_dotenv()
+
+CREATE_TOOL_TABLE = """
+CREATE TABLE IF NOT EXISTS tool_table (
+    id              VARCHAR(64) PRIMARY KEY,
+    name            VARCHAR(255) NOT NULL,
+    version         VARCHAR(64),
+    introduction    TEXT,
+    tutorial        TEXT,
+    input           JSONB,
+    output          JSONB,
+    updated_time    BIGINT,
+    status          VARCHAR(32) DEFAULT '未接入',  -- 未接入/可用/异常
+    knowledge       JSONB DEFAULT '[]',            -- 关联知识条目
+    case_knowledge  JSONB DEFAULT '[]',            -- 用例知识
+    process_knowledge JSONB DEFAULT '[]'           -- 工序知识
+) WITH (appendoptimized=false);
+"""
+
+CREATE_SKILL_TABLE = """
+CREATE TABLE IF NOT EXISTS skill_table (
+    id              VARCHAR(64) PRIMARY KEY,
+    name            VARCHAR(255) NOT NULL,
+    version         VARCHAR(64),
+    introduction    TEXT,
+    input           JSONB,
+    output          JSONB,
+    updated_time    BIGINT,
+    resource_id     VARCHAR(64),                   -- 关联的工具id (resource.id)
+    knowledge       JSONB DEFAULT '[]',            -- 关联知识条目
+    case_knowledge  JSONB DEFAULT '[]',            -- 用例知识
+    process_knowledge JSONB DEFAULT '[]',          -- 工序知识
+    status          VARCHAR(32) DEFAULT '未验证'   -- 未验证/可用/异常
+) WITH (appendoptimized=false);
+"""
+
+CREATE_REQUIREMENT_TABLE = """
+CREATE TABLE IF NOT EXISTS requirement_table (
+    id                VARCHAR(64) PRIMARY KEY,
+    task              TEXT,                          -- 任务需求
+    type              VARCHAR(64),                   -- 制作/...
+    source_type       VARCHAR(64),                   -- itemset
+    source_itemset_id VARCHAR(64),                   -- 关联pattern的id
+    source_items      JSONB DEFAULT '[]',            -- 包含的特征/分类
+    tools             JSONB DEFAULT '[]',            -- 关联的工具id与简介
+    knowledge         JSONB DEFAULT '[]',            -- 关联知识条目
+    case_knowledge    JSONB DEFAULT '[]',            -- 用例知识
+    process_knowledge JSONB DEFAULT '[]',            -- 工序知识
+    trace             JSONB DEFAULT '[]',            -- 关联的任务执行log
+    body              TEXT,                          -- str/json
+    embedding         float4[]                       -- 向量(1536维)
+) WITH (appendoptimized=false);
+"""
+
+CREATE_REQUIREMENT_EMBEDDING_INDEX = """
+CREATE INDEX IF NOT EXISTS idx_requirement_embedding
+ON requirement_table USING ann(embedding)
+WITH (dim=1536, hnsw_m=16, pq_enable=0);
+"""
+
+def create_tables():
+    conn = psycopg2.connect(
+        host=os.getenv('KNOWHUB_DB'),
+        port=int(os.getenv('KNOWHUB_PORT', 5432)),
+        user=os.getenv('KNOWHUB_USER'),
+        password=os.getenv('KNOWHUB_PASSWORD'),
+        database=os.getenv('KNOWHUB_DB_NAME')
+    )
+    conn.autocommit = True
+    cursor = conn.cursor(cursor_factory=RealDictCursor)
+
+    tables = [
+        ("tool_table", CREATE_TOOL_TABLE),
+        ("skill_table", CREATE_SKILL_TABLE),
+        ("requirement_table", CREATE_REQUIREMENT_TABLE),
+    ]
+
+    for name, sql in tables:
+        try:
+            cursor.execute(sql)
+            print(f"✅ {name} 创建成功")
+        except Exception as e:
+            print(f"❌ {name} 创建失败: {e}")
+
+    try:
+        cursor.execute(CREATE_REQUIREMENT_EMBEDDING_INDEX)
+        print("✅ requirement_table fastann 索引创建成功")
+    except Exception as e:
+        print(f"❌ requirement_table 索引创建失败: {e}")
+
+    # 验证
+    cursor.execute("""
+        SELECT tablename FROM pg_tables
+        WHERE schemaname = 'public'
+        ORDER BY tablename;
+    """)
+    tables_now = cursor.fetchall()
+    print(f"\n当前所有表 ({len(tables_now)}个):")
+    for t in tables_now:
+        print(f"  - {t['tablename']}")
+
+    cursor.close()
+    conn.close()
+
+if __name__ == "__main__":
+    create_tables()

+ 24 - 0
knowhub/knowhub_db/list_databases.py

@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+import os
+import psycopg2
+from dotenv import load_dotenv
+
+load_dotenv()
+
+conn = psycopg2.connect(
+    host=os.getenv('KNOWHUB_DB'),
+    port=int(os.getenv('KNOWHUB_PORT', 5432)),
+    user=os.getenv('KNOWHUB_USER'),
+    password=os.getenv('KNOWHUB_PASSWORD'),
+    database='postgres'  # 连接到默认数据库
+)
+conn.autocommit = True
+cursor = conn.cursor()
+
+cursor.execute("SELECT datname FROM pg_database WHERE datistemplate = false;")
+print("主机上的所有数据库:")
+for row in cursor.fetchall():
+    print(f"  - {row[0]}")
+
+cursor.close()
+conn.close()

+ 110 - 0
knowhub/knowhub_db/migrate_resources.py

@@ -0,0 +1,110 @@
+#!/usr/bin/env python3
+"""
+迁移 SQLite resources 表中的 text 数据到 PostgreSQL
+删除 code 类型数据
+"""
+
+import os
+import json
+import sqlite3
+import psycopg2
+from psycopg2.extras import RealDictCursor
+from dotenv import load_dotenv
+from datetime import datetime
+
+load_dotenv()
+
+CREATE_RESOURCES_TABLE = """
+CREATE TABLE IF NOT EXISTS resources (
+    id              TEXT PRIMARY KEY,
+    title           TEXT,
+    body            TEXT,
+    secure_body     TEXT,
+    content_type    TEXT,
+    metadata        JSONB,
+    sort_order      INTEGER DEFAULT 0,
+    submitted_by    TEXT,
+    created_at      BIGINT,
+    updated_at      BIGINT
+) WITH (appendoptimized=false);
+"""
+
+def migrate_resources():
+    # 连接 SQLite
+    sqlite_conn = sqlite3.connect('/root/Agent/knowhub/knowhub.db')
+    sqlite_conn.row_factory = sqlite3.Row
+    sqlite_cur = sqlite_conn.cursor()
+
+    # 连接 PostgreSQL
+    pg_conn = psycopg2.connect(
+        host=os.getenv('KNOWHUB_DB'),
+        port=int(os.getenv('KNOWHUB_PORT', 5432)),
+        user=os.getenv('KNOWHUB_USER'),
+        password=os.getenv('KNOWHUB_PASSWORD'),
+        database=os.getenv('KNOWHUB_DB_NAME')
+    )
+    pg_conn.autocommit = True
+    pg_cur = pg_conn.cursor(cursor_factory=RealDictCursor)
+
+    # 创建表
+    pg_cur.execute(CREATE_RESOURCES_TABLE)
+    print("✅ resources 表已创建")
+
+    # 读取 text 类型数据
+    sqlite_cur.execute("SELECT * FROM resources WHERE content_type='text'")
+    texts = sqlite_cur.fetchall()
+    print(f"\n找到 {len(texts)} 条 text 数据")
+
+    success = 0
+    for text in texts:
+        try:
+            # 转换时间戳
+            created_at = updated_at = None
+            if text['created_at']:
+                dt = datetime.fromisoformat(text['created_at'].replace('+00:00', ''))
+                created_at = int(dt.timestamp())
+            if text['updated_at']:
+                dt = datetime.fromisoformat(text['updated_at'].replace('+00:00', ''))
+                updated_at = int(dt.timestamp())
+
+            pg_cur.execute("""
+                INSERT INTO resources (id, title, body, secure_body, content_type,
+                                       metadata, sort_order, submitted_by, created_at, updated_at)
+                VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+                ON CONFLICT (id) DO UPDATE SET
+                    title = EXCLUDED.title,
+                    body = EXCLUDED.body,
+                    secure_body = EXCLUDED.secure_body,
+                    metadata = EXCLUDED.metadata,
+                    sort_order = EXCLUDED.sort_order,
+                    updated_at = EXCLUDED.updated_at
+            """, (
+                text['id'],
+                text['title'],
+                text['body'],
+                text['secure_body'],
+                text['content_type'],
+                text['metadata'],
+                text['sort_order'] or 0,
+                text['submitted_by'],
+                created_at,
+                updated_at
+            ))
+            success += 1
+        except Exception as e:
+            print(f"❌ 迁移失败 {text['id']}: {e}")
+
+    print(f"✅ text 数据迁移完成: {success}/{len(texts)}")
+
+    # 删除 code 类型数据
+    sqlite_cur.execute("SELECT COUNT(*) FROM resources WHERE content_type='code'")
+    code_count = sqlite_cur.fetchone()[0]
+    sqlite_cur.execute("DELETE FROM resources WHERE content_type='code'")
+    sqlite_conn.commit()
+    print(f"✅ 已删除 {code_count} 条 code 数据")
+
+    sqlite_conn.close()
+    pg_conn.close()
+
+if __name__ == "__main__":
+    migrate_resources()

+ 94 - 0
knowhub/knowhub_db/migrate_tools.py

@@ -0,0 +1,94 @@
+#!/usr/bin/env python3
+"""
+迁移 SQLite resources 表中的 tool 数据到 PostgreSQL tool_table
+"""
+
+import os
+import json
+import sqlite3
+import psycopg2
+from psycopg2.extras import RealDictCursor
+from dotenv import load_dotenv
+from datetime import datetime
+
+load_dotenv()
+
+def migrate_tools():
+    # 连接 SQLite
+    sqlite_conn = sqlite3.connect('/root/Agent/knowhub/knowhub.db')
+    sqlite_conn.row_factory = sqlite3.Row
+    sqlite_cur = sqlite_conn.cursor()
+
+    # 连接 PostgreSQL
+    pg_conn = psycopg2.connect(
+        host=os.getenv('KNOWHUB_DB'),
+        port=int(os.getenv('KNOWHUB_PORT', 5432)),
+        user=os.getenv('KNOWHUB_USER'),
+        password=os.getenv('KNOWHUB_PASSWORD'),
+        database=os.getenv('KNOWHUB_DB_NAME')
+    )
+    pg_conn.autocommit = False
+    pg_cur = pg_conn.cursor(cursor_factory=RealDictCursor)
+
+    # 读取所有 tool 类型数据
+    sqlite_cur.execute("SELECT * FROM resources WHERE content_type='tool';")
+    tools = sqlite_cur.fetchall()
+
+    print(f"找到 {len(tools)} 条 tool 数据")
+
+    success = 0
+    failed = 0
+
+    for tool in tools:
+        try:
+            metadata = json.loads(tool['metadata']) if tool['metadata'] else {}
+
+            # 转换时间戳
+            updated_time = None
+            if tool['updated_at']:
+                dt = datetime.fromisoformat(tool['updated_at'].replace('+00:00', ''))
+                updated_time = int(dt.timestamp())
+
+            # 插入数据
+            pg_cur.execute("""
+                INSERT INTO tool_table (
+                    id, name, version, introduction, tutorial, input, output,
+                    updated_time, status, knowledge, case_knowledge, process_knowledge
+                ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+                ON CONFLICT (id) DO UPDATE SET
+                    name = EXCLUDED.name,
+                    version = EXCLUDED.version,
+                    introduction = EXCLUDED.introduction,
+                    tutorial = EXCLUDED.tutorial,
+                    input = EXCLUDED.input,
+                    output = EXCLUDED.output,
+                    updated_time = EXCLUDED.updated_time,
+                    status = EXCLUDED.status,
+                    knowledge = EXCLUDED.knowledge
+            """, (
+                tool['id'],
+                metadata.get('tool_name'),
+                metadata.get('version'),
+                metadata.get('description'),
+                metadata.get('usage'),
+                json.dumps(metadata.get('input')) if metadata.get('input') else None,
+                json.dumps(metadata.get('output')) if metadata.get('output') else None,
+                updated_time,
+                metadata.get('status', '未接入'),
+                json.dumps(metadata.get('knowledge_ids', [])),
+                json.dumps([]),
+                json.dumps([])
+            ))
+            success += 1
+        except Exception as e:
+            print(f"❌ 迁移失败 {tool['id']}: {e}")
+            failed += 1
+
+    pg_conn.commit()
+    print(f"\n✅ 迁移完成: 成功 {success} 条, 失败 {failed} 条")
+
+    sqlite_conn.close()
+    pg_conn.close()
+
+if __name__ == "__main__":
+    migrate_tools()

+ 184 - 0
knowhub/knowhub_db/pg_resource_store.py

@@ -0,0 +1,184 @@
+#!/usr/bin/env python3
+"""
+PostgreSQL Resources 存储封装
+"""
+
+import os
+import json
+import time
+import psycopg2
+from psycopg2.extras import RealDictCursor
+from typing import Optional, List, Dict
+from dotenv import load_dotenv
+
+load_dotenv()
+
+
+class PostgreSQLResourceStore:
+    def __init__(self):
+        """初始化 PostgreSQL 连接"""
+        self.conn = psycopg2.connect(
+            host=os.getenv('KNOWHUB_DB'),
+            port=int(os.getenv('KNOWHUB_PORT', 5432)),
+            user=os.getenv('KNOWHUB_USER'),
+            password=os.getenv('KNOWHUB_PASSWORD'),
+            database=os.getenv('KNOWHUB_DB_NAME')
+        )
+        self.conn.autocommit = False
+
+    def _get_cursor(self):
+        """获取游标"""
+        return self.conn.cursor(cursor_factory=RealDictCursor)
+
+    def insert_or_update(self, resource: Dict):
+        """插入或更新资源"""
+        cursor = self._get_cursor()
+        try:
+            now_ts = int(time.time())
+            cursor.execute("""
+                INSERT INTO resources (id, title, body, secure_body, content_type,
+                                       metadata, sort_order, submitted_by, created_at, updated_at)
+                VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+                ON CONFLICT (id) DO UPDATE SET
+                    title = EXCLUDED.title,
+                    body = EXCLUDED.body,
+                    secure_body = EXCLUDED.secure_body,
+                    content_type = EXCLUDED.content_type,
+                    metadata = EXCLUDED.metadata,
+                    sort_order = EXCLUDED.sort_order,
+                    updated_at = EXCLUDED.updated_at
+            """, (
+                resource['id'],
+                resource['title'],
+                resource['body'],
+                resource.get('secure_body', ''),
+                resource['content_type'],
+                json.dumps(resource.get('metadata', {})),
+                resource.get('sort_order', 0),
+                resource.get('submitted_by', ''),
+                resource.get('created_at', now_ts),
+                now_ts
+            ))
+            self.conn.commit()
+        finally:
+            cursor.close()
+
+    def get_by_id(self, resource_id: str) -> Optional[Dict]:
+        """根据ID获取资源"""
+        cursor = self._get_cursor()
+        try:
+            cursor.execute("""
+                SELECT id, title, body, secure_body, content_type, metadata, sort_order,
+                       created_at, updated_at
+                FROM resources WHERE id = %s
+            """, (resource_id,))
+            row = cursor.fetchone()
+            if not row:
+                return None
+            result = dict(row)
+            if result.get('metadata'):
+                result['metadata'] = json.loads(result['metadata']) if isinstance(result['metadata'], str) else result['metadata']
+            return result
+        finally:
+            cursor.close()
+
+    def list_resources(self, prefix: Optional[str] = None, content_type: Optional[str] = None,
+                       limit: int = 100, offset: int = 0) -> List[Dict]:
+        """列出资源"""
+        cursor = self._get_cursor()
+        try:
+            conditions = []
+            params = []
+
+            if prefix:
+                conditions.append("id LIKE %s")
+                params.append(f"{prefix}%")
+            if content_type:
+                conditions.append("content_type = %s")
+                params.append(content_type)
+
+            where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
+            sql = f"""
+                SELECT id, title, content_type, metadata, created_at, updated_at
+                FROM resources
+                {where_clause}
+                ORDER BY sort_order, id
+                LIMIT %s OFFSET %s
+            """
+            params.extend([limit, offset])
+
+            cursor.execute(sql, params)
+            results = cursor.fetchall()
+            return [dict(r) for r in results]
+        finally:
+            cursor.close()
+
+    def update(self, resource_id: str, updates: Dict):
+        """更新资源"""
+        cursor = self._get_cursor()
+        try:
+            set_parts = []
+            params = []
+            for key, value in updates.items():
+                if key == 'metadata':
+                    set_parts.append(f"{key} = %s")
+                    params.append(json.dumps(value))
+                else:
+                    set_parts.append(f"{key} = %s")
+                    params.append(value)
+
+            set_parts.append("updated_at = %s")
+            params.append(int(time.time()))
+            params.append(resource_id)
+
+            sql = f"UPDATE resources SET {', '.join(set_parts)} WHERE id = %s"
+            cursor.execute(sql, params)
+            self.conn.commit()
+        finally:
+            cursor.close()
+
+    def delete(self, resource_id: str):
+        """删除资源"""
+        cursor = self._get_cursor()
+        try:
+            cursor.execute("DELETE FROM resources WHERE id = %s", (resource_id,))
+            self.conn.commit()
+        finally:
+            cursor.close()
+
+    def get_siblings(self, resource_id: str) -> tuple:
+        """获取同级资源(用于导航)"""
+        cursor = self._get_cursor()
+        try:
+            # 获取父级前缀
+            parts = resource_id.split("/")
+            if len(parts) <= 1:
+                return None, None
+
+            parent_prefix = "/".join(parts[:-1])
+
+            # 获取前一个
+            cursor.execute("""
+                SELECT id, title FROM resources
+                WHERE id LIKE %s AND id < %s
+                ORDER BY id DESC LIMIT 1
+            """, (f"{parent_prefix}/%", resource_id))
+            prev_row = cursor.fetchone()
+
+            # 获取后一个
+            cursor.execute("""
+                SELECT id, title FROM resources
+                WHERE id LIKE %s AND id > %s
+                ORDER BY id ASC LIMIT 1
+            """, (f"{parent_prefix}/%", resource_id))
+            next_row = cursor.fetchone()
+
+            return (dict(prev_row) if prev_row else None,
+                    dict(next_row) if next_row else None)
+        finally:
+            cursor.close()
+
+    def close(self):
+        """关闭连接"""
+        if self.conn:
+            self.conn.close()

+ 244 - 0
knowhub/knowhub_db/pg_store.py

@@ -0,0 +1,244 @@
+"""
+PostgreSQL 存储封装(替代 Milvus)
+
+使用远程 PostgreSQL + pgvector/fastann 存储知识数据
+"""
+
+import os
+import json
+import psycopg2
+from psycopg2.extras import RealDictCursor, execute_batch
+from typing import List, Dict, Optional
+from dotenv import load_dotenv
+
+load_dotenv()
+
+
+class PostgreSQLStore:
+    def __init__(self):
+        """初始化 PostgreSQL 连接"""
+        self.conn = psycopg2.connect(
+            host=os.getenv('KNOWHUB_DB'),
+            port=int(os.getenv('KNOWHUB_PORT', 5432)),
+            user=os.getenv('KNOWHUB_USER'),
+            password=os.getenv('KNOWHUB_PASSWORD'),
+            database=os.getenv('KNOWHUB_DB_NAME')
+        )
+        self.conn.autocommit = False
+        print(f"[PostgreSQL] 已连接到远程数据库: {os.getenv('KNOWHUB_DB')}")
+
+    def _get_cursor(self):
+        """获取游标"""
+        return self.conn.cursor(cursor_factory=RealDictCursor)
+
+    def insert(self, knowledge: Dict):
+        """插入单条知识"""
+        cursor = self._get_cursor()
+        try:
+            cursor.execute("""
+                INSERT INTO knowledge (
+                    id, embedding, message_id, task, content, types, tags,
+                    tag_keys, scopes, owner, resource_ids, source, eval,
+                    created_at, updated_at, status, relationships
+                ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+            """, (
+                knowledge['id'],
+                knowledge['embedding'],
+                knowledge['message_id'],
+                knowledge['task'],
+                knowledge['content'],
+                knowledge.get('types', []),
+                json.dumps(knowledge.get('tags', {})),
+                knowledge.get('tag_keys', []),
+                knowledge.get('scopes', []),
+                knowledge['owner'],
+                knowledge.get('resource_ids', []),
+                json.dumps(knowledge.get('source', {})),
+                json.dumps(knowledge.get('eval', {})),
+                knowledge['created_at'],
+                knowledge['updated_at'],
+                knowledge.get('status', 'approved'),
+                json.dumps(knowledge.get('relationships', []))
+            ))
+            self.conn.commit()
+        finally:
+            cursor.close()
+
+    def search(self, query_embedding: List[float], filters: Optional[str] = None, limit: int = 10) -> List[Dict]:
+        """向量检索(使用余弦相似度)"""
+        cursor = self._get_cursor()
+        try:
+            where_clause = self._build_where_clause(filters) if filters else ""
+            sql = f"""
+                SELECT id, message_id, task, content, types, tags, tag_keys,
+                       scopes, owner, resource_ids, source, eval, created_at,
+                       updated_at, status, relationships,
+                       1 - (embedding <=> %s::real[]) as score
+                FROM knowledge
+                {where_clause}
+                ORDER BY embedding <=> %s::real[]
+                LIMIT %s
+            """
+            cursor.execute(sql, (query_embedding, query_embedding, limit))
+            results = cursor.fetchall()
+            return [self._format_result(r) for r in results]
+        finally:
+            cursor.close()
+
+    def query(self, filters: str, limit: int = 100) -> List[Dict]:
+        """纯标量查询"""
+        cursor = self._get_cursor()
+        try:
+            where_clause = self._build_where_clause(filters)
+            sql = f"""
+                SELECT id, message_id, task, content, types, tags, tag_keys,
+                       scopes, owner, resource_ids, source, eval, created_at,
+                       updated_at, status, relationships
+                FROM knowledge
+                {where_clause}
+                LIMIT %s
+            """
+            cursor.execute(sql, (limit,))
+            results = cursor.fetchall()
+            return [self._format_result(r) for r in results]
+        finally:
+            cursor.close()
+
+    def get_by_id(self, knowledge_id: str, include_embedding: bool = False) -> Optional[Dict]:
+        """根据ID获取知识(默认不返回embedding以提升性能)"""
+        cursor = self._get_cursor()
+        try:
+            # 默认不返回embedding(1536维向量太大,详情页不需要)
+            if include_embedding:
+                fields = "id, embedding, message_id, task, content, types, tags, tag_keys, scopes, owner, resource_ids, source, eval, created_at, updated_at, status, relationships"
+            else:
+                fields = "id, message_id, task, content, types, tags, tag_keys, scopes, owner, resource_ids, source, eval, created_at, updated_at, status, relationships"
+
+            cursor.execute(f"""
+                SELECT {fields}
+                FROM knowledge WHERE id = %s
+            """, (knowledge_id,))
+            result = cursor.fetchone()
+            return self._format_result(result) if result else None
+        finally:
+            cursor.close()
+
+    def update(self, knowledge_id: str, updates: Dict):
+        """更新知识"""
+        cursor = self._get_cursor()
+        try:
+            set_parts = []
+            params = []
+            for key, value in updates.items():
+                if key in ('tags', 'source', 'eval'):
+                    set_parts.append(f"{key} = %s")
+                    params.append(json.dumps(value))
+                elif key == 'relationships':
+                    set_parts.append(f"{key} = %s")
+                    params.append(json.dumps(value) if isinstance(value, list) else value)
+                else:
+                    set_parts.append(f"{key} = %s")
+                    params.append(value)
+            params.append(knowledge_id)
+            sql = f"UPDATE knowledge SET {', '.join(set_parts)} WHERE id = %s"
+            cursor.execute(sql, params)
+            self.conn.commit()
+        finally:
+            cursor.close()
+
+    def delete(self, knowledge_id: str):
+        """删除知识"""
+        cursor = self._get_cursor()
+        try:
+            cursor.execute("DELETE FROM knowledge WHERE id = %s", (knowledge_id,))
+            self.conn.commit()
+        finally:
+            cursor.close()
+
+    def count(self) -> int:
+        """返回知识总数"""
+        cursor = self._get_cursor()
+        try:
+            cursor.execute("SELECT COUNT(*) as count FROM knowledge")
+            return cursor.fetchone()['count']
+        finally:
+            cursor.close()
+
+    def _build_where_clause(self, filters: str) -> str:
+        """将Milvus风格的过滤表达式转换为PostgreSQL WHERE子句"""
+        if not filters:
+            return ""
+
+        where = filters
+
+        import re
+
+        # 替换操作符
+        where = where.replace(' == ', ' = ')
+        where = where.replace(' or ', ' OR ')
+        where = where.replace(' and ', ' AND ')
+
+        # 处理数组包含操作
+        where = re.sub(r'array_contains\((\w+),\s*"([^"]+)"\)', r"\1 @> ARRAY['\2']", where)
+
+        # 处理 eval["score"] 语法
+        where = where.replace('eval["score"]', "(eval->>'score')::int")
+
+        # 把所有剩余的双引号字符串值替换为单引号(PostgreSQL标准)
+        where = re.sub(r'"([^"]*)"', r"'\1'", where)
+
+        return f"WHERE {where}"
+
+    def _format_result(self, row: Dict) -> Dict:
+        """格式化查询结果"""
+        if not row:
+            return None
+        result = dict(row)
+        if 'tags' in result and isinstance(result['tags'], str):
+            result['tags'] = json.loads(result['tags'])
+        if 'source' in result and isinstance(result['source'], str):
+            result['source'] = json.loads(result['source'])
+        if 'eval' in result and isinstance(result['eval'], str):
+            result['eval'] = json.loads(result['eval'])
+        if 'relationships' in result and isinstance(result['relationships'], str):
+            result['relationships'] = json.loads(result['relationships'])
+        if 'created_at' in result and result['created_at']:
+            result['created_at'] = result['created_at'] * 1000
+        if 'updated_at' in result and result['updated_at']:
+            result['updated_at'] = result['updated_at'] * 1000
+        return result
+
+    def close(self):
+        """关闭连接"""
+        if self.conn:
+            self.conn.close()
+
+    def insert_batch(self, knowledge_list: List[Dict]):
+        """批量插入知识"""
+        if not knowledge_list:
+            return
+
+        cursor = self._get_cursor()
+        try:
+            data = []
+            for k in knowledge_list:
+                data.append((
+                    k['id'], k['embedding'], k['message_id'], k['task'],
+                    k['content'], k.get('types', []),
+                    json.dumps(k.get('tags', {})), k.get('tag_keys', []),
+                    k.get('scopes', []), k['owner'], k.get('resource_ids', []),
+                    json.dumps(k.get('source', {})), json.dumps(k.get('eval', {})),
+                    k['created_at'], k['updated_at'], k.get('status', 'approved'),
+                    json.dumps(k.get('relationships', []))
+                ))
+
+            execute_batch(cursor, """
+                INSERT INTO knowledge (
+                    id, embedding, message_id, task, content, types, tags,
+                    tag_keys, scopes, owner, resource_ids, source, eval,
+                    created_at, updated_at, status, relationships
+                ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+            """, data)
+            self.conn.commit()
+        finally:
+            cursor.close()

+ 82 - 0
knowhub/knowhub_db/test_pg_connection.py

@@ -0,0 +1,82 @@
+#!/usr/bin/env python3
+"""
+测试PostgreSQL数据库连接
+"""
+
+import os
+import psycopg2
+from psycopg2.extras import RealDictCursor
+from dotenv import load_dotenv
+
+# 加载环境变量
+load_dotenv()
+
+def test_connection():
+    """测试数据库连接"""
+    try:
+        # 从环境变量读取配置
+        conn_params = {
+            'host': os.getenv('KNOWHUB_DB'),
+            'port': int(os.getenv('KNOWHUB_PORT', 5432)),
+            'user': os.getenv('KNOWHUB_USER'),
+            'password': os.getenv('KNOWHUB_PASSWORD'),
+            'database': os.getenv('KNOWHUB_DB_NAME'),
+        }
+
+        print("正在连接到PostgreSQL...")
+        print(f"主机: {conn_params['host']}")
+        print(f"端口: {conn_params['port']}")
+        print(f"用户: {conn_params['user']}")
+        print(f"数据库: {conn_params['database']}")
+        print("-" * 50)
+
+        # 建立连接
+        conn = psycopg2.connect(**conn_params)
+        cursor = conn.cursor(cursor_factory=RealDictCursor)
+
+        # 测试查询
+        cursor.execute("SELECT version();")
+        version = cursor.fetchone()
+        print(f"✅ 连接成功!")
+        print(f"PostgreSQL版本: {version['version']}")
+        print("-" * 50)
+
+        # 检查pgvector扩展
+        cursor.execute("""
+            SELECT * FROM pg_extension WHERE extname = 'vector';
+        """)
+        pgvector = cursor.fetchone()
+        if pgvector:
+            print(f"✅ pgvector扩展已安装: {pgvector['extversion']}")
+        else:
+            print("⚠️  pgvector扩展未安装,需要先安装才能存储向量")
+            print("   安装命令: CREATE EXTENSION vector;")
+        print("-" * 50)
+
+        # 列出所有表
+        cursor.execute("""
+            SELECT tablename FROM pg_tables
+            WHERE schemaname = 'public'
+            ORDER BY tablename;
+        """)
+        tables = cursor.fetchall()
+        if tables:
+            print(f"数据库中的表 ({len(tables)}个):")
+            for table in tables:
+                print(f"  - {table['tablename']}")
+        else:
+            print("数据库中暂无表")
+
+        cursor.close()
+        conn.close()
+        print("-" * 50)
+        print("✅ 测试完成,连接正常!")
+        return True
+
+    except Exception as e:
+        print(f"❌ 连接失败: {e}")
+        return False
+
+
+if __name__ == "__main__":
+    test_connection()

+ 0 - 56
knowhub/migrate_contents.py

@@ -1,56 +0,0 @@
-#!/usr/bin/env python3
-"""
-数据库迁移脚本:为contents表添加新字段
-"""
-
-import sqlite3
-from pathlib import Path
-
-DB_PATH = Path(__file__).parent / "knowhub.db"
-
-def migrate():
-    print(f"数据库路径: {DB_PATH}")
-
-    if not DB_PATH.exists():
-        print("数据库不存在,无需迁移")
-        return
-
-    conn = sqlite3.connect(str(DB_PATH))
-    cursor = conn.cursor()
-
-    # 检查是否已有新字段
-    cursor.execute("PRAGMA table_info(contents)")
-    columns = {row[1] for row in cursor.fetchall()}
-    print(f"现有字段: {columns}")
-
-    migrations = []
-
-    if "secure_body" not in columns:
-        migrations.append("ALTER TABLE contents ADD COLUMN secure_body TEXT DEFAULT ''")
-
-    if "content_type" not in columns:
-        migrations.append("ALTER TABLE contents ADD COLUMN content_type TEXT DEFAULT 'text'")
-
-    if "metadata" not in columns:
-        migrations.append("ALTER TABLE contents ADD COLUMN metadata TEXT DEFAULT '{}'")
-
-    if "updated_at" not in columns:
-        migrations.append("ALTER TABLE contents ADD COLUMN updated_at TEXT DEFAULT ''")
-
-    if not migrations:
-        print("✅ 数据库已是最新版本,无需迁移")
-        conn.close()
-        return
-
-    print(f"执行 {len(migrations)} 个迁移...")
-    for sql in migrations:
-        print(f"  {sql}")
-        cursor.execute(sql)
-
-    conn.commit()
-    conn.close()
-    print("✅ 迁移完成")
-
-
-if __name__ == "__main__":
-    migrate()

+ 1 - 2
knowhub/requirements.txt

@@ -1,8 +1,7 @@
 fastapi
 uvicorn[standard]
 pydantic
-pymilvus
-milvus
 httpx
 cryptography
 python-dotenv
+psycopg2-binary

+ 187 - 322
knowhub/server.py

@@ -8,14 +8,13 @@ FastAPI + Milvus Lite(知识)+ SQLite(资源),单文件部署。
 import os
 import re
 import json
-import sqlite3
 import asyncio
 import base64
 import time
 import uuid
 from contextlib import asynccontextmanager
 from datetime import datetime, timezone
-from typing import Optional, List
+from typing import Optional, List, Dict
 from pathlib import Path
 from cryptography.hazmat.primitives.ciphers.aead import AESGCM
 
@@ -37,7 +36,8 @@ _dedup_llm = create_openrouter_llm_call(model="google/gemini-2.5-flash-lite")
 _tool_analysis_llm = create_qwen_llm_call(model="qwen3.5-plus")
 
 # 导入向量存储和 embedding
-from knowhub.vector_store import MilvusStore
+from knowhub.knowhub_db.pg_store import PostgreSQLStore
+from knowhub.knowhub_db.pg_resource_store import PostgreSQLResourceStore
 from knowhub.embeddings import get_embedding, get_embeddings_batch
 
 BRAND_NAME    = os.getenv("BRAND_NAME", "KnowHub")
@@ -54,19 +54,10 @@ if ORG_KEYS_RAW:
             ORG_KEYS[org.strip()] = key_b64.strip()
 
 DB_PATH = Path(__file__).parent / BRAND_DB
-MILVUS_DATA_DIR = Path(__file__).parent / "milvus_data"
-
-# 全局 Milvus 存储实例
-milvus_store: Optional[MilvusStore] = None
-
-# --- 数据库 ---
-
-def get_db() -> sqlite3.Connection:
-    conn = sqlite3.connect(str(DB_PATH))
-    conn.row_factory = sqlite3.Row
-    conn.execute("PRAGMA journal_mode=WAL")
-    return conn
 
+# 全局 PostgreSQL 存储实例
+pg_store: Optional[PostgreSQLStore] = None
+pg_resource_store: Optional[PostgreSQLResourceStore] = None
 
 # --- 加密/解密 ---
 
@@ -183,53 +174,6 @@ def serialize_milvus_result(data):
     return None
 
 
-def init_db():
-    """初始化 SQLite(仅用于 resources)"""
-    conn = get_db()
-    conn.execute("""
-        CREATE TABLE IF NOT EXISTS experiences (
-            id            INTEGER PRIMARY KEY AUTOINCREMENT,
-            name          TEXT NOT NULL,
-            url           TEXT DEFAULT '',
-            category      TEXT DEFAULT '',
-            task          TEXT NOT NULL,
-            score         INTEGER CHECK(score BETWEEN 1 AND 5),
-            outcome       TEXT DEFAULT '',
-            tips          TEXT DEFAULT '',
-            content_id    TEXT DEFAULT '',
-            submitted_by  TEXT DEFAULT '',
-            created_at    TEXT NOT NULL
-        )
-    """)
-    conn.execute("CREATE INDEX IF NOT EXISTS idx_name ON experiences(name)")
-
-    conn.execute("""
-        CREATE TABLE IF NOT EXISTS resources (
-            id            TEXT PRIMARY KEY,
-            title         TEXT DEFAULT '',
-            body          TEXT NOT NULL,
-            secure_body   TEXT DEFAULT '',
-            content_type  TEXT DEFAULT 'text',
-            metadata      TEXT DEFAULT '{}',
-            sort_order    INTEGER DEFAULT 0,
-            submitted_by  TEXT DEFAULT '',
-            created_at    TEXT NOT NULL,
-            updated_at    TEXT DEFAULT ''
-        )
-    """)
-
-    conn.execute("""
-        CREATE TABLE IF NOT EXISTS relation_cache (
-            id   INTEGER PRIMARY KEY CHECK(id = 1),
-            data TEXT NOT NULL DEFAULT '{}'
-        )
-    """)
-    conn.execute("INSERT OR IGNORE INTO relation_cache(id, data) VALUES(1, '{}')")
-
-    conn.commit()
-    conn.close()
-
-
 # --- Models ---
 
 class ResourceIn(BaseModel):
@@ -459,31 +403,22 @@ content: {content}
 # --- Dedup: RelationCache ---
 
 class RelationCache:
-    """关系缓存,存储在 SQLite relation_cache 表(单行 JSON)"""
+    """关系缓存,存储在内存中"""
+
+    def __init__(self):
+        self._cache: Dict[str, List[str]] = {}
 
     def load(self) -> dict:
-        conn = get_db()
-        try:
-            row = conn.execute("SELECT data FROM relation_cache WHERE id=1").fetchone()
-            return json.loads(row["data"]) if row else {}
-        finally:
-            conn.close()
+        return self._cache
 
     def save(self, cache: dict):
-        conn = get_db()
-        try:
-            conn.execute("UPDATE relation_cache SET data=? WHERE id=1", (json.dumps(cache),))
-            conn.commit()
-        finally:
-            conn.close()
+        self._cache = cache
 
     def add_relation(self, relation_type: str, knowledge_id: str):
-        cache = self.load()
-        if relation_type not in cache:
-            cache[relation_type] = []
-        if knowledge_id not in cache[relation_type]:
-            cache[relation_type].append(knowledge_id)
-        self.save(cache)
+        if relation_type not in self._cache:
+            self._cache[relation_type] = []
+        if knowledge_id not in self._cache[relation_type]:
+            self._cache[relation_type].append(knowledge_id)
 
 
 # --- Dedup: KnowledgeProcessor ---
@@ -501,7 +436,7 @@ class KnowledgeProcessor:
             # 第一阶段:处理 pending(去重)
             while True:
                 try:
-                    pending = milvus_store.query('status == "pending"', limit=50)
+                    pending = pg_store.query('status == "pending"', limit=50)
                 except Exception as e:
                     print(f"[KnowledgeProcessor] 查询 pending 失败: {e}")
                     break
@@ -512,7 +447,7 @@ class KnowledgeProcessor:
             # 第二阶段:处理 dedup_passed(工具关联)
             while True:
                 try:
-                    dedup_passed = milvus_store.query('status == "dedup_passed"', limit=50)
+                    dedup_passed = pg_store.query('status == "dedup_passed"', limit=50)
                 except Exception as e:
                     print(f"[KnowledgeProcessor] 查询 dedup_passed 失败: {e}")
                     break
@@ -526,7 +461,7 @@ class KnowledgeProcessor:
         now = int(time.time())
         # 乐观锁:pending → processing(时间戳存秒级)
         try:
-            milvus_store.update(kid, {"status": "processing", "updated_at": now})
+            pg_store.update(kid, {"status": "processing", "updated_at": now})
         except Exception as e:
             print(f"[KnowledgeProcessor] 锁定 {kid} 失败: {e}")
             return
@@ -535,7 +470,7 @@ class KnowledgeProcessor:
             embedding = knowledge.get("embedding")
             if not embedding:
                 embedding = await get_embedding(knowledge["task"])
-            candidates = milvus_store.search(
+            candidates = pg_store.search(
                 query_embedding=embedding,
                 filters='(status == "approved" or status == "checked")',
                 limit=10
@@ -545,7 +480,7 @@ class KnowledgeProcessor:
             candidates = [c for c in candidates if c.get("score", 0) >= 0.75]
 
             if not candidates:
-                milvus_store.update(kid, {"status": "dedup_passed", "updated_at": now})
+                pg_store.update(kid, {"status": "dedup_passed", "updated_at": now})
                 return
 
             llm_result = await self._llm_judge_relations(knowledge, candidates)
@@ -554,7 +489,7 @@ class KnowledgeProcessor:
         except Exception as e:
             print(f"[KnowledgeProcessor] 处理 {kid} 失败: {e},回退到 pending")
             try:
-                milvus_store.update(kid, {"status": "pending", "updated_at": int(time.time())})
+                pg_store.update(kid, {"status": "pending", "updated_at": int(time.time())})
             except Exception:
                 pass
 
@@ -617,7 +552,7 @@ class KnowledgeProcessor:
                     rejected_relationships.append({"type": rel_type, "target": old_id})
                 if rel_type in ("duplicate", "subset") and old_id:
                     try:
-                        old = milvus_store.get_by_id(old_id)
+                        old = pg_store.get_by_id(old_id)
                         if not old:
                             continue
                         eval_data = old.get("eval") or {}
@@ -630,10 +565,10 @@ class KnowledgeProcessor:
                             "timestamp": now
                         })
                         eval_data["helpful_history"] = helpful_history
-                        milvus_store.update(old_id, {"eval": eval_data, "updated_at": now})
+                        pg_store.update(old_id, {"eval": eval_data, "updated_at": now})
                     except Exception as e:
                         print(f"[Apply Decision] 更新旧知识 {old_id} helpful 失败: {e}")
-            milvus_store.update(kid, {"status": "rejected", "relationships": json.dumps(rejected_relationships), "updated_at": now})
+            pg_store.update(kid, {"status": "rejected", "relationships": json.dumps(rejected_relationships), "updated_at": now})
         else:
             new_relationships = []
             for rel in relations:
@@ -647,16 +582,16 @@ class KnowledgeProcessor:
                 self._relation_cache.add_relation(rel_type, old_id)
                 if reverse_type and reverse_type != "none":
                     try:
-                        old = milvus_store.get_by_id(old_id)
+                        old = pg_store.get_by_id(old_id)
                         if old:
                             old_rels = old.get("relationships") or []
                             old_rels.append({"type": reverse_type, "target": kid})
-                            milvus_store.update(old_id, {"relationships": json.dumps(old_rels), "updated_at": now})
+                            pg_store.update(old_id, {"relationships": json.dumps(old_rels), "updated_at": now})
                             self._relation_cache.add_relation(reverse_type, old_id)
                             self._relation_cache.add_relation(reverse_type, kid)
                     except Exception as e:
                         print(f"[Apply Decision] 更新旧知识关系 {old_id} 失败: {e}")
-            milvus_store.update(kid, {
+            pg_store.update(kid, {
                 "status": "dedup_passed",
                 "relationships": json.dumps(new_relationships),
                 "updated_at": now
@@ -691,62 +626,61 @@ class KnowledgeProcessor:
             raise
 
     async def _create_or_get_tool_resource(self, tool_info: dict) -> Optional[str]:
-        """创建或获取工具资源"""
+        """创建或获取工具资源(存入 PostgreSQL tool_table)"""
         category = tool_info.get("category", "other")
         slug = tool_info.get("slug", "")
         if not slug:
             return None
         tool_id = f"tools/{category}/{slug}"
-        conn = get_db()
+        now_ts = int(time.time())
+        cursor = pg_store._get_cursor()
         try:
-            row = conn.execute("SELECT id FROM resources WHERE id = ?", (tool_id,)).fetchone()
-            if row:
+            cursor.execute("SELECT id FROM tool_table WHERE id = %s", (tool_id,))
+            if cursor.fetchone():
                 return tool_id
-            now_str = datetime.now(timezone.utc).isoformat()
-            metadata = {
-                "tool_name": tool_info.get("name", ""),
-                "tool_slug": slug,
-                "category": category,
-                "version": tool_info.get("version", ""),
-                "description": tool_info.get("description", ""),
-                "usage": tool_info.get("usage", ""),
-                "scenarios": tool_info.get("scenarios", []),
-                "input": tool_info.get("input", ""),
-                "output": tool_info.get("output", ""),
-                "status": tool_info.get("status", "未接入"),
-                "knowledge_ids": []
-            }
-            conn.execute(
-                "INSERT INTO resources (id, title, body, content_type, metadata, submitted_by, created_at, updated_at)"
-                " VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
-                (tool_id, tool_info.get("name", slug), "", "tool",
-                 json.dumps(metadata), "knowledge_processor", now_str, now_str),
-            )
-            conn.commit()
+            cursor.execute("""
+                INSERT INTO tool_table (id, name, version, introduction, tutorial, input, output,
+                                        updated_time, status, knowledge, case_knowledge, process_knowledge)
+                VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+            """, (
+                tool_id,
+                tool_info.get("name", slug),
+                tool_info.get("version") or None,
+                tool_info.get("description", ""),
+                tool_info.get("usage", ""),
+                json.dumps(tool_info.get("input", "")),
+                json.dumps(tool_info.get("output", "")),
+                now_ts,
+                tool_info.get("status", "未接入"),
+                json.dumps([]),
+                json.dumps([]),
+                json.dumps([]),
+            ))
+            pg_store.conn.commit()
             print(f"[Tool Resource] 创建新工具: {tool_id}")
             return tool_id
         finally:
-            conn.close()
+            cursor.close()
 
     async def _update_tool_knowledge_index(self, tool_id: str, knowledge_id: str):
-        """更新工具资源的 knowledge_ids 索引"""
-        conn = get_db()
+        """更新工具的 knowledge 关联索引(PostgreSQL tool_table)"""
+        now_ts = int(time.time())
+        cursor = pg_store._get_cursor()
         try:
-            row = conn.execute("SELECT metadata FROM resources WHERE id = ?", (tool_id,)).fetchone()
+            cursor.execute("SELECT knowledge FROM tool_table WHERE id = %s", (tool_id,))
+            row = cursor.fetchone()
             if not row:
                 return
-            metadata = json.loads(row["metadata"] or "{}")
-            knowledge_ids = metadata.get("knowledge_ids", [])
+            knowledge_ids = row["knowledge"] if isinstance(row["knowledge"], list) else json.loads(row["knowledge"] or "[]")
             if knowledge_id not in knowledge_ids:
                 knowledge_ids.append(knowledge_id)
-                metadata["knowledge_ids"] = knowledge_ids
-                conn.execute(
-                    "UPDATE resources SET metadata = ?, updated_at = ? WHERE id = ?",
-                    (json.dumps(metadata), datetime.now(timezone.utc).isoformat(), tool_id)
+                cursor.execute(
+                    "UPDATE tool_table SET knowledge = %s, updated_time = %s WHERE id = %s",
+                    (json.dumps(knowledge_ids), now_ts, tool_id)
                 )
-                conn.commit()
+                pg_store.conn.commit()
         finally:
-            conn.close()
+            cursor.close()
 
     async def _analyze_tool_relation(self, knowledge: dict):
         """分析知识与工具的关联关系"""
@@ -754,7 +688,7 @@ class KnowledgeProcessor:
         now = int(time.time())
         # 乐观锁:dedup_passed → analyzing
         try:
-            milvus_store.update(kid, {"status": "analyzing", "updated_at": now})
+            pg_store.update(kid, {"status": "analyzing", "updated_at": now})
         except Exception as e:
             print(f"[Tool Analysis] 锁定 {kid} 失败: {e}")
             return
@@ -772,13 +706,13 @@ class KnowledgeProcessor:
                 has_tools = bool(tool_analysis and tool_analysis.get("has_tools"))
                 # 重新分析后仍然不一致 → 知识模糊,rejected
                 if not has_tools:
-                    milvus_store.update(kid, {"status": "rejected", "updated_at": now})
+                    pg_store.update(kid, {"status": "rejected", "updated_at": now})
                     print(f"[Tool Analysis] {kid} 两次判定不一致,知识模糊,rejected")
                     return
 
             # 情况2:无工具且无 tool tag → 直接 approved
             if not has_tools:
-                milvus_store.update(kid, {"status": "approved", "updated_at": now})
+                pg_store.update(kid, {"status": "approved", "updated_at": now})
                 return
 
             # 情况3/4:有工具 → 创建资源并关联
@@ -803,7 +737,7 @@ class KnowledgeProcessor:
                 updates["tags"] = updated_tags
                 print(f"[Tool Analysis] {kid} 添加 tool tag")
 
-            milvus_store.update(kid, updates)
+            pg_store.update(kid, updates)
 
             for tool_id in tool_ids:
                 await self._update_tool_knowledge_index(tool_id, kid)
@@ -813,7 +747,7 @@ class KnowledgeProcessor:
         except Exception as e:
             print(f"[Tool Analysis] {kid} 分析失败: {e},回退到 dedup_passed")
             try:
-                milvus_store.update(kid, {"status": "dedup_passed", "updated_at": int(time.time())})
+                pg_store.update(kid, {"status": "dedup_passed", "updated_at": int(time.time())})
             except Exception:
                 pass
 
@@ -826,22 +760,22 @@ async def _periodic_processor():
             now = int(time.time())
             # 回滚超时的 processing(5分钟 → pending)
             timeout_5min = now - 300
-            processing = milvus_store.query('status == "processing"', limit=200)
+            processing = pg_store.query('status == "processing"', limit=200)
             for item in processing:
                 updated_at = item.get("updated_at", 0) or 0
                 updated_at_sec = updated_at // 1000 if updated_at > 1_000_000_000_000 else updated_at
                 if updated_at_sec < timeout_5min:
                     print(f"[Periodic] 回滚超时 processing → pending: {item['id']}")
-                    milvus_store.update(item["id"], {"status": "pending", "updated_at": int(time.time())})
+                    pg_store.update(item["id"], {"status": "pending", "updated_at": int(time.time())})
             # 回滚超时的 analyzing(10分钟 → dedup_passed)
             timeout_10min = now - 600
-            analyzing = milvus_store.query('status == "analyzing"', limit=200)
+            analyzing = pg_store.query('status == "analyzing"', limit=200)
             for item in analyzing:
                 updated_at = item.get("updated_at", 0) or 0
                 updated_at_sec = updated_at // 1000 if updated_at > 1_000_000_000_000 else updated_at
                 if updated_at_sec < timeout_10min:
                     print(f"[Periodic] 回滚超时 analyzing → dedup_passed: {item['id']}")
-                    milvus_store.update(item["id"], {"status": "dedup_passed", "updated_at": int(time.time())})
+                    pg_store.update(item["id"], {"status": "dedup_passed", "updated_at": int(time.time())})
         except Exception as e:
             print(f"[Periodic] 定时任务错误: {e}")
 
@@ -850,13 +784,11 @@ async def _periodic_processor():
 
 @asynccontextmanager
 async def lifespan(app: FastAPI):
-    global milvus_store, knowledge_processor
+    global pg_store, pg_resource_store, knowledge_processor
 
-    # 初始化 SQLite(resources)
-    init_db()
-
-    # 初始化 Milvus Lite(knowledge)
-    milvus_store = MilvusStore(data_dir=str(MILVUS_DATA_DIR))
+    # 初始化 PostgreSQL(knowledge + resources)
+    pg_store = PostgreSQLStore()
+    pg_resource_store = PostgreSQLResourceStore()
 
     # 初始化去重处理器 + 启动定时兜底任务
     knowledge_processor = KnowledgeProcessor()
@@ -870,6 +802,8 @@ async def lifespan(app: FastAPI):
         await periodic_task
     except asyncio.CancelledError:
         pass
+    pg_store.close()
+    pg_resource_store.close()
 
 
 app = FastAPI(title=BRAND_NAME, lifespan=lifespan)
@@ -879,52 +813,36 @@ app = FastAPI(title=BRAND_NAME, lifespan=lifespan)
 
 @app.post("/api/resource", status_code=201)
 def submit_resource(resource: ResourceIn):
-    conn = get_db()
+    """提交资源(存入 PostgreSQL resources 表)"""
     try:
-        now = datetime.now(timezone.utc).isoformat()
-
         # 加密敏感内容
         encrypted_secure_body = encrypt_content(resource.id, resource.secure_body)
 
-        conn.execute(
-            "INSERT OR REPLACE INTO resources"
-            "(id, title, body, secure_body, content_type, metadata, sort_order, submitted_by, created_at, updated_at)"
-            " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
-            (
-                resource.id,
-                resource.title,
-                resource.body,
-                encrypted_secure_body,
-                resource.content_type,
-                json.dumps(resource.metadata),
-                resource.sort_order,
-                resource.submitted_by,
-                now,
-                now,
-            ),
-        )
-        conn.commit()
+        pg_resource_store.insert_or_update({
+            'id': resource.id,
+            'title': resource.title,
+            'body': resource.body,
+            'secure_body': encrypted_secure_body,
+            'content_type': resource.content_type,
+            'metadata': resource.metadata,
+            'sort_order': resource.sort_order,
+            'submitted_by': resource.submitted_by
+        })
         return {"status": "ok", "id": resource.id}
-    finally:
-        conn.close()
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
 
 
 @app.get("/api/resource/{resource_id:path}", response_model=ResourceOut)
 def get_resource(resource_id: str, x_org_key: Optional[str] = Header(None)):
-    conn = get_db()
+    """获取资源详情(从 PostgreSQL)"""
     try:
-        row = conn.execute(
-            "SELECT id, title, body, secure_body, content_type, metadata, sort_order FROM resources WHERE id = ?",
-            (resource_id,),
-        ).fetchone()
+        row = pg_resource_store.get_by_id(resource_id)
         if not row:
             raise HTTPException(status_code=404, detail=f"Resource not found: {resource_id}")
 
         # 解密敏感内容
-        secure_body = decrypt_content(resource_id, row["secure_body"] or "", x_org_key)
-
-        # 解析metadata
-        metadata = json.loads(row["metadata"] or "{}")
+        secure_body = decrypt_content(resource_id, row.get("secure_body", ""), x_org_key)
 
         # 计算导航上下文
         root_id = resource_id.split("/")[0] if "/" in resource_id else resource_id
@@ -932,36 +850,19 @@ def get_resource(resource_id: str, x_org_key: Optional[str] = Header(None)):
         # TOC (根节点)
         toc = None
         if "/" in resource_id:
-            toc_row = conn.execute(
-                "SELECT id, title FROM resources WHERE id = ?",
-                (root_id,),
-            ).fetchone()
+            toc_row = pg_resource_store.get_by_id(root_id)
             if toc_row:
                 toc = ResourceNode(id=toc_row["id"], title=toc_row["title"])
 
         # Children (子节点)
-        children = []
-        children_rows = conn.execute(
-            "SELECT id, title FROM resources WHERE id LIKE ? AND id != ? ORDER BY sort_order",
-            (f"{resource_id}/%", resource_id),
-        ).fetchall()
-        children = [ResourceNode(id=r["id"], title=r["title"]) for r in children_rows]
+        children_rows = pg_resource_store.list_resources(prefix=f"{resource_id}/", limit=1000)
+        children = [ResourceNode(id=r["id"], title=r["title"]) for r in children_rows
+                    if r["id"].count("/") == resource_id.count("/") + 1]
 
         # Prev/Next (同级节点)
-        prev_node = None
-        next_node = None
-        if "/" in resource_id:
-            siblings = conn.execute(
-                "SELECT id, title, sort_order FROM resources 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"] == resource_id:
-                    if i > 0:
-                        prev_node = ResourceNode(id=siblings[i-1]["id"], title=siblings[i-1]["title"])
-                    if i < len(siblings) - 1:
-                        next_node = ResourceNode(id=siblings[i+1]["id"], title=siblings[i+1]["title"])
-                    break
+        prev_node, next_node = pg_resource_store.get_siblings(resource_id)
+        prev = ResourceNode(id=prev_node["id"], title=prev_node["title"]) if prev_node else None
+        next = ResourceNode(id=next_node["id"], title=next_node["title"]) if next_node else None
 
         return ResourceOut(
             id=row["id"],
@@ -969,67 +870,49 @@ def get_resource(resource_id: str, x_org_key: Optional[str] = Header(None)):
             body=row["body"],
             secure_body=secure_body,
             content_type=row["content_type"],
-            metadata=metadata,
+            metadata=row.get("metadata", {}),
             toc=toc,
             children=children,
-            prev=prev_node,
-            next=next_node,
+            prev=prev,
+            next=next,
         )
-    finally:
-        conn.close()
+    except HTTPException:
+        raise
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
 
 
 @app.patch("/api/resource/{resource_id:path}")
 def patch_resource(resource_id: str, patch: ResourcePatchIn):
-    """更新resource字段"""
-    conn = get_db()
+    """更新resource字段(PostgreSQL)"""
     try:
         # 检查是否存在
-        row = conn.execute("SELECT id FROM resources WHERE id = ?", (resource_id,)).fetchone()
-        if not row:
+        if not pg_resource_store.get_by_id(resource_id):
             raise HTTPException(status_code=404, detail=f"Resource not found: {resource_id}")
 
-        # 构建更新语句
-        updates = []
-        params = []
+        # 构建更新字典
+        updates = {}
 
         if patch.title is not None:
-            updates.append("title = ?")
-            params.append(patch.title)
-
+            updates['title'] = patch.title
         if patch.body is not None:
-            updates.append("body = ?")
-            params.append(patch.body)
-
+            updates['body'] = patch.body
         if patch.secure_body is not None:
-            encrypted = encrypt_content(resource_id, patch.secure_body)
-            updates.append("secure_body = ?")
-            params.append(encrypted)
-
+            updates['secure_body'] = encrypt_content(resource_id, patch.secure_body)
         if patch.content_type is not None:
-            updates.append("content_type = ?")
-            params.append(patch.content_type)
-
+            updates['content_type'] = patch.content_type
         if patch.metadata is not None:
-            updates.append("metadata = ?")
-            params.append(json.dumps(patch.metadata))
+            updates['metadata'] = patch.metadata
 
         if not updates:
             return {"status": "ok", "message": "No fields to update"}
 
-        # 添加updated_at
-        updates.append("updated_at = ?")
-        params.append(datetime.now(timezone.utc).isoformat())
-
-        # 执行更新
-        params.append(resource_id)
-        sql = f"UPDATE resources SET {', '.join(updates)} WHERE id = ?"
-        conn.execute(sql, params)
-        conn.commit()
-
+        pg_resource_store.update(resource_id, updates)
         return {"status": "ok", "id": resource_id}
-    finally:
-        conn.close()
+    except HTTPException:
+        raise
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
 
 
 @app.get("/api/resource")
@@ -1037,50 +920,30 @@ def list_resources(
     content_type: Optional[str] = Query(None),
     limit: int = Query(100, ge=1, le=1000)
 ):
-    """列出所有resource"""
-    conn = get_db()
+    """列出所有resource(PostgreSQL)"""
     try:
-        sql = "SELECT id, title, content_type, metadata, created_at FROM resources"
-        params = []
-
-        if content_type:
-            sql += " WHERE content_type = ?"
-            params.append(content_type)
-
-        sql += " ORDER BY id LIMIT ?"
-        params.append(limit)
-
-        rows = conn.execute(sql, params).fetchall()
-
-        results = []
-        for row in rows:
-            results.append({
-                "id": row["id"],
-                "title": row["title"],
-                "content_type": row["content_type"],
-                "metadata": json.loads(row["metadata"] or "{}"),
-                "created_at": row["created_at"],
-            })
-
+        results = pg_resource_store.list_resources(
+            content_type=content_type,
+            limit=limit
+        )
         return {"results": results, "count": len(results)}
-    finally:
-        conn.close()
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
 
 
 @app.delete("/api/resource/{resource_id:path}")
 def delete_resource(resource_id: str):
-    """删除单个resource"""
-    conn = get_db()
+    """删除单个resource(PostgreSQL)"""
     try:
-        row = conn.execute("SELECT id FROM resources WHERE id = ?", (resource_id,)).fetchone()
-        if not row:
+        if not pg_resource_store.get_by_id(resource_id):
             raise HTTPException(status_code=404, detail=f"Resource not found: {resource_id}")
 
-        conn.execute("DELETE FROM resources WHERE id = ?", (resource_id,))
-        conn.commit()
+        pg_resource_store.delete(resource_id)
         return {"status": "ok", "id": resource_id}
-    finally:
-        conn.close()
+    except HTTPException:
+        raise
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
 
 
 # --- Knowledge API ---
@@ -1177,7 +1040,7 @@ async def search_knowledge_api(
 
         # 3. 向量召回(3*k 个候选)
         recall_limit = top_k * 3
-        candidates = milvus_store.search(
+        candidates = pg_store.search(
             query_embedding=query_embedding,
             filters=filter_expr,
             limit=recall_limit
@@ -1272,7 +1135,7 @@ async def save_knowledge(knowledge: KnowledgeIn, background_tasks: BackgroundTas
         print(f"[Save Knowledge] 插入数据: {json.dumps({k: v for k, v in insert_data.items() if k != 'embedding'}, ensure_ascii=False)}")
 
         # 插入 Milvus
-        milvus_store.insert(insert_data)
+        pg_store.insert(insert_data)
 
         # 触发后台去重处理
         background_tasks.add_task(knowledge_processor.process_pending)
@@ -1334,7 +1197,7 @@ def list_knowledge(
         # 查询 Milvus(先获取所有符合条件的数据)
         # Milvus 的 limit 是总数限制,我们需要获取足够多的数据来支持分页
         max_limit = 10000  # 设置一个合理的上限
-        results = milvus_store.query(filter_expr, limit=max_limit)
+        results = pg_store.query(filter_expr, limit=max_limit)
 
         # 转换为可序列化的格式
         serialized_results = [serialize_milvus_result(r) for r in results]
@@ -1369,7 +1232,7 @@ def get_all_tags():
     """获取所有已有的 tags"""
     try:
         # 查询所有知识
-        results = milvus_store.query('id != ""', limit=10000)
+        results = pg_store.query('id != ""', limit=10000)
 
         all_tags = set()
         for item in results:
@@ -1391,7 +1254,7 @@ def get_all_tags():
 def get_pending_knowledge(limit: int = Query(default=50, ge=1, le=200)):
     """查询待处理队列(pending + processing + dedup_passed + analyzing)"""
     try:
-        pending = milvus_store.query(
+        pending = pg_store.query(
             'status == "pending" or status == "processing" or status == "dedup_passed" or status == "analyzing"',
             limit=limit
         )
@@ -1407,13 +1270,13 @@ async def trigger_process(force: bool = Query(default=False)):
     """手动触发去重处理。force=true 时先回滚所有 processing → pending,analyzing → dedup_passed"""
     try:
         if force:
-            processing = milvus_store.query('status == "processing"', limit=200)
+            processing = pg_store.query('status == "processing"', limit=200)
             for item in processing:
-                milvus_store.update(item["id"], {"status": "pending", "updated_at": int(time.time())})
+                pg_store.update(item["id"], {"status": "pending", "updated_at": int(time.time())})
             print(f"[Manual Process] 回滚 {len(processing)} 条 processing → pending")
-            analyzing = milvus_store.query('status == "analyzing"', limit=200)
+            analyzing = pg_store.query('status == "analyzing"', limit=200)
             for item in analyzing:
-                milvus_store.update(item["id"], {"status": "dedup_passed", "updated_at": int(time.time())})
+                pg_store.update(item["id"], {"status": "dedup_passed", "updated_at": int(time.time())})
             print(f"[Manual Process] 回滚 {len(analyzing)} 条 analyzing → dedup_passed")
         asyncio.create_task(knowledge_processor.process_pending())
         return {"status": "ok", "message": "处理任务已触发"}
@@ -1424,20 +1287,15 @@ async def trigger_process(force: bool = Query(default=False)):
 
 @app.post("/api/knowledge/migrate")
 async def migrate_knowledge_schema():
-    """手动触发 schema 迁移(中转 collection 模式,将旧数据升级到含 status/relationships 的新 schema)"""
-    try:
-        count = milvus_store.migrate_schema()
-        return {"status": "ok", "migrated": count, "message": f"迁移完成,共迁移 {count} 条知识"}
-    except Exception as e:
-        print(f"[Migrate] 错误: {e}")
-        raise HTTPException(status_code=500, detail=str(e))
+    """手动触发 schema 迁移(PostgreSQL不需要此功能)"""
+    return {"status": "ok", "message": "PostgreSQL不需要schema迁移"}
 
 
 @app.get("/api/knowledge/status/{knowledge_id}")
 def get_knowledge_status(knowledge_id: str):
     """查询单条知识的处理状态和关系"""
     try:
-        result = milvus_store.get_by_id(knowledge_id)
+        result = pg_store.get_by_id(knowledge_id)
         if not result:
             raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
         serialized = serialize_milvus_result(result)
@@ -1459,7 +1317,7 @@ def get_knowledge_status(knowledge_id: str):
 def get_knowledge(knowledge_id: str):
     """获取单条知识"""
     try:
-        result = milvus_store.get_by_id(knowledge_id)
+        result = pg_store.get_by_id(knowledge_id)
         if not result:
             raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
 
@@ -1506,7 +1364,7 @@ async def update_knowledge(knowledge_id: str, update: KnowledgeUpdateIn):
     """更新知识评估,支持知识进化"""
     try:
         # 获取现有知识
-        existing = milvus_store.get_by_id(knowledge_id)
+        existing = pg_store.get_by_id(knowledge_id)
         if not existing:
             raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
 
@@ -1551,7 +1409,7 @@ async def update_knowledge(knowledge_id: str, update: KnowledgeUpdateIn):
             updates["embedding"] = embedding
 
         # 更新 Milvus
-        milvus_store.update(knowledge_id, updates)
+        pg_store.update(knowledge_id, updates)
 
         return {"status": "ok", "knowledge_id": knowledge_id}
 
@@ -1567,7 +1425,7 @@ async def patch_knowledge(knowledge_id: str, patch: KnowledgePatchIn):
     """直接编辑知识字段"""
     try:
         # 获取现有知识
-        existing = milvus_store.get_by_id(knowledge_id)
+        existing = pg_store.get_by_id(knowledge_id)
         if not existing:
             raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
 
@@ -1606,7 +1464,7 @@ async def patch_knowledge(knowledge_id: str, patch: KnowledgePatchIn):
             updates["embedding"] = embedding
 
         # 更新 Milvus
-        milvus_store.update(knowledge_id, updates)
+        pg_store.update(knowledge_id, updates)
 
         return {"status": "ok", "knowledge_id": knowledge_id}
 
@@ -1622,12 +1480,12 @@ def delete_knowledge(knowledge_id: str):
     """删除单条知识"""
     try:
         # 检查知识是否存在
-        existing = milvus_store.get_by_id(knowledge_id)
+        existing = pg_store.get_by_id(knowledge_id)
         if not existing:
             raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
 
-        # 从 Milvus 删除
-        milvus_store.collection.delete(expr=f'id == "{knowledge_id}"')
+        # 从 PostgreSQL 删除
+        pg_store.delete(knowledge_id)
         print(f"[Delete Knowledge] 已删除知识: {knowledge_id}")
 
         return {"status": "ok", "knowledge_id": knowledge_id}
@@ -1646,15 +1504,19 @@ def batch_delete_knowledge(knowledge_ids: List[str] = Body(...)):
         if not knowledge_ids:
             raise HTTPException(status_code=400, detail="knowledge_ids cannot be empty")
 
-        # 构建删除表达式
-        ids_str = '", "'.join(knowledge_ids)
-        expr = f'id in ["{ids_str}"]'
-
         # 批量删除
-        milvus_store.collection.delete(expr=expr)
-        print(f"[Batch Delete] 已删除 {len(knowledge_ids)} 条知识")
-
-        return {"status": "ok", "deleted_count": len(knowledge_ids)}
+        cursor = pg_store._get_cursor()
+        try:
+            cursor.execute(
+                "DELETE FROM knowledge WHERE id = ANY(%s)",
+                (knowledge_ids,)
+            )
+            pg_store.conn.commit()
+            deleted_count = cursor.rowcount
+            print(f"[Batch Delete] 已删除 {deleted_count} 条知识")
+            return {"status": "ok", "deleted_count": deleted_count}
+        finally:
+            cursor.close()
 
     except HTTPException:
         raise
@@ -1674,7 +1536,7 @@ async def batch_verify_knowledge(batch: KnowledgeBatchVerifyIn):
         updated_count = 0
 
         for kid in batch.knowledge_ids:
-            existing = milvus_store.get_by_id(kid)
+            existing = pg_store.get_by_id(kid)
             if not existing:
                 continue
 
@@ -1687,7 +1549,7 @@ async def batch_verify_knowledge(batch: KnowledgeBatchVerifyIn):
                 "issue_type": None,
                 "issue_action": None,
             }
-            milvus_store.update(kid, {"eval": eval_data, "status": "checked", "updated_at": int(time.time())})
+            pg_store.update(kid, {"eval": eval_data, "status": "checked", "updated_at": int(time.time())})
             updated_count += 1
 
         return {"status": "ok", "updated": updated_count}
@@ -1701,7 +1563,7 @@ async def batch_verify_knowledge(batch: KnowledgeBatchVerifyIn):
 async def verify_knowledge(knowledge_id: str, verify: KnowledgeVerifyIn):
     """知识验证:approve 切换 approved↔checked,reject 设为 rejected"""
     try:
-        existing = milvus_store.get_by_id(knowledge_id)
+        existing = pg_store.get_by_id(knowledge_id)
         if not existing:
             raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
 
@@ -1710,7 +1572,7 @@ async def verify_knowledge(knowledge_id: str, verify: KnowledgeVerifyIn):
         if verify.action == "approve":
             # checked → approved(取消验证),其他 → checked
             new_status = "approved" if current_status == "checked" else "checked"
-            milvus_store.update(knowledge_id, {
+            pg_store.update(knowledge_id, {
                 "status": new_status,
                 "updated_at": int(time.time())
             })
@@ -1718,7 +1580,7 @@ async def verify_knowledge(knowledge_id: str, verify: KnowledgeVerifyIn):
                     "message": "已取消验证" if new_status == "approved" else "验证通过"}
 
         elif verify.action == "reject":
-            milvus_store.update(knowledge_id, {
+            pg_store.update(knowledge_id, {
                 "status": "rejected",
                 "updated_at": int(time.time())
             })
@@ -1753,7 +1615,7 @@ async def batch_update_knowledge(batch: KnowledgeBatchUpdateIn):
             if not knowledge_id:
                 continue
 
-            existing = milvus_store.get_by_id(knowledge_id)
+            existing = pg_store.get_by_id(knowledge_id)
             if not existing:
                 continue
 
@@ -1771,7 +1633,7 @@ async def batch_update_knowledge(batch: KnowledgeBatchUpdateIn):
             else:
                 eval_data["harmful"] = eval_data.get("harmful", 0) + 1
 
-            milvus_store.update(knowledge_id, {"eval": eval_data})
+            pg_store.update(knowledge_id, {"eval": eval_data})
 
         # 并发执行知识进化
         if evolution_tasks:
@@ -1786,7 +1648,7 @@ async def batch_update_knowledge(batch: KnowledgeBatchUpdateIn):
                 # 重新生成向量(只基于 task)
                 embedding = await get_embedding(task)
 
-                milvus_store.update(knowledge_id, {
+                pg_store.update(knowledge_id, {
                     "content": evolved_content,
                     "eval": eval_data,
                     "embedding": embedding
@@ -1804,7 +1666,7 @@ async def slim_knowledge(model: str = "google/gemini-2.5-flash-lite"):
     """知识库瘦身:合并语义相似知识"""
     try:
         # 获取所有知识
-        all_knowledge = milvus_store.query('id != ""', limit=10000)
+        all_knowledge = pg_store.query('id != ""', limit=10000)
         # 转换为可序列化的格式
         all_knowledge = [serialize_milvus_result(item) for item in all_knowledge]
 
@@ -1928,10 +1790,13 @@ REPORT: 原有 X 条,合并后 Y 条,精简了 Z 条。
         texts = [e['task'] for e in new_entries]
         embeddings = await get_embeddings_batch(texts)
 
-        # 清空并重建
-        now = int(time.time())
-        milvus_store.drop_collection()
-        milvus_store._init_collection()
+        # 清空并重建(PostgreSQL使用TRUNCATE)
+        cursor = pg_store._get_cursor()
+        try:
+            cursor.execute("TRUNCATE TABLE knowledge")
+            pg_store.conn.commit()
+        finally:
+            cursor.close()
 
         knowledge_list = []
         for e, embedding in zip(new_entries, embeddings):
@@ -1971,7 +1836,7 @@ REPORT: 原有 X 条,合并后 Y 条,精简了 Z 条。
                 "relationships": json.dumps([])
             })
 
-        milvus_store.insert_batch(knowledge_list)
+        pg_store.insert_batch(knowledge_list)
 
         result_msg = f"瘦身完成:{len(all_knowledge)} → {len(new_entries)} 条知识"
         if report_line:
@@ -2136,7 +2001,7 @@ async def extract_knowledge_from_messages(extract_req: MessageExtractIn, backgro
 
         # 批量插入
         if knowledge_list:
-            milvus_store.insert_batch(knowledge_list)
+            pg_store.insert_batch(knowledge_list)
             background_tasks.add_task(knowledge_processor.process_pending)
 
         print(f"[Extract] 成功提取并保存 {len(knowledge_ids)} 条知识")

+ 746 - 0
knowhub/static/app.js

@@ -0,0 +1,746 @@
+// 全局变量
+let allKnowledge = [];
+let availableTags = [];
+let currentPage = 1;
+let pageSize = 200;
+let totalPages = 1;
+let totalCount = 0;
+let isSearchMode = false;
+let selectedIds = new Set();
+let _allTools = [];
+let _activeCategory = 'all';
+
+// 加载 Tags
+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 => {
+        let types = [];
+        if (Array.isArray(k.types)) {
+            types = k.types;
+        } else if (typeof k.types === 'string') {
+            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';
+
+        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);
+    }
+}
+
+// 批量验证
+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(); }
+    }
+}
+
+// 验证单个知识
+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;
+        }
+    }
+}
+
+// 模态框操作
+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) {
+        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 || {});
+
+    const scopes = Array.isArray(k.scopes) ? k.scopes : [];
+    document.getElementById('scopesInput').value = scopes.join(', ');
+    document.getElementById('ownerInput').value = k.owner || '';
+
+    const types = Array.isArray(k.types) ? k.types : [];
+    document.querySelectorAll('.type-checkbox').forEach(el => {
+        el.checked = types.includes(el.value);
+    });
+
+    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.addEventListener('DOMContentLoaded', function() {
+    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();
+    });
+
+    // 初始化加载
+    loadTags();
+    loadKnowledge();
+});
+
+// 工具表相关函数
+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');
+}
+
+function escapeHtml(text) {
+    const div = document.createElement('div');
+    div.textContent = text;
+    return div.innerHTML;
+}

+ 192 - 0
knowhub/static/index.html

@@ -0,0 +1,192 @@
+<!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 src="/static/app.js"></script>
+</body>
+</html>

+ 0 - 463
knowhub/vector_store.py

@@ -1,463 +0,0 @@
-"""
-Milvus Lite 存储封装
-
-单一存储架构,存储完整知识数据 + 向量。
-"""
-
-from milvus import default_server
-from pymilvus import (
-    connections, Collection, FieldSchema,
-    CollectionSchema, DataType, utility
-)
-from typing import List, Dict, Optional
-import json
-import time
-
-
-class MilvusStore:
-    def __init__(self, data_dir: str = "./milvus_data"):
-        """
-        初始化 Milvus Lite 存储
-
-        Args:
-            data_dir: 数据存储目录
-        """
-        # 启动内嵌服务器
-        default_server.set_base_dir(data_dir)
-
-        # 检查是否已经有 Milvus 实例在运行
-        try:
-            # 尝试连接到可能已存在的实例
-            connections.connect(
-                alias="default",
-                host='127.0.0.1',
-                port=default_server.listen_port,
-                timeout=5
-            )
-            print(f"[Milvus] 连接到已存在的 Milvus 实例 (端口 {default_server.listen_port})")
-        except Exception:
-            # 没有运行的实例,启动新的
-            print(f"[Milvus] 启动新的 Milvus Lite 实例...")
-            try:
-                default_server.start()
-                print(f"[Milvus] Milvus Lite 启动成功 (端口 {default_server.listen_port})")
-                # 启动后建立连接
-                connections.connect(
-                    alias="default",
-                    host='127.0.0.1',
-                    port=default_server.listen_port,
-                    timeout=5
-                )
-                print(f"[Milvus] 已连接到新启动的实例")
-            except Exception as e:
-                print(f"[Milvus] 启动失败: {e}")
-                # 尝试连接到可能已经在运行的实例
-                try:
-                    connections.connect(
-                        alias="default",
-                        host='127.0.0.1',
-                        port=default_server.listen_port,
-                        timeout=5
-                    )
-                    print(f"[Milvus] 连接到已存在的实例")
-                except Exception as e2:
-                    raise RuntimeError(f"无法启动或连接到 Milvus: {e}, {e2}")
-
-        self._init_collection()
-
-    def _init_collection(self):
-        """初始化 collection"""
-        collection_name = "knowledge_dedup"
-
-        if utility.has_collection(collection_name):
-            self.collection = Collection(collection_name)
-        else:
-            # 定义 schema
-            fields = [
-                FieldSchema(name="id", dtype=DataType.VARCHAR,
-                           max_length=100, is_primary=True),
-                FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR,
-                           dim=1536),
-                FieldSchema(name="message_id", dtype=DataType.VARCHAR,
-                           max_length=100),
-                FieldSchema(name="task", dtype=DataType.VARCHAR,
-                           max_length=2000),
-                FieldSchema(name="content", dtype=DataType.VARCHAR,
-                           max_length=50000),
-                FieldSchema(name="types", dtype=DataType.ARRAY,
-                           element_type=DataType.VARCHAR, max_capacity=20, max_length=50),
-                FieldSchema(name="tags", dtype=DataType.JSON),
-                FieldSchema(name="tag_keys", dtype=DataType.ARRAY,
-                           element_type=DataType.VARCHAR, max_capacity=50, max_length=100),
-                FieldSchema(name="scopes", dtype=DataType.ARRAY,
-                           element_type=DataType.VARCHAR, max_capacity=20, max_length=100),
-                FieldSchema(name="owner", dtype=DataType.VARCHAR,
-                           max_length=200),
-                FieldSchema(name="resource_ids", dtype=DataType.ARRAY,
-                           element_type=DataType.VARCHAR, max_capacity=50, max_length=200),
-                FieldSchema(name="source", dtype=DataType.JSON),
-                FieldSchema(name="eval", dtype=DataType.JSON),
-                FieldSchema(name="created_at", dtype=DataType.INT64),
-                FieldSchema(name="updated_at", dtype=DataType.INT64),
-                FieldSchema(name="status", dtype=DataType.VARCHAR,
-                           max_length=20, default_value="approved"),
-                FieldSchema(name="relationships", dtype=DataType.VARCHAR,
-                           max_length=10000, default_value="[]"),
-            ]
-
-            schema = CollectionSchema(fields, description="KnowHub Knowledge")
-            self.collection = Collection(collection_name, schema)
-
-            # 创建向量索引
-            index_params = {
-                "metric_type": "COSINE",
-                "index_type": "HNSW",
-                "params": {"M": 16, "efConstruction": 200}
-            }
-            self.collection.create_index("embedding", index_params)
-
-            # 为 status 创建 Trie 标量索引(加速过滤)
-            try:
-                self.collection.create_index("status", {"index_type": "Trie"})
-            except Exception:
-                pass
-
-        self.collection.load()
-
-    def insert(self, knowledge: Dict):
-        """
-        插入单条知识
-
-        Args:
-            knowledge: 知识数据(包含 embedding)
-        """
-        self.collection.insert([knowledge])
-        self.collection.flush()
-
-    def insert_batch(self, knowledge_list: List[Dict]):
-        """
-        批量插入知识
-
-        Args:
-            knowledge_list: 知识列表
-        """
-        if not knowledge_list:
-            return
-        self.collection.insert(knowledge_list)
-        self.collection.flush()
-
-    def search(self,
-               query_embedding: List[float],
-               filters: Optional[str] = None,
-               limit: int = 10) -> List[Dict]:
-        """
-        向量检索 + 标量过滤
-
-        Args:
-            query_embedding: 查询向量
-            filters: 过滤表达式(如: 'owner == "agent"')
-            limit: 返回数量
-
-        Returns:
-            知识列表
-        """
-        search_params = {"metric_type": "COSINE", "params": {"ef": 100}}
-
-        results = self.collection.search(
-            data=[query_embedding],
-            anns_field="embedding",
-            param=search_params,
-            limit=limit,
-            expr=filters,
-            output_fields=["id", "message_id", "task", "content", "types",
-                          "tags", "tag_keys", "scopes", "owner", "resource_ids",
-                          "source", "eval", "created_at", "updated_at",
-                          "status", "relationships"]
-        )
-
-        if not results or not results[0]:
-            return []
-
-        # 返回实体字典,包含所有字段
-        # 注意:时间戳需要转换为毫秒(JavaScript Date 需要)
-        return [
-            {
-                "id": hit.entity.get("id"),
-                "message_id": hit.entity.get("message_id"),
-                "task": hit.entity.get("task"),
-                "content": hit.entity.get("content"),
-                "types": list(hit.entity.get("types")) if hit.entity.get("types") else [],
-                "tags": hit.entity.get("tags"),
-                "tag_keys": list(hit.entity.get("tag_keys")) if hit.entity.get("tag_keys") else [],
-                "scopes": list(hit.entity.get("scopes")) if hit.entity.get("scopes") else [],
-                "owner": hit.entity.get("owner"),
-                "resource_ids": list(hit.entity.get("resource_ids")) if hit.entity.get("resource_ids") else [],
-                "source": hit.entity.get("source"),
-                "eval": hit.entity.get("eval"),
-                "created_at": hit.entity.get("created_at") * 1000 if hit.entity.get("created_at") else None,
-                "updated_at": hit.entity.get("updated_at") * 1000 if hit.entity.get("updated_at") else None,
-                "status": hit.entity.get("status", "approved"),
-                "relationships": json.loads(hit.entity.get("relationships") or "[]"),
-                "score": hit.score,
-            }
-            for hit in results[0]
-        ]
-
-    def query(self, filters: str, limit: int = 100) -> List[Dict]:
-        """
-        纯标量查询(不使用向量)
-
-        Args:
-            filters: 过滤表达式
-            limit: 返回数量
-
-        Returns:
-            知识列表
-        """
-        results = self.collection.query(
-            expr=filters,
-            output_fields=["id", "message_id", "task", "content", "types",
-                          "tags", "tag_keys", "scopes", "owner", "resource_ids",
-                          "source", "eval", "created_at", "updated_at",
-                          "status", "relationships"],
-            limit=limit
-        )
-
-        # 转换时间戳为毫秒,确保数组字段格式正确
-        for r in results:
-            if r.get("created_at"):
-                r["created_at"] = r["created_at"] * 1000
-            if r.get("updated_at"):
-                r["updated_at"] = r["updated_at"] * 1000
-            # 确保数组字段是列表格式
-            if r.get("types") and not isinstance(r["types"], list):
-                r["types"] = list(r["types"])
-            if r.get("tag_keys") and not isinstance(r["tag_keys"], list):
-                r["tag_keys"] = list(r["tag_keys"])
-            if r.get("scopes") and not isinstance(r["scopes"], list):
-                r["scopes"] = list(r["scopes"])
-            if r.get("resource_ids") and not isinstance(r["resource_ids"], list):
-                r["resource_ids"] = list(r["resource_ids"])
-            # 兼容旧数据(无 status/relationships 字段)
-            if "status" not in r:
-                r["status"] = "approved"
-            if "relationships" not in r or r["relationships"] is None:
-                r["relationships"] = []
-            else:
-                r["relationships"] = json.loads(r["relationships"]) if isinstance(r["relationships"], str) else r["relationships"]
-
-        return results
-
-    def get_by_id(self, knowledge_id: str) -> Optional[Dict]:
-        """
-        根据 ID 获取知识
-
-        Args:
-            knowledge_id: 知识 ID
-
-        Returns:
-            知识数据,不存在返回 None
-        """
-        results = self.collection.query(
-            expr=f'id == "{knowledge_id}"',
-            output_fields=["id", "embedding", "message_id", "task", "content", "types",
-                          "tags", "tag_keys", "scopes", "owner", "resource_ids",
-                          "source", "eval", "created_at", "updated_at",
-                          "status", "relationships"]
-        )
-
-        if not results:
-            return None
-
-        # 转换时间戳和数组字段
-        r = results[0]
-        if r.get("created_at"):
-            r["created_at"] = r["created_at"] * 1000
-        if r.get("updated_at"):
-            r["updated_at"] = r["updated_at"] * 1000
-        if r.get("types") and not isinstance(r["types"], list):
-            r["types"] = list(r["types"])
-        if r.get("tag_keys") and not isinstance(r["tag_keys"], list):
-            r["tag_keys"] = list(r["tag_keys"])
-        if r.get("scopes") and not isinstance(r["scopes"], list):
-            r["scopes"] = list(r["scopes"])
-        if r.get("resource_ids") and not isinstance(r["resource_ids"], list):
-            r["resource_ids"] = list(r["resource_ids"])
-        # 兼容旧数据
-        if "status" not in r:
-            r["status"] = "approved"
-        if "relationships" not in r or r["relationships"] is None:
-            r["relationships"] = []
-        else:
-            r["relationships"] = json.loads(r["relationships"]) if isinstance(r["relationships"], str) else r["relationships"]
-
-        return r
-
-    def update(self, knowledge_id: str, updates: Dict):
-        """
-        更新知识(先删除再插入)
-
-        Args:
-            knowledge_id: 知识 ID
-            updates: 更新字段
-        """
-        # 1. 查询现有数据
-        existing = self.get_by_id(knowledge_id)
-        if not existing:
-            raise ValueError(f"Knowledge not found: {knowledge_id}")
-
-        # 2. 合并更新
-        existing.update(updates)
-        existing["updated_at"] = int(time.time())
-
-        # 3. 还原 get_by_id 的展示层转换,确保存储格式正确
-        # created_at 被 get_by_id 乘以 1000(毫秒),需还原为秒
-        if existing.get("created_at") and existing["created_at"] > 1_000_000_000_000:
-            existing["created_at"] = existing["created_at"] // 1000
-        # relationships 被 get_by_id 反序列化为 list,需还原为 JSON 字符串
-        if isinstance(existing.get("relationships"), list):
-            existing["relationships"] = json.dumps(existing["relationships"])
-
-        # 4. 删除旧数据
-        self.delete(knowledge_id)
-
-        # 5. 插入新数据
-        self.insert(existing)
-
-    def delete(self, knowledge_id: str):
-        """
-        删除知识
-
-        Args:
-            knowledge_id: 知识 ID
-        """
-        self.collection.delete(f'id == "{knowledge_id}"')
-        self.collection.flush()
-
-    def count(self) -> int:
-        """返回知识总数"""
-        return self.collection.num_entities
-
-    def drop_collection(self):
-        """删除 collection(危险操作)"""
-        utility.drop_collection("knowledge")
-
-    def migrate_schema(self) -> int:
-        """
-        将旧 collection(无 status/relationships 字段)迁移到新 schema。
-        采用中转 collection 模式,Step 3 之前数据始终有两份副本。
-        返回迁移的知识条数。
-        """
-        MIGRATION_NAME = "knowledge_migration"
-        MAIN_NAME = "knowledge"
-
-        # 如果中转 collection 已存在(上次迁移中途失败),先清理
-        if utility.has_collection(MIGRATION_NAME):
-            print(f"[Migrate] 检测到残留中转 collection,清理...")
-            utility.drop_collection(MIGRATION_NAME)
-
-        # Step 1: 创建中转 collection(新 schema)
-        print(f"[Migrate] Step 1: 创建中转 collection {MIGRATION_NAME}...")
-        fields = [
-            FieldSchema(name="id", dtype=DataType.VARCHAR, max_length=100, is_primary=True),
-            FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=1536),
-            FieldSchema(name="message_id", dtype=DataType.VARCHAR, max_length=100),
-            FieldSchema(name="task", dtype=DataType.VARCHAR, max_length=2000),
-            FieldSchema(name="content", dtype=DataType.VARCHAR, max_length=50000),
-            FieldSchema(name="types", dtype=DataType.ARRAY, element_type=DataType.VARCHAR, max_capacity=20, max_length=50),
-            FieldSchema(name="tags", dtype=DataType.JSON),
-            FieldSchema(name="tag_keys", dtype=DataType.ARRAY, element_type=DataType.VARCHAR, max_capacity=50, max_length=100),
-            FieldSchema(name="scopes", dtype=DataType.ARRAY, element_type=DataType.VARCHAR, max_capacity=20, max_length=100),
-            FieldSchema(name="owner", dtype=DataType.VARCHAR, max_length=200),
-            FieldSchema(name="resource_ids", dtype=DataType.ARRAY, element_type=DataType.VARCHAR, max_capacity=50, max_length=200),
-            FieldSchema(name="source", dtype=DataType.JSON),
-            FieldSchema(name="eval", dtype=DataType.JSON),
-            FieldSchema(name="created_at", dtype=DataType.INT64),
-            FieldSchema(name="updated_at", dtype=DataType.INT64),
-            FieldSchema(name="status", dtype=DataType.VARCHAR, max_length=20, default_value="approved"),
-            FieldSchema(name="relationships", dtype=DataType.VARCHAR, max_length=10000, default_value="[]"),
-        ]
-        schema = CollectionSchema(fields, description="KnowHub Knowledge")
-        migration_col = Collection(MIGRATION_NAME, schema)
-        migration_col.create_index("embedding", {"metric_type": "COSINE", "index_type": "HNSW", "params": {"M": 16, "efConstruction": 200}})
-        try:
-            migration_col.create_index("status", {"index_type": "Trie"})
-        except Exception:
-            pass
-        migration_col.load()
-
-        # Step 2: 从旧 collection 逐批读取,补字段,插入中转
-        print(f"[Migrate] Step 2: 读取旧数据并插入中转 collection...")
-        batch_size = 200
-        offset = 0
-        total = 0
-        while True:
-            batch = self.collection.query(
-                expr='id != ""',
-                output_fields=["id", "embedding", "message_id", "task", "content", "types",
-                               "tags", "tag_keys", "scopes", "owner", "resource_ids",
-                               "source", "eval", "created_at", "updated_at"],
-                limit=batch_size,
-                offset=offset
-            )
-            if not batch:
-                break
-            for item in batch:
-                item["status"] = item.get("status", "approved")
-                item["relationships"] = item.get("relationships") or []
-                # 时间戳已是秒级(query 返回原始值,未乘 1000)
-            migration_col.insert(batch)
-            migration_col.flush()
-            total += len(batch)
-            offset += len(batch)
-            print(f"[Migrate] 已迁移 {total} 条...")
-            if len(batch) < batch_size:
-                break
-
-        # Step 3: drop 旧 collection
-        print(f"[Migrate] Step 3: drop 旧 collection {MAIN_NAME}...")
-        self.collection.release()
-        utility.drop_collection(MAIN_NAME)
-
-        # Step 4: 创建新 collection(同名,新 schema)
-        print(f"[Migrate] Step 4: 创建新 collection {MAIN_NAME}...")
-        new_col = Collection(MAIN_NAME, schema)
-        new_col.create_index("embedding", {"metric_type": "COSINE", "index_type": "HNSW", "params": {"M": 16, "efConstruction": 200}})
-        try:
-            new_col.create_index("status", {"index_type": "Trie"})
-        except Exception:
-            pass
-        new_col.load()
-
-        # Step 5: 从中转 collection 读取,插入新 collection
-        print(f"[Migrate] Step 5: 从中转 collection 回写到新 collection...")
-        offset = 0
-        while True:
-            batch = migration_col.query(
-                expr='id != ""',
-                output_fields=["id", "embedding", "message_id", "task", "content", "types",
-                               "tags", "tag_keys", "scopes", "owner", "resource_ids",
-                               "source", "eval", "created_at", "updated_at",
-                               "status", "relationships"],
-                limit=batch_size,
-                offset=offset
-            )
-            if not batch:
-                break
-            new_col.insert(batch)
-            new_col.flush()
-            offset += len(batch)
-            if len(batch) < batch_size:
-                break
-
-        # Step 6: drop 中转 collection
-        print(f"[Migrate] Step 6: drop 中转 collection {MIGRATION_NAME}...")
-        migration_col.release()
-        utility.drop_collection(MIGRATION_NAME)
-
-        # Step 7: 更新 self.collection 引用
-        print(f"[Migrate] Step 7: 更新 collection 引用...")
-        self.collection = new_col
-
-        print(f"[Migrate] 迁移完成,共迁移 {total} 条知识。")
-        return total

+ 38 - 0
test_xhs_search.py

@@ -0,0 +1,38 @@
+"""
+测试小红书渠道的 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())