📂 solution-1.0

← 返回上级
#!/usr/bin/env python3
"""
Hermes 网页聊天后端 v4 — JSON-RPC 协议版
------------------------------------------------
架构:
  浏览器 → ws://host/ws/chat → 本服务 → ws://127.0.0.1:9119/api/ws → Hermes TUI Gateway
                                               ↓
                                          JSON-RPC 协议
                                          - session.create
                                          - prompt.submit
                                          - 事件流: message.delta, reasoning.delta,
                                            thinking.delta, tool.start, tool.complete,
                                            message.complete

API:
  WS:   /ws/chat          WebSocket 聊天
  HTTP: /chat/tasks       任务列表
        /chat/tree        wiki 知识树
        /chat/history     聊天记录
        /chat/prd         PRD 文档

v4 — 改用 JSON-RPC 协议直连 Hermes Dashboard,支持推理面板 + 流式渲染
"""

HERMES_DASHBOARD_WS = "ws://127.0.0.1:9119/api/ws"
HERMES_DASHBOARD_AUTH_TOKEN = ""

import asyncio
import json
import os
import sys
import sqlite3
import signal
import urllib.parse
import re
import time as _time_module
from datetime import datetime, timezone

import websockets

# ── 从 Dashboard HTML 中自动提取 session token ──
def _fetch_dashboard_token():
    """从 Dashboard 首页 HTML 中提取 __HERMES_SESSION_TOKEN__"""
    import urllib.request
    try:
        resp = urllib.request.urlopen("http://127.0.0.1:9119/", timeout=3)
        html = resp.read().decode('utf-8', errors='replace')
        m = re.search(r'__HERMES_SESSION_TOKEN__="([^"]+)"', html)
        if m:
            return m.group(1)
    except Exception as e:
        print(f"[TOKEN FETCH] {e}", file=sys.stderr)
    return ""

HOST = "0.0.0.0"
WS_PORT = 8765
HTTP_PORT = 8766

HERMES_HOME = os.path.expanduser('~/.hermes')
STATE_DB = os.path.join(HERMES_HOME, 'state.db')

# ── 数据库 ──
CONTENT_DB = "/root/content.db"
CHAT_HISTORY_DB = os.path.join(os.path.dirname(__file__), 'chat_history.db')

# ── 子Chat配置 ──
SOURCE_CONFIG = {
    'chat': {'soul': None},
    'chat-mian': {
        'soul': os.path.join(os.path.dirname(__file__), 'soul_mian.md'),
        'allowed_skills': [],
        'blocked_skills': ['bt-search', 'bt_search'],
        'allowed_toolsets': [],
        'knowledge_root': '/root/知识库/阿棉/',
    },
    'chat-liangliang': {
        'soul': os.path.join(os.path.dirname(__file__), 'soul_liangliang.md'),
        'allowed_skills': [],
        'blocked_skills': ['bt-search', 'bt_search'],
        'allowed_toolsets': [],
        'knowledge_root': '/root/知识库/亮亮/',
    },
}

# ── 消息队列调度器 ──
chat_queues = {}
chat_queue_busy = {}
chat_queue_msg_id_counter = 0

# ── 任务看板 ──
task_board = {}
task_board_order = []

# ── WebSocket 连接状态 ──
active_connections = {}  # ws -> {device_id, current_task_id}

# ── Task → Session 映射(持久化到 tasks 表) ──
# 同一个 task 复用同一个 kernel session_id,让内核保持上下文
# session_id 存在 content.db 的 tasks 表中,重启不丢失


# ═══════════════════════════════════════════
#  数据库操作
# ═══════════════════════════════════════════

def save_chat_message(task_id, role, content):
    source = f"task-{task_id}"
    now = datetime.now(timezone.utc).timestamp()
    try:
        conn = sqlite3.connect(CHAT_HISTORY_DB)
        conn.execute(
            "INSERT INTO messages (source, role, content, created_at) VALUES (?, ?, ?, ?)",
            (source, role, content, now)
        )
        conn.commit()
        conn.close()
    except Exception:
        pass
    # 同步更新 task 更新时间为最新消息时间
    _touch_task(task_id, now)


def _touch_task(task_id, ts=None):
    """更新 task 的 updated_at 时间"""
    try:
        now = datetime.fromtimestamp(ts, tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%S') if ts else datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S')
        conn = sqlite3.connect(CONTENT_DB)
        conn.execute("UPDATE tasks SET updated_at=? WHERE id=?", (now, task_id))
        conn.commit()
        conn.close()
    except Exception:
        pass


def touch_task_api(task_id):
    """外部(微信/CLI)调用此函数更新 task 时间"""
    _touch_task(task_id)
    return True


def get_task_session_id(task_id):
    """从 tasks 表读取持久化的 session_id(内核存储用)"""
    try:
        conn = sqlite3.connect(CONTENT_DB)
        row = conn.execute("SELECT session_id FROM tasks WHERE id=?", (task_id,)).fetchone()
        conn.close()
        return row[0] if row and row[0] else None
    except Exception:
        return None


def get_task_routing_session_id(task_id):
    """从 tasks 表读取 routing_session_id(网关路由用)"""
    try:
        conn = sqlite3.connect(CONTENT_DB)
        row = conn.execute("SELECT routing_session_id FROM tasks WHERE id=?", (task_id,)).fetchone()
        conn.close()
        return row[0] if row and row[0] else None
    except Exception:
        return None


def resolve_task_for_source(source):
    """从 source(如 'chat')找到最活跃的 task_id 用于历史查询"""
    try:
        conn = sqlite3.connect(CONTENT_DB)
        rows = conn.execute(
            "SELECT id, session_id FROM tasks WHERE status IN ('active','running') "
            "ORDER BY updated_at DESC LIMIT 1"
        ).fetchall()
        conn.close()
        if rows:
            return rows[0][0]
        return None
    except Exception:
        return None


def _ensure_valid_session_id(task_id):
    """检查 session_id 是否有效。
    
    注意:不再因 messages 为空就删 session。session 刚创建时 messages 可能为 0,
    删掉会导致丢消息(下次请求创建新session,旧消息成孤儿)。
    
    仅当 session_id 在 state.db 的 sessions 表中完全不存在时才视为失效。
    """
    try:
        sid = get_task_session_id(task_id)
        if not sid:
            return None
        state_db = os.path.expanduser('~/.hermes/state.db')
        conn = sqlite3.connect(state_db)
        # 检查 sessions 表是否存在该 session(比查 messages 更准确)
        session_exists = conn.execute(
            "SELECT 1 FROM sessions WHERE id=?", (sid,)
        ).fetchone()
        conn.close()
        if not session_exists:
            # session 在 state.db 中完全不存在(可能因为内核重置/迁移)
            conn2 = sqlite3.connect(CONTENT_DB)
            conn2.execute("UPDATE tasks SET session_id=NULL WHERE id=?", (task_id,))
            conn2.commit()
            conn2.close()
            print(f"[HISTORY] stale session_id '{sid}' cleared for task {task_id} (session not in state.db)", file=sys.stderr)
            return None
        return sid
    except Exception:
        return None


def save_task_session_id(task_id, session_id, routing_session_id=''):
    """持久化 task → session_id 映射到 tasks 表"""
    try:
        conn = sqlite3.connect(CONTENT_DB)
        conn.execute("UPDATE tasks SET session_id=?, routing_session_id=? WHERE id=?",
                     (session_id, routing_session_id, task_id))
        conn.commit()
        conn.close()
        return True
    except Exception:
        return False


def load_chat_history_from_kernel(task_id, offset=0, limit=20):
    """从 Hermes 内核 state.db 拉聊天历史,不再用 chat_history.db"""
    try:
        # 1. 取 task 对应的 kernel session_id(自动校验失效 session)
        session_id = _ensure_valid_session_id(task_id) or get_task_session_id(task_id)
        if not session_id:
            # 无 session → 降级到 chat_history.db
            return load_chat_history_by_source(f"task-{task_id}", offset, limit)

        # 2. 如有必要,迁移 chat_history.db 旧消息到 state.db
        try:
            ch_db = CHAT_HISTORY_DB
            state_db = os.path.expanduser('~/.hermes/state.db')
            if os.path.exists(ch_db):
                ch_conn = sqlite3.connect(ch_db)
                ch_rows = ch_conn.execute(
                    "SELECT role, content, created_at FROM messages WHERE source=? ORDER BY id",
                    (f"task-{task_id}",)
                ).fetchall()
                ch_conn.close()
                if ch_rows:
                    st_conn = sqlite3.connect(state_db)
                    count_before = st_conn.execute(
                        "SELECT COUNT(*) FROM messages WHERE session_id=?", (session_id,)
                    ).fetchone()[0]
                    st_conn.execute("BEGIN")
                    for row in ch_rows:
                        ts = float(row[2]) if isinstance(row[2], (int, float)) else 0
                        # 用 WHERE NOT EXISTS 防止重复插入
                        st_conn.execute(
                            "INSERT INTO messages (session_id, role, content, timestamp, active) "
                            "SELECT ?, ?, ?, ?, 1 "
                            "WHERE NOT EXISTS ("
                            "  SELECT 1 FROM messages WHERE session_id=? AND role=? AND content=? AND timestamp=?"
                            ")",
                            (session_id, row[0], row[1] or '', ts,
                             session_id, row[0], row[1] or '', ts)
                        )
                    st_conn.execute(
                        "UPDATE sessions SET message_count = (SELECT COUNT(*) FROM messages WHERE session_id=?) WHERE id=?",
                        (session_id, session_id)
                    )
                    st_conn.commit()
                    count_after = st_conn.execute(
                        "SELECT COUNT(*) FROM messages WHERE session_id=?", (session_id,)
                    ).fetchone()[0]
                    st_conn.close()
                    if count_after > count_before:
                        print(f"[HISTORY] Migrated {count_after - count_before} new msgs from chat_history.db to session {session_id}", file=sys.stderr)
        except Exception as e:
            print(f"[HISTORY] migration check error: {e}", file=sys.stderr)

        # 3. 从 state.db 的 messages 表查
        state_db = os.path.expanduser('~/.hermes/state.db')
        conn = sqlite3.connect(state_db)
        conn.row_factory = sqlite3.Row
        max_limit = min(limit, 50)
        rows = conn.execute(
            "SELECT id, role, content, timestamp FROM messages "
            "WHERE session_id=? AND role IN ('user', 'assistant') AND (content IS NOT NULL AND content != '') "
            "ORDER BY id DESC LIMIT ? OFFSET ?",
            (session_id, max_limit + 1, offset)
        ).fetchall()
        conn.close()

        has_more = len(rows) > max_limit
        msgs = []
        for r in rows[:max_limit]:
            ts = r['timestamp']
            if isinstance(ts, (int, float)):
                time_str = datetime.fromtimestamp(ts).strftime('%m-%d %H:%M')
            else:
                time_str = str(ts)[:16] if ts else ''
            content = r['content'] or ''
            if len(content) > 3000:
                content = content[:3000] + '…'
            msgs.append({
                'id': r['id'],
                'role': r['role'],
                'content': content,
                'created_at': time_str,
            })
        msgs.reverse()
        return {'messages': msgs, 'has_more': has_more}
    except Exception as e:
        print(f"[HISTORY] kernel query error: {e}", file=sys.stderr)
        return {'messages': [], 'has_more': False, 'error': str(e)}


def load_chat_history(task_id, offset=0, limit=20):
    source = f"task-{task_id}"
    max_limit = min(limit, 50)
    try:
        conn = sqlite3.connect(CHAT_HISTORY_DB)
        conn.row_factory = sqlite3.Row
        rows = conn.execute(
            "SELECT id, role, content, created_at FROM messages "
            "WHERE source=? AND role IN ('user', 'assistant') "
            "ORDER BY id DESC LIMIT ? OFFSET ?",
            (source, max_limit + 1, offset)
        ).fetchall()
        conn.close()
        has_more = len(rows) > max_limit
        msgs = []
        for r in rows[:max_limit]:
            ts = r['created_at']
            if isinstance(ts, (int, float)):
                time_str = datetime.fromtimestamp(ts).strftime('%m-%d %H:%M')
            else:
                time_str = str(ts)[:16] if ts else ''
            content = r['content'] or ''
            if len(content) > 3000:
                content = content[:3000] + '…'
            msgs.append({
                'id': r['id'],
                'role': r['role'],
                'content': content,
                'created_at': time_str,
            })
        msgs.reverse()
        return {'messages': msgs, 'has_more': has_more}
    except Exception as e:
        return {'messages': [], 'has_more': False, 'error': str(e)}


def load_chat_history_by_source(source, offset=0, limit=20):
    """按 source 直接查聊天历史(兼容旧版前端,不用 task_id 关联)"""
    max_limit = min(limit, 50)
    try:
        conn = sqlite3.connect(CHAT_HISTORY_DB)
        conn.row_factory = sqlite3.Row
        rows = conn.execute(
            "SELECT id, role, content, created_at FROM messages "
            "WHERE source=? AND role IN ('user', 'assistant') "
            "ORDER BY id DESC LIMIT ? OFFSET ?",
            (source, max_limit + 1, offset)
        ).fetchall()
        conn.close()
        has_more = len(rows) > max_limit
        msgs = []
        for r in rows[:max_limit]:
            ts = r['created_at']
            if isinstance(ts, (int, float)):
                time_str = datetime.fromtimestamp(ts).strftime('%m-%d %H:%M')
            else:
                time_str = str(ts)[:16] if ts else ''
            content = r['content'] or ''
            if len(content) > 3000:
                content = content[:3000] + '…'
            msgs.append({
                'id': r['id'],
                'role': r['role'],
                'content': content,
                'created_at': time_str,
            })
        msgs.reverse()
        return {'messages': msgs, 'has_more': has_more}
    except Exception as e:
        return {'messages': [], 'has_more': False, 'error': str(e)}


def get_chat_msg_count(task_id):
    source = f"task-{task_id}"
    try:
        conn = sqlite3.connect(CHAT_HISTORY_DB)
        cnt = conn.execute(
            "SELECT COUNT(*) FROM messages WHERE source=?", (source,)
        ).fetchone()[0]
        conn.close()
        return cnt
    except Exception:
        return 0


def update_chat_message(msg_id, new_content):
    """编辑消息内容"""
    try:
        conn = sqlite3.connect(CHAT_HISTORY_DB)
        conn.execute(
            "UPDATE messages SET content=?, created_at=? WHERE id=?",
            (new_content, datetime.now(timezone.utc).timestamp(), msg_id)
        )
        affected = conn.total_changes
        conn.commit()
        conn.close()
        return affected > 0
    except Exception:
        return False


def delete_chat_message(msg_id):
    """删除消息(软删除:置空内容)"""
    try:
        conn = sqlite3.connect(CHAT_HISTORY_DB)
        conn.execute(
            "UPDATE messages SET content='[消息已删除]', created_at=? WHERE id=?",
            (datetime.now(timezone.utc).timestamp(), msg_id)
        )
        affected = conn.total_changes
        conn.commit()
        conn.close()
        return affected > 0
    except Exception:
        return False


def get_chat_message_by_id(msg_id):
    """获取单条消息"""
    try:
        conn = sqlite3.connect(CHAT_HISTORY_DB)
        conn.row_factory = sqlite3.Row
        row = conn.execute(
            "SELECT id, source, role, content, created_at FROM messages WHERE id=?", (msg_id,)
        ).fetchone()
        conn.close()
        if row:
            return {
                'id': row['id'],
                'source': row['source'],
                'role': row['role'],
                'content': row['content'] or '',
                'created_at': row['created_at'],
            }
        return None
    except Exception:
        return None


def search_chat_messages(task_id, keyword, limit=20):
    """搜索聊天消息"""
    source = f"task-{task_id}"
    max_limit = min(limit, 50)
    try:
        conn = sqlite3.connect(CHAT_HISTORY_DB)
        conn.row_factory = sqlite3.Row
        rows = conn.execute(
            "SELECT id, role, content, created_at FROM messages "
            "WHERE source=? AND role IN ('user','assistant') AND content LIKE ? "
            "ORDER BY id DESC LIMIT ?",
            (source, f'%{keyword}%', max_limit)
        ).fetchall()
        conn.close()
        msgs = []
        for r in rows:
            ts = r['created_at']
            if isinstance(ts, (int, float)):
                time_str = datetime.fromtimestamp(ts).strftime('%m-%d %H:%M')
            else:
                time_str = str(ts)[:16] if ts else ''
            content = r['content'] or ''
            msgs.append({
                'id': r['id'],
                'role': r['role'],
                'content': content,
                'created_at': time_str,
            })
        msgs.reverse()
        return {'messages': msgs, 'count': len(msgs)}
    except Exception as e:
        return {'messages': [], 'count': 0, 'error': str(e)}


def get_task_list():
    try:
        conn = sqlite3.connect(CONTENT_DB)
        conn.row_factory = sqlite3.Row
        rows = conn.execute(
            "SELECT t.id, t.name, t.status, t.folder_id, t.created_at, t.updated_at, t.dependencies "
            "FROM tasks t ORDER BY t.updated_at DESC"
        ).fetchall()
        conn.close()
        tasks = []
        for r in rows:
            tasks.append({
                'id': r['id'],
                'name': r['name'],
                'status': r['status'],
                'folder_id': r['folder_id'],
                'created_at': r['created_at'],
                'updated_at': r['updated_at'],
                'dependencies': r['dependencies'] or '',
                'msg_count': 0,
            })
        for t in tasks:
            t['msg_count'] = get_chat_msg_count(t['id'])
        return tasks
    except Exception as e:
        return []


def get_task_folder_tree(folder_id):
    try:
        conn = sqlite3.connect(CONTENT_DB)
        conn.row_factory = sqlite3.Row
        tree = _build_folder_tree(conn, folder_id)
        conn.close()
        return tree
    except Exception:
        return {'error': str(Exception)}


def get_task_folders():
    """返回文件夹分层结构,每个文件夹下挂载该文件夹的 tasks"""
    try:
        conn = sqlite3.connect(CONTENT_DB)
        conn.row_factory = sqlite3.Row
        # 获取所有文件夹
        folders = conn.execute(
            "SELECT id, name, parent_id FROM folders ORDER BY name"
        ).fetchall()
        # 获取所有 tasks(active / running / created)
        tasks = conn.execute(
            "SELECT id, name, status, folder_id, updated_at FROM tasks "
            "WHERE status IN ('active','running','created','completed') "
            "ORDER BY updated_at DESC"
        ).fetchall()
        conn.close()

        # 按 folder_id 分组 tasks
        tasks_by_folder = {}
        for t in tasks:
            fid = t['folder_id'] or ''
            if fid not in tasks_by_folder:
                tasks_by_folder[fid] = []
            tasks_by_folder[fid].append({
                'id': t['id'],
                'name': t['name'],
                'status': t['status'],
            })

        # 按 parent_id 分组文件夹
        children_by_parent = {}
        folder_map = {}
        for f in folders:
            fid = f['id']
            parent = f['parent_id'] or '-1'
            if parent not in children_by_parent:
                children_by_parent[parent] = []
            children_by_parent[parent].append(f)
            folder_map[fid] = {
                'id': fid,
                'name': f['name'],
                'parent_id': parent,
            }

        def build_node(fid, depth=0):
            node = {
                'id': fid,
                'name': folder_map.get(fid, {}).get('name', str(fid)),
                'type': 'folder',
                'tasks': tasks_by_folder.get(fid, []),
                'children': [],
            }
            for child in children_by_parent.get(fid, []):
                node['children'].append(build_node(child['id'], depth + 1))
            return node

        # 根节点
        roots = []
        for f in folders:
            parent = f['parent_id'] or '-1'
            if parent == '-1':
                roots.append(build_node(f['id']))
        # 处理没有父文件夹的顶级 folder_id(如 '私有库/tasks/')
        orphan_tasks = tasks_by_folder.get('', [])
        orphan_tasks.extend(tasks_by_folder.get(None, []))
        extra_keys = [k for k in tasks_by_folder if k and k not in folder_map and k != '']
        return {
            'roots': roots,
            'orphan_tasks': orphan_tasks,
            'extra_folders': extra_keys,  # folder_id 值在 folders 表中找不到的
        }
    except Exception as e:
        return {'error': str(e), 'roots': [], 'orphan_tasks': []}


def _build_folder_tree(conn, folder_id, max_depth=2, depth=0):
    folders = conn.execute(
        "SELECT id, name FROM folders WHERE parent_id=? ORDER BY name",
        (folder_id,)
    ).fetchall()
    docs = conn.execute(
        "SELECT id, title FROM documents WHERE folder_id=? ORDER BY title",
        (folder_id,)
    ).fetchall()
    children = []
    for f in folders:
        if depth < max_depth - 1:
            sub = _build_folder_tree(conn, f['id'], max_depth, depth + 1)
        else:
            sub = []  # 超过最大深度时不展开子节点
        children.append({
            'type': 'folder', 'id': f['id'], 'name': f['name'],
            'children': sub,
        })
    for d in docs:
        children.append({
            'type': 'doc', 'id': d['id'], 'name': d['title'],
        })
    return children


# ═══════════════════════════════════════════
#  JSON-RPC 客户端 — 连接 Hermes Dashboard
# ═══════════════════════════════════════════

class HermesRpcClient:
    """JSON-RPC WebSocket 客户端,连接 Hermes Dashboard /api/ws"""

    def __init__(self):
        self._ws = None
        self._rpc_id = 0
        self._session_id = None
        self._pending = {}  # rpc_id -> asyncio.Future
        self._running = False
        self._token = _fetch_dashboard_token()
        self._sid_counter = {}
        self._ws_lock = asyncio.Lock()  # 防止并发 recv

    async def _connect(self):
        """建立 WebSocket 连接并等待 gateway.ready"""
        token = self._token
        uri = f"{HERMES_DASHBOARD_WS}?token={token}"
        self._ws = await websockets.connect(uri, max_size=2**20)
        # 等待 gateway.ready
        msg = await asyncio.wait_for(self._ws.recv(), timeout=10)
        data = json.loads(msg)
        assert data.get('method') == 'event' and data.get('params', {}).get('type') == 'gateway.ready', \
            f"Expected gateway.ready, got: {msg[:200]}"
        print(f"[RPC] Connected, gateway ready — skin: {data['params'].get('payload', {}).get('skin')}", file=sys.stderr)
        return data

    async def call(self, method, params=None):
        """发送 JSON-RPC 请求,等待 result 返回"""
        async with self._ws_lock:
            self._rpc_id += 1
            rpc_id = self._rpc_id
            req = {
                'jsonrpc': '2.0',
                'method': method,
                'params': params or {},
                'id': rpc_id,
            }
            await self._ws.send(json.dumps(req))
            # 读取直到拿到 result
            while True:
                msg = await asyncio.wait_for(self._ws.recv(), timeout=30)
                data = json.loads(msg)
                if data.get('id') == rpc_id:
                    if 'result' in data:
                        return data['result']
                    elif 'error' in data:
                        raise Exception(f"RPC error: {data['error']}")
                # 其他事件忽略(等待 call 完成)

    async def create_session(self, source='webchat'):
        """创建新会话,返回 dict: {session_id, stored_session_id}"""
        result = await self.call('session.create', {
            'source': source,
        })
        routing_sid = result.get('session_id', '')
        stored_sid = result.get('stored_session_id', routing_sid)  # 兼容旧版
        self._session_id = routing_sid
        print(f"[RPC] Session created: routing={routing_sid} stored={stored_sid}", file=sys.stderr)
        return {'session_id': routing_sid, 'stored_session_id': stored_sid}

    async def _read_until_timeout(self, timeout=5.0):
        """读取一条消息,最多等 timeout 秒。返回 dict 或 None(超时)"""
        try:
            msg = await asyncio.wait_for(self._ws.recv(), timeout=timeout)
            return json.loads(msg)
        except asyncio.TimeoutError:
            return None
        except websockets.exceptions.ConnectionClosed:
            return None

    async def submit_prompt_stream(self, sid, text, event_callback):
        """提交 prompt 并流式读取事件,event_callback(event_type, payload)
        
        整个流式过程持有 _ws_lock,防止并发 recv 冲突。
        不同 task 的 consumer 会排队等待锁,不会报错。
        """
        async with self._ws_lock:
            # 直接发送 prompt.submit(不使用 call(),因为我们需持有锁进入流式读取)
            self._rpc_id += 1
            rpc_id = self._rpc_id
            req = {
                'jsonrpc': '2.0',
                'method': 'prompt.submit',
                'params': {'session_id': sid, 'text': text},
                'id': rpc_id,
            }
            await self._ws.send(json.dumps(req))
            # 读取直到拿到 prompt.submit 的 response
            result = None
            while True:
                msg = await asyncio.wait_for(self._ws.recv(), timeout=30)
                data = json.loads(msg)
                if data.get('id') == rpc_id:
                    if 'result' in data:
                        result = data['result']
                        break
                    elif 'error' in data:
                        raise Exception(f"RPC error: {data['error']}")
            # 校验结果
            if not isinstance(result, dict) or result.get('status') != 'streaming':
                raise Exception(f"prompt.submit failed: {result}")

            # 持有锁 → 读取事件流直到 message.complete
            tool_calls = []
            while True:
                data = await self._read_until_timeout(timeout=120)
                if data is None:
                    break

                # 跳过 RPC response
                if data.get('id') is not None:
                    continue

                params = data.get('params', {})
                if not isinstance(params, dict):
                    continue
                evt_type = params.get('type', '')
                payload = params.get('payload', {})

                if not evt_type:
                    continue

                # 调用回调
                if callable(event_callback):
                    await event_callback(evt_type, payload)

                if evt_type == 'message.complete':
                    return payload

                if evt_type == 'tool.start':
                    tool_calls.append(payload.get('name', ''))

            return {'text': 'timeout'}


# ═══════════════════════════════════════════
#  WebSocket 处理(对外)
# ═══════════════════════════════════════════

async def handle_ws(websocket):
    conn_info = {'device_id': '', 'current_task_id': None}
    active_connections[websocket] = conn_info

    try:
        async for raw in websocket:
            try:
                data = json.loads(raw)
            except json.JSONDecodeError:
                continue

            msg_type = data.get('type', '')

            if msg_type == 'auth':
                conn_info['device_id'] = data.get('device_id', '')
                await ws_send(websocket, {'type': 'ack'})

            elif msg_type == 'select_task':
                task_id = data.get('task_id', '')
                conn_info['current_task_id'] = task_id
                await ws_send(websocket, {'type': 'task_selected', 'task_id': task_id})

            elif msg_type == 'message':
                msg_id = data.get('msg_id', 0)
                content = data.get('content', '').strip()
                task_id = data.get('task_id', '') or conn_info.get('current_task_id', '')

                if not content or not task_id:
                    continue

                asyncio.create_task(
                    enqueue_chat_message(websocket, msg_id, content, task_id)
                )

    except asyncio.CancelledError:
        pass
    except Exception:
        pass
    finally:
        active_connections.pop(websocket, None)


# ═══════════════════════════════════════════
#  消息队列调度器
# ═══════════════════════════════════════════

async def enqueue_chat_message(ws, msg_id, content, task_id):
    global chat_queue_msg_id_counter
    chat_queue_msg_id_counter += 1
    queue_id = chat_queue_msg_id_counter

    # ── 微服务路由预检:记录匹配的微服务到 task_board ──
    matched_services = []
    try:
        conn = sqlite3.connect(CONTENT_DB)
        rows = conn.execute("SELECT service_name, domain_keywords FROM service_agents WHERE status='active'").fetchall()
        conn.close()
        for name, kw_json in rows:
            keywords = json.loads(kw_json) if kw_json else []
            if any(kw.lower() in content.lower() for kw in keywords):
                matched_services.append(name)
    except Exception:
        pass  # 预检失败不阻塞消息

    if task_id not in chat_queues:
        chat_queues[task_id] = asyncio.Queue()

    q = chat_queues[task_id]

    item = {
        'ws': ws,
        'msg_id': msg_id,
        'content': content,
        'task_id': task_id,
        'queue_id': queue_id,
    }
    await q.put(item)

    # 消息已通过 RPC submit 发给内核,内核自动存到 state.db
    # 不再写 chat_history.db(冗余)

    task_key = f"task-{queue_id}"
    svc_tag = f" 🏛️{','.join(matched_services)}" if matched_services else ""
    task_board[task_key] = {
        'status': f'排队中{svc_tag}',
        'content': content[:60],
        'created_at': _time_module.time(),
        'elapsed': 0,
        'queue_pos': q.qsize(),
    }
    task_board_order.append(task_key)
    _trim_task_board()

    if not chat_queue_busy.get(task_id):
        chat_queue_busy[task_id] = True
        asyncio.create_task(chat_queue_consumer(task_id))


def _trim_task_board():
    while len(task_board_order) > 20:
        old_id = task_board_order.pop(0)
        task_board.pop(old_id, None)


async def chat_queue_consumer(task_id):
    while True:
        q = chat_queues.get(task_id)
        if not q:
            chat_queue_busy[task_id] = False
            return

        try:
            item = await asyncio.wait_for(q.get(), timeout=3600)
        except asyncio.TimeoutError:
            chat_queue_busy[task_id] = False
            return

        ws = item['ws']
        msg_id = item['msg_id']
        content = item['content']
        tid = item['task_id']
        queue_id = item['queue_id']

        task_key = f"task-{queue_id}"
        if task_key in task_board:
            task_board[task_key]['status'] = '执行中'
            task_board[task_key]['created_at'] = _time_module.time()

        remaining = q.qsize()
        for tk in task_board_order:
            if task_board[tk]['status'] == '排队中':
                task_board[tk]['queue_pos'] = remaining

        if remaining > 0:
            await send_local_reply(ws, msg_id,
                f'📋 收到!当前还有 {remaining} 条消息在排队,按顺序处理中…',
                task_id=tid)

        await run_hermes_rpc(ws, msg_id, content, tid)

        if task_key in task_board:
            task_board[task_key]['status'] = '已完成'
            task_board[task_key]['created_at'] = _time_module.time()


async def send_local_reply(ws, msg_id, text, task_id=''):
    await ws_send(ws, {
        'type': 'reply_done',
        'msg_id': msg_id,
        'task_id': task_id,
        'reply': text,
    })


async def ws_send(ws, data):
    try:
        await ws.send(json.dumps(data, ensure_ascii=False))
    except Exception:
        pass


# ═══════════════════════════════════════════
#  JSON-RPC 方式调用 Hermes
# ═══════════════════════════════════════════

_rpc_client_holder = None
_rpc_client_lock = asyncio.Lock()


async def _get_hermes_rpc():
    """获取 RPC 客户端(单例,自动重连)"""
    global _rpc_client_holder
    async with _rpc_client_lock:
        if _rpc_client_holder is None:
            _rpc_client_holder = HermesRpcClient()
            await _rpc_client_holder._connect()
        # 如果连接断了,重新创建
        if _rpc_client_holder._ws is None or _rpc_client_holder._ws.close_code is not None:
            _rpc_client_holder = HermesRpcClient()
            await _rpc_client_holder._connect()
        return _rpc_client_holder


async def run_hermes_rpc(ws, msg_id, prompt, task_id):
    """使用 JSON-RPC 调用 Hermes,流式推送事件给前端"""
    try:
        # 获取任务上下文
        task_name = task_id
        task_context = ""
        try:
            conn = sqlite3.connect(CONTENT_DB)
            row = conn.execute("SELECT name, description FROM tasks WHERE id=?", (task_id,)).fetchone()
            if row:
                task_name = row[0]
                if row[1]:
                    task_context = row[1][:200]
            conn.close()
        except Exception:
            pass

        # 构建增强 prompt
        enhanced_prompt = f"[当前任务] {task_name}\n[任务ID] {task_id}\n\n{prompt}"

        # 获取 RPC 客户端
        rpc = await _get_hermes_rpc()

        # 复用 task 对应的内核 session(持久化到 content.db)
        stored_sid = _ensure_valid_session_id(task_id) or get_task_session_id(task_id)
        if stored_sid:
            routing_sid = get_task_routing_session_id(task_id) or stored_sid
            print(f"[RPC] Reuse session stored={stored_sid} routing={routing_sid}", file=sys.stderr)
        else:
            session_info = await rpc.create_session(source=f'task-{task_id}')
            routing_sid = session_info['session_id']
            stored_sid = session_info['stored_session_id']
            save_task_session_id(task_id, stored_sid, routing_sid)
            print(f"[RPC] New session stored={stored_sid} routing={routing_sid} (persisted)", file=sys.stderr)

            # 迁移 chat_history.db 中的旧消息到新 session
            try:
                ch_db = CHAT_HISTORY_DB
                if os.path.exists(ch_db):
                    ch_conn = sqlite3.connect(ch_db)
                    ch_rows = ch_conn.execute(
                        "SELECT role, content, created_at FROM messages WHERE source=? ORDER BY id",
                        (f"task-{task_id}",)
                    ).fetchall()
                    ch_conn.close()
                    if ch_rows:
                        st_conn = sqlite3.connect(os.path.expanduser('~/.hermes/state.db'))
                        st_conn.execute("BEGIN")
                        for row in ch_rows:
                            st_conn.execute(
                                "INSERT INTO messages (session_id, role, content, timestamp, active) "
                                "SELECT ?, ?, ?, ?, 1 "
                                "WHERE NOT EXISTS ("
                                "  SELECT 1 FROM messages WHERE session_id=? AND role=? AND content=? AND timestamp=?"
                                ")",
                                (stored_sid, row[0], row[1] or '', float(row[2]) if isinstance(row[2], (int, float)) else 0,
                                 stored_sid, row[0], row[1] or '', float(row[2]) if isinstance(row[2], (int, float)) else 0)
                            )
                        st_conn.execute(
                            "UPDATE sessions SET message_count = (SELECT COUNT(*) FROM messages WHERE session_id=?) WHERE id=?",
                            (stored_sid, stored_sid)
                        )
                        st_conn.commit()
                        st_conn.close()
                        print(f"[MIGRATE] Migrated {len(ch_rows)} old messages from chat_history.db to session {stored_sid}", file=sys.stderr)
            except Exception as e:
                print(f"[MIGRATE] Error: {e}", file=sys.stderr)

        # 收集完整回复用于保存
        full_text = ""
        full_reasoning = ""
        tool_calls_info = []

        async def on_event(evt_type, payload):
            nonlocal full_text, full_reasoning

            # 所有 WS 消息都带上 task_id,方便前端按任务隔离
            def msg(**kw):
                kw.setdefault('task_id', task_id)
                return kw

            if evt_type == 'message.start':
                # 发送推理面板开始信号
                await ws_send(ws, msg(**{
                    'type': 'reply_chunk',
                    'msg_id': msg_id,
                    'chunk': '',
                    'stage': 'thinking_start',
                }))

            elif evt_type == 'reasoning.delta':
                text = payload.get('text', '')
                if text:
                    full_reasoning += text
                    await ws_send(ws, msg(**{
                        'type': 'reasoning_delta',
                        'msg_id': msg_id,
                        'text': text,
                    }))

            elif evt_type == 'thinking.delta':
                text = payload.get('text', '') or ''
                if text.strip():
                    await ws_send(ws, msg(**{
                        'type': 'reply_chunk',
                        'msg_id': msg_id,
                        'chunk': text + '\n',
                        'stage': 'thinking',
                    }))

            elif evt_type == 'message.delta':
                text = payload.get('text', '')
                if text:
                    full_text += text
                    await ws_send(ws, msg(**{
                        'type': 'reply_chunk',
                        'msg_id': msg_id,
                        'chunk': text,
                        'stage': 'streaming',
                    }))

            elif evt_type == 'tool.start':
                name = payload.get('name', 'tool')
                args = payload.get('args', {})
                tool_info = {
                    'name': name,
                    'args': args,
                    'status': 'running',
                }
                tool_calls_info.append(tool_info)
                await ws_send(ws, msg(**{
                    'type': 'reply_chunk',
                    'msg_id': msg_id,
                    'chunk': f'\n\n🔧 {name}\n',
                    'stage': 'tool_start',
                    'tool': {
                        'name': name,
                        'args': args,
                    },
                }))

            elif evt_type == 'tool.complete':
                if tool_calls_info:
                    tool_calls_info[-1]['status'] = 'done'
                await ws_send(ws, msg(**{
                    'type': 'reply_chunk',
                    'msg_id': msg_id,
                    'chunk': '',
                    'stage': 'tool_complete',
                }))

            elif evt_type == 'reasoning.available':
                text = payload.get('text', '')
                if text and not full_reasoning:
                    full_reasoning = text
                    await ws_send(ws, msg(**{
                        'type': 'reasoning_delta',
                        'msg_id': msg_id,
                        'text': text,
                    }))
                    await ws_send(ws, msg(**{
                        'type': 'reasoning_done',
                        'msg_id': msg_id,
                    }))

            elif evt_type.startswith('subagent.'):
                # 子代理事件 — 转发给前端
                name = payload.get('goal', '子任务')
                task_index = payload.get('task_index', 0)
                task_count = payload.get('task_count', 1)
                subagent_id = payload.get('subagent_id', '')
                text = payload.get('text', '')
                tool_name = payload.get('tool_name', '')
                status = payload.get('status', '')
                summary = payload.get('summary', '')
                depth = payload.get('depth', 0)
                output_tokens = payload.get('output_tokens', 0)
                input_tokens = payload.get('input_tokens', 0)

                if evt_type == 'subagent.start':
                    await ws_send(ws, msg(**{
                        'type': 'subagent_event',
                        'msg_id': msg_id,
                        'subagent_type': 'start',
                        'name': name,
                        'task_index': task_index,
                        'task_count': task_count,
                        'subagent_id': subagent_id,
                        'text': text,
                    }))

                elif evt_type == 'subagent.thinking':
                    if text:
                        await ws_send(ws, msg(**{
                            'type': 'subagent_event',
                            'msg_id': msg_id,
                            'subagent_type': 'thinking',
                            'text': text,
                            'subagent_id': subagent_id,
                        }))

                elif evt_type == 'subagent.text':
                    if text:
                        await ws_send(ws, msg(**{
                            'type': 'subagent_event',
                            'msg_id': msg_id,
                            'subagent_type': 'text',
                            'text': text,
                            'subagent_id': subagent_id,
                        }))

                elif evt_type == 'subagent.tool':
                    await ws_send(ws, msg(**{
                        'type': 'subagent_event',
                        'msg_id': msg_id,
                        'subagent_type': 'tool',
                        'tool_name': tool_name or name,
                        'text': text,
                        'subagent_id': subagent_id,
                    }))

                elif evt_type == 'subagent.complete':
                    await ws_send(ws, msg(**{
                        'type': 'subagent_event',
                        'msg_id': msg_id,
                        'subagent_type': 'complete',
                        'name': name,
                        'summary': summary,
                        'subagent_id': subagent_id,
                        'input_tokens': input_tokens,
                        'output_tokens': output_tokens,
                    }))

        # 提交 prompt 并流式获取事件(带 session 失效重试)
        max_session_retry = 1
        for attempt in range(max_session_retry + 1):
            try:
                complete_payload = await rpc.submit_prompt_stream(routing_sid, enhanced_prompt, on_event)
                break  # 成功,跳出重试
            except Exception as e:
                error_str = str(e)
                is_session_404 = 'session not found' in error_str or 'code: 4001' in error_str or '4001' in error_str
                if is_session_404 and attempt < max_session_retry:
                    print(f"[RPC] Session 404 for task {task_id}, recreating and retrying...", file=sys.stderr)
                    # 清空失效的 session
                    try:
                        conn = sqlite3.connect(CONTENT_DB)
                        conn.execute("UPDATE tasks SET session_id=NULL, routing_session_id=NULL WHERE id=?", (task_id,))
                        conn.commit()
                        conn.close()
                    except Exception:
                        pass
                    # 重新创建 session
                    session_info = await rpc.create_session(source=f'task-{task_id}')
                    routing_sid = session_info['session_id']
                    stored_sid = session_info['stored_session_id']
                    save_task_session_id(task_id, stored_sid, routing_sid)
                    # 重置闭包变量,on_event 使用的将是新值
                    full_text = ""
                    full_reasoning = ""
                    tool_calls_info = []
                    print(f"[RPC] Retry with new session routing={routing_sid}", file=sys.stderr)
                    continue
                raise  # 其他错误或已达重试上限,交给外层 except

        # 如果是 message.complete 返回的,提取最终文本
        # 注意:message.complete 的 payload.text 可能只包含最后一段 delta,
        # 所以优先使用 full_text(通过 message.delta 累积的完整回复)
        if complete_payload and isinstance(complete_payload, dict):
            final_text = full_text or complete_payload.get('text', '')
            status = complete_payload.get('status', 'complete')
        else:
            final_text = full_text
            status = 'complete'

        # 发送推理结束信号
        if full_reasoning:
            await ws_send(ws, {
                'type': 'reasoning_done',
                'msg_id': msg_id,
            })

        # 发送完成信号
        await ws_send(ws, {
            'type': 'reply_done',
            'msg_id': msg_id,
            'task_id': task_id,
            'reply': final_text,
        })

        # 保存 AI 回复(内核已自动存到 state.db,这里不再重复写 chat_history.db)
        # 保留注释,如果后续需要做 task 关联分析可恢复
        if final_text.strip():
            pass

    except Exception as e:
        import traceback
        traceback.print_exc()
        await ws_send(ws, {
            'type': 'error',
            'msg_id': msg_id,
            'task_id': task_id,
            'message': str(e),
        })


# ═══════════════════════════════════════════
#  HTTP API 处理
# ═══════════════════════════════════════════

def handle_api(method, path, headers, body):
    parsed = urllib.parse.urlparse(path)
    params = urllib.parse.parse_qs(parsed.query)

    if path == '/doc-name' or path.startswith('/doc-name?'):
        doc_id = params.get('doc_id', [''])[0]
        if not doc_id:
            return (200, {'name': ''})
        try:
            conn = sqlite3.connect(CONTENT_DB)
            row = conn.execute("SELECT title FROM documents WHERE id=?", (doc_id,)).fetchone()
            conn.close()
            return (200, {'name': row[0] if row else ''})
        except Exception as e:
            return (200, {'name': ''})

    if path == '/chat/tasks' or path.startswith('/chat/tasks?'):
        tasks = get_task_list()
        return (200, {'tasks': tasks})

    elif path == '/chat/task-folders' or path.startswith('/chat/task-folders?'):
        result = get_task_folders()
        return (200, result)

    elif path == '/chat/task/touch' and method == 'POST':
        try:
            body_data = json.loads(body) if body else {}
            task_id = body_data.get('task_id', '')
            if task_id:
                touch_task_api(task_id)
                return (200, {'ok': True, 'task_id': task_id})
            return (400, {'error': 'task_id required'})
        except Exception as e:
            return (400, {'error': str(e)})

    elif path == '/chat/wechat/hook' and method == 'POST':
        """
        微信消息后处理 hook。
        微信消息回复完成后,由 Hermes Gateway 回调此接口(或我主动调用)。
        body: {
            "content": "用户说的内容",
            "reply": "我的回复",
            "source": "weixin"  
        }
        功能: 意图识别 → 关联 task → 保存消息到 chat_history.db → 更新 updated_at
        """
        try:
            body_data = json.loads(body) if body else {}
            content = body_data.get('content', '')
            reply = body_data.get('reply', '')
            
            # 从 content.db 加载所有 active 的 task
            conn = sqlite3.connect(CONTENT_DB)
            conn.row_factory = sqlite3.Row
            rows = conn.execute(
                "SELECT id, name, keywords, description FROM tasks WHERE status IN ('active','running','completed','created')"
            ).fetchall()
            conn.close()
            
            matched_task = None
            best_score = 0
            
            # 简化的意图匹配:keywords 和 description 关键词匹配
            for row in rows:
                task_id = row['id']
                keywords = (row['keywords'] or '').lower()
                desc = (row['description'] or '').lower()
                name = (row['name'] or '').lower()
                
                combined = f"{keywords} {desc} {name}"
                content_lower = content.lower()
                
                # 计算匹配分数
                score = 0
                # 关键词精确匹配
                for kw in combined.split():
                    kw = kw.strip()
                    if kw and len(kw) > 1 and kw in content_lower:
                        score += 3
                # 全名/ID精确匹配
                if name and name in content_lower:
                    score += 10
                if task_id in content_lower:
                    score += 10
                    
                if score > best_score:
                    best_score = score
                    matched_task = task_id
            
            result = {'matched': False, 'task_id': None}
            
            # 如果匹配到 task(阈值>=3)
            if matched_task and best_score >= 3:
                # 保存消息到 chat_history.db
                now = datetime.now(timezone.utc).timestamp()
                source = f"task-{matched_task}"
                try:
                    conn = sqlite3.connect(CHAT_HISTORY_DB)
                    conn.execute(
                        "INSERT INTO messages (source, role, content, created_at) VALUES (?, ?, ?, ?)",
                        (source, 'user', content, now)
                    )
                    if reply:
                        conn.execute(
                            "INSERT INTO messages (source, role, content, created_at) VALUES (?, ?, ?, ?)",
                            (source, 'assistant', reply, now + 0.001)
                        )
                    conn.commit()
                    conn.close()
                except Exception:
                    pass
                
                # 更新 task 时间
                touch_task_api(matched_task)
                result = {'matched': True, 'task_id': matched_task, 'score': best_score}
            
            return (200, result)
        except Exception as e:
            return (200, {'error': str(e)})

    elif path == '/chat/messages/move' and method == 'POST':
        """
        POST /chat/messages/move
        Body: { message_id: number, target_task_id: string }
        将消息移动到另一个 task 的会话
        """
        try:
            data = json.loads(body.decode('utf-8'))
            message_id = data.get('message_id')
            target_task_id = data.get('target_task_id')
            if not message_id or not target_task_id:
                return (400, {'error': 'message_id and target_task_id required'})

            state_db_path = os.path.expanduser('~/.hermes/state.db')
            conn = sqlite3.connect(state_db_path)

            # 1. 获取消息当前的 session_id
            row = conn.execute("SELECT session_id FROM messages WHERE id=?", (message_id,)).fetchone()
            if not row:
                conn.close()
                return (404, {'error': 'message not found'})
            source_session_id = row[0]

            # 2. 获取目标 task 的 session_id
            import time
            conn2 = sqlite3.connect(CONTENT_DB)
            target = conn2.execute("SELECT session_id FROM tasks WHERE id=?", (target_task_id,)).fetchone()
            if not target:
                conn2.close()
                conn.close()
                return (404, {'error': 'target task not found'})
            target_session_id = target[0]

            # 3. 如果目标 task 无 session,在 state.db 创建一条
            if not target_session_id:
                ts = time.time()
                target_session_id = f"mv_{target_task_id[:16]}_{int(ts)}"
                conn.execute(
                    "INSERT INTO sessions (id, source, started_at) VALUES (?, ?, ?)",
                    (target_session_id, f"task-{target_task_id}", ts)
                )
                # 持久化到 content.db
                conn2.execute("UPDATE tasks SET session_id=? WHERE id=?", (target_session_id, target_task_id))
                conn2.commit()
                print(f"[MOVE] created session {target_session_id} for task {target_task_id}", file=sys.stderr)

            conn2.close()

            if source_session_id == target_session_id:
                conn.close()
                return (200, {'ok': True, 'moved': False, 'reason': 'already in target session'})

            # 4. 移动消息
            conn.execute("UPDATE messages SET session_id=? WHERE id=?", (target_session_id, message_id))
            conn.commit()
            conn.close()

            print(f"[MOVE] message {message_id}: {source_session_id[:20]} → {target_session_id[:20]}", file=sys.stderr)
            return (200, {'ok': True, 'moved': True, 'target_session_id': target_session_id})
        except Exception as e:
            print(f"[MOVE] error: {e}", file=sys.stderr)
            return (500, {'error': str(e)})

    elif path == '/documents' and method == 'GET':
        try:
            conn = sqlite3.connect(CONTENT_DB)
            rows = conn.execute("SELECT id, title, folder_id FROM documents ORDER BY title").fetchall()
            conn.close()
            return (200, [{"id": r[0], "title": r[1], "folder_id": r[2]} for r in rows])
        except Exception as e:
            return (500, {"error": str(e)})

    elif path == '/documents-with-tasks' and method == 'GET':
        try:
            conn = sqlite3.connect(CONTENT_DB)
            docs = conn.execute("SELECT id, title, folder_id FROM documents ORDER BY title").fetchall()
            rows = conn.execute("""
                SELECT dt.doc_id, dt.task_id, t.name 
                FROM doc_tasks dt JOIN tasks t ON dt.task_id = t.id
                ORDER BY dt.doc_id
            """).fetchall()
            conn.close()
            task_map = {}
            for r in rows:
                doc_id = r[0]
                if doc_id not in task_map:
                    task_map[doc_id] = []
                task_map[doc_id].append({"id": r[1], "name": r[2]})
            result = [{
                "id": d[0],
                "title": d[1],
                "folder_id": d[2],
                "tasks": task_map.get(d[0], [])
            } for d in docs]
            return (200, result)
        except Exception as e:
            return (500, {"error": str(e)})

    elif path == '/tasks' and method == 'GET':
        try:
            conn = sqlite3.connect(CONTENT_DB)
            rows = conn.execute("SELECT id, name FROM tasks ORDER BY name").fetchall()
            conn.close()
            return (200, [{"id": r[0], "name": r[1]} for r in rows])
        except Exception as e:
            return (500, {"error": str(e)})

    elif (path == '/doc-tasks' or path.startswith('/doc-tasks?')) and method == 'GET':
        doc_id = params.get('doc_id', [None])[0]
        try:
            conn = sqlite3.connect(CONTENT_DB)
            if doc_id:
                rows = conn.execute("""
                    SELECT t.id, t.name, t.status 
                    FROM doc_tasks dt JOIN tasks t ON dt.task_id = t.id 
                    WHERE dt.doc_id = ?
                    ORDER BY t.name
                """, (doc_id,)).fetchall()
                all_tasks = conn.execute("SELECT id, name FROM tasks ORDER BY name").fetchall()
                conn.close()
                return (200, {
                    "associated": [{"id": r[0], "name": r[1], "status": r[2]} for r in rows],
                    "all_tasks": [{"id": r[0], "name": r[1]} for r in all_tasks]
                })
            else:
                rows = conn.execute("""
                    SELECT dt.doc_id, d.title, dt.task_id, t.name 
                    FROM doc_tasks dt 
                    JOIN documents d ON dt.doc_id = d.id 
                    JOIN tasks t ON dt.task_id = t.id 
                    ORDER BY d.title
                """).fetchall()
                conn.close()
                return (200, [{"doc_id": r[0], "doc_title": r[1], "task_id": r[2], "task_name": r[3]} for r in rows])
        except Exception as e:
            return (500, {"error": str(e)})
    
    elif path == '/doc-tasks' and method == 'POST':
        try:
            data = json.loads(body.decode('utf-8'))
            doc_id = data.get('doc_id')
            task_ids = data.get('task_ids', [])
            conn = sqlite3.connect(CONTENT_DB)
            conn.execute("DELETE FROM doc_tasks WHERE doc_id=?", (doc_id,))
            for tid in task_ids:
                conn.execute("INSERT OR IGNORE INTO doc_tasks (doc_id, task_id) VALUES (?, ?)", (doc_id, tid))
            conn.commit()
            conn.close()
            return (200, {"ok": True})
        except Exception as e:
            return (500, {"error": str(e)})
    
    elif path == '/tasks/create' and method == 'POST':
        try:
            data = json.loads(body.decode('utf-8'))
            name = data.get('name', '').strip()
            dir_id = data.get('dir_id') or None
            if not name:
                return (400, {"error": "name required"})
            import uuid
            task_id = 'task_' + uuid.uuid4().hex[:12]
            conn = sqlite3.connect(CONTENT_DB)
            conn.execute(
                "INSERT INTO tasks (id, name, status, folder_id, dir_id, created_at, updated_at) VALUES (?, ?, 'created', '', ?, datetime('now','localtime'), datetime('now','localtime'))",
                (task_id, name, dir_id)
            )
            conn.commit()
            conn.close()
            return (200, {"ok": True, "task_id": task_id, "name": name, "dir_id": dir_id})
        except Exception as e:
            return (500, {"error": str(e)})
    
    elif path == '/tasks/rename' and method == 'POST':
        try:
            data = json.loads(body.decode('utf-8'))
            task_id = data.get('task_id')
            new_name = data.get('name')
            conn = sqlite3.connect(CONTENT_DB)
            conn.execute("UPDATE tasks SET name=? WHERE id=?", (new_name, task_id))
            conn.commit()
            conn.close()
            return (200, {"ok": True})
        except Exception as e:
            return (500, {"error": str(e)})

    elif path == '/tasks/delete' and method == 'POST':
        try:
            data = json.loads(body.decode('utf-8'))
            task_id = data.get('task_id')
            if not task_id:
                return (400, {"error": "task_id required"})
            conn = sqlite3.connect(CONTENT_DB)
            # 清除 doc_tasks 关联
            conn.execute("DELETE FROM doc_tasks WHERE task_id=?", (task_id,))
            # 清除 task_dirs 挂靠
            conn.execute("UPDATE tasks SET dir_id=NULL, session_id=NULL, routing_session_id=NULL WHERE id=?", (task_id,))
            # 删除 task
            conn.execute("DELETE FROM tasks WHERE id=?", (task_id,))
            conn.commit()
            conn.close()
            return (200, {"ok": True})
        except Exception as e:
            return (500, {"error": str(e)})
    
    elif path == '/service-agents' and method == 'GET':
        try:
            conn = sqlite3.connect(CONTENT_DB)
            rows = conn.execute("""
                SELECT sa.service_name, sa.task_id, sa.description, sa.domain_keywords, sa.status, t.name
                FROM service_agents sa JOIN tasks t ON sa.task_id = t.id
                ORDER BY sa.service_name
            """).fetchall()
            conn.close()
            return (200, [{
                "service_name": r[0], "task_id": r[1],
                "description": r[2], "domain_keywords": json.loads(r[3]) if r[3] else [],
                "status": r[4], "task_name": r[5]
            } for r in rows])
        except Exception as e:
            return (500, {"error": str(e)})
    
    elif path == '/service-router' and method == 'POST':
        try:
            data = json.loads(body.decode('utf-8'))
            query = data.get('query', '')
            conn = sqlite3.connect(CONTENT_DB)
            rows = conn.execute("SELECT service_name, task_id, domain_keywords FROM service_agents WHERE status='active'").fetchall()
            conn.close()
            results = []
            for name, tid, kw_json in rows:
                keywords = json.loads(kw_json) if kw_json else []
                match_count = sum(1 for kw in keywords if kw.lower() in query.lower())
                if match_count > 0:
                    results.append({"service_name": name, "task_id": tid, "match_score": match_count})
            results.sort(key=lambda x: x['match_score'], reverse=True)
            return (200, {"matches": results, "status": "ok"})
        except Exception as e:
            return (500, {"error": str(e)})
    
    elif path == '/tasks-with-folder' and method == 'GET':
        try:
            conn = sqlite3.connect(CONTENT_DB)
            rows = conn.execute("""
                SELECT t.id, t.name, t.status, t.folder_id, f.name as folder_name,
                       f.parent_id as folder_parent, t.dir_id
                FROM tasks t
                LEFT JOIN folders f ON t.folder_id = f.id
                ORDER BY COALESCE(f.name, 'zzz'), t.name
            """).fetchall()
            conn.close()
            tasks = []
            for r in rows:
                tasks.append({
                    "id": r[0], "name": r[1], "status": r[2],
                    "folder_id": r[3], "folder_name": r[4], "folder_parent": r[5],
                    "dir_id": r[6]
                })
            return (200, {"tasks": tasks})
        except Exception as e:
            return (500, {"error": str(e)})
    
    elif path == '/task-dirs' and method == 'GET':
        try:
            conn = sqlite3.connect(CONTENT_DB)
            rows = conn.execute("""
                SELECT d.id, d.name, d.parent_id, d.sort_order,
                       (SELECT COUNT(*) FROM tasks t WHERE t.dir_id = d.id) as task_count
                FROM task_dirs d
                ORDER BY d.sort_order, d.name
            """).fetchall()
            conn.close()
            dirs = []
            for r in rows:
                dirs.append({
                    "id": r[0], "name": r[1], "parent_id": r[2],
                    "sort_order": r[3], "task_count": r[4]
                })
            return (200, {"dirs": dirs})
        except Exception as e:
            return (500, {"error": str(e)})
    
    elif path == '/task-dirs' and method == 'POST':
        try:
            data = json.loads(body.decode('utf-8'))
            action = data.get('action', 'create')
            conn = sqlite3.connect(CONTENT_DB)
            if action == 'create':
                import uuid
                new_id = 'td_' + uuid.uuid4().hex[:12]
                conn.execute("INSERT INTO task_dirs (id, name, parent_id, sort_order) VALUES (?, ?, ?, ?)",
                    (new_id, data['name'], data.get('parent_id'), data.get('sort_order', 0)))
                conn.commit()
                conn.close()
                return (200, {"ok": True, "id": new_id})
            elif action == 'rename':
                conn.execute("UPDATE task_dirs SET name=? WHERE id=?", (data['name'], data['id']))
                conn.commit()
                conn.close()
                return (200, {"ok": True})
            elif action == 'move_task':
                conn.execute("UPDATE tasks SET dir_id=? WHERE id=?", (data.get('dir_id'), data['task_id']))
                conn.commit()
                conn.close()
                return (200, {"ok": True})
            elif action == 'move_dir':
                dir_id = data.get('id')
                new_parent = data.get('parent_id')
                if not dir_id or new_parent is None:
                    conn.close()
                    return (400, {"error": "id and parent_id required"})
                if dir_id == new_parent:
                    conn.close()
                    return (400, {"error": "不能将目录移到自己下面"})
                # 防循环检查
                def is_descendant(pid):
                    for row in conn.execute("SELECT id FROM task_dirs WHERE parent_id=?", (pid,)):
                        if row[0] == dir_id or is_descendant(row[0]):
                            return True
                    return False
                if is_descendant(new_parent):
                    conn.close()
                    return (400, {"error": "不能将目录移到自己的子目录下(会导致循环)"})
                conn.execute("UPDATE task_dirs SET parent_id=? WHERE id=?", (new_parent, dir_id))
                conn.commit()
                conn.close()
                return (200, {"ok": True})
            elif action == 'delete':
                # 不能删除有子目录的节点
                child = conn.execute("SELECT COUNT(*) FROM task_dirs WHERE parent_id=?", (data['id'],)).fetchone()[0]
                if child > 0:
                    conn.close()
                    return (400, {"error": "请先删除子目录"})
                # 解绑tasks
                conn.execute("UPDATE tasks SET dir_id=NULL WHERE dir_id=?", (data['id'],))
                conn.execute("DELETE FROM task_dirs WHERE id=?", (data['id'],))
                conn.commit()
                conn.close()
                return (200, {"ok": True})
            conn.close()
            return (400, {"error": "unknown action"})
        except Exception as e:
            return (500, {"error": str(e)})
    
    elif path.startswith('/chat/tree'):
        folder_id = params.get('folder_id', [''])[0]
        task_id = params.get('task_id', [''])[0]

        if task_id and not folder_id:
            # 通过 task_id 查:只返回 doc_tasks 关联的文档(按文件夹组织)
            try:
                conn = sqlite3.connect(CONTENT_DB)
                conn.row_factory = sqlite3.Row
                rows = conn.execute(
                    """SELECT d.id as doc_id, d.title as doc_title, d.folder_id,
                              f.name as folder_name
                       FROM doc_tasks dt
                       JOIN documents d ON d.id = dt.doc_id
                       LEFT JOIN folders f ON f.id = d.folder_id
                       WHERE dt.task_id=?
                       ORDER BY d.folder_id, d.title""",
                    (task_id,)
                ).fetchall()
                conn.close()
                # 按 folder 分组
                folder_docs = {}
                for r in rows:
                    fid = r['folder_id']
                    if fid not in folder_docs:
                        folder_docs[fid] = {
                            'type': 'folder', 'id': fid, 'name': r['folder_name'] or str(fid),
                            'children': [],
                        }
                    folder_docs[fid]['children'].append({
                        'type': 'doc', 'id': r['doc_id'], 'name': r['doc_title'],
                    })
                tree = list(folder_docs.values())
                return (200, {'tree': tree})
            except Exception as e:
                print(f"[TREE] error for task_id={task_id}: {e}", file=sys.stderr)
                return (200, {'tree': []})

        # folder_id 模式:传统文件夹树浏览
        if not folder_id and task_id:
            try:
                conn = sqlite3.connect(CONTENT_DB)
                row = conn.execute(
                    "SELECT folder_id FROM tasks WHERE id=?", (task_id,)
                ).fetchone()
                conn.close()
                if row:
                    folder_id = row[0]
            except Exception:
                pass
        if not folder_id:
            return (200, {'tree': []})
        tree = get_task_folder_tree(folder_id)
        return (200, {'tree': tree})

    elif path.startswith('/chat/history'):
        task_id = params.get('task_id', [''])[0]
        # 兼容旧版前端用 source 参数查历史
        source = params.get('source', [''])[0]
        offset = int(params.get('offset', ['0'])[0])
        limit = min(int(params.get('limit', ['20'])[0]), 50)
        if task_id:
            # 新版:通过 kernel state.db 查(session_id 持久化在 tasks 表)
            result = load_chat_history_from_kernel(task_id, offset, limit)
            return (200, result)
        if source == 'chat' and not task_id:
            # source=chat(WebChat 首页):自动解析到最活跃 task
            auto_task_id = resolve_task_for_source('chat')
            if auto_task_id:
                print(f"[HISTORY] auto-resolved source=chat -> task {auto_task_id}", file=sys.stderr)
                result = load_chat_history_from_kernel(auto_task_id, offset, limit)
                return (200, result)
        if source and not task_id:
            # 旧版兼容:source 模式查 chat_history.db
            result = load_chat_history_by_source(source, offset, limit)
            return (200, result)
        return (200, {'messages': [], 'has_more': False})

    elif path.startswith('/chat/current-task'):
        # 返回当前活跃 task(WebChat 默认关联的任务)
        task_id = resolve_task_for_source('chat')
        if task_id:
            try:
                conn = sqlite3.connect(CONTENT_DB)
                row = conn.execute(
                    "SELECT id, name, status, session_id FROM tasks WHERE id=?",
                    (task_id,)
                ).fetchone()
                conn.close()
                if row:
                    return (200, {
                        'task_id': row[0],
                        'name': row[1],
                        'status': row[2],
                        'session_id': row[3] or '',
                    })
            except Exception:
                pass
        return (200, {'task_id': None})

    elif path.startswith('/chat/prd'):
        prd_path = os.path.join(os.path.dirname(__file__), 'PRD.md')
        if os.path.exists(prd_path):
            with open(prd_path, 'r', encoding='utf-8') as f:
                content = f.read()
            return (200, content, 'text/markdown')
        return (200, '# PRD\n\nPRD doc not found', 'text/markdown')

    elif path == '/chat/message/retain' and method == 'POST':
        """前端发消息时先保留到 state.db,防止切 task 丢消息"""
        try:
            data = json.loads(body.decode('utf-8'))
            task_id = data.get('task_id', '')
            role = data.get('role', 'user')
            content = data.get('content', '').strip()
            if not task_id or not content:
                return (400, {'error': 'task_id and content required'})

            import time
            state_db = os.path.expanduser('~/.hermes/state.db')
            conn = sqlite3.connect(state_db)

            # 获取已有的 session_id
            session_id = get_task_session_id(task_id)

            if not session_id:
                # 创建合成 session(防止外键约束)
                ts = time.time()
                session_id = f"retain_{task_id[:16]}_{int(ts)}"
                conn.execute(
                    "INSERT OR IGNORE INTO sessions (id, source, started_at) VALUES (?, ?, ?)",
                    (session_id, f"task-{task_id}", ts)
                )
                # 持久化到 content.db
                save_task_session_id(task_id, session_id, session_id)

            # 写入消息
            conn.execute(
                "INSERT INTO messages (session_id, role, content, timestamp, active) VALUES (?, ?, ?, ?, 1)",
                (session_id, role, content, time.time())
            )
            # 更新计数
            conn.execute(
                "UPDATE sessions SET message_count = (SELECT COUNT(*) FROM messages WHERE session_id=?) WHERE id=?",
                (session_id, session_id)
            )
            conn.commit()
            conn.close()

            return (200, {'ok': True, 'session_id': session_id})
        except Exception as e:
            print(f"[RETAIN ERROR] {e}", file=sys.stderr)
            return (500, {'error': str(e)})

    elif path.startswith('/chat/attachment/') and method == 'POST' and path.endswith('/upload'):
        """附件上传"""
        import time, subprocess, re, secrets

        # 生成 OID: 24字符hex (8位时间戳 + 16位随机)
        ts_hex = format(int(time.time()), '08x')
        rand_hex = secrets.token_hex(8)  # 16 hex chars
        oid = ts_hex + rand_hex  # 24 chars

        # 解析 multipart/form-data
        content_type = headers.get('content-type', '')
        boundary = ''
        if 'boundary=' in content_type:
            boundary = content_type.split('boundary=')[1].split(';')[0].strip()
            if boundary.startswith('"') and boundary.endswith('"'):
                boundary = boundary[1:-1]
        if not boundary:
            return (400, {'error': 'invalid content-type, need multipart/form-data'})

        # 从 body 解析文件
        boundary_bytes = boundary.encode('utf-8')
        parts = body.split(b'--' + boundary_bytes)
        
        filename = 'unnamed'
        file_data = None
        task_id = ''
        content_type_header_detected = ''

        for part in parts:
            if b'Content-Disposition' not in part:
                continue
            header_end = part.find(b'\r\n\r\n')
            if header_end == -1:
                continue
            header_part = part[:header_end].decode('utf-8', errors='replace')
            data_part = part[header_end+4:]
            # 去掉尾部 \r\n--
            if data_part.endswith(b'\r\n'):
                data_part = data_part[:-2]
            if data_part.endswith(b'--'):
                data_part = data_part[:-2]

            # 文件名
            fn_match = re.search(r'filename="([^"]*)"', header_part)
            if fn_match:
                filename = fn_match.group(1)

            # form字段名
            name_match = re.search(r'name="([^"]*)"', header_part)
            if name_match:
                field_name = name_match.group(1)
                if field_name == 'task_id':
                    task_id = data_part.decode('utf-8', errors='replace').strip()
                    continue
                elif field_name == 'file':
                    file_data = data_part
                    # 检测 MIME
                    ct_match = re.search(r'Content-Type:\s*(\S+)', header_part, re.I)
                    if ct_match:
                        content_type_header_detected = ct_match.group(1)

        if not file_data:
            return (400, {'error': 'no file data in upload'})
        if not task_id:
            return (400, {'error': 'task_id required'})

        filesize = len(file_data)
        filename_safe = re.sub(r'[^\w\.\-\u4e00-\u9fff\(\)\[\]]', '_', filename)
        if not filename_safe:
            filename_safe = 'unnamed'

        # 写临时文件
        tmp_dir = '/tmp/chat_uploads'
        os.makedirs(tmp_dir, exist_ok=True)
        tmp_path = f"{tmp_dir}/{oid}_{filename_safe}"
        with open(tmp_path, 'wb') as f:
            f.write(file_data)

        # OSS 路径
        oss_key = f"attachments/{oid}/{filename_safe}"
        oss_path = f"oss://rosicky-doc/{oss_key}"
        ok, out = subprocess.getstatusoutput(f"ossutil cp -f {tmp_path} {oss_path}")
        if ok != 0:
            os.remove(tmp_path)
            return (500, {'error': f'oss upload failed: {out[:200]}'})

        # 清理临时文件
        os.remove(tmp_path)

        # 文件名加时间戳前缀: 0728_a.txt
        date_prefix = time.strftime('%m%d')
        timestamped_name = f"{date_prefix}_{filename}"
        filename_safe = re.sub(r'[^\w\.\-\u4e00-\u9fff\(\)\[\]]', '_', timestamped_name)
        if not filename_safe:
            filename_safe = f"{date_prefix}_unnamed"

        # 写入 DB
        conn_att = sqlite3.connect(CONTENT_DB)
        conn_att.execute(
            "INSERT INTO attachments (id, task_id, filename, filesize, mime_type, oss_key, display_name, attach_type) VALUES (?,?,?,?,?,?,?,?)",
            (oid, task_id, filename_safe, filesize, content_type_header_detected, oss_key, filename, 'file')
        )
        conn_att.commit()
        conn_att.close()

        print(f"[ATTACH] {filename_safe} ({filesize}B) -> https://rosicky-doc.oss-cn-shanghai.aliyuncs.com/{oss_key}", file=sys.stderr)
        return (200, {'ok': True, 'id': oid, 'filename': filename_safe, 'filesize': filesize, 'url': f"https://rosicky-doc.oss-cn-shanghai.aliyuncs.com/{oss_key}"})

    elif path.startswith('/chat/attachments') and method == 'GET':
        """查询附件列表"""
        task_id = params.get('task_id', [''])[0]
        limit = min(int(params.get('limit', ['50'])[0]), 100)
        offset = int(params.get('offset', ['0'])[0])
        try:
            conn_att = sqlite3.connect(CONTENT_DB)
            conn_att.row_factory = sqlite3.Row
            if task_id:
                rows = conn_att.execute(
                    "SELECT * FROM attachments WHERE task_id=? ORDER BY created_at DESC LIMIT ? OFFSET ?",
                    (task_id, limit, offset)
                ).fetchall()
                total = conn_att.execute("SELECT COUNT(*) FROM attachments WHERE task_id=?", (task_id,)).fetchone()[0]
            else:
                rows = conn_att.execute(
                    "SELECT * FROM attachments ORDER BY created_at DESC LIMIT ? OFFSET ?",
                    (limit, offset)
                ).fetchall()
                total = conn_att.execute("SELECT COUNT(*) FROM attachments").fetchone()[0]
            conn_att.close()
            result = []
            for r in rows:
                result.append({
                    'id': r['id'],
                    'task_id': r['task_id'],
                    'filename': r['filename'],
                    'filesize': r['filesize'],
                    'mime_type': r['mime_type'],
                    'created_at': r['created_at'],
                    'url': f"https://rosicky-doc.oss-cn-shanghai.aliyuncs.com/{r['oss_key']}",
                })
            return (200, {'attachments': result, 'total': total})
        except Exception as e:
            return (500, {'error': str(e)})

    elif path.startswith('/chat/attachment/') and path.endswith('/download') and method == 'GET':
        """附件下载(返回 OSS 直链)"""
        att_id = path.split('/')[3]
        try:
            conn_att = sqlite3.connect(CONTENT_DB)
            row = conn_att.execute("SELECT * FROM attachments WHERE id=?", (att_id,)).fetchone()
            conn_att.close()
            if not row:
                return (404, {'error': 'attachment not found'})
            oss_url = f"https://rosicky-doc.oss-cn-shanghai.aliyuncs.com/{row[5]}"
            return (200, {'url': oss_url, 'filename': row[2], 'filesize': row[3]})
        except Exception as e:
            return (500, {'error': str(e)})

    elif path == '/chat/attachment/delete' and method == 'POST':
        """删除附件"""
        try:
            data = json.loads(body.decode('utf-8'))
            att_id = data.get('id', '')
            if not att_id:
                return (400, {'error': 'id required'})
            conn_att = sqlite3.connect(CONTENT_DB)
            row = conn_att.execute("SELECT oss_key FROM attachments WHERE id=?", (att_id,)).fetchone()
            if row:
                subprocess.getstatusoutput(f"ossutil rm oss://rosicky-doc/{row[0]}")
                conn_att.execute("DELETE FROM attachments WHERE id=?", (att_id,))
                conn_att.commit()
            conn_att.close()
            return (200, {'ok': True})
        except Exception as e:
            return (400, {'error': str(e)})

    elif path == '/chat/message/edit' and method == 'POST':
        # 编辑消息
        try:
            data = json.loads(body.decode('utf-8'))
            msg_id = data.get('id')
            new_content = data.get('content', '').strip()
            if not msg_id or not new_content:
                return (400, {'error': 'id and content required'})
            ok = update_chat_message(msg_id, new_content)
            if ok:
                msg = get_chat_message_by_id(msg_id)
                return (200, {'success': True, 'message': msg})
            return (404, {'error': 'message not found or not editable'})
        except Exception as e:
            return (400, {'error': str(e)})

    elif path == '/chat/message/delete' and method == 'POST':
        try:
            data = json.loads(body.decode('utf-8'))
            msg_id = data.get('id')
            if not msg_id:
                return (400, {'error': 'id required'})
            ok = delete_chat_message(msg_id)
            return (200, {'success': ok})
        except Exception as e:
            return (400, {'error': str(e)})

    elif path.startswith('/chat/search'):
        task_id = params.get('task_id', [''])[0]
        keyword = params.get('q', [''])[0]
        limit = min(int(params.get('limit', ['20'])[0]), 50)
        if not task_id or not keyword:
            return (200, {'messages': [], 'count': 0})
        result = search_chat_messages(task_id, keyword, limit)
        return (200, result)

    elif path.startswith('/skills'):
        try:
            conn = sqlite3.connect(CONTENT_DB)
            conn.row_factory = sqlite3.Row
            # 技能列表
            skills = conn.execute(
                "SELECT s.name, s.category, s.description, s.file_path FROM skills s ORDER BY s.category, s.name"
            ).fetchall()
            # 技能→task 映射
            skill_tasks_map = {}
            st_rows = conn.execute(
                "SELECT st.skill_name, st.task_id, t.name as task_name FROM skill_tasks st LEFT JOIN tasks t ON st.task_id = t.id"
            ).fetchall()
            for row in st_rows:
                sn = row['skill_name']
                if sn not in skill_tasks_map:
                    skill_tasks_map[sn] = []
                skill_tasks_map[sn].append({
                    'task_id': row['task_id'],
                    'task_name': row['task_name'] or row['task_id'],
                })
            conn.close()
            
            result = []
            for s in skills:
                name = s['name']
                result.append({
                    'name': name,
                    'category': s['category'] or '',
                    'description': s['description'] or '',
                    'file_path': s['file_path'],
                    'tasks': skill_tasks_map.get(name, []),
                })
            return (200, result)
        except Exception as e:
            return (500, {'error': str(e)})
    
    elif path == '/skill-tasks' and method == 'POST':
        try:
            data = json.loads(body.decode('utf-8'))
            skill_name = data.get('skill_name', '')
            task_ids = data.get('task_ids', [])
            if not skill_name:
                return (400, {'error': 'skill_name required'})
            conn = sqlite3.connect(CONTENT_DB)
            conn.execute("DELETE FROM skill_tasks WHERE skill_name=?", (skill_name,))
            for tid in task_ids:
                if tid:
                    conn.execute("INSERT OR IGNORE INTO skill_tasks (skill_name, task_id) VALUES (?,?)", (skill_name, tid))
            conn.commit()
            conn.close()
            return (200, {'ok': True})
        except Exception as e:
            return (400, {'error': str(e)})
    
    elif path.startswith('/skill-content'):
        try:
            name = params.get('name', [None])[0]
            if not name:
                return (400, {'error': 'name required'})
            conn = sqlite3.connect(CONTENT_DB)
            row = conn.execute("SELECT file_path FROM skills WHERE name=?", (name,)).fetchone()
            conn.close()
            if not row:
                return (404, {'error': 'skill not found'})
            fp = row[0]
            skill_path = os.path.join(os.path.expanduser('~/.hermes/skills'), fp)
            if not os.path.exists(skill_path):
                # Try SKILL.md variants
                alt = skill_path
                if not alt.endswith('.md'):
                    alt = os.path.join(skill_path, 'SKILL.md')
                if not os.path.exists(alt):
                    alt2 = os.path.join(os.path.dirname(skill_path), 'SKILL.md')
                    if os.path.exists(alt2):
                        alt = alt2
                    else:
                        return (404, {'error': 'skill file not found', 'path': skill_path})
                skill_path = alt
            with open(skill_path, 'r', encoding='utf-8') as f:
                content = f.read()
            return (200, {'name': name, 'content': content, 'file_path': fp})
        except Exception as e:
            return (500, {'error': str(e)})
    
    elif path.startswith('/doc-content'):
        try:
            doc_id = params.get('doc_id', [None])[0]
            if not doc_id:
                return (400, {'error': 'doc_id required'})
            # Fetch from OSS
            import subprocess
            oss_key = f"oss://rosicky-doc/contents/{doc_id}"
            r = subprocess.run(["ossutil", "cat", oss_key], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30)
            content = r.stdout.decode('utf-8', errors='replace')
            if not content.strip() and r.returncode != 0:
                # Try with .md suffix
                oss_key_md = f"oss://rosicky-doc/contents/{doc_id}.md"
                r = subprocess.run(["ossutil", "cat", oss_key_md], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30)
                content = r.stdout.decode('utf-8', errors='replace')
            return (200, {'doc_id': doc_id, 'content': content})
        except Exception as e:
            return (500, {'error': str(e)})
    
    elif path == '/chat/upload' and method == 'POST':
        """上传附件 — 接受 multipart/form-data,保存到 /var/www/webchat/uploads/"""
        UPLOAD_DIR = '/var/www/webchat/uploads'
        MAX_FILE_SIZE = 10 * 1024 * 1024  # 10MB
        try:
            content_type = headers.get('content-type', '')
            if 'multipart/form-data' not in content_type:
                return (400, {'error': 'expected multipart/form-data'})

            # 提取 boundary
            m = re.search(r'boundary=([^;]+)', content_type)
            if not m:
                return (400, {'error': 'boundary not found'})
            boundary = m.group(1).strip().strip('"').encode()

            # 解析 multipart body
            body_bytes = body if isinstance(body, bytes) else body.encode('utf-8')
            parts = body_bytes.split(b'--' + boundary)
            file_data = None
            filename = None

            for part in parts:
                if part.startswith(b'\r\n') and b'Content-Disposition' in part[:500]:
                    # 分离 header 和 body
                    header_end = part.find(b'\r\n\r\n')
                    if header_end == -1:
                        continue
                    header_section = part[:header_end].decode('utf-8', errors='replace')
                    data_start = header_end + 4
                    # 去掉尾部 \r\n--\r\n
                    file_bytes = part[data_start:]
                    if file_bytes.endswith(b'\r\n'):
                        file_bytes = file_bytes[:-2]

                    # 提取文件名
                    fn_match = re.search(r'filename="([^"]*)"', header_section)
                    if fn_match:
                        filename = fn_match.group(1)
                        file_data = file_bytes

            if not file_data or not filename:
                return (400, {'error': 'no file uploaded'})

            if len(file_data) > MAX_FILE_SIZE:
                return (400, {'error': f'file too large (max {MAX_FILE_SIZE//1024//1024}MB)'})

            # 安全处理文件名
            safe_name = re.sub(r'[^\w\.\-]', '_', filename)
            import uuid
            unique_name = f"{uuid.uuid4().hex[:8]}_{safe_name}"
            file_path = os.path.join(UPLOAD_DIR, unique_name)

            with open(file_path, 'wb') as f:
                f.write(file_data)

            url = f"/uploads/{unique_name}"
            print(f"[UPLOAD] {filename} -> {file_path} ({len(file_data)} bytes)", file=sys.stderr)
            return (200, {
                'ok': True,
                'url': url,
                'filename': filename,
                'size': len(file_data),
            })
        except Exception as e:
            print(f"[UPLOAD ERROR] {e}", file=sys.stderr)
            return (500, {'error': str(e)})

    elif path == '/wiki/create' and method == 'POST':
        """创建 wiki 文档 — 写 OSS + 写 content.db,供微服务/主 agent 统一调用"""
        try:
            data = json.loads(body.decode('utf-8'))
            title = (data.get('title') or '').strip()
            content = (data.get('content') or '').strip()
            folder_id = data.get('folder_id') or ''
            task_id = data.get('task_id') or ''
            if not title or not content:
                return (400, {'error': 'title and content required'})

            # 生成 doc_id(同现有模式:timestamp_based)
            import random, time
            _EPOCH = 1700000000000
            ts = int(time.time() * 1000) - _EPOCH
            doc_id = str((ts << 10) | random.randint(0, 1023))

            # 写临时文件 → ossutil cp
            tmp_path = f'/tmp/wiki_create_{doc_id}.md'
            with open(tmp_path, 'w', encoding='utf-8') as f:
                f.write(content)

            import subprocess
            r = subprocess.run(
                ['ossutil', 'cp', tmp_path, f'oss://rosicky-doc/contents/{doc_id}'],
                stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30
            )
            os.unlink(tmp_path)  # 删临时文件
            if r.returncode != 0:
                return (500, {'error': f'OSS upload failed: {r.stderr.decode()[:200]}'})

            # 写 content.db
            conn = sqlite3.connect(CONTENT_DB)
            if folder_id:
                # 验证 folder 存在
                row = conn.execute("SELECT id FROM folders WHERE id=?", (folder_id,)).fetchone()
                if not row:
                    folder_id = ''
            if not folder_id:
                # 默认放到「未归类」或根目录
                row = conn.execute("SELECT id FROM folders WHERE name='未归类' LIMIT 1").fetchone()
                folder_id = row[0] if row else '-1'
            conn.execute(
                "INSERT INTO documents (id, title, type, folder_id) VALUES (?, ?, 1, ?)",
                (doc_id, title, folder_id)
            )
            # 关联 task(可选)
            if task_id:
                conn.execute("INSERT OR IGNORE INTO doc_tasks (doc_id, task_id) VALUES (?, ?)", (doc_id, task_id))
            conn.commit()
            conn.close()

            url = f"http://doc.rosicky.top/view?doc={doc_id}"
            return (200, {
                'ok': True,
                'doc_id': doc_id,
                'title': title,
                'url': url
            })
        except Exception as e:
            return (500, {'error': str(e)})

    # ── 表单 API ──
    elif path == '/api/forms' and method == 'GET':
        task_id = params.get('task_id', [''])[0]
        try:
            conn = sqlite3.connect(CONTENT_DB)
            conn.row_factory = sqlite3.Row
            if task_id:
                rows = conn.execute(
                    "SELECT id, task_id, title, schema_json, created_at, updated_at FROM form_schemas WHERE task_id=? ORDER BY created_at DESC",
                    (task_id,)
                ).fetchall()
            else:
                rows = conn.execute(
                    "SELECT id, task_id, title, schema_json, created_at, updated_at FROM form_schemas ORDER BY created_at DESC"
                ).fetchall()
            forms = []
            for r in rows:
                f = dict(r)
                f['schema_json'] = json.loads(f['schema_json'])
                forms.append(f)
            conn.close()
            return (200, {'forms': forms})
        except Exception as e:
            return (500, {'error': str(e)})

    elif path == '/api/forms' and method == 'POST':
        try:
            body_data = json.loads(body) if body else {}
            task_id = body_data.get('task_id', '')
            title = body_data.get('title', '')
            schema = body_data.get('schema', {})
            if not task_id or not title or not schema:
                return (400, {'error': 'task_id, title, schema required'})
            import uuid, time
            form_id = f"fm_{int(time.time())}_{uuid.uuid4().hex[:8]}"
            conn = sqlite3.connect(CONTENT_DB)
            conn.execute(
                "INSERT INTO form_schemas (id, task_id, title, schema_json) VALUES (?, ?, ?, ?)",
                (form_id, task_id, title, json.dumps(schema, ensure_ascii=False))
            )
            # 同时创建空值记录
            val_id = f"fv_{int(time.time())}_{uuid.uuid4().hex[:8]}"
            conn.execute(
                "INSERT INTO form_values (id, form_id, values_json) VALUES (?, ?, '{}')",
                (val_id, form_id)
            )
            conn.commit()
            conn.close()
            return (200, {'ok': True, 'form_id': form_id, 'value_id': val_id})
        except Exception as e:
            return (500, {'error': str(e)})

    elif path.startswith('/api/forms/') and path.endswith('/values') and method == 'GET':
        form_id = path.split('/')[3]
        try:
            conn = sqlite3.connect(CONTENT_DB)
            conn.row_factory = sqlite3.Row
            row = conn.execute(
                "SELECT id, form_id, values_json, created_at, updated_at FROM form_values WHERE form_id=?",
                (form_id,)
            ).fetchone()
            conn.close()
            if not row:
                return (404, {'error': 'no values for this form'})
            vals = dict(row)
            vals['values_json'] = json.loads(vals['values_json'])
            return (200, vals)
        except Exception as e:
            return (500, {'error': str(e)})

    elif path.startswith('/api/forms/') and path.endswith('/values') and method == 'POST':
        form_id = path.split('/')[3]
        try:
            body_data = json.loads(body) if body else {}
            values = body_data.get('values', {})
            conn = sqlite3.connect(CONTENT_DB)
            existing = conn.execute(
                "SELECT id FROM form_values WHERE form_id=?", (form_id,)
            ).fetchone()
            if existing:
                conn.execute(
                    "UPDATE form_values SET values_json=?, updated_at=datetime('now') WHERE form_id=?",
                    (json.dumps(values, ensure_ascii=False), form_id)
                )
            else:
                val_id = f"fv_{int(time.time())}_{uuid.uuid4().hex[:8]}"
                conn.execute(
                    "INSERT INTO form_values (id, form_id, values_json) VALUES (?, ?, ?)",
                    (val_id, form_id, json.dumps(values, ensure_ascii=False))
                )
            conn.commit()
            conn.close()
            return (200, {'ok': True})
        except Exception as e:
            return (500, {'error': str(e)})

    elif path.startswith('/api/forms/render/') and method == 'GET':
        form_id = path.split('/')[-1]
        try:
            conn = sqlite3.connect(CONTENT_DB)
            conn.row_factory = sqlite3.Row
            srow = conn.execute(
                "SELECT id, task_id, title, schema_json FROM form_schemas WHERE id=?", (form_id,)
            ).fetchone()
            conn.close()
            if not srow:
                return (404, {'error': 'form not found'})
            schema = json.loads(srow['schema_json'])
            return (200, {
                'form_id': srow['id'],
                'title': srow['title'],
                'schema': schema,
            })
        except Exception as e:
            return (500, {'error': str(e)})

    return (404, {'error': 'not found'})


async def handle_http(reader, writer):
    try:
        header_bytes = b''
        while True:
            line = await asyncio.wait_for(reader.readline(), timeout=10)
            if not line:
                break
            header_bytes += line
            if line == b'\r\n':
                break

        if not header_bytes:
            writer.close()
            return

        lines = header_bytes.decode('utf-8', errors='replace').split('\r\n')
        if not lines or not lines[0]:
            writer.close()
            return

        first = lines[0].split(' ')
        if len(first) < 2:
            writer.close()
            return

        method = first[0]
        path = first[1]
        headers = {}
        for line in lines[1:]:
            if ':' in line:
                k, v = line.split(':', 1)
                headers[k.strip().lower()] = v.strip()

        body = b''
        if 'content-length' in headers:
            cl = int(headers['content-length'])
            if cl > 0:
                body = await asyncio.wait_for(reader.readexactly(cl), timeout=10)

        result_or_data = handle_api(method, path, headers, body)
        status = result_or_data[0]
        if isinstance(result_or_data, (list, tuple)) and len(result_or_data) == 3:
            content, mime = result_or_data[1], result_or_data[2]
        else:
            data = result_or_data
            if isinstance(data, (list, tuple)) and len(data) >= 2:
                data = data[1]
            content = json.dumps(data, ensure_ascii=False)
            mime = 'application/json; charset=utf-8'

        body_bytes = content.encode('utf-8') if isinstance(content, str) else content
        resp = (
            f'HTTP/1.1 {status} {"OK" if status == 200 else "Not Found"}\r\n'
            f'Content-Type: {mime}\r\n'
            f'Access-Control-Allow-Origin: *\r\n'
            f'Content-Length: {len(body_bytes)}\r\n'
            f'Connection: close\r\n'
            f'\r\n'
        ).encode() + body_bytes
        writer.write(resp)
        await writer.drain()
    except Exception as e:
        print(f"[HTTP ERROR] {e}", file=sys.stderr)
        import traceback
        traceback.print_exc()
    finally:
        writer.close()


# ═══════════════════════════════════════════
#  启动
# ═══════════════════════════════════════════

async def main():
    from websockets.asyncio.server import serve as ws_serve

    # 启动时获取 Dashboard token
    token = _fetch_dashboard_token()
    if token:
        HermesRpcClient._token_override = token
        print(f"[启动] Dashboard token 获取成功", file=sys.stderr)
    else:
        print(f"[启动] ⚠️ 未能获取 Dashboard token,请在 dashboard 启动后再启动本服务", file=sys.stderr)

    print(f"\n{'='*50}")
    print(f"  Hermes WebSocket 聊天后端 v4 — JSON-RPC")
    print(f"  WS:   ws://{HOST}:{WS_PORT}/")
    print(f"  HTTP: http://{HOST}:{HTTP_PORT}/chat/...")
    print(f"  RPC:  → {HERMES_DASHBOARD_WS} (Hermes Dashboard)")
    print(f"  API:  /chat/tasks, /chat/history, /chat/tree")
    print(f"{'='*50}\n")

    async with ws_serve(handle_ws, HOST, WS_PORT):
        http_server = await asyncio.start_server(handle_http, HOST, HTTP_PORT)
        await asyncio.gather(
            http_server.serve_forever(),
            asyncio.Future()
        )


if __name__ == '__main__':
    asyncio.run(main())