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

reconstruct knowhub and database

guantao 3 дней назад
Родитель
Сommit
5239a3767a

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

@@ -219,7 +219,10 @@ class SuggestSearchChannel(str, Enum):
                 "channel": "搜索渠道",
                 "channel": "搜索渠道",
                 "cursor": "分页游标",
                 "cursor": "分页游标",
                 "max_count": "返回条数",
                 "max_count": "返回条数",
-                "content_type": "内容类型-视频/图文"
+                "content_type": "内容类型-视频/图文",
+                "sort_type": "排序方式(xhs专用)",
+                "publish_time": "发布时间筛选(xhs专用)",
+                "filter_note_range": "笔记时长筛选(xhs专用)"
             }
             }
         },
         },
         "en": {
         "en": {
@@ -229,7 +232,10 @@ class SuggestSearchChannel(str, Enum):
                 "channel": "Search channel",
                 "channel": "Search channel",
                 "cursor": "Pagination cursor",
                 "cursor": "Pagination cursor",
                 "max_count": "Max results",
                 "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(
 async def search_posts(
     keyword: str,
     keyword: str,
     channel: str = "xhs",
     channel: str = "xhs",
-    cursor: str = "0",
+    cursor: str = "",
     max_count: int = 20,
     max_count: int = 20,
-    content_type: str = ""
+    content_type: str = "",
+    sort_type: str = "综合排序",
+    publish_time: str = "不限",
+    filter_note_range: str = "不限"
 ) -> ToolResult:
 ) -> ToolResult:
     """
     """
     帖子搜索(浏览模式)
     帖子搜索(浏览模式)
@@ -259,9 +268,14 @@ async def search_posts(
             - bili: B站
             - bili: B站
             - zhihu: 知乎
             - zhihu: 知乎
             - weibo: 微博
             - weibo: 微博
-        cursor: 分页游标,默认为 "0"(第一页)
+        cursor: 分页游标,首次请求为空字符串,后续使用上次返回的 cursor
         max_count: 返回的最大条数,默认为 20
         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:
     Returns:
         ToolResult 包含搜索结果摘要列表(封面图+标题+内容截断),
         ToolResult 包含搜索结果摘要列表(封面图+标题+内容截断),
@@ -272,13 +286,24 @@ async def search_posts(
         channel_value = channel.value if isinstance(channel, PostSearchChannel) else channel
         channel_value = channel.value if isinstance(channel, PostSearchChannel) else channel
 
 
         url = f"{BASE_URL}/data"
         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:
         async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
             response = await client.post(
             response = await client.post(
@@ -300,10 +325,11 @@ async def search_posts(
         summary_list = []
         summary_list = []
         for idx, post in enumerate(posts):
         for idx, post in enumerate(posts):
             body = post.get("body_text", "") or ""
             body = post.get("body_text", "") or ""
+            title = post.get("title") or body[:20] or ""
             summary_list.append({
             summary_list.append({
                 "index": idx + 1,
                 "index": idx + 1,
                 "channel_content_id": post.get("channel_content_id"),
                 "channel_content_id": post.get("channel_content_id"),
-                "title": post.get("title"),
+                "title": title,
                 "body_text": body[:100] + ("..." if len(body) > 100 else ""),
                 "body_text": body[:100] + ("..." if len(body) > 100 else ""),
                 "like_count": post.get("like_count"),
                 "like_count": post.get("like_count"),
                 "collect_count": post.get("collect_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
 fastapi
 uvicorn[standard]
 uvicorn[standard]
 pydantic
 pydantic
-pymilvus
-milvus
 httpx
 httpx
 cryptography
 cryptography
 python-dotenv
 python-dotenv
+psycopg2-binary

+ 187 - 322
knowhub/server.py

@@ -8,14 +8,13 @@ FastAPI + Milvus Lite(知识)+ SQLite(资源),单文件部署。
 import os
 import os
 import re
 import re
 import json
 import json
-import sqlite3
 import asyncio
 import asyncio
 import base64
 import base64
 import time
 import time
 import uuid
 import uuid
 from contextlib import asynccontextmanager
 from contextlib import asynccontextmanager
 from datetime import datetime, timezone
 from datetime import datetime, timezone
-from typing import Optional, List
+from typing import Optional, List, Dict
 from pathlib import Path
 from pathlib import Path
 from cryptography.hazmat.primitives.ciphers.aead import AESGCM
 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")
 _tool_analysis_llm = create_qwen_llm_call(model="qwen3.5-plus")
 
 
 # 导入向量存储和 embedding
 # 导入向量存储和 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
 from knowhub.embeddings import get_embedding, get_embeddings_batch
 
 
 BRAND_NAME    = os.getenv("BRAND_NAME", "KnowHub")
 BRAND_NAME    = os.getenv("BRAND_NAME", "KnowHub")
@@ -54,19 +54,10 @@ if ORG_KEYS_RAW:
             ORG_KEYS[org.strip()] = key_b64.strip()
             ORG_KEYS[org.strip()] = key_b64.strip()
 
 
 DB_PATH = Path(__file__).parent / BRAND_DB
 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
     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 ---
 # --- Models ---
 
 
 class ResourceIn(BaseModel):
 class ResourceIn(BaseModel):
@@ -459,31 +403,22 @@ content: {content}
 # --- Dedup: RelationCache ---
 # --- Dedup: RelationCache ---
 
 
 class RelationCache:
 class RelationCache:
-    """关系缓存,存储在 SQLite relation_cache 表(单行 JSON)"""
+    """关系缓存,存储在内存中"""
+
+    def __init__(self):
+        self._cache: Dict[str, List[str]] = {}
 
 
     def load(self) -> dict:
     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):
     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):
     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 ---
 # --- Dedup: KnowledgeProcessor ---
@@ -501,7 +436,7 @@ class KnowledgeProcessor:
             # 第一阶段:处理 pending(去重)
             # 第一阶段:处理 pending(去重)
             while True:
             while True:
                 try:
                 try:
-                    pending = milvus_store.query('status == "pending"', limit=50)
+                    pending = pg_store.query('status == "pending"', limit=50)
                 except Exception as e:
                 except Exception as e:
                     print(f"[KnowledgeProcessor] 查询 pending 失败: {e}")
                     print(f"[KnowledgeProcessor] 查询 pending 失败: {e}")
                     break
                     break
@@ -512,7 +447,7 @@ class KnowledgeProcessor:
             # 第二阶段:处理 dedup_passed(工具关联)
             # 第二阶段:处理 dedup_passed(工具关联)
             while True:
             while True:
                 try:
                 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:
                 except Exception as e:
                     print(f"[KnowledgeProcessor] 查询 dedup_passed 失败: {e}")
                     print(f"[KnowledgeProcessor] 查询 dedup_passed 失败: {e}")
                     break
                     break
@@ -526,7 +461,7 @@ class KnowledgeProcessor:
         now = int(time.time())
         now = int(time.time())
         # 乐观锁:pending → processing(时间戳存秒级)
         # 乐观锁:pending → processing(时间戳存秒级)
         try:
         try:
-            milvus_store.update(kid, {"status": "processing", "updated_at": now})
+            pg_store.update(kid, {"status": "processing", "updated_at": now})
         except Exception as e:
         except Exception as e:
             print(f"[KnowledgeProcessor] 锁定 {kid} 失败: {e}")
             print(f"[KnowledgeProcessor] 锁定 {kid} 失败: {e}")
             return
             return
@@ -535,7 +470,7 @@ class KnowledgeProcessor:
             embedding = knowledge.get("embedding")
             embedding = knowledge.get("embedding")
             if not embedding:
             if not embedding:
                 embedding = await get_embedding(knowledge["task"])
                 embedding = await get_embedding(knowledge["task"])
-            candidates = milvus_store.search(
+            candidates = pg_store.search(
                 query_embedding=embedding,
                 query_embedding=embedding,
                 filters='(status == "approved" or status == "checked")',
                 filters='(status == "approved" or status == "checked")',
                 limit=10
                 limit=10
@@ -545,7 +480,7 @@ class KnowledgeProcessor:
             candidates = [c for c in candidates if c.get("score", 0) >= 0.75]
             candidates = [c for c in candidates if c.get("score", 0) >= 0.75]
 
 
             if not candidates:
             if not candidates:
-                milvus_store.update(kid, {"status": "dedup_passed", "updated_at": now})
+                pg_store.update(kid, {"status": "dedup_passed", "updated_at": now})
                 return
                 return
 
 
             llm_result = await self._llm_judge_relations(knowledge, candidates)
             llm_result = await self._llm_judge_relations(knowledge, candidates)
@@ -554,7 +489,7 @@ class KnowledgeProcessor:
         except Exception as e:
         except Exception as e:
             print(f"[KnowledgeProcessor] 处理 {kid} 失败: {e},回退到 pending")
             print(f"[KnowledgeProcessor] 处理 {kid} 失败: {e},回退到 pending")
             try:
             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:
             except Exception:
                 pass
                 pass
 
 
@@ -617,7 +552,7 @@ class KnowledgeProcessor:
                     rejected_relationships.append({"type": rel_type, "target": old_id})
                     rejected_relationships.append({"type": rel_type, "target": old_id})
                 if rel_type in ("duplicate", "subset") and old_id:
                 if rel_type in ("duplicate", "subset") and old_id:
                     try:
                     try:
-                        old = milvus_store.get_by_id(old_id)
+                        old = pg_store.get_by_id(old_id)
                         if not old:
                         if not old:
                             continue
                             continue
                         eval_data = old.get("eval") or {}
                         eval_data = old.get("eval") or {}
@@ -630,10 +565,10 @@ class KnowledgeProcessor:
                             "timestamp": now
                             "timestamp": now
                         })
                         })
                         eval_data["helpful_history"] = helpful_history
                         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:
                     except Exception as e:
                         print(f"[Apply Decision] 更新旧知识 {old_id} helpful 失败: {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:
         else:
             new_relationships = []
             new_relationships = []
             for rel in relations:
             for rel in relations:
@@ -647,16 +582,16 @@ class KnowledgeProcessor:
                 self._relation_cache.add_relation(rel_type, old_id)
                 self._relation_cache.add_relation(rel_type, old_id)
                 if reverse_type and reverse_type != "none":
                 if reverse_type and reverse_type != "none":
                     try:
                     try:
-                        old = milvus_store.get_by_id(old_id)
+                        old = pg_store.get_by_id(old_id)
                         if old:
                         if old:
                             old_rels = old.get("relationships") or []
                             old_rels = old.get("relationships") or []
                             old_rels.append({"type": reverse_type, "target": kid})
                             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, old_id)
                             self._relation_cache.add_relation(reverse_type, kid)
                             self._relation_cache.add_relation(reverse_type, kid)
                     except Exception as e:
                     except Exception as e:
                         print(f"[Apply Decision] 更新旧知识关系 {old_id} 失败: {e}")
                         print(f"[Apply Decision] 更新旧知识关系 {old_id} 失败: {e}")
-            milvus_store.update(kid, {
+            pg_store.update(kid, {
                 "status": "dedup_passed",
                 "status": "dedup_passed",
                 "relationships": json.dumps(new_relationships),
                 "relationships": json.dumps(new_relationships),
                 "updated_at": now
                 "updated_at": now
@@ -691,62 +626,61 @@ class KnowledgeProcessor:
             raise
             raise
 
 
     async def _create_or_get_tool_resource(self, tool_info: dict) -> Optional[str]:
     async def _create_or_get_tool_resource(self, tool_info: dict) -> Optional[str]:
-        """创建或获取工具资源"""
+        """创建或获取工具资源(存入 PostgreSQL tool_table)"""
         category = tool_info.get("category", "other")
         category = tool_info.get("category", "other")
         slug = tool_info.get("slug", "")
         slug = tool_info.get("slug", "")
         if not slug:
         if not slug:
             return None
             return None
         tool_id = f"tools/{category}/{slug}"
         tool_id = f"tools/{category}/{slug}"
-        conn = get_db()
+        now_ts = int(time.time())
+        cursor = pg_store._get_cursor()
         try:
         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
                 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}")
             print(f"[Tool Resource] 创建新工具: {tool_id}")
             return tool_id
             return tool_id
         finally:
         finally:
-            conn.close()
+            cursor.close()
 
 
     async def _update_tool_knowledge_index(self, tool_id: str, knowledge_id: str):
     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:
         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:
             if not row:
                 return
                 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:
             if knowledge_id not in knowledge_ids:
                 knowledge_ids.append(knowledge_id)
                 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:
         finally:
-            conn.close()
+            cursor.close()
 
 
     async def _analyze_tool_relation(self, knowledge: dict):
     async def _analyze_tool_relation(self, knowledge: dict):
         """分析知识与工具的关联关系"""
         """分析知识与工具的关联关系"""
@@ -754,7 +688,7 @@ class KnowledgeProcessor:
         now = int(time.time())
         now = int(time.time())
         # 乐观锁:dedup_passed → analyzing
         # 乐观锁:dedup_passed → analyzing
         try:
         try:
-            milvus_store.update(kid, {"status": "analyzing", "updated_at": now})
+            pg_store.update(kid, {"status": "analyzing", "updated_at": now})
         except Exception as e:
         except Exception as e:
             print(f"[Tool Analysis] 锁定 {kid} 失败: {e}")
             print(f"[Tool Analysis] 锁定 {kid} 失败: {e}")
             return
             return
@@ -772,13 +706,13 @@ class KnowledgeProcessor:
                 has_tools = bool(tool_analysis and tool_analysis.get("has_tools"))
                 has_tools = bool(tool_analysis and tool_analysis.get("has_tools"))
                 # 重新分析后仍然不一致 → 知识模糊,rejected
                 # 重新分析后仍然不一致 → 知识模糊,rejected
                 if not has_tools:
                 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")
                     print(f"[Tool Analysis] {kid} 两次判定不一致,知识模糊,rejected")
                     return
                     return
 
 
             # 情况2:无工具且无 tool tag → 直接 approved
             # 情况2:无工具且无 tool tag → 直接 approved
             if not has_tools:
             if not has_tools:
-                milvus_store.update(kid, {"status": "approved", "updated_at": now})
+                pg_store.update(kid, {"status": "approved", "updated_at": now})
                 return
                 return
 
 
             # 情况3/4:有工具 → 创建资源并关联
             # 情况3/4:有工具 → 创建资源并关联
@@ -803,7 +737,7 @@ class KnowledgeProcessor:
                 updates["tags"] = updated_tags
                 updates["tags"] = updated_tags
                 print(f"[Tool Analysis] {kid} 添加 tool tag")
                 print(f"[Tool Analysis] {kid} 添加 tool tag")
 
 
-            milvus_store.update(kid, updates)
+            pg_store.update(kid, updates)
 
 
             for tool_id in tool_ids:
             for tool_id in tool_ids:
                 await self._update_tool_knowledge_index(tool_id, kid)
                 await self._update_tool_knowledge_index(tool_id, kid)
@@ -813,7 +747,7 @@ class KnowledgeProcessor:
         except Exception as e:
         except Exception as e:
             print(f"[Tool Analysis] {kid} 分析失败: {e},回退到 dedup_passed")
             print(f"[Tool Analysis] {kid} 分析失败: {e},回退到 dedup_passed")
             try:
             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:
             except Exception:
                 pass
                 pass
 
 
@@ -826,22 +760,22 @@ async def _periodic_processor():
             now = int(time.time())
             now = int(time.time())
             # 回滚超时的 processing(5分钟 → pending)
             # 回滚超时的 processing(5分钟 → pending)
             timeout_5min = now - 300
             timeout_5min = now - 300
-            processing = milvus_store.query('status == "processing"', limit=200)
+            processing = pg_store.query('status == "processing"', limit=200)
             for item in processing:
             for item in processing:
                 updated_at = item.get("updated_at", 0) or 0
                 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
                 updated_at_sec = updated_at // 1000 if updated_at > 1_000_000_000_000 else updated_at
                 if updated_at_sec < timeout_5min:
                 if updated_at_sec < timeout_5min:
                     print(f"[Periodic] 回滚超时 processing → pending: {item['id']}")
                     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)
             # 回滚超时的 analyzing(10分钟 → dedup_passed)
             timeout_10min = now - 600
             timeout_10min = now - 600
-            analyzing = milvus_store.query('status == "analyzing"', limit=200)
+            analyzing = pg_store.query('status == "analyzing"', limit=200)
             for item in analyzing:
             for item in analyzing:
                 updated_at = item.get("updated_at", 0) or 0
                 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
                 updated_at_sec = updated_at // 1000 if updated_at > 1_000_000_000_000 else updated_at
                 if updated_at_sec < timeout_10min:
                 if updated_at_sec < timeout_10min:
                     print(f"[Periodic] 回滚超时 analyzing → dedup_passed: {item['id']}")
                     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:
         except Exception as e:
             print(f"[Periodic] 定时任务错误: {e}")
             print(f"[Periodic] 定时任务错误: {e}")
 
 
@@ -850,13 +784,11 @@ async def _periodic_processor():
 
 
 @asynccontextmanager
 @asynccontextmanager
 async def lifespan(app: FastAPI):
 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()
     knowledge_processor = KnowledgeProcessor()
@@ -870,6 +802,8 @@ async def lifespan(app: FastAPI):
         await periodic_task
         await periodic_task
     except asyncio.CancelledError:
     except asyncio.CancelledError:
         pass
         pass
+    pg_store.close()
+    pg_resource_store.close()
 
 
 
 
 app = FastAPI(title=BRAND_NAME, lifespan=lifespan)
 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)
 @app.post("/api/resource", status_code=201)
 def submit_resource(resource: ResourceIn):
 def submit_resource(resource: ResourceIn):
-    conn = get_db()
+    """提交资源(存入 PostgreSQL resources 表)"""
     try:
     try:
-        now = datetime.now(timezone.utc).isoformat()
-
         # 加密敏感内容
         # 加密敏感内容
         encrypted_secure_body = encrypt_content(resource.id, resource.secure_body)
         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}
         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)
 @app.get("/api/resource/{resource_id:path}", response_model=ResourceOut)
 def get_resource(resource_id: str, x_org_key: Optional[str] = Header(None)):
 def get_resource(resource_id: str, x_org_key: Optional[str] = Header(None)):
-    conn = get_db()
+    """获取资源详情(从 PostgreSQL)"""
     try:
     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:
         if not row:
             raise HTTPException(status_code=404, detail=f"Resource not found: {resource_id}")
             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
         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 (根节点)
         toc = None
         toc = None
         if "/" in resource_id:
         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:
             if toc_row:
                 toc = ResourceNode(id=toc_row["id"], title=toc_row["title"])
                 toc = ResourceNode(id=toc_row["id"], title=toc_row["title"])
 
 
         # Children (子节点)
         # 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/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(
         return ResourceOut(
             id=row["id"],
             id=row["id"],
@@ -969,67 +870,49 @@ def get_resource(resource_id: str, x_org_key: Optional[str] = Header(None)):
             body=row["body"],
             body=row["body"],
             secure_body=secure_body,
             secure_body=secure_body,
             content_type=row["content_type"],
             content_type=row["content_type"],
-            metadata=metadata,
+            metadata=row.get("metadata", {}),
             toc=toc,
             toc=toc,
             children=children,
             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}")
 @app.patch("/api/resource/{resource_id:path}")
 def patch_resource(resource_id: str, patch: ResourcePatchIn):
 def patch_resource(resource_id: str, patch: ResourcePatchIn):
-    """更新resource字段"""
-    conn = get_db()
+    """更新resource字段(PostgreSQL)"""
     try:
     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}")
             raise HTTPException(status_code=404, detail=f"Resource not found: {resource_id}")
 
 
-        # 构建更新语句
-        updates = []
-        params = []
+        # 构建更新字典
+        updates = {}
 
 
         if patch.title is not None:
         if patch.title is not None:
-            updates.append("title = ?")
-            params.append(patch.title)
-
+            updates['title'] = patch.title
         if patch.body is not None:
         if patch.body is not None:
-            updates.append("body = ?")
-            params.append(patch.body)
-
+            updates['body'] = patch.body
         if patch.secure_body is not None:
         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:
         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:
         if patch.metadata is not None:
-            updates.append("metadata = ?")
-            params.append(json.dumps(patch.metadata))
+            updates['metadata'] = patch.metadata
 
 
         if not updates:
         if not updates:
             return {"status": "ok", "message": "No fields to update"}
             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}
         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")
 @app.get("/api/resource")
@@ -1037,50 +920,30 @@ def list_resources(
     content_type: Optional[str] = Query(None),
     content_type: Optional[str] = Query(None),
     limit: int = Query(100, ge=1, le=1000)
     limit: int = Query(100, ge=1, le=1000)
 ):
 ):
-    """列出所有resource"""
-    conn = get_db()
+    """列出所有resource(PostgreSQL)"""
     try:
     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)}
         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}")
 @app.delete("/api/resource/{resource_id:path}")
 def delete_resource(resource_id: str):
 def delete_resource(resource_id: str):
-    """删除单个resource"""
-    conn = get_db()
+    """删除单个resource(PostgreSQL)"""
     try:
     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}")
             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}
         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 ---
 # --- Knowledge API ---
@@ -1177,7 +1040,7 @@ async def search_knowledge_api(
 
 
         # 3. 向量召回(3*k 个候选)
         # 3. 向量召回(3*k 个候选)
         recall_limit = top_k * 3
         recall_limit = top_k * 3
-        candidates = milvus_store.search(
+        candidates = pg_store.search(
             query_embedding=query_embedding,
             query_embedding=query_embedding,
             filters=filter_expr,
             filters=filter_expr,
             limit=recall_limit
             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)}")
         print(f"[Save Knowledge] 插入数据: {json.dumps({k: v for k, v in insert_data.items() if k != 'embedding'}, ensure_ascii=False)}")
 
 
         # 插入 Milvus
         # 插入 Milvus
-        milvus_store.insert(insert_data)
+        pg_store.insert(insert_data)
 
 
         # 触发后台去重处理
         # 触发后台去重处理
         background_tasks.add_task(knowledge_processor.process_pending)
         background_tasks.add_task(knowledge_processor.process_pending)
@@ -1334,7 +1197,7 @@ def list_knowledge(
         # 查询 Milvus(先获取所有符合条件的数据)
         # 查询 Milvus(先获取所有符合条件的数据)
         # Milvus 的 limit 是总数限制,我们需要获取足够多的数据来支持分页
         # Milvus 的 limit 是总数限制,我们需要获取足够多的数据来支持分页
         max_limit = 10000  # 设置一个合理的上限
         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]
         serialized_results = [serialize_milvus_result(r) for r in results]
@@ -1369,7 +1232,7 @@ def get_all_tags():
     """获取所有已有的 tags"""
     """获取所有已有的 tags"""
     try:
     try:
         # 查询所有知识
         # 查询所有知识
-        results = milvus_store.query('id != ""', limit=10000)
+        results = pg_store.query('id != ""', limit=10000)
 
 
         all_tags = set()
         all_tags = set()
         for item in results:
         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)):
 def get_pending_knowledge(limit: int = Query(default=50, ge=1, le=200)):
     """查询待处理队列(pending + processing + dedup_passed + analyzing)"""
     """查询待处理队列(pending + processing + dedup_passed + analyzing)"""
     try:
     try:
-        pending = milvus_store.query(
+        pending = pg_store.query(
             'status == "pending" or status == "processing" or status == "dedup_passed" or status == "analyzing"',
             'status == "pending" or status == "processing" or status == "dedup_passed" or status == "analyzing"',
             limit=limit
             limit=limit
         )
         )
@@ -1407,13 +1270,13 @@ async def trigger_process(force: bool = Query(default=False)):
     """手动触发去重处理。force=true 时先回滚所有 processing → pending,analyzing → dedup_passed"""
     """手动触发去重处理。force=true 时先回滚所有 processing → pending,analyzing → dedup_passed"""
     try:
     try:
         if force:
         if force:
-            processing = milvus_store.query('status == "processing"', limit=200)
+            processing = pg_store.query('status == "processing"', limit=200)
             for item in processing:
             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")
             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:
             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")
             print(f"[Manual Process] 回滚 {len(analyzing)} 条 analyzing → dedup_passed")
         asyncio.create_task(knowledge_processor.process_pending())
         asyncio.create_task(knowledge_processor.process_pending())
         return {"status": "ok", "message": "处理任务已触发"}
         return {"status": "ok", "message": "处理任务已触发"}
@@ -1424,20 +1287,15 @@ async def trigger_process(force: bool = Query(default=False)):
 
 
 @app.post("/api/knowledge/migrate")
 @app.post("/api/knowledge/migrate")
 async def migrate_knowledge_schema():
 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}")
 @app.get("/api/knowledge/status/{knowledge_id}")
 def get_knowledge_status(knowledge_id: str):
 def get_knowledge_status(knowledge_id: str):
     """查询单条知识的处理状态和关系"""
     """查询单条知识的处理状态和关系"""
     try:
     try:
-        result = milvus_store.get_by_id(knowledge_id)
+        result = pg_store.get_by_id(knowledge_id)
         if not result:
         if not result:
             raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
             raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
         serialized = serialize_milvus_result(result)
         serialized = serialize_milvus_result(result)
@@ -1459,7 +1317,7 @@ def get_knowledge_status(knowledge_id: str):
 def get_knowledge(knowledge_id: str):
 def get_knowledge(knowledge_id: str):
     """获取单条知识"""
     """获取单条知识"""
     try:
     try:
-        result = milvus_store.get_by_id(knowledge_id)
+        result = pg_store.get_by_id(knowledge_id)
         if not result:
         if not result:
             raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
             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:
     try:
         # 获取现有知识
         # 获取现有知识
-        existing = milvus_store.get_by_id(knowledge_id)
+        existing = pg_store.get_by_id(knowledge_id)
         if not existing:
         if not existing:
             raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
             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
             updates["embedding"] = embedding
 
 
         # 更新 Milvus
         # 更新 Milvus
-        milvus_store.update(knowledge_id, updates)
+        pg_store.update(knowledge_id, updates)
 
 
         return {"status": "ok", "knowledge_id": knowledge_id}
         return {"status": "ok", "knowledge_id": knowledge_id}
 
 
@@ -1567,7 +1425,7 @@ async def patch_knowledge(knowledge_id: str, patch: KnowledgePatchIn):
     """直接编辑知识字段"""
     """直接编辑知识字段"""
     try:
     try:
         # 获取现有知识
         # 获取现有知识
-        existing = milvus_store.get_by_id(knowledge_id)
+        existing = pg_store.get_by_id(knowledge_id)
         if not existing:
         if not existing:
             raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
             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
             updates["embedding"] = embedding
 
 
         # 更新 Milvus
         # 更新 Milvus
-        milvus_store.update(knowledge_id, updates)
+        pg_store.update(knowledge_id, updates)
 
 
         return {"status": "ok", "knowledge_id": knowledge_id}
         return {"status": "ok", "knowledge_id": knowledge_id}
 
 
@@ -1622,12 +1480,12 @@ def delete_knowledge(knowledge_id: str):
     """删除单条知识"""
     """删除单条知识"""
     try:
     try:
         # 检查知识是否存在
         # 检查知识是否存在
-        existing = milvus_store.get_by_id(knowledge_id)
+        existing = pg_store.get_by_id(knowledge_id)
         if not existing:
         if not existing:
             raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
             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}")
         print(f"[Delete Knowledge] 已删除知识: {knowledge_id}")
 
 
         return {"status": "ok", "knowledge_id": 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:
         if not knowledge_ids:
             raise HTTPException(status_code=400, detail="knowledge_ids cannot be empty")
             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:
     except HTTPException:
         raise
         raise
@@ -1674,7 +1536,7 @@ async def batch_verify_knowledge(batch: KnowledgeBatchVerifyIn):
         updated_count = 0
         updated_count = 0
 
 
         for kid in batch.knowledge_ids:
         for kid in batch.knowledge_ids:
-            existing = milvus_store.get_by_id(kid)
+            existing = pg_store.get_by_id(kid)
             if not existing:
             if not existing:
                 continue
                 continue
 
 
@@ -1687,7 +1549,7 @@ async def batch_verify_knowledge(batch: KnowledgeBatchVerifyIn):
                 "issue_type": None,
                 "issue_type": None,
                 "issue_action": 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
             updated_count += 1
 
 
         return {"status": "ok", "updated": updated_count}
         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):
 async def verify_knowledge(knowledge_id: str, verify: KnowledgeVerifyIn):
     """知识验证:approve 切换 approved↔checked,reject 设为 rejected"""
     """知识验证:approve 切换 approved↔checked,reject 设为 rejected"""
     try:
     try:
-        existing = milvus_store.get_by_id(knowledge_id)
+        existing = pg_store.get_by_id(knowledge_id)
         if not existing:
         if not existing:
             raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
             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":
         if verify.action == "approve":
             # checked → approved(取消验证),其他 → checked
             # checked → approved(取消验证),其他 → checked
             new_status = "approved" if current_status == "checked" else "checked"
             new_status = "approved" if current_status == "checked" else "checked"
-            milvus_store.update(knowledge_id, {
+            pg_store.update(knowledge_id, {
                 "status": new_status,
                 "status": new_status,
                 "updated_at": int(time.time())
                 "updated_at": int(time.time())
             })
             })
@@ -1718,7 +1580,7 @@ async def verify_knowledge(knowledge_id: str, verify: KnowledgeVerifyIn):
                     "message": "已取消验证" if new_status == "approved" else "验证通过"}
                     "message": "已取消验证" if new_status == "approved" else "验证通过"}
 
 
         elif verify.action == "reject":
         elif verify.action == "reject":
-            milvus_store.update(knowledge_id, {
+            pg_store.update(knowledge_id, {
                 "status": "rejected",
                 "status": "rejected",
                 "updated_at": int(time.time())
                 "updated_at": int(time.time())
             })
             })
@@ -1753,7 +1615,7 @@ async def batch_update_knowledge(batch: KnowledgeBatchUpdateIn):
             if not knowledge_id:
             if not knowledge_id:
                 continue
                 continue
 
 
-            existing = milvus_store.get_by_id(knowledge_id)
+            existing = pg_store.get_by_id(knowledge_id)
             if not existing:
             if not existing:
                 continue
                 continue
 
 
@@ -1771,7 +1633,7 @@ async def batch_update_knowledge(batch: KnowledgeBatchUpdateIn):
             else:
             else:
                 eval_data["harmful"] = eval_data.get("harmful", 0) + 1
                 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:
         if evolution_tasks:
@@ -1786,7 +1648,7 @@ async def batch_update_knowledge(batch: KnowledgeBatchUpdateIn):
                 # 重新生成向量(只基于 task)
                 # 重新生成向量(只基于 task)
                 embedding = await get_embedding(task)
                 embedding = await get_embedding(task)
 
 
-                milvus_store.update(knowledge_id, {
+                pg_store.update(knowledge_id, {
                     "content": evolved_content,
                     "content": evolved_content,
                     "eval": eval_data,
                     "eval": eval_data,
                     "embedding": embedding
                     "embedding": embedding
@@ -1804,7 +1666,7 @@ async def slim_knowledge(model: str = "google/gemini-2.5-flash-lite"):
     """知识库瘦身:合并语义相似知识"""
     """知识库瘦身:合并语义相似知识"""
     try:
     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]
         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]
         texts = [e['task'] for e in new_entries]
         embeddings = await get_embeddings_batch(texts)
         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 = []
         knowledge_list = []
         for e, embedding in zip(new_entries, embeddings):
         for e, embedding in zip(new_entries, embeddings):
@@ -1971,7 +1836,7 @@ REPORT: 原有 X 条,合并后 Y 条,精简了 Z 条。
                 "relationships": json.dumps([])
                 "relationships": json.dumps([])
             })
             })
 
 
-        milvus_store.insert_batch(knowledge_list)
+        pg_store.insert_batch(knowledge_list)
 
 
         result_msg = f"瘦身完成:{len(all_knowledge)} → {len(new_entries)} 条知识"
         result_msg = f"瘦身完成:{len(all_knowledge)} → {len(new_entries)} 条知识"
         if report_line:
         if report_line:
@@ -2136,7 +2001,7 @@ async def extract_knowledge_from_messages(extract_req: MessageExtractIn, backgro
 
 
         # 批量插入
         # 批量插入
         if knowledge_list:
         if knowledge_list:
-            milvus_store.insert_batch(knowledge_list)
+            pg_store.insert_batch(knowledge_list)
             background_tasks.add_task(knowledge_processor.process_pending)
             background_tasks.add_task(knowledge_processor.process_pending)
 
 
         print(f"[Extract] 成功提取并保存 {len(knowledge_ids)} 条知识")
         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())