"""
web/api_server.py - 管理后台 HTTP API
======================================
为管理 UI 提供 RESTful API。
"""
from __future__ import annotations
import asyncio, json, os, time, shutil, signal
from urllib.parse import unquote
from pathlib import Path
from typing import Optional, Set
from aiohttp import web
from core.logger import get_logger
from core.llm import Message
from config import ModelEntry, ChatPlatformConfig
import datetime
import uuid
from core.utils import next_agent_id
from web.tts_handler import synthesize, preprocess_for_tts, AVAILABLE_VOICES

logger = get_logger("myagent.api")

# [v1.16.17] 文件上传存储目录
UPLOADS_DIR = Path(__file__).parent.parent / "data" / "uploads"
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)

import hashlib

def _save_upload_file(filename: str, mime_type: str, data: bytes) -> str:
    """Save uploaded file to disk, return file_id"""
    file_id = str(uuid.uuid4())[:12]
    # Organize by date: data/uploads/2024-01/
    date_dir = UPLOADS_DIR / time.strftime("%Y-%m")
    date_dir.mkdir(parents=True, exist_ok=True)
    # Sanitize filename (remove extension first to avoid double .pdf.pdf)
    stem = Path(filename).stem
    safe_name = "".join(c for c in stem if c.isalnum() or c in "._- ").strip("._- ")
    if not safe_name:
        safe_name = "file"
    ext = Path(filename).suffix.lower() or _ext_from_mime(mime_type)
    stored_name = f"{file_id}_{safe_name}{ext}"
    stored_path = date_dir / stored_name
    stored_path.write_bytes(data)
    return file_id

def _ext_from_mime(mime_type: str) -> str:
    """Get file extension from MIME type"""
    mapping = {
        "application/pdf": ".pdf", "image/png": ".png", "image/jpeg": ".jpg",
        "image/gif": ".gif", "image/webp": ".webp",
        "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
        "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
        "text/plain": ".txt", "text/csv": ".csv", "text/markdown": ".md",
        "application/json": ".json", "text/html": ".html",
    }
    return mapping.get(mime_type, "")

def _find_upload_file(file_id: str):
    """Find uploaded file by file_id, return (path, mime_type_guess) or (None, None)"""
    for date_dir in sorted(UPLOADS_DIR.iterdir(), reverse=True):
        if not date_dir.is_dir():
            continue
        for f in date_dir.iterdir():
            if f.name.startswith(file_id + "_"):
                ext = f.suffix.lower()
                mime_map = {
                    ".pdf": "application/pdf", ".png": "image/png", ".jpg": "image/jpeg",
                    ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp",
                    ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
                    ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
                    ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
                    ".txt": "text/plain", ".csv": "text/csv", ".md": "text/markdown",
                    ".json": "application/json", ".html": "text/html",
                }
                return f, mime_map.get(ext, "application/octet-stream")
    return None, None

def _now_iso():
    """返回配置时区的 ISO 时间戳"""
    from core.utils import get_config_tz
    return datetime.datetime.now(get_config_tz()).isoformat()

def _safe_load_json(filepath, default=None):
    """安全读取 JSON 文件，解析失败返回默认值"""
    try:
        return json.loads(filepath.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, ValueError, OSError):
        logger.warning(f"JSON 文件读取/解析失败: {filepath}")
        return default if default is not None else {}

# 允许上传的文件扩展名
_KB_ALLOWED_EXTENSIONS = {".md", ".txt", ".json", ".csv", ".py", ".js", ".html", ".htm",
                           ".xml", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf",
                           ".sh", ".bat", ".ps1", ".log", ".sql", ".r", ".java", ".cpp",
                           ".c", ".h", ".go", ".rs", ".ts", ".tsx", ".jsx", ".vue", ".svelte"}

async def _read_multipart_files(request):
    """从 multipart/form-data 请求中读取文件列表，返回 [{"name": str, "content": str}]"""
    files = []
    SUPPORTED_EXTENSIONS = _KB_ALLOWED_EXTENSIONS
    try:
        reader = await request.multipart()
        while True:
            field = await reader.next()
            if field is None:
                break
            if field.name == "files":
                # 文件字段
                filename = field.filename or "unknown"
                # 使用 webkitRelativePath 作为文件名（文件夹上传时浏览器会设置此属性）
                rel_path = field.headers.get("X-File-Path", "")
                if rel_path:
                    # [v1.31.4] 安全修复: 先 URL 解码再做路径校验，防止编码绕过
                    rel_path = unquote(rel_path)
                    filename = rel_path
                # 安全校验：检查路径遍历（先规范化再检查，防止编码绕过）
                normalized = os.path.normpath(filename)
                if ".." in normalized or normalized.startswith("/") or normalized.startswith(os.sep):
                    logger.warning(f"文件上传路径遍历攻击拦截: {filename}")
                    continue
                ext = Path(filename).suffix.lower()
                if ext and ext not in SUPPORTED_EXTENSIONS:
                    continue
                # 读取文件内容
                try:
                    chunks = []
                    while True:
                        chunk = await field.read_chunk()
                        if not chunk:
                            break
                        chunks.append(chunk)
                    content = b"".join(chunks).decode("utf-8", errors="replace")
                    files.append({"name": filename, "content": content})
                except Exception as e:
                    logger.warning(f"读取上传文件失败 {filename}: {e}")
    except Exception as e:
        # 如果不是 multipart 请求，尝试 JSON 格式
        logger.debug(f"Multipart 读取失败，尝试 JSON: {e}")
        try:
            data = await request.json()
            raw_files = data.get("files", [])
            if not raw_files:
                fname = data.get("filename", data.get("name", ""))
                fcontent = data.get("content", "")
                if fname and fcontent is not None:
                    raw_files = [{"name": fname, "content": fcontent}]
            return raw_files
        except Exception:
            pass
    return files



CONFIG_HELPER_PROMPT = """你是 MyAgent 的智能配置助手，专门帮助用户完成初始配置和日常配置管理。你的名字叫"配置助手"。

## 你的核心职责
1. **引导新用户**：首次使用时，友好地介绍 MyAgent 的功能，帮助用户完成初始配置
2. **配置管理**：帮助用户修改配置（LLM模型、执行引擎、权限等）
3. **Agent 创建指导**：帮助用户创建和配置新的 Agent
4. **问题解答**：回答用户关于 MyAgent 使用的问题

## 工作方式

### 首次引导流程
当用户首次进入时，你应该：
1. 友好地打招呼，简要介绍 MyAgent 是什么
2. 询问用户的 LLM API 配置情况
3. 引导用户配置：
   - 推荐 ModelScope API（免费额度大）：
     - provider: custom
     - base_url: https://api-inference.modelscope.cn/v1
     - 推荐模型：stepfun-ai/Step-3.5-Flash
   - 也支持 OpenAI、Anthropic、Ollama（本地）、智谱GLM 等
4. 配置完成后，建议用户先从单个 Agent 开始使用
5. 介绍核心功能：代码执行、文件操作、网络搜索、技能系统

### 日常使用
- 回答配置相关的问题
- 帮助修改配置（你只能通过聊天引导用户去修改，或调用配置API）
- 建议最佳实践

## 重要规则
- **禁止编写代码创建 Agent**：当用户要求创建 Agent 或部门时，必须调用 API 操作，禁止通过编写 `.py` 文件或修改 `__init__.py` 来尝试在源码层级创建。
- **配置优先原则**：MyAgent 是配置驱动的。创建 Agent 应使用 `POST /api/agents`，创建部门应使用 `POST /api/departments`。
- 修改配置时，系统会自动备份和校验，确保配置不会被改坏。
- 如果配置校验失败，修改不会生效，你需要告知用户并帮助修正。
- 建议用户从单 Agent 开始使用，熟悉后再扩展。
- 使用中文回复，保持简洁友好。

## 组织与 Agent 管理 API 指南
当你需要帮助用户管理组织结构时，请优先使用以下 API（通过 `requests` 或 `http` 调用）：
- **创建 Agent**: `POST /api/agents` (参数: `name`, `description`, `system_prompt`, `model_id`)
- **创建子 Agent**: `POST /api/agents/{parent}/children` (参数: `name`, `description`, `system_prompt`)
- **创建部门**: `POST /api/departments` (参数: `name`, `parent`)
- **配置 Agent 灵魂/身份**: `PUT /api/agents/{name}/soul` 和 `PUT /api/agents/{name}/identity`
- **分配 Agent 到部门**: `PUT /api/departments/{path}/agents` (参数: `agents`: list of names)

## 知识库
你内置了 MyAgent 完整的配置使用说明文档。当用户询问时不确定的功能时，必须先参考知识库。
"""


def _agent_color(name: str) -> str:
    """Generate a consistent color for an agent based on its name."""
    colors = ['#4f46e5','#7c3aed','#ec4899','#ef4444','#f59e0b','#10b981',
              '#06b6d4','#3b82f6','#8b5cf6','#f97316','#14b8a6','#6366f1']
    h = sum(ord(c) for c in name)
    return colors[h % len(colors)]


class ApiServer:
    # [v1.32.2] 需要鉴权保护的敏感端点
    _SENSITIVE_ROUTES = {
        "/api/shutdown",
        "/api/config/export",
        "/api/config/import",
        "/api/config/safe-save",
        "/api/config/set",
        "/api/update/apply",
    }

    def __init__(self, app_core):
        self.core = app_core
        # [v1.32.2] 生成随机 API Token，保护敏感接口
        self._api_token = os.urandom(24).hex()
        # 使用闭包创建中间件，绑定 self
        def _auth_middleware_factory():
            server_ref = self
            sensitive_routes = self._SENSITIVE_ROUTES
            @web.middleware
            async def _auth_middleware(request, handler):
                path = request.path
                matched = False
                for sensitive in sensitive_routes:
                    if path == sensitive or path.startswith(sensitive + "/"):
                        matched = True
                        break
                if matched:
                    token = (
                        request.headers.get("Authorization", "").removeprefix("Bearer ").strip()
                        or request.headers.get("X-API-Token", "")
                        or request.query.get("token", "")
                    )
                    if not token or token != server_ref._api_token:
                        logger.warning(f"[鉴权] 未授权的敏感接口访问: {path} from {request.remote}")
                        return web.json_response({"error": "未授权: 需要有效的 API Token"}, status=401)
                return await handler(request)
            return _auth_middleware

        self.app = web.Application(
            client_max_size=50 * 1024 * 1024,  # 50MB，支持大文件上传（PDF/Word等）
            middlewares=[
                _auth_middleware_factory(),
                self._no_cache_middleware_factory()
            ],
        )
        self._exec_progress: dict = {}
        # Wrap the executor to track progress in real-time (deferred to initialize())
        self._executor_wrapped = False
        # [v1.33.5] Per-Agent 执行锁：每个 agent 独立锁，互不干扰
        # 旧版: 全局 _execution_lock 导致所有 agent 排队
        # 新版: 每个 agent_path 独立锁，不同 agent 可并发执行
        self._agent_locks: Dict[str, Dict] = {}  # {agent_path: {"locked": bool, "locked_by": str, "locked_at": str, "locked_at_ts": float}}
        self._agent_lock_events: Dict[str, asyncio.Event] = {}  # {agent_path: asyncio.Event}
        self._agent_locks_mutex = asyncio.Lock()  # 保护 _agent_locks 和 _agent_lock_events 的互斥锁
        # 保留全局执行锁作为向后兼容（API 读取状态用），但不再用于实际锁定
        self._execution_lock = {"locked": False, "locked_by": None, "locked_at": None, "locked_at_ts": None}
        self._execution_lock_event = asyncio.Event()
        self._execution_lock_event.set()
        self._execution_lock_waiters: List[Dict] = []
        # [v1.35.0] 浏览器锁等待队列
        self._browser_lock_waiters: List[Dict] = []
        # [v1.20.13] 会话级锁：防止同一会话并发请求导致 MainAgent 共享状态被覆盖
        self._session_locks: Dict[str, asyncio.Lock] = {}
        self._session_locks_mutex = asyncio.Lock()
        # 消息队列（用于存放待执行的消息，key: session_id）
        self._msg_queues: Dict[str, List[Dict]] = {}
        # 任务列表内存存储（exec 模式，替代 task.md）
        self._task_list_store: dict[str, list] = {}  # session_id -> [{text, status}]  (per-session, not per-agent)
        # [v1.33.5] 模型链并发锁已移除：不再修改共享 self.core.llm，改为创建临时 LLM 快照
        # 旧版: self._model_chain_lock = asyncio.Lock() — 串行化所有模型切换
        # 新版: _try_model_chain_inner 为每个请求创建独立的 LLM 实例，无需加锁
        self._setup_routes()
        self._runner: Optional[web.AppRunner] = None
        # [v1.31.1] Agent 初始化锁：防止并发请求重复创建全权Agent和配置助手
        self._agents_initialized = False
        self._agents_init_lock = asyncio.Lock()
        # [v1.34.1] 桌面环境网站登录 Chrome 进程追踪: {site_name: Popen}
        # 用于检测同一 profile 的 Chrome 是否已运行，避免重复开新窗口
        self._site_chrome_procs: dict = {}

    def _no_cache_middleware_factory(self):
        """创建中间件，为静态文件响应添加禁用缓存的头"""
        @web.middleware
        async def _no_cache_middleware(request, handler):
            response = await handler(request)
            # 只对 /ui/ 路径下的静态文件响应添加禁用缓存头
            if request.path.startswith('/ui/'):
                response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
                response.headers['Pragma'] = 'no-cache'
                response.headers['Expires'] = '0'
            return response
        return _no_cache_middleware

    # ── 统一热更新辅助方法 ──
    def _hot_reload_llm(self):
        """热更新 LLM 客户端：同步最新配置并重置客户端实例"""
        if not self.core.llm:
            return
        cfg = self.core.config_mgr.config.llm
        self.core.llm.provider = cfg.provider
        self.core.llm.model = cfg.model
        self.core.llm.base_url = cfg.base_url
        self.core.llm.api_key = cfg.api_key
        self.core.llm.temperature = cfg.temperature
        self.core.llm.max_tokens = cfg.max_tokens
        self.core.llm.timeout = cfg.timeout
        self.core.llm.max_retries = cfg.max_retries
        self.core.llm.reasoning = cfg.reasoning
        self.core.llm.anthropic_api_key = cfg.anthropic_api_key
        self.core.llm.context_window = cfg.context_window
        # 强制重建客户端
        self.core.llm._client = None
        # 同步更新 context_builder 的 context_window
        for agent in self.core.agents.values():
            if hasattr(agent, 'context_builder') and agent.context_builder:
                agent.context_builder.context_window = cfg.context_window
                logger.debug(f"Agent {getattr(agent, 'name', '?')} context_window 已同步为 {cfg.context_window}")
        logger.info(f"LLM 客户端已热更新 (context_window={cfg.context_window})")

    def _hot_reload_executor(self):
        """热更新执行引擎"""
        if not self.core.executor:
            return
        cfg = self.core.config_mgr.config.executor
        self.core.executor.timeout = cfg.timeout
        self.core.executor.max_retries = cfg.max_retries
        self.core.executor.auto_fix = cfg.auto_fix
        self.core.executor.max_output_length = cfg.max_output_length
        if cfg.sandbox_image:
            self.core.executor.sandbox_image = cfg.sandbox_image
        if cfg.sandbox_network:
            self.core.executor.sandbox_network = cfg.sandbox_network
        if cfg.sandbox_memory:
            self.core.executor.sandbox_memory = cfg.sandbox_memory
        if cfg.execution_mode:
            self.core.executor.set_execution_mode(cfg.execution_mode)
        logger.info("执行引擎已热更新")

    def _hot_reload_communication(self):
        """热更新通信管理器"""
        mgr = getattr(self.core, "communication_manager", None)
        if not mgr:
            return
        cfg = self.core.config_mgr.config.communication
        mgr.config.enabled = cfg.enabled
        mgr.config.server_url = cfg.server_url
        mgr.config.max_friends = cfg.max_friends
        mgr.config.auto_accept = cfg.auto_accept
        logger.info("通信管理器已热更新")

    async def _hot_reload_chat_platforms(self):
        """热更新聊天平台：重新加载所有平台配置到 ChatBotManager
        
        [v1.20.7] 修复: 调用 setup_platforms 时会自动停止被禁用的平台并启动新启用的平台。
        [v1.20.9] 修复: chat_manager 为 None 时懒创建，修复首次启用平台不生效的问题。
        [v1.20.12] 改为 async 并补启未运行的 bot，修复首次启用平台后 bot 不启动的问题。
        """
        if not self.core.chat_manager:
            from chatbot.manager import ChatBotManager
            self.core.chat_manager = ChatBotManager()
            logger.info("聊天平台管理器已懒创建")
        mgr = self.core.chat_manager
        platform_configs = self.core.config_mgr.config.chat_platforms
        handler = mgr._message_handler if hasattr(mgr, '_message_handler') else None
        if not handler and hasattr(self.core, '_handle_chat_message'):
            handler = self.core._handle_chat_message
        if not handler:
            logger.warning("聊天平台消息处理器为 None，平台可能无法处理消息")
        mgr.setup_platforms(platform_configs, handler)
        # [v1.20.12] 补启未运行的 bot
        import time as _time
        enabled_keys = [cfg.id or cfg.platform for cfg in platform_configs if cfg.enabled]
        for key in enabled_keys:
            if key in mgr._bots and key not in mgr._bot_tasks:
                # 冷却期检查：刚崩溃的 bot 不要立即重启，防止 Telegram polling 冲突
                last_crash = getattr(mgr, '_last_crash', {}).get(key, 0)
                if _time.time() - last_crash < mgr._RESTART_COOLDOWN:
                    logger.info(f"聊天平台 {key} 在冷却期内，跳过重启")
                    continue
                bot = mgr._bots.get(key)
                if bot:
                    logger.info(f"补启动聊天平台: {key}")
                    task = asyncio.create_task(mgr._run_bot(key, bot), name=f"bot_{key}")
                    mgr._bot_tasks[key] = task
        logger.info("聊天平台配置已热更新")

    def _setup_routes(self):
        r = self.app.router
        # ── 系统状态 ──
        r.add_get("/api/status", self.handle_status)
        r.add_post("/api/shutdown", self.handle_shutdown)
        # ── Agent 列表/创建 ──
        r.add_get("/api/agents", self.handle_list_agents)
        r.add_get("/api/agents/tree", self.handle_agents_tree)
        r.add_post("/api/agents", self.handle_create_agent)
        # ── Agent 子资源路由（必须在通用 {name} 路由之前注册） ──
        r.add_get("/api/agents/{name:[^/]+}/soul", self.handle_get_soul)
        r.add_put("/api/agents/{name:[^/]+}/soul", self.handle_set_soul)
        r.add_get("/api/agents/{name:[^/]+}/identity", self.handle_get_identity)
        r.add_put("/api/agents/{name:[^/]+}/identity", self.handle_set_identity)
        r.add_get("/api/agents/{name:[^/]+}/user", self.handle_get_user)
        r.add_put("/api/agents/{name:[^/]+}/user", self.handle_set_user)
        r.add_get("/api/agents/{name:[^/]+}/sessions", self.handle_agent_sessions)
        r.add_get("/api/agents/{name:[^/]+}/children", self.handle_list_children)
        r.add_post("/api/agents/{name:[^/]+}/children", self.handle_create_child)
        r.add_get("/api/agents/{name:[^/]+}/bindings", self.handle_agent_bindings)
        r.add_get("/api/agents/{name:[^/]+}/knowledge", self.handle_list_agent_knowledge)
        r.add_get("/api/agents/{name:[^/]+}/knowledge/file", self.handle_read_agent_knowledge)
        r.add_post("/api/agents/{name:[^/]+}/knowledge/upload", self.handle_upload_agent_knowledge)
        r.add_delete("/api/agents/{name:[^/]+}/knowledge", self.handle_delete_agent_knowledge)
        r.add_post("/api/agents/{name:[^/]+}/avatar", self.handle_upload_agent_avatar)
        r.add_get("/api/agents/{name:[^/]+}/avatar.png", self.handle_get_agent_avatar)
        # ── Agent 通用 CRUD（放在子资源之后，name 不含斜杠避免吞掉子路由） ──
        r.add_get("/api/agents/{name:[^/]+}", self.handle_get_agent)
        r.add_put("/api/agents/{name:[^/]+}", self.handle_update_agent)
        r.add_delete("/api/agents/{name:.+}", self.handle_delete_agent)
        # ── 平台管理 ──
        r.add_get("/api/platforms", self.handle_list_platforms)
        r.add_post("/api/platforms", self.handle_add_platform)
        r.add_get("/api/platforms/{name}", self.handle_get_platform)
        r.add_put("/api/platforms/{name}", self.handle_update_platform)
        r.add_delete("/api/platforms/{name}", self.handle_delete_platform)
        r.add_post("/api/platforms/{name}/toggle", self.handle_toggle_platform)
        r.add_post("/api/platforms/{name}/restart", self.handle_restart_platform)
        r.add_get("/api/platforms/{name}/agents", self.handle_platform_agents)
        r.add_get("/api/platforms/{name}/qr", self.handle_get_platform_qr)
        r.add_post("/api/platforms/{name}/qr", self.handle_start_platform_qr)
        # ── 模型库 CRUD ──
        r.add_get("/api/models", self.handle_list_models)
        r.add_post("/api/models", self.handle_add_model)
        r.add_put("/api/models/{model_id}", self.handle_update_model)
        r.add_delete("/api/models/{model_id}", self.handle_delete_model)
        # ── 会话管理 ──
        r.add_get("/api/sessions", self.handle_list_sessions)
        r.add_get("/api/sessions/{sid}/messages", self.handle_get_messages)
        r.add_get("/api/sessions/{sid}/raw", self.handle_get_raw_messages)
        r.add_get("/api/session/messages", self.handle_get_messages_query)  # query param version (supports / in sid)
        r.add_get("/api/session/raw", self.handle_get_raw_messages_query)  # query param version for raw
        r.add_delete("/api/sessions/{sid}", self.handle_delete_session)
        r.add_delete("/api/session", self.handle_delete_session_query)  # query param version
        r.add_delete("/api/sessions/{sid}/messages", self.handle_clear_session_messages)
        r.add_delete("/api/session/messages", self.handle_clear_session_messages_query)  # query param version
        r.add_put("/api/sessions/{sid}/rename", self.handle_rename_session)
        r.add_put("/api/session/rename", self.handle_rename_session_query)  # query param version
        r.add_get("/api/session/status", self.handle_session_status_query)  # query param version
        r.add_get("/api/memory/stats", self.handle_memory_stats)
        r.add_get("/api/memory/search", self.handle_memory_search)
        r.add_get("/api/memory/list", self.handle_memory_list)
        r.add_delete("/api/memory/{mid}", self.handle_delete_memory)
        r.add_post("/api/memory/cleanup", self.handle_memory_cleanup)
        # 权限管理
        r.add_get("/api/permissions", self.handle_get_permissions)
        r.add_put("/api/permissions/defaults", self.handle_set_default_permissions)
        r.add_get("/api/permissions/{agent}", self.handle_get_agent_permissions)
        r.add_put("/api/permissions/{agent}", self.handle_set_agent_permissions)
        r.add_delete("/api/permissions/{agent}", self.handle_reset_agent_permissions)
        r.add_get("/api/llm", self.handle_get_llm)
        r.add_put("/api/llm", self.handle_update_llm)
        r.add_post("/api/llm/test", self.handle_test_llm)
        r.add_get("/api/llm/usage", self.handle_llm_usage)
        r.add_get("/api/skills", self.handle_list_skills)
        r.add_get("/api/skills/{name}", self.handle_get_skill)
        r.add_post("/api/skills/{name}/toggle", self.handle_toggle_skill)
        r.add_get("/api/executor", self.handle_get_executor)
        r.add_put("/api/executor", self.handle_update_executor)
        r.add_get("/api/workdir", self.handle_get_workdir)
        # ── Task Plan ──
        r.add_get("/api/task-plan", self.handle_get_task_plan)
        r.add_get("/api/task-plan/all", self.handle_get_all_task_plans)
        r.add_put("/api/task-plan", self.handle_update_task_plan)
        r.add_post("/api/task-plan", self.handle_add_task_item)
        r.add_delete("/api/task-plan/{idx:int}", self.handle_delete_task_item)
        r.add_put("/api/workdir", self.handle_set_workdir)
        r.add_get("/api/workdir/files", self.handle_list_workdir)
        r.add_get(r"/api/workdir/download/{path:.*}", self.handle_workdir_download)
        r.add_get("/api/logs", self.handle_get_logs)
        r.add_get("/api/logs/stream", self.handle_log_stream)
        r.add_post("/api/chat", self.handle_chat)
        r.add_post("/api/chat/stream", self.handle_chat_stream)
        r.add_post("/api/chat/inject", self.handle_chat_inject)
        # [v1.23.37] 停止执行
        r.add_post("/api/chat/stop", self.handle_chat_stop)
        r.add_post("/api/voice-optimize", self.handle_voice_optimize)
        r.add_post("/api/voice-stt", self.handle_voice_stt)
        r.add_get("/chat", self.handle_chat_page)
        r.add_get("/api/execution/progress", self.handle_execution_progress)
        # ── 组织管理 ──
        r.add_get("/api/organization", self.handle_get_organization)
        r.add_put("/api/organization", self.handle_update_organization)
        r.add_get("/api/organization/info", self.handle_get_org_info)
        r.add_put("/api/organization/info", self.handle_update_org_info)
        r.add_get("/api/organization/knowledge", self.handle_list_org_knowledge)
        r.add_get("/api/organization/knowledge/file", self.handle_read_org_knowledge)
        r.add_post("/api/organization/knowledge/upload", self.handle_upload_org_knowledge)
        r.add_delete("/api/organization/knowledge", self.handle_delete_org_knowledge)
        # ── 知识库 RAG 搜索 ──
        r.add_post("/api/knowledge/search", self.handle_knowledge_search)
        # ── 配置管理 (热重载/导入/导出) ──
        r.add_get("/api/config", self.handle_get_config)
        r.add_post("/api/config/get", self.handle_get_config_key)
        r.add_post("/api/config/set", self.handle_set_config_key)
        r.add_post("/api/config/reload", self.handle_reload_config)
        r.add_post("/api/config/export", self.handle_export_config)
        r.add_post("/api/config/import", self.handle_import_config)
        r.add_post("/api/config/safe-save", self.handle_safe_save_config)
        r.add_post("/api/config/diff", self.handle_config_diff)
        # ── 首次启动检测 ──
        r.add_get("/api/setup/status", self.handle_setup_status)
        r.add_post("/api/setup/complete", self.handle_setup_complete)
        # ── 自动更新 ──
        r.add_get("/api/update/status", self.handle_update_status)
        r.add_post("/api/update/check", self.handle_update_check)
        r.add_post("/api/update/apply", self.handle_update_apply)
        r.add_get("/api/update/history", self.handle_update_history)
        # ── Agent 间通信 ──
        r.add_get("/api/communication", self.handle_get_communication)
        r.add_put("/api/communication", self.handle_update_communication)
        r.add_get("/api/communication/status", self.handle_comm_status)
        r.add_get("/api/communication/peers", self.handle_list_peers)
        r.add_post("/api/communication/peers", self.handle_add_peer)
        r.add_delete("/api/communication/peers/{agent_id}", self.handle_remove_peer)
        r.add_get("/api/communication/messages", self.handle_comm_messages)
        r.add_post("/api/communication/messages", self.handle_send_message)
        r.add_post("/api/communication/messages/{msg_id}/ack", self.handle_ack_message)
        # ── DuckDNS 动态域名 ──
        r.add_get("/api/duckdns", self.handle_get_duckdns)
        r.add_post("/api/duckdns", self.handle_save_duckdns)
        r.add_post("/api/duckdns/update", self.handle_update_duckdns)
        # ── 群聊管理 ──
        r.add_get("/api/groups", self.handle_list_groups)
        r.add_post("/api/groups", self.handle_create_group)
        r.add_get("/api/groups/{gid}", self.handle_get_group)
        r.add_put("/api/groups/{gid}", self.handle_update_group)
        r.add_delete("/api/groups/{gid}", self.handle_delete_group)
        r.add_get("/api/groups/{gid}/stats", self.handle_group_stats)
        r.add_get("/api/groups/{gid}/session", self.handle_get_group_session)
        r.add_get("/api/groups/{gid}/status", self.handle_group_status)
        # ── 群成员管理 ──
        r.add_post("/api/groups/{gid}/members", self.handle_add_member)
        r.add_delete("/api/groups/{gid}/members/{agent_path:[a-zA-Z0-9_/-]+}", self.handle_remove_member)
        r.add_put("/api/groups/{gid}/members/{agent_path:[a-zA-Z0-9_/-]+}/role", self.handle_set_member_role)
        r.add_put("/api/groups/{gid}/members/{agent_path:[a-zA-Z0-9_/-]+}/mute", self.handle_set_member_muted)
        # ── 群消息 ──
        r.add_get("/api/groups/{gid}/messages", self.handle_get_group_messages)
        r.add_post("/api/groups/{gid}/messages", self.handle_send_group_message)
        r.add_post("/api/groups/{gid}/messages/stream", self.handle_send_group_message_stream)
        r.add_delete("/api/groups/{gid}/messages", self.handle_clear_group_messages)
        # [v1.23.37] Agent间私聊记录查询
        r.add_get("/api/agent-chat/pairs", self.handle_get_agent_chat_pairs)
        r.add_get("/api/agent-chat/messages", self.handle_get_agent_chat_messages)
        r.add_delete("/api/agent-chat/messages", self.handle_clear_agent_chat_messages)
        # [v1.35.0] 用户-Agent 私聊会话（后台管理面板）
        r.add_get("/api/private-chats/sessions", self.handle_get_private_chat_sessions)
        # ── 部门管理 ──
        r.add_get("/api/departments", self.handle_dept_tree)
        r.add_post("/api/departments", self.handle_create_dept)
        # 部门子资源路由（必须在通用路径路由之前注册）
        # 注意：路径正则使用 [\w/-]+ 支持中文部门名（\w 匹配 Unicode 字母数字下划线）
        r.add_get(r"/api/departments/{path:[\w%/-]+}/info", self.handle_get_dept_info)
        r.add_put(r"/api/departments/{path:[\w%/-]+}/info", self.handle_update_dept_info)
        r.add_get(r"/api/departments/{path:[\w%/-]+}/knowledge", self.handle_list_dept_knowledge)
        r.add_post(r"/api/departments/{path:[\w%/-]+}/knowledge/upload", self.handle_upload_dept_knowledge)
        r.add_get(r"/api/departments/{path:[\w%/-]+}/knowledge/file", self.handle_read_dept_knowledge)
        r.add_delete(r"/api/departments/{path:[\w%/-]+}/knowledge", self.handle_delete_dept_knowledge)
        r.add_put(r"/api/departments/{path:[\w%/-]+}/agents", self.handle_dept_agents)
        r.add_put(r"/api/departments/{path:[\w%/-]+}/head", self.handle_dept_head)
        r.add_get(r"/api/departments/{path:[\w%/-]+}", self.handle_get_dept)
        r.add_put(r"/api/departments/{path:[\w%/-]+}", self.handle_update_dept)
        r.add_delete(r"/api/departments/{path:[\w%/-]+}", self.handle_delete_dept)
        # ── 网站管理 (SiteRegistry) ──
        r.add_get("/api/sites", self.handle_list_sites)
        r.add_get("/api/sites/categories", self.handle_site_categories)
        r.add_get("/api/sites/{name}", self.handle_get_site)
        r.add_post("/api/sites", self.handle_add_site)
        r.add_put("/api/sites/{name}", self.handle_update_site)
        r.add_delete("/api/sites/{name}", self.handle_remove_site)
        r.add_post("/api/sites/{name}/init-profile", self.handle_init_site_profile)
        r.add_get("/api/sites/profiles", self.handle_list_profiles)
        r.add_delete("/api/sites/profiles/{name}", self.handle_delete_profile)
        r.add_post("/api/sites/{name}/open-browser", self.handle_open_site_browser)
        r.add_post("/api/sites/{name}/close-browser", self.handle_close_site_browser)
        # ── 工作流 ──
        r.add_get("/api/workflows", self.handle_list_workflows)
        r.add_get("/api/workflows/stats", self.handle_workflow_stats)
        r.add_get(r"/api/workflows/{agent:[\d]+}", self.handle_list_agent_workflows)
        r.add_post(r"/api/workflows/{agent:[\d]+}", self.handle_create_workflow)
        r.add_get(r"/api/workflows/{agent:[\d]+}/{wid:[\w\-]+}", self.handle_get_workflow)
        r.add_put(r"/api/workflows/{agent:[\d]+}/{wid:[\w\-]+}", self.handle_update_workflow)
        r.add_delete(r"/api/workflows/{agent:[\d]+}/{wid:[\w\-]+}", self.handle_delete_workflow)
        r.add_post(r"/api/workflows/{agent:[\d]+}/{wid:[\w\-]+}/run", self.handle_run_workflow)
        r.add_post(r"/api/workflows/{agent:[\d]+}/{wid:[\w\-]+}/toggle", self.handle_toggle_workflow)
        r.add_post(r"/api/workflows/{agent:[\d]+}/{wid:[\w\-]+}/duplicate", self.handle_duplicate_workflow)
        r.add_get(r"/api/workflows/{agent:[\d]+}/{wid:[\w\-]+}/runs", self.handle_list_workflow_runs)
        r.add_get(r"/api/workflows/{agent:[\d]+}/runs/{rid:[\w\-]+}", self.handle_get_workflow_run)
        # ── 任务持久化 ──
        r.add_get("/api/tasks", self.handle_list_tasks)
        r.add_post("/api/tasks/{task_id}/retry", self.handle_retry_task)
        r.add_delete("/api/tasks/{task_id}", self.handle_delete_task)
        # ── TTS 语音合成 ──
        r.add_post("/api/tts", self.handle_tts)
        r.add_get("/api/tts/voices", self.handle_tts_voices)
        # ── 执行锁 & 大文本 ──
        r.add_post("/api/chat/check-text-size", self.handle_check_text_size)
        r.add_post("/api/chat/save-to-knowledge", self.handle_save_to_knowledge)
        r.add_get("/api/execution-lock", self.handle_get_execution_lock)
        r.add_post("/api/execution-lock", self.handle_execution_lock)
        r.add_get("/api/browser-lock/status", self.handle_get_browser_lock_status)
        r.add_get("/ui/", self.handle_ui_index)
        r.add_get("/", self.handle_index)
        # [v1.16.17] 文件服务 API
        r.add_get('/api/file/{file_id}', self.handle_get_file)
        r.add_get('/api/file/{file_id}/download', self.handle_download_file)
        # [v1.17.0] 远程桌面 (VNC) API
        r.add_get("/api/vnc/status", self.handle_vnc_status)
        r.add_post("/api/vnc/start", self.handle_vnc_start)
        r.add_post("/api/vnc/stop", self.handle_vnc_stop)
        r.add_post("/api/vnc/restart", self.handle_vnc_restart)
        r.add_get("/api/vnc/screenshot", self.handle_vnc_screenshot)
        r.add_post("/api/vnc/rotate", self.handle_vnc_rotate)  # [v1.47.1] 屏幕旋转
        r.add_post("/api/vnc/resize", self.handle_vnc_resize)  # [v1.47.30] 自适应分辨率
        r.add_get("/vnc/{path:.*}", self.handle_novnc_proxy)  # noVNC 客户端页面代理
        # [v1.20.8] VNC WebSocket 代理 — 通过主服务器转发 VNC WebSocket，解决 HTTPS/WSS 问题
        r.add_get("/api/vnc/ws", self.handle_vnc_websocket)
        # [v1.21.0] Web Control API
        r.add_get("/api/web_control/status", self.handle_wc_status)
        r.add_post("/api/web_control/create", self.handle_wc_create_session)
        r.add_post("/api/web_control/close", self.handle_wc_close_session)
        r.add_get("/api/web_control/poll", self.handle_wc_poll_commands)
        r.add_post("/api/web_control/result", self.handle_wc_submit_result)
        r.add_get("/api/web_control/proxy", self.handle_wc_proxy)
        r.add_post("/api/web_control/proxy", self.handle_wc_proxy_post)
        r.add_get("/api/web_control/panel", self.handle_wc_panel)
        ui_dir = Path(__file__).parent / "ui"
        if ui_dir.exists():
            r.add_static("/ui", str(ui_dir), append_version=True)

    async def handle_index(self, request):
        raise web.HTTPFound("/ui/chat/chat_container.html")

    async def handle_ui_index(self, request):
        raise web.HTTPFound("/ui/index.html")

    def _get_group_manager(self):
        """获取群聊管理器实例（懒加载）"""
        if not hasattr(self, '_group_manager'):
            from groups.manager import GroupManager
            self._group_manager = GroupManager(
                data_dir=self.core.config_mgr.data_dir
            )
            self._group_manager.initialize()
        return self._group_manager

    def _enrich_group_dict(self, group_dict: dict) -> dict:
        """为群聊字典中的成员注入 Agent 名称/头像信息"""
        members = group_dict.get("members", [])
        if not members:
            return group_dict
        # 批量查询 Agent 信息
        agent_map = {}
        for m in members:
            path = m.get("agent_path", "")
            if path and path not in agent_map:
                ad = self._agent_dir(path)
                if (ad / "config.json").exists():
                    try:
                        cfg = json.loads((ad / "config.json").read_text(encoding="utf-8"))
                        agent_map[path] = {
                            "name": cfg.get("name", path),
                            "avatar_emoji": cfg.get("avatar_emoji", "🤖"),
                            "avatar_color": cfg.get("avatar_color", ""),
                        }
                    except Exception:
                        agent_map[path] = {"name": path, "avatar_emoji": "🤖", "avatar_color": ""}
                else:
                    agent_map[path] = {"name": path, "avatar_emoji": "🤖", "avatar_color": ""}
        # 注入到成员字典
        for m in members:
            info = agent_map.get(m.get("agent_path", ""), {})
            if "agent_name" not in m:
                m["agent_name"] = info.get("name", "")
            if "avatar_emoji" not in m:
                m["avatar_emoji"] = info.get("avatar_emoji", "")
            if "avatar_color" not in m:
                m["avatar_color"] = info.get("avatar_color", "")
        return group_dict

    def _get_dept_manager(self):
        """获取部门管理器实例（懒加载）"""
        if not hasattr(self, '_dept_manager'):
            from departments.manager import DepartmentManager
            self._dept_manager = DepartmentManager(
                data_dir=self.core.config_mgr.data_dir,
                group_manager=self._get_group_manager(),
                agent_storage=self.core.agent_storage,
            )
            self._dept_manager.initialize()
        return self._dept_manager

    def _get_task_persistence(self):
        """获取任务持久化实例（懒加载）"""
        if not hasattr(self, '_task_persistence'):
            from core.task_persistence import TaskPersistence
            self._task_persistence = TaskPersistence(
                data_dir=self.core.config_mgr.data_dir
            )
            self._task_persistence.initialize()
        return self._task_persistence

    @staticmethod
    def _extract_text_from_file(filename: str, mime_type: str, data: bytes) -> str:
        """[v1.16.12] 从文件中提取文本内容，支持 txt/pdf/csv/md/json/py/js/html 等格式。
        
        Args:
            filename: 文件名
            mime_type: MIME 类型
            data: 文件二进制数据
            
        Returns:
            提取的文本内容，最多 50000 字符
        """
        import io
        
        fname_lower = filename.lower()
        
        # 纯文本格式：直接读取
        _text_exts = {'.txt', '.md', '.csv', '.tsv', '.log', '.yaml', '.yml',
                      '.toml', '.ini', '.cfg', '.conf', '.env', '.sh', '.bash',
                      '.zsh', '.fish', '.ps1', '.bat', '.cmd'}
        _code_exts = {'.py', '.js', '.ts', '.jsx', '.tsx', '.java', '.c', '.cpp',
                      '.h', '.hpp', '.cs', '.go', '.rs', '.rb', '.php', '.swift',
                      '.kt', '.scala', '.r', '.sql', '.lua', '.vim', '.el'}
        _markup_exts = {'.html', '.htm', '.xml', '.svg', '.css', '.scss', '.less'}
        _data_exts = {'.json', '.jsonl'}
        
        if (fname_lower.endswith(tuple(_text_exts | _code_exts | _markup_exts | _data_exts))):
            # 尝试 UTF-8 解码，失败则尝试其他编码
            for encoding in ('utf-8', 'gbk', 'gb2312', 'latin-1'):
                try:
                    return data.decode(encoding)[:50000]
                except (UnicodeDecodeError, LookupError):
                    continue
            return f"[无法解码文件 {filename}]"
        
        # PDF 格式
        if fname_lower.endswith('.pdf') or 'pdf' in mime_type:
            extracted = None
            # 方法1: pdftotext (系统工具，更快更准，需要 poppler-utils)
            try:
                import subprocess
                result = subprocess.run(
                    ['pdftotext', '-', '-'],
                    input=data, capture_output=True, text=True, timeout=30,
                )
                if result.returncode == 0 and result.stdout.strip():
                    extracted = result.stdout.strip()[:50000]
                    logger.debug(f"PDF 文本提取成功 (pdftotext): {filename}, {len(extracted)} 字符")
            except Exception:
                pass
            # 方法2: PyPDF2 (纯 Python，无需系统依赖，作为 fallback)
            if not extracted:
                try:
                    import io as _io
                    from PyPDF2 import PdfReader
                    reader = PdfReader(_io.BytesIO(data))
                    texts = []
                    for page in reader.pages:
                        text = page.extract_text()
                        if text:
                            texts.append(text)
                    if texts:
                        extracted = "\n".join(texts)[:50000]
                        logger.debug(f"PDF 文本提取成功 (PyPDF2): {filename}, {len(extracted)} 字符")
                except ImportError:
                    logger.warning(f"PyPDF2 未安装，无法提取 PDF 文本: {filename}")
                except Exception as pdf_e:
                    logger.warning(f"PyPDF2 提取 PDF 文本失败: {filename}, {pdf_e}")
            if extracted:
                return extracted
            return f"[无法提取 PDF 文件 {filename} 的文本内容（PDF 可能是扫描件或加密文件）]"
        
        # Excel 格式
        if fname_lower.endswith(('.xlsx', '.xls')) or 'excel' in mime_type or 'spreadsheet' in mime_type:
            try:
                from openpyxl import load_workbook
                wb = load_workbook(io.BytesIO(data), read_only=True, data_only=True)
                texts = []
                for ws in wb.worksheets:
                    rows = []
                    for row in ws.iter_rows(values_only=True):
                        row_text = "\t".join(str(c) if c is not None else "" for c in row)
                        rows.append(row_text)
                    if rows:
                        texts.append(f"[Sheet: {ws.title}]\n" + "\n".join(rows[:500]))
                wb.close()
                if texts:
                    return "\n".join(texts)[:50000]
            except ImportError:
                return f"[无法读取 Excel 文件，需安装 openpyxl: pip install openpyxl]"
            except Exception as e:
                return f"[Excel 文件读取失败: {e}]"
        
        # Word 文档
        if fname_lower.endswith('.docx') or 'wordprocessingml' in mime_type:
            try:
                from docx import Document
                doc = Document(io.BytesIO(data))
                text = "\n".join(p.text for p in doc.paragraphs if p.text)
                return text[:50000] if text else "[Word 文档内容为空]"
            except ImportError:
                return f"[无法读取 Word 文件，需安装 python-docx: pip install python-docx]"
            except Exception as e:
                return f"[Word 文件读取失败: {e}]"
        
        # 图片格式 — 返回描述提示（图片由 Vision API 直接处理）
        _image_exts = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.ico'}
        if fname_lower.endswith(_image_exts) or 'image/' in mime_type:
            return f"[图片文件: {filename}]"
        
        return f"[不支持的文件格式: {filename} ({mime_type})]"

    # --- File Serving (v1.16.17) ---
    async def handle_get_file(self, request):
        """GET /api/file/{file_id} - 在新窗口中打开/预览文件"""
        file_id = request.match_info.get('file_id', '')
        if not file_id or len(file_id) < 8:
            return web.Response(status=404, text="File not found")
        fpath, mime = _find_upload_file(file_id)
        if not fpath or not fpath.exists():
            # [v1.20.13] 回退：尝试在工作目录中查找
            try:
                from urllib.parse import unquote
                wd = self.core.config_mgr.data_dir / "workspace"
                if wd.exists():
                    for f in wd.rglob("*"):
                        if f.is_file() and f.name.startswith(file_id + "_"):
                            fpath = f
                            break
            except Exception:
                pass
        if not fpath or not fpath.exists():
            return web.Response(status=404, text="File not found")
        # [fix] aiohttp FileResponse 不支持 content_type 参数，改用属性赋值
        resp = web.FileResponse(fpath)
        if mime:
            resp.content_type = mime
        # [v1.20.13] 修复非 ASCII 文件名编码
        from urllib.parse import quote as _url_quote
        _safe_name = _url_quote(fpath.name)
        resp.headers["Content-Disposition"] = f"inline; filename=\"{_safe_name}\"; filename*=UTF-8''{_safe_name}"
        return resp

    async def handle_download_file(self, request):
        """GET /api/file/{file_id}/download - 强制下载文件"""
        file_id = request.match_info.get('file_id', '')
        if not file_id or len(file_id) < 8:
            return web.Response(status=404, text="File not found")
        fpath, mime = _find_upload_file(file_id)
        if not fpath or not fpath.exists():
            # [v1.20.13] 回退：尝试在工作目录中查找
            try:
                from urllib.parse import unquote
                wd = self.core.config_mgr.data_dir / "workspace"
                if wd.exists():
                    for f in wd.rglob("*"):
                        if f.is_file() and f.name.startswith(file_id + "_"):
                            fpath = f
                            break
            except Exception:
                pass
        if not fpath or not fpath.exists():
            return web.Response(status=404, text="File not found")
        # [fix] aiohttp FileResponse 不支持 content_type 参数，改用属性赋值
        resp = web.FileResponse(fpath)
        if mime:
            resp.content_type = mime
        # [v1.20.13] 修复非 ASCII 文件名编码
        from urllib.parse import quote as _url_quote
        _safe_name = _url_quote(fpath.name)
        resp.headers["Content-Disposition"] = f"attachment; filename=\"{_safe_name}\"; filename*=UTF-8''{_safe_name}"
        return resp

    # --- [v1.17.0] Remote Desktop (VNC) ---

    def _get_vnc_manager(self):
        """获取 VNC 管理器实例（懒加载）"""
        if not hasattr(self, '_vnc_manager'):
            from core.vnc_manager import get_vnc_manager
            self._vnc_manager = get_vnc_manager()
        return self._vnc_manager

    async def handle_vnc_status(self, request):
        """GET /api/vnc/status - 获取 VNC 远程桌面状态"""
        mgr = self._get_vnc_manager()
        return web.json_response(mgr.status)

    async def handle_vnc_start(self, request):
        """POST /api/vnc/start - 启动远程桌面"""
        mgr = self._get_vnc_manager()
        result = await mgr.start()
        if result["success"]:
            # VNC 启动后，重建 MCP 客户端以使用有头浏览器
            try:
                from aiskills.chromedev_mcp import rebuild_mcp_client
                await rebuild_mcp_client()
            except Exception as e:
                logger.warning(f"VNC 启动后重建 MCP 客户端失败: {e}")
        return web.json_response(result)

    async def handle_vnc_stop(self, request):
        """POST /api/vnc/stop - 停止远程桌面"""
        mgr = self._get_vnc_manager()
        # 停止前先重建 MCP 客户端回无头模式
        try:
            from aiskills.chromedev_mcp import rebuild_mcp_client
            await rebuild_mcp_client(force_headless=True)
        except Exception as e:
            logger.warning(f"VNC 停止前重建 MCP 客户端失败: {e}")
        result = await mgr.stop()
        return web.json_response(result)

    async def handle_vnc_restart(self, request):
        """POST /api/vnc/restart - 重启远程桌面"""
        mgr = self._get_vnc_manager()
        result = await mgr.restart()
        return web.json_response(result)

    async def handle_vnc_screenshot(self, request):
        """GET /api/vnc/screenshot - 截取远程桌面截图"""
        mgr = self._get_vnc_manager()
        screenshot_path = await mgr.take_screenshot()
        if not screenshot_path or not os.path.isfile(screenshot_path):
            return web.json_response({"error": "截图失败，VNC 可能未运行"}, status=400)
        try:
            resp = web.FileResponse(screenshot_path)
            resp.content_type = "image/png"
            return resp
        except Exception as e:
            return web.json_response({"error": str(e)}, status=500)

    async def handle_vnc_rotate(self, request):
        """POST /api/vnc/rotate - [v1.47.1] 屏幕旋转（xrandr服务端旋转）"""
        mgr = self._get_vnc_manager()
        try:
            data = await request.json()
        except Exception:
            data = {}
        direction = data.get("direction", "toggle")
        result = mgr.rotate_display(direction)
        return web.json_response(result)

    async def handle_vnc_resize(self, request):
        """POST /api/vnc/resize - [v1.47.30] 自适应调整 VNC 分辨率匹配客户端视口"""
        mgr = self._get_vnc_manager()
        try:
            data = await request.json()
        except Exception:
            data = {}
        width = data.get("width", 0)
        height = data.get("height", 0)
        if not width or not height:
            return web.json_response({"success": False, "error": "需要 width 和 height 参数"})
        result = mgr.resize_display(int(width), int(height))
        return web.json_response(result)

    async def handle_novnc_proxy(self, request):
        """GET /vnc/{path} - 代理 noVNC 客户端页面
        
        如果 websockify 已配置 --web 目录，代理到该目录；
        否则尝试从 data/novnc/lib/ 目录提供本地 noVNC 库文件；
        最后返回内置的轻量 VNC 客户端页面。
        """
        mgr = self._get_vnc_manager()
        path = request.match_info.get('path', 'vnc.html')

        # 方法1: 如果 websockify 自带了 noVNC web 目录，直接代理
        if mgr._novnc_web_dir:
            file_path = os.path.join(mgr._novnc_web_dir, path)
            if os.path.isfile(file_path):
                # 根据文件扩展名设置 MIME 类型
                ext = Path(file_path).suffix.lower()
                mime_map = {
                    '.html': 'text/html', '.htm': 'text/html',
                    '.js': 'application/javascript', '.css': 'text/css',
                    '.json': 'application/json', '.png': 'image/png',
                    '.svg': 'image/svg+xml', '.woff': 'font/woff',
                    '.woff2': 'font/woff2', '.wasm': 'application/wasm',
                }
                content_type = mime_map.get(ext, 'application/octet-stream')
                resp = web.FileResponse(file_path)
                if content_type:
                    resp.content_type = content_type
                return resp

        # [v1.20.11] 方法1.5: 从本地 data/novnc/lib/ 目录提供 noVNC 库文件
        _novnc_lib_dir = Path(__file__).parent.parent / "data" / "novnc" / "lib"
        if _novnc_lib_dir.exists() and path.startswith("lib/"):
            _lib_path = _novnc_lib_dir / path[4:]  # strip "lib/" prefix
            if _lib_path.is_file():
                ext = _lib_path.suffix.lower()
                mime_map = {
                    '.html': 'text/html', '.htm': 'text/html',
                    '.js': 'application/javascript', '.css': 'text/css',
                    '.json': 'application/json', '.png': 'image/png',
                    '.svg': 'image/svg+xml', '.woff': 'font/woff',
                    '.woff2': 'font/woff2', '.wasm': 'application/wasm',
                }
                content_type = mime_map.get(ext, 'application/octet-stream')
                resp = web.FileResponse(str(_lib_path))
                if content_type:
                    resp.content_type = content_type
                return resp

        # 方法2: 返回内置的轻量 noVNC 客户端
        if path in ('vnc.html', 'vnc_lite.html', ''):
            html = self._get_builtin_novnc_page(request)
            return web.Response(text=html, content_type='text/html', charset='utf-8')

        # 方法3: 对于 WebSocket 连接，代理到 websockify
        if path == 'websockify' or 'ws' in path:
            # [v1.20.8] 返回代理 WebSocket URL（通过主服务器转发）
            ws_scheme = 'wss' if request.scheme == 'https' else 'ws'
            return web.json_response({
                "ws_url": f"{ws_scheme}://{request.host}/api/vnc/ws"
            })

        return web.Response(status=404, text="Not found")

    async def handle_vnc_websocket(self, request):
        """[v1.20.8] GET /api/vnc/ws - VNC WebSocket 代理
        
        通过主服务器的 HTTP/HTTPS 通道代理 VNC WebSocket 连接。
        解决 HTTPS 页面无法连接 ws://localhost:6080 的问题。
        
        浏览器 ←(wss://host/api/vnc/ws)→ aiohttp代理 ←(ws://localhost:6080)→ websockify ←(VNC)→ x11vnc
        
        [v1.23.50] 修复：不发送子协议避免 websockify "protocols don't overlap" 拒绝连接
        """
        import aiohttp

        mgr = self._get_vnc_manager()
        if not mgr.is_running:
            return web.Response(status=503, text="VNC 服务未运行")

        # 准备 WebSocket upgrade — 接受客户端的 'binary' 子协议
        ws_server = web.WebSocketResponse(protocols=('binary',))
        await ws_server.prepare(request)

        # 连接到后端 websockify
        # [v1.23.50] 不发送子协议，避免 websockify 报 "Client protocols ['binary'] don't overlap server-known ones"
        # websockify 默认不需要子协议协商，直接裸连即可
        target_url = f"ws://127.0.0.1:{mgr.novnc_port}"
        try:
            async with aiohttp.ClientSession() as session:
                async with session.ws_connect(
                    target_url,
                    protocols=(),           # 不发送子协议，避免 websockify 协议协商失败
                    timeout=aiohttp.ClientWSTimeout(ws_close=10),
                ) as ws_backend:
                    # 双向转发: 客户端 ↔ 后端 websockify
                    async def client_to_backend():
                        async for msg in ws_server:
                            if msg.type == aiohttp.WSMsgType.TEXT:
                                await ws_backend.send_str(msg.data)
                            elif msg.type == aiohttp.WSMsgType.BINARY:
                                await ws_backend.send_bytes(msg.data)
                            elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING, aiohttp.WSMsgType.CLOSED):
                                await ws_backend.close()
                                break
                            elif msg.type == aiohttp.WSMsgType.ERROR:
                                break

                    async def backend_to_client():
                        async for msg in ws_backend:
                            if msg.type == aiohttp.WSMsgType.TEXT:
                                await ws_server.send_str(msg.data)
                            elif msg.type == aiohttp.WSMsgType.BINARY:
                                await ws_server.send_bytes(msg.data)
                            elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING, aiohttp.WSMsgType.CLOSED):
                                await ws_server.close()
                                break
                            elif msg.type == aiohttp.WSMsgType.ERROR:
                                break

                    await asyncio.gather(
                        client_to_backend(),
                        backend_to_client(),
                        return_exceptions=True,
                    )
        except Exception as e:
            logger.warning(f"VNC WebSocket 代理错误: {e}")
        finally:
            try:
                await ws_server.close()
            except Exception:
                pass

        return ws_server

    def _get_builtin_novnc_page(self, request):
        """生成内置的轻量 noVNC 客户端页面
        
        使用 noVNC 的 CDN 版本，连接到本地 websockify 代理。
        [v1.20.8] 通过 /api/vnc/ws 代理连接，解决 HTTPS/WSS 兼容问题。
        [v1.20.9] 添加 CDN 加载超时检测，加载失败时提示用户。
        [v1.34.0] 全面触屏优化：虚拟键盘、虚拟按键栏、手势改进、触控友好UI。
        """
        # 获取 WebSocket URL — [v1.20.8] 优先使用代理路径
        ws_port = self._get_vnc_manager().novnc_port

        return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>MyAgent 远程桌面</title>
<style>
  * {{ margin: 0; padding: 0; box-sizing: border-box; }}
  html, body {{ width: 100%; height: 100%; overflow: hidden; background: #1a1a2e; color: #eee; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; touch-action: none; -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; }}

  /* 顶部工具栏 */
  .toolbar {{ display: flex; align-items: center; justify-content: space-between; padding: 6px 12px; background: #16213e; border-bottom: 1px solid #0f3460; height: 40px; min-height: 40px; }}
  .toolbar h1 {{ font-size: 13px; font-weight: 600; white-space: nowrap; }}
  .toolbar-info {{ font-size: 11px; color: #888; margin-left: 8px; }}
  .toolbar-right {{ display: flex; gap: 6px; flex-shrink: 0; }}
  .toolbar-btn {{ padding: 5px 10px; border-radius: 4px; border: 1px solid #0f3460; background: #0f3460; color: #eee; cursor: pointer; font-size: 12px; white-space: nowrap; touch-action: manipulation; }}
  .toolbar-btn:hover, .toolbar-btn:active {{ background: #1a1a5e; }}

  /* VNC 画面区域 */
  #screen {{ width: 100%; height: calc(100% - 40px - 44px); position: relative; overflow: hidden; }}
  .status {{ position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; max-width: 500px; z-index: 100; }}
  .status h2 {{ margin-bottom: 12px; color: #e94560; }}
  .status p {{ color: #888; margin-bottom: 16px; font-size: 14px; line-height: 1.6; }}
  .connecting {{ color: #ffd700; }}
  .connected {{ color: #00ff88; }}
  .error {{ color: #ff4444; }}
  .spinner {{ display: inline-block; width: 24px; height: 24px; border: 3px solid #333; border-top-color: #ffd700; border-radius: 50%; animation: spin 1s linear infinite; margin-bottom: 12px; }}
  @keyframes spin {{ to {{ transform: rotate(360deg); }} }}

  /* [v1.34.0] 底部虚拟按键栏 — 触屏优化核心 */
  .vk-bar {{
    display: flex; align-items: center; justify-content: center; gap: 4px;
    padding: 4px 6px; background: #16213e; border-top: 1px solid #0f3460;
    height: 44px; min-height: 44px; flex-shrink: 0;
    overflow-x: auto; -webkit-overflow-scrolling: touch;
  }}
  .vk-key {{
    min-width: 38px; height: 34px; border-radius: 4px; border: 1px solid #1a3a6e;
    background: #0d2137; color: #ccc; font-size: 11px; font-weight: 600;
    display: flex; align-items: center; justify-content: center;
    cursor: pointer; touch-action: manipulation; flex-shrink: 0;
    transition: background 0.1s, color 0.1s;
    padding: 0 4px;
  }}
  .vk-key:active, .vk-key.active {{ background: #1a5a9e; color: #fff; border-color: #2a7ade; }}
  .vk-key.modifier {{ background: #1a2a4e; border-color: #2a4a7e; }}
  .vk-key.modifier.active {{ background: #2a6ade; color: #fff; }}
  .vk-key.wide {{ min-width: 52px; }}
  .vk-sep {{ width: 1px; height: 24px; background: #0f3460; margin: 0 2px; flex-shrink: 0; }}

  /* [v1.34.0] 虚拟键盘弹出层 */
  .vk-popup {{
    position: fixed; bottom: 44px; left: 0; right: 0;
    background: #0d1a2e; border-top: 1px solid #1a3a6e;
    padding: 6px; z-index: 200;
    display: none; flex-direction: column; gap: 4px;
  }}
  .vk-popup.show {{ display: flex; }}
  .vk-popup-row {{ display: flex; gap: 3px; justify-content: center; flex-wrap: nowrap; }}
  .vk-popup-key {{
    min-width: 28px; height: 36px; border-radius: 4px; border: 1px solid #1a3a6e;
    background: #152a44; color: #ddd; font-size: 13px; font-weight: 500;
    display: flex; align-items: center; justify-content: center;
    cursor: pointer; touch-action: manipulation; flex-shrink: 0;
    padding: 0 5px; transition: background 0.1s;
  }}
  .vk-popup-key:active {{ background: #2a5a9e; color: #fff; }}
  .vk-popup-key.special {{ background: #1a2a4e; font-size: 11px; min-width: 42px; }}
  .vk-popup-key.special:active {{ background: #2a4a8e; }}
  .vk-popup-key.space {{ flex: 1; max-width: 200px; min-width: 120px; }}

  /* [v1.34.0] 触屏手势提示浮窗 */
  .touch-hint {{
    position: fixed; top: 48px; right: 8px;
    background: rgba(22, 33, 62, 0.95); border: 1px solid #0f3460;
    border-radius: 8px; padding: 10px 14px; z-index: 150;
    font-size: 12px; line-height: 1.8; max-width: 240px;
    display: none; color: #aab;
  }}
  .touch-hint.show {{ display: block; }}
  .touch-hint h3 {{ font-size: 13px; color: #ffd700; margin-bottom: 6px; }}
  .touch-hint .close-btn {{ position: absolute; top: 6px; right: 8px; cursor: pointer; color: #888; font-size: 16px; }}
</style>
</head>
<body>
<div class="toolbar">
  <div style="display:flex;align-items:center;overflow:hidden">
    <h1>MyAgent 远程桌面</h1>
    <span class="toolbar-info" id="statusText">正在连接...</span>
  </div>
  <div class="toolbar-right">
    <button class="toolbar-btn" onclick="toggleTouchHint()" title="触屏操作说明">👆帮助</button>
    <button class="toolbar-btn" onclick="setRotation('portrait')" title="竖屏" id="portraitBtn">📱竖</button>
    <button class="toolbar-btn" onclick="setRotation('landscape')" title="横屏" id="landscapeBtn">💻横</button>
    <button class="toolbar-btn" onclick="takeScreenshot()" title="截图">📷</button>
    <button class="toolbar-btn" onclick="toggleFullscreen()" title="全屏">⛶</button>
    <button class="toolbar-btn" onclick="closeVNC()" title="关闭" style="border-color:#e94560;color:#e94560;background:transparent">关闭</button>
  </div>
</div>
<div id="screen">
  <div class="status" id="statusOverlay">
    <div class="spinner" id="statusSpinner"></div>
    <h2 id="statusTitle" class="connecting">正在连接远程桌面...</h2>
    <p id="statusMsg">正在加载 VNC 客户端组件...</p>
    <div id="statusActions" style="display:none">
      <button class="toolbar-btn" onclick="location.reload()" style="margin-right:8px">重新连接</button>
      <button class="toolbar-btn" onclick="tryDirectConnect()" style="margin-right:8px">切换连接方式</button>
    </div>
  </div>
</div>

<!-- [v1.34.0] 底部虚拟按键栏 -->
<div class="vk-bar" id="vkBar">
  <div class="vk-key modifier" id="vkCtrl" onclick="toggleModifier('ControlLeft','Ctrl')" title="Ctrl">Ctrl</div>
  <div class="vk-key modifier" id="vkAlt" onclick="toggleModifier('AltLeft','Alt')" title="Alt">Alt</div>
  <div class="vk-key" onclick="sendKey('Tab')" title="Tab">Tab</div>
  <div class="vk-key" onclick="sendKey('Escape')" title="ESC">Esc</div>
  <div class="vk-sep"></div>
  <div class="vk-key" onclick="sendKey('ArrowUp')" title="↑">▲</div>
  <div class="vk-key" onclick="sendKey('ArrowDown')" title="↓">▼</div>
  <div class="vk-key" onclick="sendKey('ArrowLeft')" title="←">◄</div>
  <div class="vk-key" onclick="sendKey('ArrowRight')" title="→">►</div>
  <div class="vk-sep"></div>
  <div class="vk-key wide" onclick="sendKey('Backspace')" title="Backspace">⌫</div>
  <div class="vk-key wide" onclick="activateMobileKeyboard()" title="手机输入法" id="vkMobileBtn" style="background:#1a3a4e;border-color:#2a5a7e;">🔤输入</div>
  <div class="vk-key wide" onclick="sendKey('Delete')" title="Delete">Del</div>
  <div class="vk-key wide" onclick="sendKey('Enter')" title="Enter">↵</div>
  <div class="vk-sep"></div>
  <div class="vk-key" onclick="sendCtrlKey('c')" title="Ctrl+C 复制">CC</div>
  <div class="vk-key" onclick="sendCtrlKey('v')" title="Ctrl+V 粘贴">CV</div>
  <div class="vk-key wide" onclick="toggleKeyboard()" title="虚拟键盘" id="vkKbBtn">⌨键盘</div>
  <div class="vk-key wide" onclick="sendRightClick()" title="右键">右键</div>
</div>

<!-- [v1.34.0] 虚拟键盘弹出层 -->
<!-- [v1.46.2] 隐藏输入框 — 激活手机自带输入法 -->
<input type="text" id="mobileInput" style="position:fixed;left:0;top:0;opacity:0;width:1px;height:1px;font-size:16px;border:none;outline:none;background:transparent;" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
<div class="vk-popup" id="vkPopup">
  <div class="vk-popup-row">
    <div class="vk-popup-key" onclick="sendChar('1')">1</div>
    <div class="vk-popup-key" onclick="sendChar('2')">2</div>
    <div class="vk-popup-key" onclick="sendChar('3')">3</div>
    <div class="vk-popup-key" onclick="sendChar('4')">4</div>
    <div class="vk-popup-key" onclick="sendChar('5')">5</div>
    <div class="vk-popup-key" onclick="sendChar('6')">6</div>
    <div class="vk-popup-key" onclick="sendChar('7')">7</div>
    <div class="vk-popup-key" onclick="sendChar('8')">8</div>
    <div class="vk-popup-key" onclick="sendChar('9')">9</div>
    <div class="vk-popup-key" onclick="sendChar('0')">0</div>
    <div class="vk-popup-key special" onclick="sendKey('Backspace')">⌫</div>
  </div>
  <div class="vk-popup-row">
    <div class="vk-popup-key" onclick="sendChar('q')">q</div>
    <div class="vk-popup-key" onclick="sendChar('w')">w</div>
    <div class="vk-popup-key" onclick="sendChar('e')">e</div>
    <div class="vk-popup-key" onclick="sendChar('r')">r</div>
    <div class="vk-popup-key" onclick="sendChar('t')">t</div>
    <div class="vk-popup-key" onclick="sendChar('y')">y</div>
    <div class="vk-popup-key" onclick="sendChar('u')">u</div>
    <div class="vk-popup-key" onclick="sendChar('i')">i</div>
    <div class="vk-popup-key" onclick="sendChar('o')">o</div>
    <div class="vk-popup-key" onclick="sendChar('p')">p</div>
  </div>
  <div class="vk-popup-row">
    <div class="vk-popup-key" onclick="sendChar('a')">a</div>
    <div class="vk-popup-key" onclick="sendChar('s')">s</div>
    <div class="vk-popup-key" onclick="sendChar('d')">d</div>
    <div class="vk-popup-key" onclick="sendChar('f')">f</div>
    <div class="vk-popup-key" onclick="sendChar('g')">g</div>
    <div class="vk-popup-key" onclick="sendChar('h')">h</div>
    <div class="vk-popup-key" onclick="sendChar('j')">j</div>
    <div class="vk-popup-key" onclick="sendChar('k')">k</div>
    <div class="vk-popup-key" onclick="sendChar('l')">l</div>
    <div class="vk-popup-key special" onclick="sendKey('Enter')">↵</div>
  </div>
  <div class="vk-popup-row">
    <div class="vk-popup-key special" onclick="toggleShift()" id="vkShift">⇧</div>
    <div class="vk-popup-key" onclick="sendChar('z')">z</div>
    <div class="vk-popup-key" onclick="sendChar('x')">x</div>
    <div class="vk-popup-key" onclick="sendChar('c')">c</div>
    <div class="vk-popup-key" onclick="sendChar('v')">v</div>
    <div class="vk-popup-key" onclick="sendChar('b')">b</div>
    <div class="vk-popup-key" onclick="sendChar('n')">n</div>
    <div class="vk-popup-key" onclick="sendChar('m')">m</div>
    <div class="vk-popup-key" onclick="sendChar('.')">.</div>
    <div class="vk-popup-key special" onclick="toggleShift()" id="vkShift2">⇧</div>
  </div>
  <div class="vk-popup-row">
    <div class="vk-popup-key special" onclick="sendChar('/')">/</div>
    <div class="vk-popup-key special" onclick="sendChar('@')">@</div>
    <div class="vk-popup-key space" onclick="sendChar(' ')">空格</div>
    <div class="vk-popup-key special" onclick="sendChar(',')">,</div>
    <div class="vk-popup-key special" onclick="sendKey('Delete')">Del</div>
  </div>
</div>

<!-- [v1.34.0] 触屏操作说明浮窗 -->
<div class="touch-hint" id="touchHint">
  <span class="close-btn" onclick="toggleTouchHint()">✕</span>
  <h3>触屏操作说明</h3>
  👆 单指点击 = 左键点击<br>
  👆 单指拖动 = 鼠标移动<br>
  👆 长按 = 右键点击<br>
  ✌️ 双指点击 = 右键<br>
  🖐 三指点击 = 中键<br>
  ✌️ 双指拖动 = 滚轮滚动<br>
  🤏 双指捏合 = 缩放画面<br>
  ⌨️ 底部栏 = 虚拟按键<br>
  💡 Ctrl/Alt 点亮后与下次按键组合
</div>


<!-- [v1.23.16] noVNC 组件加载：本地优先，CDN 回退 -->
<script>
// [v1.23.16] 本地 noVNC 加载器 — 支持 ../ 相对路径解析
async function loadLocalNoVNC() {{
    const basePath = '/vnc/lib/';
    const _modules = {{}};

    // 规范化路径：处理 ./ 和 ../ 相对路径
    function resolvePath(fromModId, depId) {{
        if (!depId.startsWith('.')) return depId;
        // 取 fromModId 所在目录
        const baseParts = fromModId.split('/');
        baseParts.pop(); // 去掉文件名，得到目录
        const depParts = depId.split('/');
        for (const part of depParts) {{
            if (part === '.') continue;
            else if (part === '..') baseParts.pop();
            else baseParts.push(part);
        }}
        return baseParts.join('/');
    }}

    // 去掉 .js 后缀（模块 ID 不含后缀）
    function stripJs(p) {{ return p.endsWith('.js') ? p.slice(0, -3) : p; }}

    // 加载并注册单个模块
    // [v1.23.26] modId 使用完整路径（含 lib/ 前缀）保证 Babel 相对路径解析正确，
    // fetchPath 剥离 lib/ 前缀用于 URL 请求（basePath 已含 /vnc/lib/）
    async function loadModule(filePath) {{
        const modId = stripJs(filePath);  // e.g. "lib/deflator" or "lib/vendor/pako/lib/zlib/zstream"
        if (_modules[modId]) return _modules[modId]; // 已加载
        // strip leading "lib/" for fetch URL since basePath is already "/vnc/lib/"
        const fetchPath = filePath.startsWith('lib/') ? filePath.slice(4) : filePath;
        const resp = await fetch(basePath + fetchPath);
        if (!resp.ok) throw new Error('Failed to fetch ' + filePath + ': ' + resp.status);
        const code = await resp.text();
        const modExports = {{}};
        _modules[modId] = modExports;
        try {{
            const fn = new Function('exports', 'require', 'module', code);
            fn(modExports, function(depId) {{
                const resolved = stripJs(resolvePath(modId, depId));
                return _modules[resolved] || {{}};
            }}, {{ exports: modExports }});
        }} catch(e) {{
            console.warn('[noVNC] Module error:', filePath, e);
        }}
        return modExports;
    }}

    // [v1.23.26] 降级 noVNC 到 1.4.0 — 修复 _zstream.default is not a constructor 错误
    // 1.5.0 的 ES Modules 与自定义 module loader 不兼容，1.4.0 为稳定版本。
    // 模块 ID 使用 lib/ 前缀，Babel 编译的 require() 使用相对于原始 lib/ 的相对路径。
    const depFiles = [
        // pako vendor (zlib 依赖) — 必须使用 lib/ 前缀
        'lib/vendor/pako/lib/utils/common.js',
        'lib/vendor/pako/lib/zlib/adler32.js',
        'lib/vendor/pako/lib/zlib/crc32.js',
        'lib/vendor/pako/lib/zlib/messages.js',
        'lib/vendor/pako/lib/zlib/inffast.js',
        'lib/vendor/pako/lib/zlib/inftrees.js',
        'lib/vendor/pako/lib/zlib/trees.js',
        'lib/vendor/pako/lib/zlib/inflate.js',
        'lib/vendor/pako/lib/zlib/deflate.js',
        'lib/vendor/pako/lib/zlib/gzheader.js',
        'lib/vendor/pako/lib/zlib/zstream.js',
        // noVNC 1.4.0 core — 1.4.0 没有 crypto/ 目录
        'lib/util/logging.js',
        'lib/util/events.js',
        'lib/util/eventtarget.js',
        'lib/util/strings.js',
        'lib/util/browser.js',
        'lib/util/int.js',
        'lib/util/element.js',
        'lib/util/cursor.js',
        'lib/base64.js',
        'lib/des.js',
        'lib/util/md5.js',
        'lib/websock.js',
        'lib/display.js',
        'lib/inflator.js',
        'lib/deflator.js',
        'lib/decoders/copyrect.js',
        'lib/decoders/raw.js',
        'lib/decoders/rre.js',
        'lib/decoders/hextile.js',
        'lib/decoders/tight.js',
        'lib/decoders/tightpng.js',
        'lib/decoders/zrle.js',
        'lib/decoders/jpeg.js',
        'lib/encodings.js',
        'lib/input/keysymdef.js',
        'lib/input/keysym.js',
        'lib/input/xtscancodes.js',
        'lib/input/vkeys.js',
        'lib/input/fixedkeys.js',
        'lib/input/domkeytable.js',
        'lib/input/util.js',
        'lib/input/keyboard.js',
        'lib/input/gesturehandler.js',
        'lib/ra2.js',
    ];
    for (const file of depFiles) {{
        await loadModule(file);
    }}
    // 最后加载 rfb.js
    const rfbExports = await loadModule('lib/rfb.js');
    return rfbExports.default || rfbExports;
}}
</script>
<script type="module">
try {{
    let RFB = null;
    let loadError = '';

    // [v1.23.16] 从本地服务器加载 noVNC（data/novnc/lib/）
    try {{
        RFB = await loadLocalNoVNC();
    }} catch (localErr) {{
        loadError = localErr.message;
        console.error('[VNC] 本地 noVNC 加载失败:', localErr);
    }}

    if (!RFB) {{
        throw new Error('noVNC 本地加载失败: ' + loadError);
    }}

    initVNC(RFB);
}} catch (e) {{
    document.getElementById('statusSpinner').style.display = 'none';
    document.getElementById('statusTitle').className = 'error';
    document.getElementById('statusTitle').textContent = 'VNC 组件加载失败';
    document.getElementById('statusMsg').innerHTML =
        'noVNC 组件加载失败: ' + e.message + '<br><br>' +
        '请确保 myagent/data/novnc/lib/ 目录存在。<br>' +
        '在服务器上运行: myagent-ai reinstall';
    document.getElementById('statusActions').style.display = '';
    console.error('[VNC] 组件加载失败:', e);
}}

function initVNC(RFBClass) {{
    const host = window.location.hostname;
    const wsPort = {ws_port};
    const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
    const proxyUrl = wsProtocol + '//' + window.location.host + '/api/vnc/ws';
    const directUrl = 'ws://' + host + ':' + wsPort;
    const isHttps = window.location.protocol === 'https:';

    let rfb = null;
    let isConnected = false;
    let useProxy = null;  // null = 未决定, true = 代理, false = 直连
    let connectTimeout = null;

    function updateStatus(connected, title, msg) {{
        const titleEl = document.getElementById('statusTitle');
        const msgEl = document.getElementById('statusMsg');
        const overlay = document.getElementById('statusOverlay');
        const spinner = document.getElementById('statusSpinner');
        const actions = document.getElementById('statusActions');
        const textEl = document.getElementById('statusText');

        if (connected) {{
            overlay.style.display = 'none';
            titleEl.className = 'connected';
            textEl.textContent = '已连接';
            isConnected = true;
        }} else {{
            overlay.style.display = '';
            spinner.style.display = '';
            actions.style.display = 'none';
            titleEl.className = 'connecting';
            titleEl.textContent = title || '连接中断';
            msgEl.textContent = msg || '';
            textEl.textContent = '已断开';
            isConnected = false;
        }}
    }}

    function getConnectUrl() {{
        return useProxy ? proxyUrl : directUrl;
    }}

    // [v1.23.50] 智能连接：先检测最佳连接方式，避免无效的代理尝试
    // 策略：
    //   1. HTTPS 页面必须走代理（浏览器安全策略禁止 https 页面发起 ws:// 连接）
    //   2. HTTP 页面优先直连（更快、更稳定），代理作为备选
    //   3. 记住上次成功的连接方式到 localStorage，下次优先使用
    //   4. 先通过 /api/vnc/status 探测代理是否可用
    function smartConnect() {{
        // 读取上次成功的连接方式
        const lastMethod = localStorage.getItem('vnc_connect_method');

        if (isHttps) {{
            // HTTPS 页面：必须走 wss 代理
            useProxy = true;
            connect();
            return;
        }}

        // HTTP 页面：智能选择
        if (lastMethod === 'direct') {{
            // 上次直连成功，优先直连
            useProxy = false;
            connect();
            return;
        }}

        if (lastMethod === 'proxy') {{
            // 上次代理成功，优先代理（但先快速检测代理可用性）
            checkProxyAvailable(function(available) {{
                if (available) {{
                    useProxy = true;
                    connect();
                }} else {{
                    useProxy = false;
                    connect();
                }}
            }});
            return;
        }}

        // 首次连接：先快速检测代理可用性，不可用则直接用直连
        updateStatus(false, '正在检测最佳连接方式...', '');
        checkProxyAvailable(function(available) {{
            if (available) {{
                // 代理可用，优先尝试代理（更好的兼容性）
                useProxy = true;
                connect();
            }} else {{
                // 代理不可用，直接用直连
                useProxy = false;
                connect();
            }}
        }});
    }}

    // 快速检测代理 WebSocket 端点是否可用
    function checkProxyAvailable(callback) {{
        var checked = false;
        try {{
            fetch('/api/vnc/status', {{ method: 'GET', signal: AbortSignal.timeout(3000) }})
                .then(function(r) {{
                    if (!checked) {{
                        checked = true;
                        if (r.ok) {{
                            return r.json().then(function(data) {{
                                callback(data && data.running === true);
                            }}).catch(function() {{ callback(false); }});
                        }} else {{
                            callback(false);
                        }}
                    }}
                }})
                .catch(function() {{
                    if (!checked) {{ checked = true; callback(false); }}
                }});
            // 3秒超时兜底
            setTimeout(function() {{
                if (!checked) {{ checked = true; callback(false); }}
            }}, 3500);
        }} catch (e) {{
            callback(false);
        }}
    }}

    function connect() {{
        // 清理之前的连接超时
        if (connectTimeout) {{
            clearTimeout(connectTimeout);
            connectTimeout = null;
        }}

        const connUrl = getConnectUrl();
        try {{
            document.getElementById('statusMsg').textContent =
                (useProxy ? '通过代理连接: ' + connUrl : '直连: ' + connUrl);
            rfb = new RFBClass(document.getElementById('screen'), connUrl, {{
                credentials: {{ password: '' }},
                shared: true,
                wsProtocols: ['binary'],
            }});

            // [v1.47.36] 移动端适配：
            // scaleViewport=true: 缩放VNC画面适配客户端容器（主要适配方式）
            // - 移动端竖屏：1280x720 的桌面自动缩放到手机屏幕宽度
            // - 视觉上完整显示，内容较小但可触控操作
            // - 用户可点"旋转"按钮切换为竖屏分辨率（服务端重启）
            //
            // 不使用 resizeSession=true：x11vnc 在 Termux proot 下不支持 -randr 参数
            // （会导致首次启动崩溃），VNC DesktopSize 协议不可用
            //
            // 不在连接后自动调用 /api/vnc/resize：
            // - xrandr --fb 在 Termux proot 下不支持，会导致 VNC 全部重启
            // - 用户体验差（20+秒断连重连）
            // - 改为仅用 scaleViewport 视觉缩放 + 手动旋转按钮
            rfb.scaleViewport = true;
            rfb.background = '#1a1a2e';
            rfb.qualityLevel = 6;
            rfb.compressionLevel = 2;

            rfb.addEventListener('connect', function() {{
                // 连接成功，记住此方式
                localStorage.setItem('vnc_connect_method', useProxy ? 'proxy' : 'direct');
                updateStatus(true, '', '');
            }});

            rfb.addEventListener('disconnect', function(e) {{
                const clean = e.detail.clean;
                if (clean) {{
                    updateStatus(false, '连接已关闭', '远程桌面服务已停止');
                    document.getElementById('statusSpinner').style.display = 'none';
                    document.getElementById('statusActions').style.display = '';
                }} else {{
                    // 智能回退：只有在当前方式确实不可用时才切换
                    var otherMethod = useProxy ? 'direct' : 'proxy';
                    var otherUrl = useProxy ? directUrl : proxyUrl;

                    // HTTPS 页面不允许直连 ws://，所以代理失败后只重试代理
                    if (isHttps && useProxy) {{
                        updateStatus(false, '连接中断', '3秒后重新连接...');
                        setTimeout(connect, 3000);
                    }} else if (useProxy) {{
                        // 代理失败，尝试直连
                        useProxy = false;
                        updateStatus(false, '正在切换连接方式...', '');
                        setTimeout(connect, 500);
                    }} else {{
                        // 直连也失败，5秒后重试
                        updateStatus(false, '连接中断', '5秒后重新连接...');
                        setTimeout(connect, 5000);
                    }}
                }}
            }});

            rfb.addEventListener('credentialsrequired', function() {{}});

            rfb.addEventListener('desktopname', function(e) {{
                document.title = e.detail.name + ' - MyAgent';
            }});

            // [v1.20.9] 连接建立超时检测（15秒，缩短以加快回退）
            connectTimeout = setTimeout(function() {{
                if (!isConnected && rfb) {{
                    rfb.disconnect();
                    if (useProxy && !isHttps) {{
                        useProxy = false;
                        updateStatus(false, '正在切换连接方式...', '');
                        setTimeout(connect, 500);
                    }} else {{
                        updateStatus(false, '连接超时', '5秒后重新连接...');
                        setTimeout(connect, 5000);
                    }}
                }}
            }}, 15000);

        }} catch (e) {{
            updateStatus(false, '连接失败', '无法连接到 ' + connUrl + ' 错误: ' + e.message);
            document.getElementById('statusSpinner').style.display = 'none';
            document.getElementById('statusActions').style.display = '';
        }}
    }}

    // 暴露给全局
    window.rfb = rfb;
    window._vncConnect = connect;
    window._vncUseProxy = useProxy;

    // [v1.47.36] 不再自动调整服务器分辨率
    // 原因：xrandr --fb 在 Termux proot 下不支持，自动 resize 会触发 VNC 重启
    // 改为仅用 scaleViewport 视觉缩放，用户点"旋转"按钮手动切换


    window.takeScreenshot = function() {{
        fetch('/api/vnc/screenshot').then(r => {{
            if (r.ok) return r.blob();
            throw new Error('截图失败');
        }}).then(blob => {{
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = 'desktop_' + new Date().toISOString().replace(/[:.]/g, '-') + '.png';
            a.click();
            URL.revokeObjectURL(url);
        }}).catch(e => alert('截图失败: ' + e.message));
    }};
    window.toggleFullscreen = function() {{
        if (!document.fullscreenElement) {{
            document.documentElement.requestFullscreen();
        }} else {{
            document.exitFullscreen();
        }}
    }};

    // [v1.47.34] 关闭远程桌面 — 通知父窗口关闭覆盖层
    window.closeVNC = function() {{
        // 先退出全屏（如果在全屏模式）
        if (document.fullscreenElement) {{
            document.exitFullscreen();
        }}
        // 通知父窗口关闭VNC覆盖层
        if (window.parent && window.parent !== window) {{
            window.parent.postMessage({{ type: 'closeVNC' }}, '*');
        }} else {{
            // 如果不是iframe，直接关闭窗口
            window.close();
        }}
    }};

    // [v1.47.38] 竖/横两个按钮，直接指定方向
    window.setRotation = function(direction) {{
        var pBtn = document.getElementById('portraitBtn');
        var lBtn = document.getElementById('landscapeBtn');
        if (pBtn) pBtn.disabled = true;
        if (lBtn) lBtn.disabled = true;
        fetch('/api/vnc/rotate', {{
            method: 'POST',
            headers: {{ 'Content-Type': 'application/json' }},
            body: JSON.stringify({{ direction: direction }})
        }})
        .then(function(r) {{ return r.json(); }})
        .then(function(data) {{
            if (data.success) {{
                var statusEl = document.getElementById('statusText');
                if (statusEl) statusEl.textContent = (data.rotation || '') + ' ' + (data.resolution || '');
                // 如果VNC重启了，需要重新连接
                setTimeout(function() {{
                    var stillConnected = false;
                    try {{
                        stillConnected = rfb && (
                            rfb._rfb_connection_state === 'connected' ||
                            rfb._rfbConnectionState === 'connected' ||
                            (rfb._connect && rfb._sock && !rfb._sock._closed)
                        );
                    }} catch(e) {{}}
                    if (!stillConnected) {{
                        location.reload();
                    }}
                }}, 3000);
            }} else {{
                alert('旋转失败: ' + (data.error || '未知错误'));
            }}
            if (pBtn) pBtn.disabled = false;
            if (lBtn) lBtn.disabled = false;
        }})
        .catch(function(e) {{
            if (pBtn) pBtn.disabled = false;
            if (lBtn) lBtn.disabled = false;
            alert('旋转请求失败: ' + e.message);
        }});
    }};



    // [v1.34.0] ====== 触屏优化：虚拟键盘 & 按键功能 ======
    // 修饰键状态
    var activeModifiers = {{ ControlLeft: false, AltLeft: false, ShiftLeft: false }};
    var shiftActive = false;

    // X11 keysym 映射表
    var XK = {{
        Backspace: 0xff08, Tab: 0xff09, Return: 0xff0d, Enter: 0xff0d,
        Escape: 0xff1b, Delete: 0xffff, Home: 0xff50, Left: 0xff51,
        Up: 0xff52, Right: 0xff53, Down: 0xff54, Page_Up: 0xff55,
        Page_Down: 0xff56, End: 0xff57, Insert: 0xff63,
        Control_L: 0xffe3, Control_R: 0xffe4, Alt_L: 0xffe9, Alt_R: 0xffea,
        Shift_L: 0xffe1, Shift_R: 0xffe6, Super_L: 0xffeb,
        F1: 0xffbe, F2: 0xffbf, F3: 0xffc0, F4: 0xffc1,
        F5: 0xffc2, F6: 0xffc3, F7: 0xffc4, F8: 0xffc5,
        ArrowLeft: 0xff51, ArrowUp: 0xff52, ArrowRight: 0xff53, ArrowDown: 0xff54,
        ControlLeft: 0xffe3, AltLeft: 0xffe9, ShiftLeft: 0xffe1,
    }};

    // DOM key → X11 keysym 映射
    var keyToXK = {{
        'ControlLeft': 0xffe3, 'ControlRight': 0xffe4,
        'AltLeft': 0xffe9, 'AltRight': 0xffea,
        'ShiftLeft': 0xffe1, 'ShiftRight': 0xffe6,
        'Tab': 0xff09, 'Escape': 0xff1b,
        'Backspace': 0xff08, 'Delete': 0xffff,
        'Enter': 0xff0d, 'Return': 0xff0d,
        'ArrowUp': 0xff52, 'ArrowDown': 0xff54,
        'ArrowLeft': 0xff51, 'ArrowRight': 0xff53,
        'Home': 0xff50, 'End': 0xff57,
        'Page_Up': 0xff55, 'Page_Down': 0xff56,
        'Insert': 0xff63,
    }};

    // 字符 → X11 keysym（Unicode 范围）
    function charToXK(ch) {{
        var code = ch.charCodeAt(0);
        if (code >= 0x20 && code <= 0x7e) return code; // ASCII 可打印字符
        if (code === 0x20) return 0x20; // 空格
        return code; // 其他 Unicode
    }}

    // 发送 X11 键事件
    function sendX11Key(keysym, down, up) {{
        if (!rfb) return;
        if (down) rfb.sendKey(keysym, '', true);
        if (up) rfb.sendKey(keysym, '', false);
    }}

    // 发送特殊键（Tab, Esc, 方向键等）
    window.sendKey = function(keyName) {{
        if (!rfb) return;
        var ks = keyToXK[keyName];
        if (ks === undefined) {{
            console.warn('Unknown key:', keyName);
            return;
        }}
        // 先发送激活的修饰键按下
        sendActiveModifiersDown();
        rfb.sendKey(ks, '', true);
        rfb.sendKey(ks, '', false);
        // 释放修饰键
        releaseAllModifiers();
    }};

    // 发送字符
    window.sendChar = function(ch) {{
        if (!rfb) return;
        var ks = charToXK(ch);
        // 如果 Shift 激活，大写
        if (shiftActive && ch >= 'a' && ch <= 'z') {{
            ks = charToXK(ch.toUpperCase());
        }}
        sendActiveModifiersDown();
        rfb.sendKey(ks, '', true);
        rfb.sendKey(ks, '', false);
        releaseAllModifiers();
        if (shiftActive) {{
            shiftActive = false;
            updateShiftUI();
        }}
    }};

    // 发送 Ctrl+X 组合键
    window.sendCtrlKey = function(ch) {{
        if (!rfb) return;
        rfb.sendKey(0xffe3, '', true); // Control_L down
        var ks = charToXK(ch);
        rfb.sendKey(ks, '', true);
        rfb.sendKey(ks, '', false);
        rfb.sendKey(0xffe3, '', false); // Control_L up
    }};

    // 发送右键点击（在画面中心或最后触摸位置）
    window.sendRightClick = function() {{
        if (!rfb) return;
        var screen = document.getElementById('screen');
        var canvas = screen.querySelector('canvas');
        if (canvas) {{
            var rect = canvas.getBoundingClientRect();
            var x = rect.width / 2;
            var y = rect.height / 2;
            rfb.sendMouse(x, y, 0x04); // button 3 = right click
            setTimeout(function() {{ rfb.sendMouse(x, y, 0); }}, 50);
        }}
    }};

    // 修饰键切换
    window.toggleModifier = function(keyName, label) {{
        if (!rfb) return;
        var wasActive = activeModifiers[keyName];
        activeModifiers[keyName] = !wasActive;

        var el = document.getElementById('vk' + label);
        if (el) {{
            if (activeModifiers[keyName]) {{
                el.classList.add('active');
                // 发送修饰键按下
                var ks = keyToXK[keyName];
                if (ks) rfb.sendKey(ks, '', true);
            }} else {{
                el.classList.remove('active');
                // 发送修饰键释放
                var ks = keyToXK[keyName];
                if (ks) rfb.sendKey(ks, '', false);
            }}
        }}
    }};

    // 发送所有激活的修饰键按下
    function sendActiveModifiersDown() {{
        for (var k in activeModifiers) {{
            if (activeModifiers[k]) {{
                var ks = keyToXK[k];
                if (ks) rfb.sendKey(ks, '', true);
            }}
        }}
    }}

    // 释放所有修饰键
    function releaseAllModifiers() {{
        for (var k in activeModifiers) {{
            if (activeModifiers[k]) {{
                var ks = keyToXK[k];
                if (ks) rfb.sendKey(ks, '', false);
                activeModifiers[k] = false;
            }}
        }}
        // 更新 UI
        var ctrlEl = document.getElementById('vkCtrl');
        var altEl = document.getElementById('vkAlt');
        if (ctrlEl) ctrlEl.classList.remove('active');
        if (altEl) altEl.classList.remove('active');
    }}

    // Shift 切换
    window.toggleShift = function() {{
        shiftActive = !shiftActive;
        if (rfb) {{
            rfb.sendKey(0xffe1, '', shiftActive); // Shift_L
        }}
        updateShiftUI();
    }};

    function updateShiftUI() {{
        var s1 = document.getElementById('vkShift');
        var s2 = document.getElementById('vkShift2');
        [s1, s2].forEach(function(el) {{
            if (el) {{
                if (shiftActive) el.classList.add('active');
                else el.classList.remove('active');
            }}
        }});
    }}

    // 虚拟键盘弹出
    window.toggleKeyboard = function() {{
        var popup = document.getElementById('vkPopup');
        var btn = document.getElementById('vkKbBtn');
        if (popup.classList.contains('show')) {{
            popup.classList.remove('show');
            btn.classList.remove('active');
        }} else {{
            popup.classList.add('show');
            btn.classList.add('active');
        }}
    }};

    // [v1.46.2→v1.46.3] 手机输入法激活 — 通过隐藏输入框触发手机软键盘
    // 支持中文输入法：监听 compositionend 事件获取 IME 组合完成后的文字
    var mobileInput = document.getElementById('mobileInput');
    var mobileKbActive = false;
    var isComposing = false;  // [v1.46.3] 标记是否正在IME组合输入中

    // [v1.46.3] 发送 Unicode 文字到 VNC — 支持中文等多字节字符
    // X11 keysym for Unicode: 0x01000000 + Unicode codepoint
    function sendUnicodeChar(ch) {{
        if (!rfb) return;
        var code = ch.charCodeAt(0);
        if (code >= 0x20 && code <= 0x7e) {{
            // ASCII 可打印字符，直接用 charCode 作为 keysym
            releaseAllModifiers();
            rfb.sendKey(code, '', true);
            rfb.sendKey(code, '', false);
        }} else {{
            // Unicode 字符（中文等），用 X11 Unicode keysym 格式
            // X11 keysym = 0x01000000 + Unicode codepoint
            var keysym = 0x01000000 + code;
            releaseAllModifiers();
            rfb.sendKey(keysym, '', true);
            rfb.sendKey(keysym, '', false);
        }}
    }}

    // [v1.46.3] IME 组合开始
    mobileInput.addEventListener('compositionstart', function(e) {{
        isComposing = true;
    }});

    // [v1.46.3] IME 组合结束 — 发送最终文字
    mobileInput.addEventListener('compositionend', function(e) {{
        isComposing = false;
        var text = e.data || mobileInput.value;
        if (text && rfb) {{
            for (var i = 0; i < text.length; i++) {{
                sendUnicodeChar(text[i]);
            }}
        }}
        mobileInput.value = '';
    }});

    // 输入框内容变化时，发送字符到 VNC
    mobileInput.addEventListener('input', function(e) {{
        // [v1.46.3] 如果正在IME组合中，不处理（等compositionend处理）
        if (isComposing) return;
        var text = mobileInput.value;
        if (text && rfb) {{
            // 逐字符发送到 VNC
            for (var i = 0; i < text.length; i++) {{
                sendUnicodeChar(text[i]);
            }}
        }}
        mobileInput.value = '';  // 清空，准备下次输入
    }});

    // 防止回车键提交
    mobileInput.addEventListener('keydown', function(e) {{
        if (e.key === 'Enter') {{
            e.preventDefault();
            if (rfb) {{
                rfb.sendKey(0xff0d, '', true);  // XK_Return
                rfb.sendKey(0xff0d, '', false);
            }}
            mobileInput.value = '';
        }} else if (e.key === 'Backspace') {{
            e.preventDefault();
            if (rfb) {{
                rfb.sendKey(0xff08, '', true);  // XK_BackSpace
                rfb.sendKey(0xff08, '', false);
            }}
            mobileInput.value = '';
        }}
    }});

    window.activateMobileKeyboard = function() {{
        var btn = document.getElementById('vkMobileBtn');
        // [v1.46.4] 隐藏输入框方式激活手机键盘
        // 输入框保持在屏幕内(opacity:0)但不可见，手机浏览器允许focus
        // 不显示可见输入框，手机键盘弹出后直接输入到VNC
        mobileInput.style.position = 'fixed';
        mobileInput.style.left = '0';
        mobileInput.style.top = '0';
        mobileInput.style.opacity = '0';
        mobileInput.style.width = '1px';
        mobileInput.style.height = '1px';
        mobileInput.style.fontSize = '16px';
        mobileInput.style.background = 'transparent';
        mobileInput.style.color = 'transparent';
        mobileInput.style.border = 'none';
        mobileInput.style.outline = 'none';
        mobileInput.style.padding = '0';
        mobileInput.style.zIndex = '9999';
        mobileInput.placeholder = '';
        mobileInput.value = '';
        mobileInput.focus();
        btn.classList.add('active');
        mobileKbActive = true;
    }};

    // 输入框失焦时保持隐藏状态
    mobileInput.addEventListener('blur', function() {{
        setTimeout(function() {{
            // 延迟恢复，防止点击其他按钮时闪烁
            if (document.activeElement !== mobileInput) {{
                mobileInput.style.position = 'fixed';
                mobileInput.style.left = '0';
                mobileInput.style.top = '0';
                mobileInput.style.opacity = '0';
                mobileInput.style.width = '1px';
                mobileInput.style.height = '1px';
                mobileInput.style.background = 'transparent';
                mobileInput.style.color = 'transparent';
                mobileInput.style.border = 'none';
                mobileInput.style.padding = '0';
                var btn = document.getElementById('vkMobileBtn');
                btn.classList.remove('active');
                mobileKbActive = false;
            }}
        }}, 300);
    }});

    // 触屏操作说明
    window.toggleTouchHint = function() {{
        var hint = document.getElementById('touchHint');
        hint.classList.toggle('show');
    }};

    // [v1.34.0] 阻止虚拟按键栏的触摸事件冒泡到 VNC canvas
    document.getElementById('vkBar').addEventListener('touchstart', function(e) {{
        e.stopPropagation();
    }}, {{ passive: true }});
    document.getElementById('vkBar').addEventListener('touchmove', function(e) {{
        e.stopPropagation();
    }}, {{ passive: true }});

    var vkPopup = document.getElementById('vkPopup');
    vkPopup.addEventListener('touchstart', function(e) {{
        e.stopPropagation();
    }}, {{ passive: true }});
    vkPopup.addEventListener('touchmove', function(e) {{
        e.stopPropagation();
    }}, {{ passive: true }});

    // [v1.34.0] 防止页面意外滚动/缩放
    document.body.addEventListener('touchmove', function(e) {{
        if (e.target.closest('.vk-bar') || e.target.closest('.vk-popup')) return;
    }}, {{ passive: true }});

    // [v1.34.0] 首次触屏使用时自动显示手势说明
    var touchHintShown = sessionStorage.getItem('vnc_touch_hint_shown');
    if ('ontouchstart' in window && !touchHintShown) {{
        setTimeout(function() {{
            document.getElementById('touchHint').classList.add('show');
            sessionStorage.setItem('vnc_touch_hint_shown', '1');
            // 5秒后自动隐藏
            setTimeout(function() {{
                document.getElementById('touchHint').classList.remove('show');
            }}, 6000);
        }}, 2000);
    }}

    smartConnect();
}}

// 直连按钮
function tryDirectConnect() {{
    localStorage.setItem('vnc_connect_method', 'direct');
    location.reload();
}}
</script>
</body>
</html>"""

    # =========================================================================
    # [v1.21.0] Web Control 网页控制器
    # =========================================================================

    def _get_wc_manager(self):
        """获取 WebControlManager 实例（懒加载）"""
        if not hasattr(self, '_wc_manager'):
            from core.web_control import get_web_control_manager
            self._wc_manager = get_web_control_manager()
        return self._wc_manager

    async def handle_wc_status(self, request):
        """GET /api/web_control/status - 获取 Web Control 状态"""
        mgr = self._get_wc_manager()
        mgr.cleanup_expired()
        sessions = []
        for sid, session in mgr.sessions.items():
            sessions.append({
                "sid": sid,
                "current_url": session.current_url,
                "is_panel_open": session.is_panel_open,
                "created_at": session.created_at,
                "last_activity": session.last_activity,
            })
        return web.json_response({"sessions": sessions, "total": len(sessions)})

    async def handle_wc_create_session(self, request):
        """POST /api/web_control/create - 创建 Web Control 会话"""
        mgr = self._get_wc_manager()
        session = mgr.create_session()
        return web.json_response({
            "success": True,
            "sid": session.session_id,
            "panel_url": f"/api/web_control/panel?sid={session.session_id}",
        })

    async def handle_wc_close_session(self, request):
        """POST /api/web_control/close - 关闭 Web Control 会话"""
        data = await request.json() if request.content_type == 'application/json' else {}
        sid = data.get('sid') or data.get('session_id') or request.query.get('sid', '')
        if not sid:
            return web.json_response({"success": False, "error": "Missing sid"}, status=400)
        self._get_wc_manager().close_session(sid)
        return web.json_response({"success": True})

    async def handle_wc_poll_commands(self, request):
        """GET /api/web_control/poll?sid=xxx - 客户端轮询待执行命令"""
        sid = request.query.get('sid', '')
        if not sid:
            return web.json_response({"commands": [], "error": "Missing session_id"})
        result = await self._get_wc_manager().poll_commands(sid)
        return web.json_response(result)

    async def handle_wc_submit_result(self, request):
        """POST /api/web_control/result - 客户端提交命令执行结果"""
        try:
            data = await request.json()
        except:
            return web.json_response({"success": False, "error": "Invalid JSON"}, status=400)
        sid = data.get('sid') or data.get('session_id', '')
        cmd_id = data.get('cmd_id', '')
        result = data.get('result', {})
        if not sid or not cmd_id:
            return web.json_response({"success": False, "error": "Missing sid or cmd_id"}, status=400)
        ok = await self._get_wc_manager().submit_result(sid, cmd_id, result)
        return web.json_response({"success": ok})

    async def handle_wc_proxy(self, request):
        """GET /api/web_control/proxy?url=xxx&sid=xxx - 代理网页请求"""
        url = request.query.get('url', '')
        sid = request.query.get('sid', '')
        if not url:
            return web.Response(status=400, text="Missing url parameter")

        mgr = self._get_wc_manager()
        try:
            content, content_type, resp_headers = await mgr.proxy_request(url, session_id=sid)
        except ValueError as e:
            return web.Response(status=400, text=str(e))
        except Exception as e:
            logger.warning(f"[WebControl] Proxy error for {url}: {e}")
            return web.Response(status=502, text=f"Proxy error: {e}")

        # 基础 content type
        base_ct = content_type.split(';')[0].strip().lower() if content_type else ''

        if base_ct == 'text/html':
            try:
                html = content.decode('utf-8', errors='replace')
                html = mgr.rewrite_html(html, url, sid)
                # 更新会话当前 URL
                session = mgr.get_session(sid)
                if session:
                    session.current_url = url
                return web.Response(text=html, content_type='text/html', charset='utf-8')
            except Exception as e:
                logger.warning(f"[WebControl] HTML rewrite error: {e}")
                return web.Response(body=content, content_type=content_type)

        elif base_ct == 'text/css':
            try:
                css = content.decode('utf-8', errors='replace')
                css = mgr.rewrite_css(css, url, sid)
                return web.Response(text=css, content_type='text/css', charset='utf-8')
            except Exception as e:
                return web.Response(body=content, content_type=content_type)

        else:
            # 其他资源直接透传
            resp = web.Response(body=content)
            ct_header = resp_headers.get('Content-Type', content_type)
            if ct_header:
                resp.content_type = ct_header.split(';')[0].strip()
            # CORS header 允许代理资源被加载
            resp.headers['Access-Control-Allow-Origin'] = '*'
            return resp

    async def handle_wc_proxy_post(self, request):
        """POST /api/web_control/proxy?url=xxx&sid=xxx - 代理 POST 请求"""
        url = request.query.get('url', '')
        sid = request.query.get('sid', '')
        if not url:
            return web.Response(status=400, text="Missing url parameter")

        # 读取 POST body
        body = await request.read()
        content_type = request.headers.get('Content-Type', 'application/x-www-form-urlencoded')

        import aiohttp
        headers = {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
            'Content-Type': content_type,
        }

        # 注入 cookie
        mgr = self._get_wc_manager()
        session = mgr.get_session(sid) if sid else None
        if session and session.cookies:
            parsed = urlparse(url)
            hostname = parsed.hostname
            domain_cookies = []
            for key, val in session.cookies.items():
                parts = key.split('::', 1)
                if len(parts) == 2 and hostname:
                    if hostname == parts[0] or hostname.endswith('.' + parts[0]):
                        domain_cookies.append(f"{parts[1]}={val}")
            if domain_cookies:
                headers['Cookie'] = '; '.join(domain_cookies)

        try:
            async with aiohttp.ClientSession() as http_session:
                async with http_session.post(
                    url, headers=headers, data=body,
                    allow_redirects=True,
                    timeout=aiohttp.ClientTimeout(total=20),
                    ssl=False,
                ) as resp:
                    content = await resp.read()
                    resp_ct = resp.headers.get('Content-Type', 'application/octet-stream')

            # 收集响应中的 Set-Cookie
            if session:
                for cookie_header in resp.headers.getall('Set-Cookie', []):
                    self._process_set_cookie(session, url, cookie_header)

            base_ct = resp_ct.split(';')[0].strip().lower()
            if base_ct == 'text/html':
                html = content.decode('utf-8', errors='replace')
                html = mgr.rewrite_html(html, url, sid)
                if session:
                    session.current_url = url
                return web.Response(text=html, content_type='text/html', charset='utf-8')
            else:
                resp_obj = web.Response(body=content)
                resp_obj.content_type = base_ct
                return resp_obj
        except Exception as e:
            return web.Response(status=502, text=f"POST proxy error: {e}")

    def _process_set_cookie(self, session, url, cookie_header):
        """解析 Set-Cookie 并存入会话"""
        parsed = urlparse(url)
        domain = parsed.hostname or ''
        parts = cookie_header.split(';')
        name_value = parts[0].strip()
        if '=' not in name_value:
            return
        cookie_name, cookie_value = name_value.split('=', 1)
        cookie_name = cookie_name.strip()
        cookie_value = cookie_value.strip()
        # 确定域名
        cookie_domain = domain
        for part in parts[1:]:
            part = part.strip().lower()
            if part.startswith('domain='):
                cookie_domain = part.split('=', 1)[1].strip().lstrip('.')
        if cookie_domain and cookie_name:
            session.cookies[f"{cookie_domain}::{cookie_name}"] = cookie_value

    async def handle_wc_panel(self, request):
        """GET /api/web_control/panel?sid=xxx - 返回 Web Control 控制面板 HTML"""
        sid = request.query.get('sid', '')
        theme = request.query.get('theme', 'light')
        mgr = self._get_wc_manager()
        session = mgr.get_session(sid) if sid else None

        html = self._build_wc_panel_html(sid, session, theme)
        return web.Response(text=html, content_type='text/html', charset='utf-8')

    def _build_wc_panel_html(self, session_id, session, theme):
        """构建 Web Control 控制面板页面"""
        initial_url = session.current_url if session else ''
        is_dark = theme == 'dark'

        bg = '#1a1a2e' if is_dark else '#f8f9fa'
        header_bg = '#16213e' if is_dark else '#ffffff'
        header_border = '#0f3460' if is_dark else '#e0e0e0'
        text_color = '#eee' if is_dark else '#333'
        input_bg = '#0a1628' if is_dark else '#ffffff'
        input_border = '#0f3460' if is_dark else '#ccc'
        btn_bg = '#0f3460' if is_dark else '#1890ff'
        btn_hover = '#1a4a8a' if is_dark else '#40a9ff'
        status_color = '#888'

        return f'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Web Control</title>
<style>
* {{ margin:0; padding:0; box-sizing:border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background:{bg}; color:{text_color}; height:100vh; display:flex; flex-direction:column; }}
.wc-header {{ display:flex; align-items:center; justify-content:space-between; padding:6px 12px; background:{header_bg}; border-bottom:1px solid {header_border}; flex-shrink:0; }}
.wc-header-left {{ display:flex; align-items:center; gap:10px; flex:1; min-width:0; }}
.wc-title {{ font-size:13px; font-weight:600; white-space:nowrap; }}
.wc-url-bar {{ flex:1; display:flex; align-items:center; gap:6px; }}
.wc-url-input {{ flex:1; padding:4px 10px; border-radius:4px; border:1px solid {input_border}; background:{input_bg}; color:{text_color}; font-size:12px; outline:none; min-width:0; }}
.wc-url-input:focus {{ border-color:{btn_bg}; }}
.wc-btn {{ padding:4px 10px; border-radius:4px; border:none; cursor:pointer; font-size:11px; font-weight:500; white-space:nowrap; }}
.wc-btn-primary {{ background:{btn_bg}; color:#fff; }}
.wc-btn-primary:hover {{ background:{btn_hover}; }}
.wc-btn-danger {{ background:#e94560; color:#fff; }}
.wc-btn-danger:hover {{ background:#ff6b81; }}
.wc-status {{ font-size:11px; color:{status_color}; white-space:nowrap; }}
.wc-status.connected {{ color:#00ff88; }}
.wc-body {{ flex:1; position:relative; }}
.wc-iframe {{ width:100%; height:100%; border:none; background:#fff; }}
.wc-loading {{ position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); text-align:center; color:{status_color}; font-size:13px; }}
.wc-loading-icon {{ font-size:28px; margin-bottom:8px; }}
.wc-toolbar {{ display:flex; align-items:center; gap:6px; padding:4px 12px; background:{header_bg}; border-top:1px solid {header_border}; flex-shrink:0; font-size:11px; }}
.wc-toolbar-info {{ color:{status_color}; }}
.wc-overlay {{ position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.5); display:none; z-index:100; justify-content:center; align-items:center; }}
.wc-overlay.show {{ display:flex; }}
.wc-modal {{ background:{header_bg}; border-radius:8px; padding:16px; width:90%; max-width:400px; max-height:70vh; overflow-y:auto; }}
.wc-modal h3 {{ margin-bottom:10px; font-size:14px; }}
.wc-modal textarea {{ width:100%; min-height:120px; padding:8px; border-radius:4px; border:1px solid {input_border}; background:{input_bg}; color:{text_color}; font-size:12px; font-family:monospace; resize:vertical; }}
.wc-modal-actions {{ display:flex; gap:8px; margin-top:10px; justify-content:flex-end; }}
</style>
</head>
<body>
<div class="wc-header">
  <div class="wc-header-left">
    <span class="wc-title">Web Control</span>
    <div class="wc-url-bar">
      <input class="wc-url-input" id="wcUrlInput" type="text" value="{initial_url}" placeholder="输入 URL..." onkeydown="if(event.key==='Enter')wcNavigate(this.value)">
      <button class="wc-btn wc-btn-primary" onclick="wcNavigate(document.getElementById('wcUrlInput').value)">GO</button>
    </div>
  </div>
  <div style="display:flex;align-items:center;gap:8px">
    <span class="wc-status" id="wcStatus">等待连接</span>
    <button class="wc-btn" onclick="wcOpenConsole()" title="执行JS">JS</button>
    <button class="wc-btn" onclick="wcToggleFullscreen()" title="全屏">⛶</button>
  </div>
</div>

<div class="wc-body">
  <iframe id="wcIframe" class="wc-iframe" sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-downloads" allow="clipboard-read; clipboard-write; fullscreen"></iframe>
  <div class="wc-loading" id="wcLoading">
    <div class="wc-loading-icon">&#x1f310;</div>
    <div id="wcLoadingText">在上方输入 URL 开始浏览</div>
  </div>
</div>

<div class="wc-toolbar">
  <span class="wc-toolbar-info" id="wcToolbarInfo">Session: {session_id or 'N/A'}</span>
  <span style="flex:1"></span>
  <span id="wcPageInfo" class="wc-toolbar-info"></span>
</div>

<!-- JS Console Modal -->
<div class="wc-overlay" id="wcConsoleOverlay">
  <div class="wc-modal">
    <h3>JavaScript Console</h3>
    <textarea id="wcConsoleInput" placeholder="输入 JavaScript 代码..."></textarea>
    <div class="wc-modal-actions">
      <button class="wc-btn" onclick="wcCloseConsole()">取消</button>
      <button class="wc-btn wc-btn-primary" onclick="wcExecConsole()">执行</button>
    </div>
  </div>
</div>

<script>
// ── Web Control Panel 基础JS框架 ──

var WC_SESSION_ID = '{session_id}';
var WC_POLL_INTERVAL = 300;  // 轮询间隔(ms)
var WC_PROXY_BASE = '/api/web_control/proxy?sid=' + WC_SESSION_ID + '&url=';
var wcPollTimer = null;
var wcReady = false;

// ── 导航 ──
function wcNavigate(url) {{
  if (!url) return;
  if (!url.startsWith('http://') && !url.startsWith('https://')) {{
    url = 'https://' + url;
  }}
  document.getElementById('wcUrlInput').value = url;
  document.getElementById('wcIframe').src = WC_PROXY_BASE + encodeURIComponent(url);
  wcShowLoading('正在加载页面...');
}}

// ── iframe 消息监听（接收控制脚本的反馈）──
window.addEventListener('message', function(event) {{
  if (event.data && event.data.__webControlEvent) {{
    var evt = event.data;
    if (evt.type === 'ready') {{
      wcReady = true;
      wcHideLoading();
      var url = evt.data.url || '';
      var title = evt.data.title || '';
      document.getElementById('wcUrlInput').value = url;
      document.getElementById('wcPageInfo').textContent = title ? title + ' - ' + url : url;
      document.getElementById('wcStatus').textContent = '已连接';
      document.getElementById('wcStatus').className = 'wc-status connected';
      // 通知服务端面板已就绪
      wcPollCommands();
    }} else if (evt.type === 'result') {{
      // 命令执行结果 — 提交给服务端
      wcSubmitResult(evt.data);
    }}
  }}
}});

// ── iframe 加载事件 ──
document.getElementById('wcIframe').addEventListener('load', function() {{
  var href;
  try {{ href = this.contentWindow.location.href; }} catch(e) {{ href = 'about:blank'; }}
  if (href === 'about:blank') return;
  wcHideLoading();
  document.getElementById('wcStatus').textContent = '已加载';
  document.getElementById('wcStatus').className = 'wc-status connected';
  wcReady = true;
}});

// ── 轮询命令 ──
async function wcPollCommands() {{
  try {{
    var resp = await fetch('/api/web_control/poll?sid=' + WC_SESSION_ID);
    if (!resp.ok) return;
    var data = await resp.json();
    if (data.commands && data.commands.length > 0) {{
      for (var i = 0; i < data.commands.length; i++) {{
        wcExecuteCommand(data.commands[i]);
      }}
    }}
  }} catch(e) {{
    console.error('[WebControl] Poll error:', e);
  }}
}}

function wcExecuteCommand(cmd) {{
  var iframe = document.getElementById('wcIframe');
  if (!iframe || !iframe.contentWindow) {{
    wcSubmitResult({{ cmdId: cmd.id, success: false, error: 'No iframe window' }});
    return;
  }}

  // 导航命令直接在面板处理
  if (cmd.action === 'navigate') {{
    wcNavigate(cmd.params.url || '');
    wcSubmitResult({{ cmdId: cmd.id, success: true, navigated_to: cmd.params.url }});
    return;
  }}
  if (cmd.action === 'close') {{
    window.parent.postMessage({{ __webControlPanel: true, action: 'close', sid: WC_SESSION_ID }}, '*');
    wcSubmitResult({{ cmdId: cmd.id, success: true, closed: true }});
    return;
  }}

  // 其他命令通过 postMessage 发送到 iframe 中的控制脚本
  try {{
    iframe.contentWindow.postMessage({{
      __webControlCmd: true,
      cmdId: cmd.id,
      action: cmd.action,
      params: cmd.params
    }}, '*');
  }} catch(e) {{
    wcSubmitResult({{ cmdId: cmd.id, success: false, error: 'Cannot send message to iframe: ' + e.message }});
  }}
}}

// ── 提交命令结果到服务端 ──
async function wcSubmitResult(resultData) {{
  if (!resultData || !resultData.cmdId) return;
  try {{
    await fetch('/api/web_control/result', {{
      method: 'POST',
      headers: {{ 'Content-Type': 'application/json' }},
      body: JSON.stringify({{
        sid: WC_SESSION_ID,
        cmd_id: resultData.cmdId,
        result: resultData
      }})
    }});
  }} catch(e) {{
    console.error('[WebControl] Submit result error:', e);
  }}
}}

// ── JS Console ──
function wcOpenConsole() {{
  document.getElementById('wcConsoleOverlay').classList.add('show');
  document.getElementById('wcConsoleInput').focus();
}}
function wcCloseConsole() {{
  document.getElementById('wcConsoleOverlay').classList.remove('show');
}}
function wcExecConsole() {{
  var script = document.getElementById('wcConsoleInput').value.trim();
  if (!script) return;
  var iframe = document.getElementById('wcIframe');
  if (iframe && iframe.contentWindow) {{
    iframe.contentWindow.postMessage({{
      __webControlCmd: true,
      cmdId: 'manual_' + Date.now(),
      action: 'evaluate',
      params: {{ script: script }}
    }}, '*');
  }}
  wcCloseConsole();
}}

// ── 全屏 ──
function wcToggleFullscreen() {{
  if (!document.fullscreenElement) {{
    document.documentElement.requestFullscreen();
  }} else {{
    document.exitFullscreen();
  }}
}}

// ── 辅助 ──
function wcShowLoading(text) {{
  var el = document.getElementById('wcLoading');
  var txt = document.getElementById('wcLoadingText');
  if (el) el.style.display = 'block';
  if (txt) txt.textContent = text || '加载中...';
  document.getElementById('wcStatus').textContent = '加载中...';
  document.getElementById('wcStatus').className = 'wc-status';
}}
function wcHideLoading() {{
  var el = document.getElementById('wcLoading');
  if (el) el.style.display = 'none';
}}

// ── 启动轮询 ──
wcPollTimer = setInterval(wcPollCommands, WC_POLL_INTERVAL);

// ── 如果有初始 URL 则自动加载 ──
if ('{initial_url}') {{
  wcNavigate('{initial_url}');
}}

// ── 通知父窗口面板已打开 ──
window.parent.postMessage({{ __webControlPanel: true, action: 'opened', sid: WC_SESSION_ID }}, '*');

// ── 页面卸载时清理 ──
window.addEventListener('beforeunload', function() {{
  window.parent.postMessage({{ __webControlPanel: true, action: 'closed', sid: WC_SESSION_ID }}, '*');
}});
</script>
</body>
</html>'''

    # --- Execution Progress ---
    async def handle_execution_progress(self, request):
        """GET /api/execution/progress - 返回当前执行进度"""
        active = []
        now = time.time()
        for eid, info in list(self._exec_progress.items()):
            if info["status"] == "running":
                info["elapsed"] = now - info["start_time"]
            active.append({"id": eid, **info})
        return web.json_response({
            "active": active,
            "has_running": any(i["status"] == "running" for i in active),
        })

    # --- Chat ---
    async def handle_chat(self, request):
        """POST /api/chat - 聊天消息处理"""
        try:
            data = await request.json()
        except Exception:
            return web.json_response({"error": "invalid JSON"}, status=400)

        message = data.get("message", "").strip()
        if not message:
            return web.json_response({"error": "message is required"}, status=400)

        agent_name = data.get("agent_name", "1") or "1"
        # 支持 path 格式 (如 "3")
        agent_path = data.get("agent_path", agent_name)
        # 获取数字 agent_id
        agent_id = self.core.memory.get_agent_id(agent_path)
        session_id = data.get("sid", "") or data.get("session_id", "")
        chat_mode = data.get("mode", "")  # "exec" = 执行模式
        escalated = data.get("escalated", False)  # 临时提权到 local

        # ── [v1.20.13] 会话级锁：防止同一会话并发请求 ──
        session_lock = await self._get_session_lock(session_id)
        if session_lock.locked():
            try:
                await asyncio.wait_for(session_lock.acquire(), timeout=2.0)
                session_lock.release()
            except asyncio.TimeoutError:
                return web.json_response({"error": "该会话正忙，请稍后重试"}, status=429)
        await session_lock.acquire()

        # ── [v1.33.5] Per-Agent 执行锁（替代全局锁）──
        # 不同 agent 互不干扰，同一 agent 内排队
        agent_cfg_early = self._read_agent_config(agent_path)
        execution_mode = agent_cfg_early.get("execution_mode", "sandbox") if agent_cfg_early else "sandbox"
        needs_lock_check = (execution_mode == "local") or escalated
        if needs_lock_check:
            acquired = await self._acquire_agent_lock(agent_path, timeout=120.0)
            if not acquired:
                return web.json_response({
                    "error": f"Agent {agent_path} 正忙，请稍后重试。",
                    "locked_by": agent_path,
                    "timeout": True,
                }, status=423)

        try:
            # 检查 Agent 是否指定了特定模型（含备用模型列表）
            agent_cfg = self._read_agent_config(agent_path)
            model_chain = self._build_model_chain(agent_cfg, agent_path)

            # ── 注入 Agent 专属 system_prompt 和知识库上下文 ──
            clean_message, agent_system_prompt = self._build_agent_chat_context(agent_path, agent_cfg, message)

            # ── 执行模式: 将任务规划上下文注入到 system_prompt（而非用户消息）──
            task_plan_context = self._build_task_plan_context(agent_path, chat_mode, message, session_id=session_id)
            if task_plan_context:
                agent_system_prompt += "\n\n## 任务规划\n" + task_plan_context

            if model_chain and self.core.llm:
                # 依次尝试主模型和备用模型
                response = await self._try_model_chain(
                    model_chain, clean_message, session_id,
                    agent_path=agent_path, agent_system_prompt=agent_system_prompt,
                    chat_mode=chat_mode,
                )
            else:
                response = await self.core.process_message(clean_message, session_id)

            # 记忆保存已由 MainAgent._process_inner() 完成，此处不再重复保存
            # （MainAgent 内部会保存 user + assistant 消息到会话记忆）

            # ── 收集执行事件（供前端展示命令执行过程） ──
            exec_events = []
            if self.core.main_agent:
                exec_events = self.core.main_agent.get_execution_events()

            # ── 执行模式: 从回复中提取任务列表 ──
            if chat_mode == "exec" and self.core.llm:
                try:
                    tl = self._extract_task_list_json(response)
                    if tl is not None:
                        self._task_list_store[session_id] = tl
                except Exception as tp_err:
                    logger.warning(f"任务列表更新失败: {tp_err}")

            resp_data = {"response": response, "sid": session_id, "agent_name": agent_path, "agent_path": agent_path}
            if exec_events:
                resp_data["exec_events"] = exec_events
            return web.json_response(resp_data)
        except Exception as e:
            logger.error(f"Chat error: {e}", exc_info=True)
            return web.json_response({"error": str(e)}, status=500)
        finally:
            # [v1.33.5] 释放 per-agent 执行锁
            if needs_lock_check:
                self._release_agent_lock(agent_path)
            # [v1.20.13] 释放会话级锁
            try:
                session_lock.release()
            except RuntimeError:
                pass

    # ── 会话运行状态追踪 (用于断线重连) ──
    # {session_id: {running: bool, started_at: float, result: str, done: bool, error: str}}
    _running_sessions: Dict[str, Dict] = {}
    _running_sessions_ttl = 300  # 过期时间（秒），用于自动清理已完成会话
    # [v1.23.37] 存储后台 asyncio.Task 引用，用于取消执行
    _running_tasks: Dict[str, asyncio.Task] = {}
    # [v1.23.37] 会话取消标志，供 agent 执行循环检查
    _session_cancelled: Dict[str, bool] = {}

    async def _get_session_lock(self, session_id: str) -> asyncio.Lock:
        """获取或创建会话级锁（每个 session_id 一个锁，防并发覆盖 MainAgent 共享状态）"""
        async with self._session_locks_mutex:
            if session_id not in self._session_locks:
                self._session_locks[session_id] = asyncio.Lock()
            return self._session_locks[session_id]

    async def _acquire_agent_lock(self, agent_path: str, timeout: float = 120.0) -> bool:
        """[v1.33.5] 获取 per-agent 执行锁。不同 agent 互不干扰，同一 agent 内排队。

        Args:
            agent_path: Agent 标识
            timeout: 等待超时秒数

        Returns:
            True 如果成功获取锁，False 如果超时
        """
        async with self._agent_locks_mutex:
            if agent_path not in self._agent_locks:
                self._agent_locks[agent_path] = {"locked": False, "locked_by": None, "locked_at": None, "locked_at_ts": None}
            if agent_path not in self._agent_lock_events:
                self._agent_lock_events[agent_path] = asyncio.Event()
                self._agent_lock_events[agent_path].set()

        lock = self._agent_locks[agent_path]
        event = self._agent_lock_events[agent_path]

        if not lock["locked"]:
            # 直接获取
            lock["locked"] = True
            lock["locked_by"] = agent_path
            lock["locked_at"] = _now_iso()
            lock["locked_at_ts"] = time.time()
            event.clear()
            # 同步到全局锁（向后兼容 UI 展示）
            self._execution_lock["locked"] = True
            self._execution_lock["locked_by"] = agent_path
            self._execution_lock["locked_at"] = lock["locked_at"]
            self._execution_lock["locked_at_ts"] = lock["locked_at_ts"]
            return True

        # 同一 agent 已在执行，等待释放
        logger.info(f"Agent {agent_path} 等待自身执行锁释放...")
        wait_start = time.time()
        while lock["locked"]:
            elapsed = time.time() - wait_start
            if elapsed > timeout:
                logger.warning(f"Agent {agent_path} 等待执行锁超时 ({timeout:.0f}s)")
                return False
            try:
                await asyncio.wait_for(event.wait(), timeout=5.0)
                event.clear()
            except asyncio.TimeoutError:
                pass

        # 获取锁
        lock["locked"] = True
        lock["locked_by"] = agent_path
        lock["locked_at"] = _now_iso()
        lock["locked_at_ts"] = time.time()
        event.clear()
        # 同步到全局锁
        self._execution_lock["locked"] = True
        self._execution_lock["locked_by"] = agent_path
        self._execution_lock["locked_at"] = lock["locked_at"]
        self._execution_lock["locked_at_ts"] = lock["locked_at_ts"]
        return True

    def _release_agent_lock(self, agent_path: str):
        """[v1.33.5] 释放 per-agent 执行锁"""
        if agent_path not in self._agent_locks:
            return
        lock = self._agent_locks[agent_path]
        if lock["locked_by"] == agent_path:
            lock["locked"] = False
            lock["locked_by"] = None
            lock["locked_at"] = None
            lock["locked_at_ts"] = None
            if agent_path in self._agent_lock_events:
                self._agent_lock_events[agent_path].set()
            # 检查是否还有其他 agent 持有锁，同步到全局锁
            any_locked = any(al["locked"] for al in self._agent_locks.values())
            if not any_locked:
                self._execution_lock["locked"] = False
                self._execution_lock["locked_by"] = None
                self._execution_lock["locked_at"] = None
                self._execution_lock["locked_at_ts"] = None
                self._execution_lock_event.set()
            logger.debug(f"Agent {agent_path} 执行锁已释放")

    def _cleanup_stale_sessions(self):
        """清理已完成的过期会话状态，防止 _running_sessions 无限增长"""
        import time as _time
        now = _time.time()
        stale = []
        for sid, info in self._running_sessions.items():
            if info.get("done") and now - info.get("completed_at", now) > self._running_sessions_ttl:
                stale.append(sid)
        for sid in stale:
            del self._running_sessions[sid]
            logger.debug(f"清理过期会话状态: {sid}")
        # 同步清理对应的 session lock
        if stale:
            for sid in stale:
                self._session_locks.pop(sid, None)

    async def handle_chat_stream(self, request):
        """POST /api/chat/stream - SSE 流式聊天

        核心设计: 即使前端断开，后台任务仍继续执行并保存完整结果。
        前端可通过 /api/sessions/{sid}/status 查询任务状态并恢复。
        """
        try:
            data = await request.json()
        except Exception:
            return web.Response(text="data: " + json.dumps({"error": "invalid JSON"}) + "\n\n", content_type="text/event-stream")

        # 添加调试日志
        import sys
        print(f"[STREAM_MESSAGE] 函数被调用, PYTHONIOENCODING={getattr(sys, 'stdout', None)}", flush=True, file=sys.stdout)

        message = data.get("message", "").strip()
        # [v1.16.12] 支持图片附件
        user_images = data.get("images", [])  # [{"type": "image/png", "data": "base64..."}, ...]
        # 支持文件附件（文档等，提取文本后作为上下文）
        user_files = data.get("files", [])    # [{"name": "xxx.pdf", "type": "application/pdf", "data": "base64..."}, ...]
        
        # [v1.17.1] 图片调试日志
        if user_images:
            _img_info = []
            for _img in user_images:
                _b64len = len(_img.get("data", "")) if isinstance(_img.get("data"), str) else 0
                _img_info.append(f"{ _img.get('name','?') }(type={_img.get('type','?')}, b64len={_b64len})")
            logger.info(f"[{session_id if 'session_id' in dir() else 'req'}] 收到图片: {_img_info}")
        
        # 如果消息为空但有图片，设置默认提示
        if not message and user_images:
            message = "请查看这些图片"
        if not message and not user_images and not user_files:
            return web.Response(text="data: " + json.dumps({"error": "message is required"}) + "\n\n", content_type="text/event-stream")

        agent_path = data.get("agent_path", data.get("agent_name", "1")) or "1"
        session_id = data.get("sid", "") or data.get("session_id", "")
        chat_mode = data.get("mode", "")
        escalated = data.get("escalated", False)
        voice_text = data.get("voice_text", "").strip()  # 语音转文字原始文本（用于 usersays_correct）

        # ── [v1.20.13] 清理过期的会话状态，防止内存泄漏 ──
        self._cleanup_stale_sessions()

        # ── [v1.20.13] 获取会话级锁：防止同一会话并发请求导致 MainAgent 共享状态被覆盖 ──
        session_lock = await self._get_session_lock(session_id)
        if session_lock.locked():
            # 同一会话已有请求在处理
            running_info = self._running_sessions.get(session_id)
            if running_info and running_info.get("running") and not running_info.get("done"):
                # [v1.26.10] 修复：返回 SSE 流，先发送已生成的内容，然后保持连接接收新内容
                response = web.StreamResponse(
                    status=200,
                    headers={
                        "Content-Type": "text/event-stream",
                        "Cache-Control": "no-cache",
                        "Connection": "keep-alive",
                        "X-Accel-Buffering": "no",
                    }
                )
                await response.prepare(request)
                
                # 先发送已生成的内容
                existing_result = running_info.get("result", "")
                if existing_result:
                    await response.write(("data: " + json.dumps({"type": "resume", "sid": session_id, "running": True, "started_at": running_info.get("started_at"), "result": existing_result}, ensure_ascii=False) + "\n\n").encode())
                else:
                    await response.write(("data: " + json.dumps({"type": "resume", "sid": session_id, "running": True, "started_at": running_info.get("started_at")}, ensure_ascii=False) + "\n\n").encode())
                
                # 持续检查任务是否完成，完成后发送完成事件
                while not running_info.get("done"):
                    await asyncio.sleep(0.5)  # 每 500ms 检查一次
                    running_info = self._running_sessions.get(session_id)
                    if running_info and running_info.get("done"):
                        await response.write(("data: " + json.dumps({"type": "resume_complete", "sid": session_id, "result": running_info.get("result", "")}, ensure_ascii=False) + "\n\n").encode())
                        break
                
                return response
            # 锁被持有但无 running 记录（异常残留），等待后继续
            try:
                await asyncio.wait_for(session_lock.acquire(), timeout=2.0)
                session_lock.release()
            except asyncio.TimeoutError:
                return web.Response(text="data: " + json.dumps({"error": "会话正忙，请稍后重试"}) + "\n\n", content_type="text/event-stream")
        await session_lock.acquire()

        # [v1.33.5] Per-Agent 执行锁（替代全局锁）
        agent_cfg_early = self._read_agent_config(agent_path)
        execution_mode = agent_cfg_early.get("execution_mode", "sandbox") if agent_cfg_early else "sandbox"
        needs_lock_check = (execution_mode == "local") or escalated
        if needs_lock_check:
            # 通过 SSE 通知客户端正在等待（如果锁被占用）
            acquired = await self._acquire_agent_lock(agent_path, timeout=120.0)
            if not acquired:
                error_sse = json.dumps({
                    "type": "error", "sid": session_id,
                    "error": f"Agent {agent_path} 正忙，请稍后重试。",
                }, ensure_ascii=False)
                await response.write(("data: " + error_sse + "\n\n").encode())
                try:
                    session_lock.release()
                except RuntimeError:
                    pass
                return response

        response = web.StreamResponse(
            status=200,
            headers={
                "Content-Type": "text/event-stream",
                "Cache-Control": "no-cache",
                "Connection": "keep-alive",
                "X-Accel-Buffering": "no",
            }
        )
        await response.prepare(request)

        # ── 创建线程安全的 SSE 写入器和结果存储 ──
        result_store = {"full_response": "", "exec_events": [], "error": None, "done": False}
        client_connected = {"value": True}  # 客户端是否仍连接
        _write_lock = asyncio.Lock()

        async def safe_write(data):
            """安全写入SSE，客户端断开时不抛异常"""
            if not client_connected["value"]:
                return False
            try:
                async with _write_lock:
                    # 判断数据类型：bytes 直接写入，dict 需要序列化
                    if isinstance(data, bytes):
                        await response.write(data)
                    else:
                        await response.write(("data: " + json.dumps(data, ensure_ascii=False) + "\n\n").encode())
                        # [v1.26.10] 实时更新 _running_sessions 中的 result，支持刷新后接续
                        # 捕获 text 和 text_delta 两种类型
                        if isinstance(data, dict) and data.get("type") in ("text", "text_delta"):
                            content = data.get("content", "")
                            if content and session_id in self._running_sessions:
                                # 追加到已有的 result 中
                                current_result = self._running_sessions[session_id].get("result", "")
                                self._running_sessions[session_id]["result"] = current_result + content
                return True
            except Exception as e:
                client_connected["value"] = False
                logger.debug(f"[{session_id}] SSE 写入失败: {e}")
                return False

        # ── 标记会话为运行状态（前端断线后仍可查询） ──
        # [修复] 清理旧的会话记录，避免状态混乱
        if session_id in self._running_sessions:
            old_session = self._running_sessions[session_id]
            if old_session.get("done") and (time.time() - old_session.get("completed_at", 0)) > 300:  # 5分钟前完成的
                logger.debug(f"[{session_id}] 清理过期会话记录")
                del self._running_sessions[session_id]
        
        self._running_sessions[session_id] = {
            "running": True,
            "started_at": time.time(),
            "agent_path": agent_path,
            "message": message,
            "chat_mode": chat_mode,
            "last_sent_content": "",  # [修复] 记录最后发送的内容，防止重复
        }

        # 发送 session 信息
        logger.info(f"[{session_id}] 准备发送 session 事件")
        await safe_write({"type": "session", "sid": session_id, "agent_path": agent_path})
        logger.info(f"[{session_id}] session 事件已发送")

        # ── 定义后台流式处理任务（独立于 SSE 连接） ──
        async def _run_stream_task():
            """后台任务：即使 SSE 断开也完整执行并保存结果"""
            logger.info(f"[{session_id}] _run_stream_task 开始执行")
            # [v1.32.2] 安全修复: 定义 agent_id，避免 except 块中引用时 NameError
            _stream_agent_id = self.core.memory.get_agent_id(agent_path) if self.core.memory and agent_path else 1
            try:
                agent_cfg = self._read_agent_config(agent_path)
                model_chain = self._build_model_chain(agent_cfg, agent_path)
                # [v1.16.14] 有图片时，优先使用支持 vision 的模型
                model_chain = self._reorder_model_chain_for_images(model_chain, bool(user_images))
                # [v1.17.1] 详细模型链日志
                _chain_detail = []
                for _mc in model_chain:
                    _chain_detail.append(f"{_mc.get('name','?')}@{_mc.get('model','?')}(modes={_mc.get('input_modes',[])}provider={_mc.get('provider','?')})")
                logger.info(f"[{session_id}] model_chain ({len(model_chain)} models): {_chain_detail}")
                logger.info(f"[{session_id}] user_images={len(user_images) if user_images else 0}, has_images={bool(user_images)}")

                task_plan_context = self._build_task_plan_context(agent_path, chat_mode, message, session_id=session_id)
                clean_message, agent_system_prompt = self._build_agent_chat_context(agent_path, agent_cfg, message)
                if task_plan_context:
                    agent_system_prompt += "\n\n## 任务规划\n" + task_plan_context

                # 创建一个代理 response 对象，转发到安全写入器
                class ProxyStream:
                    def __init__(self, write_fn):
                        self._write_fn = write_fn
                    async def write(self, data):
                        return await self._write_fn(data)
                    async def write_eof(self):
                        pass
                proxy = ProxyStream(safe_write)

                # 执行流式处理
                if model_chain and self.core.llm:
                    logger.info(f"[{session_id}] 使用 model_chain 流式处理")
                    full_response = await self._try_model_chain_stream(
                        model_chain, clean_message, session_id,
                        agent_path=agent_path, agent_system_prompt=agent_system_prompt,
                        chat_mode=chat_mode, stream_response=proxy,
                        voice_text=voice_text,
                        user_images=user_images, user_files=user_files,
                    )
                elif self.core.main_agent and self.core.llm:
                    full_response = await self._stream_process_message(
                        clean_message, session_id, proxy,
                        agent_path=agent_path, agent_system_prompt=agent_system_prompt,
                        chat_mode=chat_mode, voice_text=voice_text,
                        user_images=user_images, user_files=user_files,
                    )
                else:
                    full_response = await self.core.process_message(clean_message, session_id)
                    await safe_write({"type": "text", "content": full_response})

                # 收集结果
                exec_events = []
                if self.core.main_agent:
                    exec_events = self.core.main_agent.get_execution_events()

                # 任务列表更新（exec 模式）
                if chat_mode == "exec" and self.core.llm and full_response:
                    try:
                        tl = self._extract_task_list_json(full_response)
                        if tl is not None:
                            self._task_list_store[session_id] = tl
                            await safe_write({"type": "task_list_update", "tasks": tl})
                    except Exception:
                        pass

                # 保存结果
                result_store["full_response"] = full_response or ""
                result_store["exec_events"] = exec_events
                result_store["done"] = True
                
                # [v1.26.10] 实时更新 _running_sessions 中的 result 字段，支持刷新页面接续
                if session_id in self._running_sessions:
                    self._running_sessions[session_id]["result"] = full_response or ""
                    self._running_sessions[session_id]["done"] = True
                    self._running_sessions[session_id]["completed_at"] = time.time()

                # 发送完成事件
                await safe_write({"type": "done", "exec_events": exec_events, "sid": session_id})

                # ── 处理消息队列 ──
                while session_id in self._msg_queues and self._msg_queues[session_id]:
                    next_msg_data = self._msg_queues[session_id].pop(0)
                    next_message = next_msg_data.get("message", "").strip()
                    if not next_message:
                        continue
                    logger.info(f"[{session_id}] 处理队列消息: {next_message[:50]}...")
                    await safe_write({"type": "queue_start", "message": next_message, "sid": session_id})
                    clean_message_q, agent_system_prompt_q = self._build_agent_chat_context(agent_path, agent_cfg, next_message)
                    if model_chain and self.core.llm:
                        full_response = await self._try_model_chain_stream(
                            model_chain, clean_message_q, session_id,
                            agent_path=agent_path, agent_system_prompt=agent_system_prompt_q,
                            chat_mode=chat_mode, stream_response=proxy,
                            voice_text="",
                        )
                    elif self.core.main_agent and self.core.llm:
                        full_response = await self._stream_process_message(
                            clean_message_q, session_id, proxy,
                            agent_path=agent_path, agent_system_prompt=agent_system_prompt_q,
                            chat_mode=chat_mode, voice_text="",
                        )
                    else:
                        full_response = await self.core.process_message(clean_message_q, session_id)
                        await safe_write({"type": "text", "content": full_response})
                    exec_events_q = self.core.main_agent.get_execution_events() if self.core.main_agent else []
                    result_store["full_response"] = full_response or ""
                    result_store["exec_events"] = exec_events_q
                    result_store["done"] = True
                    await safe_write({"type": "done", "exec_events": exec_events_q, "sid": session_id})

            except asyncio.CancelledError:
                # [v1.23.38] 任务被取消（用户点击停止），清理资源
                logger.info(f"[{session_id}] 后台流式任务被取消")
                result_store["done"] = True
                result_store["cancelled"] = True
                try:
                    await safe_write({"type": "stopped", "sid": session_id})
                except Exception:
                    pass
            except Exception as e:
                logger.error(f"[{session_id}] 后台流式任务异常: {e}", exc_info=True)
                result_store["error"] = str(e)
                result_store["done"] = True
                # 确保异常时也保存已有内容
                if self.core.memory and session_id:
                    try:
                        conv = self.core.memory.get_conversation(session_id, limit=10)
                        # 检查是否已有 error key 的消息，避免重复保存
                        _has_error_msg = any(
                            (getattr(e, 'key', '') or '') == 'error' for e in (conv or [])
                        )
                        if not _has_error_msg:
                            # [v1.35.2] 始终保存异常信息到 DB，不再判断 conv[-1].role
                            # 因为 reasoning/tool_call 等中间消息可能已被保存
                            self.core.memory.add_session(
                                agent_id=_stream_agent_id,
                                session_id=session_id, role="assistant",
                                content=f"⚠️ [执行异常] {str(e)[:500]}",
                                key="error",
                                importance=0.6,
                            )
                        # 如果最后一条是用户消息且没有任何 assistant 消息，额外保存 full_response
                        if conv and conv[-1].role == "user":
                            saved = result_store.get("full_response", "")
                            if saved:
                                self.core.memory.add_session(agent_id=_stream_agent_id,
                                    session_id=session_id, role="assistant", content=saved,
                                )
                    except Exception:
                        pass
                try:
                    await safe_write({"type": "error", "error": str(e)})
                except Exception:
                    pass
            finally:
                self._running_sessions[session_id]["running"] = False
                self._running_sessions[session_id]["done"] = True
                self._running_sessions[session_id]["completed_at"] = time.time()
                self._running_sessions[session_id]["result"] = result_store.get("full_response", "")
                # [v1.33.5] 释放 per-agent 执行锁
                if needs_lock_check:
                    self._release_agent_lock(agent_path)
                # [v1.20.13] 释放会话级锁
                try:
                    session_lock.release()
                except RuntimeError:
                    pass
                # [v1.23.37] 清理任务引用和取消标志
                self._running_tasks.pop(session_id, None)
                self._session_cancelled.pop(session_id, None)

        # ── 启动后台任务（不等它完成，前端断开也不影响） ──
        logger.info(f"[{session_id}] 准备创建后台任务")
        bg_task = asyncio.create_task(_run_stream_task())
        self._running_tasks[session_id] = bg_task  # [v1.23.37] 保存引用用于取消
        self._session_cancelled[session_id] = False  # [v1.23.37] 初始化取消标志
        logger.info(f"[{session_id}] 后台任务已创建: {bg_task}")

        # ── SSE 事件循环：实时转发后台任务的事件到客户端 ──
        try:
            # 等待后台任务完成
            while not result_store["done"]:
                await asyncio.sleep(0.1)
            # 任务完成后，再等一小会确保所有事件已发送
            await asyncio.sleep(0.2)
        except (asyncio.CancelledError, ConnectionResetError, ConnectionError):
            logger.info(f"[{session_id}] SSE 连接断开，后台任务继续执行")
            client_connected["value"] = False
            # 不等待后台任务完成，让 finally 处理清理
            return
        except Exception as e:
            logger.error(f"[{session_id}] SSE 循环异常: {e}")
            client_connected["value"] = False
            return
        finally:
            try:
                await response.write_eof()
            except Exception:
                pass

        return response

    # [v1.23.37] ── 停止执行 ──

    async def handle_chat_stop(self, request):
        """POST /api/chat/stop - 停止正在执行的任务

        停止链：前端 abort → 调用此 API → 设置取消标志 + cancel task
        → agent 循环检测到取消标志 → 退出循环 → 任务结束
        """
        try:
            data = await request.json()
        except Exception:
            return web.json_response({"error": "invalid JSON"}, status=400)

        session_id = data.get("sid", "") or data.get("session_id", "")
        if not session_id:
            return web.json_response({"ok": False, "error": "missing sid"})

        logger.info(f"[{session_id}] 收到停止执行请求")

        # 1. 设置取消标志（agent 循环会检查）
        self._session_cancelled[session_id] = True

        # 1.5 设置 MainAgent 上的取消标志（agent 循环会检查 _cancelled_sessions）
        if self.core.main_agent:
            self.core.main_agent._cancelled_sessions.add(session_id)

        # 2. 尝试取消 asyncio.Task
        task = self._running_tasks.get(session_id)
        cancelled = False
        if task and not task.done():
            task.cancel()
            cancelled = True
            logger.info(f"[{session_id}] 已发送 task.cancel()")

        # 3. 更新会话状态
        sess = self._running_sessions.get(session_id)
        if sess and sess.get("running"):
            sess["running"] = False
            sess["done"] = True
            sess["cancelled"] = True

        # 4. 尝试终止正在运行的子进程（如果 executor 正在执行命令）
        try:
            if hasattr(self.core, 'executor') and self.core.executor:
                self.core.executor.cancel_current()
        except Exception as e:
            logger.debug(f"[{session_id}] executor 取消失败: {e}")

        return web.json_response({"ok": True, "cancelled": cancelled})

    async def handle_chat_inject(self, request):
        """POST /api/chat/inject - 注入消息到正在执行的任务，或进入排队序列"""
        try:
            data = await request.json()
        except Exception:
            return web.json_response({"error": "invalid JSON"}, status=400)

        message = data.get("message", "").strip()
        if not message:
            return web.json_response({"error": "message is required"}, status=400)

        agent_path = data.get("agent_path", "1") or "1"
        session_id = data.get("sid", "") or data.get("session_id", "")
        choice = data.get("choice", "queue")  # "continue" (插入后继续) 或 "queue" (排队)

        # 检查会话是否正在运行
        if not self.core.main_agent:
            return web.json_response({"error": "Agent is not ready"}, status=500)

        running_ctx = self.core.main_agent.active_contexts.get(session_id)

        if not running_ctx:
            return web.json_response({
                "error": "当前会话没有正在运行的任务，请直接通过 /api/chat 发送消息。",
                "session_not_running": True
            }, status=400)

        if choice == "continue":
            # 模式 a: 插入消息后，任务继续
            running_ctx.pending_injected_messages.append(message)
            logger.info(f"[Injected] 消息已注入到会话 {session_id} (continue模式)")
            return web.json_response({
                "ok": True,
                "action": "injected",
                "message": "消息已注入，将在下一轮迭代中生效"
            })
        else:
            # 模式 b: 排队，待当前任务完成后发送
            queue = self._msg_queues.setdefault(session_id, [])
            queue.append(data)
            logger.info(f"[Queued] 消息已进入会话 {session_id} 的队列 (queue模式)")
            return web.json_response({
                "ok": True,
                "action": "queued",
                "message": "消息已进入队列，将在当前任务完成后自动开始"
            })

    async def handle_voice_optimize(self, request):
        """POST /api/voice-optimize - 语音输入文本优化（LLM）"""
        try:
            data = await request.json()
        except Exception:
            return web.json_response({"error": "invalid JSON"}, status=400)

        raw_text = data.get("text", "").strip()
        if not raw_text:
            return web.json_response({"error": "text is required"}, status=400)

        agent_path = data.get("agent_path", "1") or "1"
        session_id = data.get("sid", "") or data.get("session_id", "")
        chat_mode = data.get("mode", "")

        # Build full session ID
        if session_id and not session_id.startswith(f"{agent_path}_"):
            full_session_id = f"{agent_path}_{session_id}"
        else:
            full_session_id = session_id or f"{agent_path}_web_voice"

        try:
            # Get recent conversation context from memory
            context_msgs = []
            try:
                history = self.core.memory.get_conversation(full_session_id, limit=10)
                if history:
                    for msg in history:
                        role = getattr(msg, "role", "user")
                        content = getattr(msg, "content", "")
                        if content and role in ("user", "assistant"):
                            context_msgs.append((role, content))
            except Exception:
                pass  # No history available, that's fine

            # Build context string
            context_str = ""
            if context_msgs:
                lines = []
                for r, m in context_msgs[-8:]:
                    role_label = "用户" if r == "user" else "助手"
                    lines.append(f"[{role_label}] {m}")
                context_str = "\n\n以下是最近的对话上下文，供参考：\n" + "\n".join(lines)

            # Build optimization prompt
            system_prompt = (
                "你是一个语音输入优化助手。用户通过语音输入了一段话，"
                "由于语音识别可能有错误、口语化表达、缺少标点等问题，"
                "请你帮用户优化这段语音转写文本。\n\n"
                "规则：\n"
                "1. 修正语音识别错误\n"
                "2. 补充适当的标点符号\n"
                "3. 将口语化表达转为书面语（但保持自然）\n"
                "4. 不要改变用户原本的意思和意图\n"
                "5. 如果用户提到技术术语、代码、命令等，确保准确性\n"
                "6. 直接输出优化后的文本，不要添加任何解释或前缀\n"
                "7. 如果原文已经很清晰，保持原样或只做最小修改"
            )

            user_prompt = f"请优化以下语音转写文本：\n\n{raw_text}"
            if context_str:
                user_prompt += context_str

            # Use agent's model chain if available, otherwise use default LLM
            agent_cfg = self._read_agent_config(agent_path)
            model_chain = self._build_model_chain(agent_cfg, agent_path)

            from core.llm import Message as LLMMsg

            messages = [
                LLMMsg(role="system", content=system_prompt),
                LLMMsg(role="user", content=user_prompt),
            ]

            llm = self.core.llm
            # [v1.31.4] 使用 _model_chain_lock 防止并发请求互相覆盖全局 LLM 配置
            async with self._model_chain_lock:
                # Save original LLM settings
                orig_settings = {
                    "provider": llm.provider,
                    "model": llm.model,
                    "base_url": llm.base_url,
                    "api_key": llm.api_key,
                    "temperature": llm.temperature,
                    "max_tokens": llm.max_tokens,
                }

                try:
                    optimized = raw_text  # Fallback to raw text

                    if model_chain:
                        # Try first model in chain
                        mc = model_chain[0]
                        if "provider" in mc:
                            llm.provider = mc["provider"]
                        if "model" in mc:
                            llm.model = mc["model"]
                        if "base_url" in mc:
                            llm.base_url = mc["base_url"]
                        if "api_key" in mc:
                            llm.api_key = mc["api_key"]
                        llm.temperature = 0.3  # Low temp for optimization
                        llm.max_tokens = 1024
                        llm._client = None  # Force re-init

                    response = await llm.chat(messages)

                    if response.success and response.content and response.content.strip():
                        # Extract just the optimized text (remove any markdown or quotes)
                        opt = response.content.strip()
                        # Remove surrounding quotes if present
                        if (opt.startswith('"') and opt.endswith('"')) or \
                           (opt.startswith("'") and opt.endswith("'")):
                            opt = opt[1:-1]
                        optimized = opt
                finally:
                    # Restore original LLM settings
                    for key, val in orig_settings.items():
                        setattr(llm, key, val)
                    llm._client = None  # Force re-init

            return web.json_response({
                "optimized": optimized,
                "raw": raw_text,
            })

        except Exception as e:
            logger.error(f"Voice optimize failed: {e}")
            return web.json_response({"error": str(e)}, status=500)

    async def handle_voice_stt(self, request):
        """POST /api/voice-stt - 本地语音转文字

        接受音频文件（WAV/WEBM/OGG），使用本地 STT 引擎转录。
        支持的引擎（按优先级）：
        1. SenseVoice（推荐，中文识别最佳，需：pip install funasr torch torchaudio）
        2. vosk（备选，需安装：pip install vosk）
        3. LLM API Whisper 兼容端点
        4. SpeechRecognition（Google，需外网）
        """
        try:
            reader = await request.multipart()
            audio_data = None
            audio_format = None

            while True:
                field = await reader.next()
                if field is None:
                    break
                if field.name == 'audio':
                    audio_data = await field.read(decode=False)
                elif field.name == 'format':
                    audio_format = (await field.read(decode=True)).decode('utf-8').strip()

            if not audio_data:
                # 也支持 JSON body：{"audio": "base64...", "format": "wav"}
                try:
                    data = await request.json()
                    audio_b64 = data.get("audio", "")
                    audio_format = data.get("format", "wav")
                    if audio_b64:
                        import base64
                        audio_data = base64.b64decode(audio_b64)
                except Exception:
                    pass

            if not audio_data:
                return web.json_response({"error": "未收到音频数据"}, status=400)

            import io

            # ── [v1.18.7] 首选: SenseVoice（阿里达摩院，中文识别极佳） ──
            try:
                sv_model = getattr(self, '_sensevoice_model', None)
                if sv_model is None:
                    os.environ.setdefault("HF_HUB_DISABLE_TELEMETRY", "1")
                    os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
                    from funasr import AutoModel
                    model_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'models', 'sensevoice')
                    sv_model = AutoModel(model="iic/SenseVoiceSmall", model_dir=model_dir,
                                         device="cpu", disable_pbar=True, disable_update=True)
                    self._sensevoice_model = sv_model
                    logger.info("SenseVoice 模型已加载 (iic/SenseVoiceSmall, CPU)")

                # SenseVoice 接受 16kHz WAV
                # [v1.23.2] 增强: pydub 转换失败时记录警告、验证 WAV 头、检查音频长度
                wav_path = f"/tmp/myagent_stt_{id(audio_data) % 100000}.wav"
                wav_buf = io.BytesIO()
                pydub_ok = False
                try:
                    from pydub import AudioSegment
                    audio_buf = io.BytesIO(audio_data)
                    seg = AudioSegment.from_file(audio_buf, format=audio_format or "webm")
                    # 检查音频时长，过短直接跳过
                    if seg.duration_seconds < 0.1:
                        logger.debug(f"SenseVoice 跳过: 音频过短 ({seg.duration_seconds:.2f}s)")
                    else:
                        seg = seg.set_channels(1).set_frame_rate(16000).set_sample_width(2)
                        seg.export(wav_buf, format="wav")
                        pydub_ok = True
                except Exception as conv_err:
                    import shutil
                    if not shutil.which("ffmpeg"):
                        logger.warning(f"pydub 转换失败且缺少 ffmpeg: {conv_err}. 安装: sudo apt install ffmpeg")
                    else:
                        logger.warning(f"pydub 音频转换失败: {conv_err}")

                if not pydub_ok:
                    wav_buf = io.BytesIO(audio_data)

                wav_buf.seek(0)
                wav_bytes = wav_buf.read()

                # 验证 WAV 文件头 (RIFF....WAVE)
                if len(wav_bytes) < 44 or wav_bytes[:4] != b'RIFF' or wav_bytes[8:12] != b'WAVE':
                    logger.warning(f"SenseVoice 跳过: 无效 WAV 数据 (size={len(wav_bytes)}, header={wav_bytes[:12].hex()})")
                else:
                    with open(wav_path, 'wb') as f:
                        f.write(wav_bytes)

                    # SenseVoice 推理
                    res = sv_model.generate(input=wav_path, cache={},
                                            language="auto",  # 自动检测语言
                                            use_itn=True,     # 逆文本标准化（数字/日期等）
                                            batch_size_s=300)
                    if res and len(res) > 0 and len(res[0]) > 0:
                        text = res[0][0]["text"] if isinstance(res[0][0], dict) else str(res[0][0])
                        # SenseVoice 可能输出带 <|zh|><|en|><|EMO|> 等特殊 token，清理掉
                        import re
                        text = re.sub(r'<\|[^|]+\|>', '', text).strip()
                        if text:
                            try:
                                os.remove(wav_path)
                            except Exception:
                                pass
                            return web.json_response({"text": text, "engine": "sensevoice"})
                try:
                    os.remove(wav_path)
                except Exception:
                    pass
            except ImportError:
                logger.debug("SenseVoice (funasr) 未安装，跳过。安装: pip install funasr torch torchaudio")
            except Exception as e:
                err_str = str(e)
                if "ffmpeg" in err_str.lower() or "No such file" in err_str:
                    logger.warning(f"SenseVoice 转录失败 (缺少 ffmpeg): {e}")
                    # [v1.20.9] 检测 ffmpeg 并给出安装提示
                    import shutil
                    if not shutil.which("ffmpeg"):
                        logger.warning("⚠ 系统缺少 ffmpeg，SenseVoice 无法转换音频格式。安装: sudo apt install ffmpeg 或 brew install ffmpeg")
                else:
                    logger.warning(f"SenseVoice 转录失败: {e}")

            # ── 尝试 vosk ──
            try:
                import vosk
                model = getattr(self, '_vosk_model', None)
                if model is None:
                    import os, zipfile
                    model_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'models', 'vosk', 'vosk-model-cn')
                    if not os.path.exists(model_dir):
                        # 自动下载 vosk 小型中文模型
                        logger.info("正在下载 vosk 中文模型...")
                        import urllib.request
                        url = "https://alphacephei.com/vosk/models/vosk-model-small-cn-0.22.zip"
                        zip_path = model_dir + ".zip"
                        os.makedirs(os.path.dirname(model_dir), exist_ok=True)
                        try:
                            urllib.request.urlretrieve(url, zip_path)
                            with zipfile.ZipFile(zip_path, 'r') as zf:
                                zf.extractall(os.path.dirname(model_dir))
                            os.remove(zip_path)
                        except Exception as de:
                            logger.warning(f"vosk 模型下载失败: {de}")
                    if os.path.exists(model_dir):
                        self._vosk_model = vosk.Model(model_dir)
                        model = self._vosk_model

                if model:
                    import json as _json
                    rec = vosk.KaldiRecognizer(model, 16000)
                    rec.AcceptWaveform(audio_data)
                    result = _json.loads(rec.Result())
                    text = result.get("text", "").strip()
                    if text:
                        return web.json_response({"text": text, "engine": "vosk"})
            except ImportError:
                logger.debug("vosk 未安装，跳过")
            except Exception as e:
                logger.warning(f"vosk 转录失败: {e}")

            # ── 尝试用户已配置的 LLM API (Whisper 兼容端点) ──
            # 大多数 OpenAI 兼容 API 都支持 /v1/audio/transcriptions
            # 无需额外依赖、无需编译、国内可用
            try:
                import aiohttp
                cfg = self.core.config_mgr.config.llm
                api_key = cfg.api_key or ""
                base_url = (cfg.base_url or "").rstrip("/")
                if api_key and base_url:
                    # 构造 Whisper API URL
                    if base_url.endswith("/v1"):
                        whisper_url = base_url + "/audio/transcriptions"
                    else:
                        whisper_url = base_url.rstrip("/v1") + "/v1/audio/transcriptions"
                    # 准备音频数据
                    audio_bytes = audio_data
                    if audio_format and audio_format not in ("wav", "WAV"):
                        # 非 WAV 格式，尝试用内置 wave 模块（如果已经是 WAV 则直接用）
                        try:
                            import wave, io
                            buf = io.BytesIO(audio_data)
                            with wave.open(buf, 'rb') as rf:
                                audio_bytes = audio_data  # 已经是 WAV
                        except Exception:
                            audio_bytes = audio_data  # 不是 WAV 也尝试发送

                    import mimetypes
                    fmt = audio_format or "wav"
                    mime = mimetypes.guess_type(f"audio.{fmt}")[0] or "audio/wav"

                    data = aiohttp.FormData()
                    data.add_field('file', audio_bytes, filename=f'audio.{fmt}', content_type=mime)
                    data.add_field('model', 'whisper-1')
                    data.add_field('language', 'zh')

                    headers = {"Authorization": f"Bearer {api_key}"}
                    async with aiohttp.ClientSession() as session:
                        async with session.post(whisper_url, data=data, headers=headers, timeout=aiohttp.ClientTimeout(total=30)) as resp:
                            if resp.status == 200:
                                result = await resp.json()
                                text = result.get("text", "").strip()
                                if text:
                                    logger.info(f"LLM API (Whisper) 转录成功: {base_url}")
                                    return web.json_response({"text": text, "engine": "llm_api"})
                            else:
                                err_text = await resp.text()
                                logger.debug(f"LLM API Whisper 端点不可用 ({resp.status}): {err_text[:200]}")
            except Exception as e:
                logger.debug(f"LLM API Whisper 转录失败: {e}")

            # ── 尝试 SpeechRecognition (Google Web Speech API, 纯 Python，需外网) ──
            try:
                import speech_recognition as sr
                wav_buf = io.BytesIO(audio_data)
                try:
                    audio_buf = io.BytesIO(audio_data)
                    from pydub import AudioSegment
                    seg = AudioSegment.from_file(audio_buf, format=audio_format or "webm")
                    seg = seg.set_channels(1).set_frame_rate(16000).set_sample_width(2)
                    seg.export(wav_buf, format="wav")
                except Exception:
                    wav_buf = io.BytesIO(audio_data)
                wav_buf.seek(0)
                recognizer = sr.Recognizer()
                with sr.AudioFile(wav_buf) as source:
                    audio = recognizer.record(source)
                text = recognizer.recognize_google(audio, language="zh-CN")
                if text:
                    logger.info("SpeechRecognition (Google API) 转录成功")
                    return web.json_response({"text": text, "engine": "speech_recognition"})
            except ImportError:
                logger.debug("SpeechRecognition 未安装，跳过")
            except sr.UnknownValueError:
                logger.debug("SpeechRecognition 无法识别音频内容")
            except sr.RequestError as e:
                logger.warning(f"SpeechRecognition API 请求失败: {e}")
            except Exception as e:
                logger.warning(f"SpeechRecognition 转录失败: {e}")

            # ── 没有可用的 STT 引擎 ──
            return web.json_response({
                "error": "未检测到可用的 STT 引擎。请尝试以下方案：\n"
                         "  1. pip install funasr torch torchaudio  (SenseVoice，中文最佳，推荐)\n"
                         "  2. 配置支持 Whisper 的 LLM API（自动使用，无需安装）\n"
                         "  3. pip install vosk             (离线本地，需下载模型)\n"
                         "  4. pip install SpeechRecognition (需外网，国内不可用)",
                "available": False,
            }, status=503)

        except Exception as e:
            logger.error(f"Voice STT failed: {e}", exc_info=True)
            return web.json_response({"error": str(e)}, status=500)

    def _build_task_plan_context(self, agent_path: str, chat_mode: str, original_message: str, session_id: str = "") -> str:
        """构建任务规划上下文（仅 exec 模式，注入到 system_prompt 中）

        [v1.38] 适配原生 tool_calling 模式，不再引用 XML 格式。
        """
        if chat_mode != "exec":
            return ""

        return (
            "你当前处于【执行模式】(Execution Mode)。\n\n"
            "## 执行模式核心规则\n"
            "1. **任务进度跟踪**：通过调用 task_plan 工具管理任务进度。\n"
            "   - 创建任务计划：task_plan(action=\"create\", plan=\"- [ ] 步骤1\\n- [ ] 步骤2\")\n"
            "   - 更新完成状态：task_plan(action=\"update\", task_index=0, completed=true)\n"
            "   - 查询当前计划：task_plan(action=\"get\")\n"
            "   - 复杂任务（超过3步）应先创建任务计划，每完成一步后更新状态\n"
            "   - 简单任务（不超过3步）可以不用创建任务计划，直接执行即可\n"
            "2. **继续执行**：任务未完成时，继续调用工具执行下一步操作。\n"
            "3. **任务完成**：所有步骤完成后，直接在回复文本中给出完成总结。\n"
        )

    async def handle_chat_page(self, request):
        """GET /chat - 重定向到聊天页面"""
        raise web.HTTPFound("/ui/chat/chat_container.html")

    # --- System ---
    async def handle_status(self, request):
        c = self.core
        from core.version import get_version

        # 模型库摘要
        models_summary = []
        for m in c.config.models_library:
            models_summary.append({
                "id": m.id, "name": m.name, "provider": m.provider,
                "model": m.model, "enabled": m.enabled,
                "input_modes": m.input_modes or ["text"],
                "is_global_fallback": getattr(m, 'is_global_fallback', True),
            })

        # 全局 LLM 信息
        global_llm = {
            "provider": c.config.llm.provider,
            "model": c.config.llm.model,
            "input_modes": c.config.llm.input_modes or ["text"],
            "base_url": c.config.llm.base_url,
        }

        # 当前运行中的 agent 会话
        running_agents = []
        for sid, info in self._running_sessions.items():
            if info.get("running") and not info.get("done"):
                running_agents.append({
                    "sid": sid,
                    "agent_path": info.get("agent_path", ""),
                    "message": info.get("message", ""),
                    "started_at": info.get("started_at"),
                })

        # [v1.47.2] 提供环境信息给前端（决定是否显示VNC按钮等）
        try:
            from core.env_detect import is_termux, is_desktop
            _is_termux = is_termux()
            _is_desktop = is_desktop()
        except ImportError:
            _is_termux = False
            _is_desktop = False

        return web.json_response({
            "running": c._running,
            "version": get_version(),
            "global_llm": global_llm,
            "models": models_summary,
            "skills": len(c.skill_registry.list_skills()) if c.skill_registry else 0,
            "memory": c.memory.get_stats() if c.memory else {},
            "queue": c.task_queue.get_stats() if c.task_queue else {},
            "running_agents": running_agents,
            # [v1.32.2] 提供 API Token 给前端（前端需要调用敏感接口时使用）
            "api_token": self._api_token,
            # [v1.47.2] 环境信息
            "is_termux": _is_termux,
            "is_desktop": _is_desktop,
        })

    # ── Task List（纯内存 JSON 存储，exec 模式专用） ──

    def _extract_task_list_json(self, response_text: str) -> list | None:
        """从 LLM 回复中提取 ```tasklist``` JSON 数组

        Returns:
            list | None: 成功返回任务列表 [{text, status, ...}]，失败返回 None
        """
        import re
        # 匹配 ```tasklist ... ``` 代码块
        match = re.search(r'```tasklist\s*\n(.*?)\n```', response_text, re.DOTALL)
        if not match:
            return None
        raw = match.group(1).strip()
        try:
            data = json.loads(raw)
        except (json.JSONDecodeError, ValueError):
            return None
        if not isinstance(data, list) or len(data) == 0:
            return None
        # 验证并规范化每个任务项
        valid_statuses = {"pending", "running", "done", "blocked"}
        tasks = []
        for item in data:
            if not isinstance(item, dict):
                continue
            text = str(item.get("text", "")).strip()
            if not text:
                continue
            status = str(item.get("status", "pending")).strip().lower()
            if status not in valid_statuses:
                status = "pending"
            tasks.append({"text": text, "status": status})
        return tasks if tasks else None

    def _parse_v2_task_plan(self, plan_text: str) -> list | None:
        """将 V2 结构化输出的 Markdown 任务计划解析为 [{text, status}] 格式。

        V2 的 task_plan 使用 Markdown 列表格式：
          - [ ] 未完成任务        → pending
          - [x] 已完成任务        → done
          - [ ] 正在进行的任务     → running (如果含特定关键词)
          - [ ] 受阻的任务         → blocked (如果含特定关键词)

        同时支持有序列表（1. 2. 3.）和无序列表（- *），
        以及混合格式如 [pending] [running] [done] 标签。

        Returns:
            list | None: 成功返回任务列表，失败返回 None
        """
        import re
        if not plan_text or not plan_text.strip():
            return None

        tasks = []
        lines = plan_text.strip().split('\n')

        for line in lines:
            line = line.strip()
            if not line:
                continue

            text = ""
            status = "pending"

            # 格式1: - [x] 或 - [ ] (Markdown checkbox)
            m = re.match(r'^[\-\*\d\.]+\s*\[([xX\s])\]\s*(.+)$', line)
            if m:
                checkbox = m.group(1).strip()
                text = m.group(2).strip()
                status = "done" if checkbox.lower() == 'x' else "pending"
            else:
                # 格式2: - [pending] / [running] / [done] / [blocked] 文本
                m = re.match(r'^[\-\*\d\.]+\s*\[(pending|running|done|blocked)\]\s*(.+)$', line, re.IGNORECASE)
                if m:
                    status = m.group(1).lower()
                    text = m.group(2).strip()
                else:
                    # 格式3: - 纯文本 或 数字. 纯文本（作为 pending）
                    m = re.match(r'^[\-\*\d\.\)]+\s+(.+)$', line)
                    if m:
                        text = m.group(1).strip()
                        # 跳过标题行（如 "## 任务计划"）
                        if text.startswith('#'):
                            continue
                    else:
                        # 跳过非列表行
                        continue

            if not text:
                continue

            # 智能检测状态关键词（即使没有 checkbox/标签）
            if status == "pending":
                lower_text = text.lower()
                if any(kw in lower_text for kw in ['正在', '进行中', '执行中', '处理中']):
                    status = "running"
                elif any(kw in lower_text for kw in ['受阻', '阻塞', '等待', '暂停']):
                    status = "blocked"

            tasks.append({"text": text, "status": status})

        return tasks if tasks else None

    def _merge_task_list(self, session_id: str, llm_task_list: list) -> list:
        """
        合并 LLM 输出的 tasklist 与服务端存储的现有任务状态。

        核心规则：
          1. 服务端通过执行成功标记为 done 的任务，不会被 LLM 覆盖回 pending/running
          2. LLM 可以将 pending → running, pending/running → done（正常推进）
          3. LLM 新增的任务直接添加
          4. 保留服务端存储中 LLM 没提到的已完成任务

        这解决了 LLM 多轮对话中忘记已完成任务、导致循环重复执行的问题。
        """
        stored = self._task_list_store.get(session_id, [])

        # 构建"服务端确认完成"集合：通过代码执行成功标记为 done 的任务
        # 这些任务的 text 和 status 都来自服务端逻辑（_execute_actions_streaming 之后的状态更新）
        server_done_texts = set()
        for t in stored:
            if t.get("status") == "done":
                server_done_texts.add(t.get("text", "").strip())

        # 合并结果
        merged = []
        matched_stored_indices = set()

        for llm_task in llm_task_list:
            llm_text = llm_task.get("text", "").strip()
            llm_status = llm_task.get("status", "pending")

            # 查找匹配的已存储任务（按文本内容匹配）
            best_idx = -1
            for i, st in enumerate(stored):
                if i in matched_stored_indices:
                    continue
                if st.get("text", "").strip() == llm_text:
                    best_idx = i
                    break

            if best_idx >= 0:
                matched_stored_indices.add(best_idx)
                stored_status = stored[best_idx].get("status", "pending")

                # 关键保护：服务端已标记 done 的任务不能被 LLM 回退
                if stored_status == "done":
                    merged.append({
                        "text": llm_text,
                        "status": "done",
                    })
                else:
                    # 允许 LLM 正常推进：pending → running → done
                    merged.append({
                        "text": llm_text,
                        "status": llm_status,
                    })
            else:
                # LLM 新增的任务
                merged.append({
                    "text": llm_text,
                    "status": llm_status,
                })

        # 保留服务端已 done 但 LLM 没提到的任务（LLM 可能漏掉了）
        for i, st in enumerate(stored):
            if i not in matched_stored_indices and st.get("status") == "done":
                merged.append({
                    "text": st.get("text", ""),
                    "status": "done",
                })

        # ── 硬上限：任务列表不允许超过 MAX_TASK_ITEMS 条 ──
        # 系统提示词虽然要求 LLM 精简到 8 条，但 LLM 经常忽略，
        # 加上 merge 保留 done 项，会导致列表无限增长。
        # 策略：优先保留未完成项 + 最近完成的项，移除最早的已完成项
        MAX_TASK_ITEMS = 10
        if len(merged) > MAX_TASK_ITEMS:
            # 分离未完成和已完成
            unfinished = [t for t in merged if t.get("status") != "done"]
            finished = [t for t in merged if t.get("status") == "done"]
            # 保留所有未完成项 + 尽可能多的已完成项（保留最新的）
            remaining = MAX_TASK_ITEMS - len(unfinished)
            if remaining > 0:
                finished = finished[-remaining:]  # 保留最近完成的
            else:
                finished = []
            merged = unfinished + finished

        return merged

    async def handle_get_task_plan(self, request):
        """GET /api/task-plan?agent=default&session=xxx - Get task list from memory.

        优先使用 session_id 查找（与聊天流存储键一致），
        回退到 agent_path 查找（手动创建的任务）。
        """
        agent_path = request.query.get("agent", "1")
        session_id = request.query.get("session", "")
        # 优先按 session_id 查找（聊天流生成的任务列表存储在此键下）
        if session_id:
            tasks = self._task_list_store.get(session_id, [])
            if tasks:
                return web.json_response({"agent": agent_path, "session": session_id, "tasks": tasks})
        # 回退按 agent_path 查找（手动添加的任务）
        tasks = self._task_list_store.get(agent_path, [])
        return web.json_response({"agent": agent_path, "session": session_id, "tasks": tasks})

    async def handle_update_task_plan(self, request):
        """PUT /api/task-plan - Overwrite entire task list."""
        data = await request.json()
        agent_path = data.get("agent", "1")
        session_id = data.get("session", "")
        tasks = data.get("tasks", [])
        # 优先按 session_id 存储（与聊天流一致），回退到 agent_path
        store_key = session_id or agent_path
        self._task_list_store[store_key] = tasks
        return web.json_response({"ok": True, "agent": agent_path, "session": session_id, "tasks": tasks})

    async def handle_add_task_item(self, request):
        """POST /api/task-plan - Add a new task item."""
        data = await request.json()
        agent_path = data.get("agent", "1")
        session_id = data.get("session", "")
        text = data.get("text", "").strip()
        if not text:
            return web.json_response({"error": "text is required"}, status=400)
        # 优先按 session_id 查找，回退到 agent_path
        store_key = session_id or agent_path
        tasks = list(self._task_list_store.get(store_key, []))
        tasks.append({"text": text, "status": "pending"})
        self._task_list_store[store_key] = tasks
        return web.json_response({"ok": True, "tasks": tasks, "added": len(tasks) - 1, "session": session_id})

    async def handle_delete_task_item(self, request):
        """DELETE /api/task-plan/{idx} - Delete task by index."""
        try:
            idx = int(request.match_info["idx"])
        except (ValueError, TypeError):
            return web.json_response({"ok": False, "error": "无效的索引"}, status=400)
        agent_path = request.query.get("agent", "1")
        session_id = request.query.get("session", "")
        # 优先按 session_id 查找，回退到 agent_path
        store_key = session_id or agent_path
        tasks = list(self._task_list_store.get(store_key, []))
        if 0 <= idx < len(tasks):
            tasks.pop(idx)
        self._task_list_store[store_key] = tasks
        return web.json_response({"ok": True, "tasks": tasks, "session": session_id})

    async def handle_get_all_task_plans(self, request):
        """GET /api/task-plan/all - 获取所有活跃的 task plan（用于后台管理任务列表）。

        返回所有 _task_list_store 中的非空任务列表，包含 session_id/agent_path 信息。
        """
        all_plans = []
        for key, tasks in self._task_list_store.items():
            if not tasks:
                continue
            # 统计状态
            status_counts = {"pending": 0, "running": 0, "done": 0}
            for t in tasks:
                s = t.get("status", "pending")
                status_counts[s] = status_counts.get(s, 0) + 1
            # 判断 key 类型并生成可读标签
            is_session = key.startswith(("web_", "cli_", "group_", "tg_", "discord_", "feishu_"))
            # 前缀映射表
            _prefix_map = {
                "web_": "网页", "cli_": "命令行", "group_": "群聊",
                "tg_": "Telegram", "discord_": "Discord", "feishu_": "飞书",
            }
            label = key
            if is_session:
                for pfx, display_name in _prefix_map.items():
                    if key.startswith(pfx):
                        short_id = key[len(pfx):len(pfx) + 12]
                        label = f"{display_name} ({short_id}…)"
                        break
            all_plans.append({
                "key": key,
                "type": "session" if is_session else "agent",
                "total": len(tasks),
                "pending": status_counts["pending"],
                "running": status_counts["running"],
                "done": status_counts["done"],
                "tasks": tasks,
                "label": label,
            })
        # 按 total 降序
        all_plans.sort(key=lambda x: x["total"], reverse=True)
        return web.json_response({"plans": all_plans, "total_keys": len(self._task_list_store)})

    async def handle_shutdown(self, request):
        self.core._running = False
        asyncio.create_task(self.core.shutdown())
        return web.json_response({"ok": True})

    # --- Agents (层级体系) ---
    # 目录结构: agents/1/{config.json, soul.md, ...}
    #           agents/2/{config.json, soul.md, ...}
    # agent path = agent ID, 如 "1", "2", "3"

    def _agents_dir(self):
        d = self.core.config_mgr.data_dir / "agents"
        d.mkdir(parents=True, exist_ok=True)
        return d

    def _agent_dir(self, aid: str) -> Path:
        """根据 agent ID 返回目录 (aid 如 '1', '2')"""
        return self._agents_dir() / aid


    async def _ensure_agents_initialized(self):
        """[v1.31.1] 异步安全地确保系统 Agent 只创建一次。
        
        使用 asyncio.Lock 防止并发请求导致重复创建。
        """
        if self._agents_initialized:
            return
        async with self._agents_init_lock:
            if self._agents_initialized:
                return
            self._ensure_default_agent()
            # [v1.33.0] 同步 agent ID 序列号：确保 .agent_id_seq 不小于已有 agent 目录的最大 ID
            self._sync_agent_id_seq()
            self._agents_initialized = True
            logger.info("系统 Agent 初始化完成（全权Agent + 配置助手）")

    def _sync_agent_id_seq(self):
        """[v1.33.0] 同步 agent ID 序列号，确保 .agent_id_seq >= 已有 agent 目录的最大数字 ID。
        
        问题：_ensure_default_agent() 用硬编码 aid=1/2 创建目录但不更新序列号，
        导致 next_agent_id() 从 0 开始计数，新建 agent 时 ID 1/2 冲突。
        修复：扫描 agents/ 目录中所有数字子目录，取最大值与 .agent_id_seq 对比，
        取较大者写入 .agent_id_seq，保证后续 next_agent_id() 不会返回已用 ID。
        """
        from core.utils import _AGENT_ID_SEQ
        try:
            # 扫描 agents/ 目录中的数字子目录
            agents_dir = self._agents_dir()
            max_id = 0
            if agents_dir.exists():
                for d in agents_dir.iterdir():
                    if d.is_dir() and d.name.isdigit():
                        max_id = max(max_id, int(d.name))

            # 读取当前序列号
            try:
                cur_seq = int(_AGENT_ID_SEQ.read_text().strip())
            except (FileNotFoundError, ValueError):
                cur_seq = 0

            # 取较大者
            new_seq = max(max_id, cur_seq)
            if new_seq > cur_seq:
                _AGENT_ID_SEQ.parent.mkdir(parents=True, exist_ok=True)
                _AGENT_ID_SEQ.write_text(str(new_seq))
                logger.info(f"Agent ID 序列号已同步: {cur_seq} → {new_seq} (最大目录 ID={max_id})")
        except Exception as e:
            logger.warning(f"同步 Agent ID 序列号失败: {e}")

    def _ensure_default_agent(self):
        """确保默认 agent 存在（ID=1，名为「全权Agent」，目录名=1）
        
        [v1.31.1] 改为内部方法，由 _ensure_agents_initialized 统一调用。
        不再由各个 API handler 单独调用，防止并发重复创建。
        """
        aid = "1"
        ad = self._agent_dir(aid)
        now = _now_iso()
        created = False
        
        # 确保文件系统存在
        if not (ad / "config.json").exists():
            ad.mkdir(parents=True, exist_ok=True)
            cfg = {
                "id": 1,
                "path": aid,
                "name": "全权Agent",
                "description": "全权Agent - 拥有完整权限的本地助手",
                "avatar_color": _agent_color("全权Agent"),
                "avatar_emoji": "🤖",
                "execution_mode": "local",
                "enabled": True,
                "system": True,
                "created_at": now,
                "updated_at": now,
                "system_prompt": "你是 MyAgent 默认助手，运行在本机模式。请用友好、专业的方式回答用户的问题。",
            }
            (ad / "config.json").write_text(json.dumps(cfg, indent=2, ensure_ascii=False), encoding="utf-8")
            for fn, default in [
                ("soul.md", "# 全权Agent\n\n## 性格\n专业、友好的AI助手\n"),
                ("identity.md", "# 全权Agent\n\n## 身份\nMyAgent 默认AI助手\n"),
                ("user.md", "# 用户信息\n\n## 用户偏好\n<!-- 在此处记录用户偏好 -->\n"),
            ]:
                if not (ad / fn).exists():
                    (ad / fn).write_text(default, encoding="utf-8")
            logger.info("已创建默认 Agent (全权Agent)")
            created = True
        
        # [v1.34.0] 同时写入数据库
        if self.core.agent_storage:
            try:
                cfg = json.loads((ad / "config.json").read_text(encoding="utf-8"))
                from core.agent_storage import AgentConfig
                agent_cfg = AgentConfig(**cfg)
                existing = self.core.agent_storage.get(aid)
                if existing:
                    self.core.agent_storage.update(aid, cfg)
                else:
                    self.core.agent_storage.create(agent_cfg)
                if created:
                    logger.info("已同步默认 Agent 到数据库")
            except Exception as e:
                logger.error(f"同步默认 Agent 到数据库失败: {e}")

        self._ensure_config_helper()
    def _ensure_config_helper(self):
        """确保系统级「配置助手」agent 存在（ID=2，目录名=2）"""
        aid = "2"
        ad = self._agent_dir(aid)
        if not (ad / "config.json").exists():
            ad.mkdir(parents=True, exist_ok=True)
            now = _now_iso()
            cfg = {
                "id": 2,
                "path": aid,
                "name": "配置助手",
                "description": "MyAgent 智能配置助手 - 内置系统Agent，帮助用户完成初始配置和日常配置管理",
                "avatar_color": "#4f46e5",
                "avatar_emoji": "🛡️",
                "execution_mode": "local",
                "enabled": True,
                "system": True,
                "created_at": now,
                "updated_at": now,
                "system_prompt": CONFIG_HELPER_PROMPT,
            }
            (ad / "config.json").write_text(json.dumps(cfg, indent=2, ensure_ascii=False), encoding="utf-8")
            for fn, default in [
                ("soul.md", "# 配置助手\n\n## 性格\n专业、友好的配置助手\n"),
                ("identity.md", "# 配置助手\n\n## 身份\nMyAgent 内置智能配置助手\n"),
                ("user.md", "# 用户信息\n\n## 用户偏好\n<!-- 在此处记录用户偏好 -->\n"),
            ]:
                if not (ad / fn).exists():
                    (ad / fn).write_text(default, encoding="utf-8")
            logger.info("已创建系统级配置助手 Agent")
        else:
            # 内置 Agent 同步最新的 system_prompt
            try:
                cfg = json.loads((ad / "config.json").read_text(encoding="utf-8"))
                if cfg.get("system_prompt") != CONFIG_HELPER_PROMPT:
                    cfg["system_prompt"] = CONFIG_HELPER_PROMPT
                    cfg["updated_at"] = _now_iso()
                    (ad / "config.json").write_text(json.dumps(cfg, indent=2, ensure_ascii=False), encoding="utf-8")
                    logger.info("已同步配置助手最新的系统提示词")
            except Exception as e:
                logger.warning(f"同步配置助手提示词失败: {e}")

        # 自动绑定知识库：将 配置使用说明.md 复制到配置助手的知识库目录
        self._bind_config_helper_kb()

    def _bind_config_helper_kb(self):
        """将 docs/配置使用说明.md 绑定到配置助手的知识库目录"""
        kb_dir = self._get_agent_knowledge_dir("2")
        kb_dir.mkdir(parents=True, exist_ok=True)
        target = kb_dir / "配置使用说明.md"

        # 查找源文件
        # __file__ = .../myagent-ai/web/api_server.py
        # parent.parent = .../myagent-ai  (包根目录，docs/ 在这里)
        pkg_root = Path(__file__).resolve().parent.parent
        source_candidates = [
            pkg_root / "docs" / "配置使用说明.md",
            pkg_root.parent / "docs" / "配置使用说明.md",
        ]
        # npm 全局安装时也尝试通过 import 定位
        try:
            import myagent
            pkg_dir = Path(myagent.__file__).resolve().parent
            source_candidates.append(pkg_dir / "docs" / "配置使用说明.md")
        except Exception:
            pass

        source = None
        for candidate in source_candidates:
            if candidate.exists():
                source = candidate
                break

        if source and source.exists():
            content = source.read_text(encoding="utf-8")
            # 仅当目标文件不存在或内容不同时才更新
            if not target.exists() or target.read_text(encoding="utf-8") != content:
                target.write_text(content, encoding="utf-8")
                logger.info(f"配置助手知识库已绑定: {source} -> {target}")
                # 刷新 RAG 索引
                if hasattr(self, '_agent_rags') and "2" in self._agent_rags:
                    self._agent_rags["2"].build_index()
        else:
            logger.warning(f"未找到 配置使用说明.md 源文件，已搜索: {[str(c) for c in source_candidates]}")

    def _read_agent_config(self, path: str) -> dict | None:
        """读取 agent 配置"""
        cfg_file = self._agent_dir(path) / "config.json"
        if not cfg_file.exists():
            return None
        try:
            return json.loads(cfg_file.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, ValueError):
            logger.warning(f"Agent config JSON 解析失败: {path}")
            return None

    def _write_agent_config(self, path: str, cfg: dict):
        """写入 agent 配置"""
        ad = self._agent_dir(path)
        ad.mkdir(parents=True, exist_ok=True)
        (ad / "config.json").write_text(json.dumps(cfg, indent=2, ensure_ascii=False), encoding="utf-8")

    def _deep_merge(self, base: dict, override: dict) -> None:
        """深度合并 override 到 base"""
        for key, value in override.items():
            if isinstance(value, dict) and isinstance(base.get(key), dict):
                self._deep_merge(base[key], value)
            else:
                base[key] = value

    def _build_agent_chat_context(self, agent_path: str, agent_cfg: dict | None, user_message: str) -> tuple[str, str]:
        """构建 Agent 专属上下文。

        Returns:
            (clean_user_message, agent_system_prompt)
            - clean_user_message: 用户原始消息（不包含任何注入内容）
            - agent_system_prompt: Agent 的 system_prompt + 知识库上下文（通过 metadata 传递给 MainAgent）

        不再将 system_prompt 或知识库上下文拼入用户消息，避免 LLM 回显注入内容。
        """
        agent_system_prompt = ""

        if not agent_cfg:
            return user_message, agent_system_prompt

        agent_system_prompt = agent_cfg.get("system_prompt", "")

        # 如果 Agent 有知识库，搜索相关知识并注入到 system_prompt 中（而非用户消息）
        try:
            rag = self._get_agent_rag(agent_path)
            if rag and rag._chunks:
                results = rag.search(user_message, top_k=3)
                if results:
                    kb_content = "\n\n".join(
                        f"[知识库片段 {i+1}] {chunk.content}"
                        for i, chunk in enumerate(results)
                        if chunk.score > 0.1
                    )
                    if kb_content.strip():
                        agent_system_prompt += "\n\n## Agent 知识库参考\n" + kb_content
        except Exception as e:
            logger.debug(f"Agent KB search failed for {agent_path}: {e}")

        return user_message, agent_system_prompt

    def _scan_agents_flat(self) -> list[dict]:
        """扫描所有 agent，返回扁平列表
        
        [v1.34.0] 改为从数据库读取，不再遍历文件系统。
        """
        if not self.core.agent_storage:
            return []
        
        agents = []
        try:
            db_agents = self.core.agent_storage.list_all()
            for agent_cfg in db_agents:
                agent = agent_cfg.to_dict()
                # 确保 avatar_color 有值
                if not agent.get("avatar_color"):
                    agent["avatar_color"] = _agent_color(agent.get("name", agent.get("path", "")))
                agents.append(agent)
        except Exception as e:
            logger.error(f"从数据库读取 agent 列表失败: {e}")
            # 降级：尝试从文件系统扫描
            return self._scan_agents_flat_fallback()
        
        return agents
    def _scan_agents_flat_fallback(self) -> list[dict]:
        """降级方案：从文件系统扫描 agent（当数据库不可用时）"""
        base_dir = self._agents_dir()
        agents = []
        if not base_dir.exists():
            return agents
        for d in sorted(base_dir.iterdir()):
            if not d.is_dir() or d.is_symlink():
                continue
            cfg_file = d / "config.json"
            if not cfg_file.exists():
                continue
            aid = d.name
            try:
                cfg = json.loads(cfg_file.read_text(encoding="utf-8"))
            except (json.JSONDecodeError, ValueError):
                continue
            agent = {"path": aid, "id": int(aid) if aid.isdigit() else cfg.get("id", aid), "name": cfg.get("name", aid), **cfg}
            agent["avatar_color"] = cfg.get("avatar_color") or _agent_color(cfg.get("name", aid))
            if not agent.get("avatar_image") and (d / "avatar.png").exists():
                agent["avatar_image"] = f"/api/agents/{aid}/avatar.png"
            agents.append(agent)
        return agents

    def _build_agent_tree(self, agents_flat: list[dict]) -> list[dict]:
        """将扁平 agent 列表构建为树结构（通过 parent 字段关联）"""
        by_id = {a["path"]: {**a, "children": []} for a in agents_flat}
        roots = []
        for a in agents_flat:
            parent = a.get("parent", "")
            node = by_id[a["path"]]
            if parent and parent in by_id:
                by_id[parent]["children"].append(node)
            else:
                roots.append(node)
        return roots

    async def handle_list_agents(self, request):
        """GET /api/agents - 返回扁平 agent 列表（系统 agent 排在最前）"""
        await self._ensure_agents_initialized()
        agents = self._scan_agents_flat()
        # 统计会话数 + 附加数字 agent_id
        if self.core.memory:
            rows = self.core.memory._get_conn().execute(
                "SELECT session_id, COUNT(*) as cnt FROM session_messages "
                "GROUP BY session_id").fetchall()
            session_counts = {}
            for r in rows:
                sid = r["session_id"]
                for ap in [sid.split("_")[0], sid]:
                    session_counts[ap] = session_counts.get(ap, 0) + r["cnt"]
            for a in agents:
                a["session_count"] = session_counts.get(a["path"], 0)
                a["aid"] = self.core.memory.get_agent_id(a["path"])
        # 系统 agent 排在最前
        agents.sort(key=lambda a: (0 if a.get("system") else 1, a.get("path", "")))
        return web.json_response(agents)

    async def handle_agents_tree(self, request):
        """GET /api/agents/tree - 返回树形结构（系统 agent 排在最前）"""
        await self._ensure_agents_initialized()
        agents_flat = self._scan_agents_flat()
        # 统计会话数
        if self.core.memory:
            rows = self.core.memory._get_conn().execute(
                "SELECT session_id, COUNT(*) as cnt FROM session_messages "
                "GROUP BY session_id").fetchall()
            session_counts = {}
            for r in rows:
                sid = r["session_id"]
                for ap in [sid.split("_")[0], sid]:
                    session_counts[ap] = session_counts.get(ap, 0) + r["cnt"]
            for a in agents_flat:
                a["session_count"] = session_counts.get(a["path"], 0)
            # [v1.27.2] 附加数字 agent_id 到每个 agent（供 URL 参数使用）
            for a in agents_flat:
                a["aid"] = self.core.memory.get_agent_id(a["path"])
        # 系统 agent 排在最前
        agents_flat.sort(key=lambda a: (0 if a.get("system") else 1, a.get("path", "")))
        tree = self._build_agent_tree(agents_flat)
        return web.json_response(tree)

    async def handle_create_agent(self, request):
        """POST /api/agents - 创建顶级 agent"""
        await self._ensure_agents_initialized()
        data = await request.json()
        name = data.get("name", "").strip()
        if not name:
            name = f"agent_{int(time.time())}"
        # 安全校验
        if "/" in name or "\\" in name or name == "default":
            return web.json_response({"error": "invalid name (no slashes, cannot be 'default')"}, status=400)

        aid = str(next_agent_id())
        ad = self._agent_dir(aid)
        # [v1.33.0] ID 碰撞时自动重试，而非直接报错（防御序列号不同步的极端情况）
        _retries = 0
        while (ad / "config.json").exists() and _retries < 20:
            logger.warning(f"Agent ID={aid} 目录已存在，跳过并重试 (retry={_retries})")
            aid = str(next_agent_id())
            ad = self._agent_dir(aid)
            _retries += 1
        if (ad / "config.json").exists():
            return web.json_response({"error": f"Agent '{name}' already exists (ID conflict)"}, status=409)

        now = _now_iso()
        cfg = {
            "id": int(aid),
            "path": aid,
            "name": name,
            "description": data.get("description", ""),
            "avatar_color": data.get("avatar_color") or _agent_color(name),
            "avatar_emoji": data.get("avatar_emoji", ""),
            "execution_mode": data.get("execution_mode", "sandbox"),
            "enabled": True,
            "created_at": now,
            "updated_at": now,
            "system_prompt": data.get("system_prompt") or data.get("soul") or f"你是{name}，一个专业的AI助手。",
        }
        if "model" in data:
            cfg["model"] = data["model"]
        # 平台绑定和模型库引用
        if data.get("platform"):
            cfg["platform"] = data["platform"]
        if data.get("platform_token"):
            cfg["platform_token"] = data["platform_token"]
        if data.get("platform_app_id"):
            cfg["platform_app_id"] = data["platform_app_id"]
        if data.get("platform_app_secret"):
            cfg["platform_app_secret"] = data["platform_app_secret"]
        if data.get("model_id"):
            cfg["model_id"] = data["model_id"]
        if data.get("backup_model_ids"):
            cfg["backup_model_ids"] = [x for x in data["backup_model_ids"] if isinstance(x, str) and x.strip()]
        if data.get("work_dir"):
            cfg["work_dir"] = data["work_dir"]

        ad.mkdir(parents=True, exist_ok=True)
        (ad / "config.json").write_text(json.dumps(cfg, indent=2, ensure_ascii=False), encoding="utf-8")
        for fn, default in [
            ("soul.md", f"# {name}\n\n## 性格\n专业AI助手\n"),
            ("identity.md", f"# {name}\n\n## 身份\nAI助手\n"),
            ("user.md", f"# {name} 用户信息\n\n## 用户偏好\n<!-- 在此处记录用户偏好 -->\n"),
        ]:
            if not (ad / fn).exists():
                (ad / fn).write_text(default, encoding="utf-8")

        # [v1.34.0] 同时写入数据库
        if self.core.agent_storage:
            try:
                from core.agent_storage import AgentConfig
                agent_cfg = AgentConfig(**cfg)
                self.core.agent_storage.create(agent_cfg)
                logger.info(f"Agent '{name}' 已写入数据库 (path={aid})")
            except Exception as e:
                logger.error(f"Agent '{name}' 写入数据库失败: {e}")
                # 数据库写入失败不影响文件系统，继续执行

        logger.info(f"创建 Agent: {name} (ID={aid})")
        return web.json_response({"ok": True, "path": aid, "id": int(aid), "name": name, "avatar_color": cfg["avatar_color"]})

    async def handle_create_child(self, request):
        """POST /api/agents/{parent}/children - 创建子 agent"""
        parent_path = request.match_info["name"]
        await self._ensure_agents_initialized()

        parent_cfg = self._read_agent_config(parent_path)
        if not parent_cfg:
            return web.json_response({"error": f"Parent agent '{parent_path}' not found"}, status=404)

        data = await request.json()
        name = data.get("name", "").strip()
        if not name:
            name = f"agent_{int(time.time())}"
        if "/" in name or "\\" in name or ".." in name:
            return web.json_response({"error": "invalid name (no slashes or path traversal)"}, status=400)

        child_path = f"{parent_path}/{name}"
        child_aid = str(next_agent_id())
        ad = self._agent_dir(child_aid)
        if (ad / "config.json").exists():
            return web.json_response({"error": f"Agent '{name}' already exists"}, status=409)

        cfg = {
            "id": int(child_aid),
            "path": child_aid,
            "name": name,
            "parent": parent_path,
            "description": data.get("description", ""),
            "avatar_color": data.get("avatar_color") or _agent_color(name),
            "avatar_emoji": data.get("avatar_emoji", ""),
            "execution_mode": data.get("execution_mode", "sandbox"),
            "enabled": True,
            "system_prompt": data.get("system_prompt") or data.get("soul") or f"你是{name}，{parent_path}的子Agent。请用专业的方式完成你的任务。",
        }
        if "model" in data:
            cfg["model"] = data["model"]
        # 平台绑定和模型库引用
        if data.get("platform"):
            cfg["platform"] = data["platform"]
        if data.get("platform_token"):
            cfg["platform_token"] = data["platform_token"]
        if data.get("platform_app_id"):
            cfg["platform_app_id"] = data["platform_app_id"]
        if data.get("platform_app_secret"):
            cfg["platform_app_secret"] = data["platform_app_secret"]
        if data.get("model_id"):
            cfg["model_id"] = data["model_id"]
        if data.get("backup_model_ids"):
            cfg["backup_model_ids"] = [x for x in data["backup_model_ids"] if isinstance(x, str) and x.strip()]

        ad.mkdir(parents=True, exist_ok=True)
        (ad / "config.json").write_text(json.dumps(cfg, indent=2, ensure_ascii=False), encoding="utf-8")
        for fn, default in [
            ("soul.md", f"# {name}\n\n## 上级: {parent_path}\n## 性格\n专业AI助手\n"),
            ("identity.md", f"# {name}\n\n## 身份\n{parent_path} 的子 Agent\n"),
            ("user.md", f"# {name} 用户信息\n\n## 用户偏好\n<!-- 在此处记录用户偏好 -->\n"),
        ]:
            if not (ad / fn).exists():
                (ad / fn).write_text(default, encoding="utf-8")

        logger.info(f"创建子 Agent: {name} (ID={child_aid}, parent={parent_path})")
        return web.json_response({"ok": True, "path": child_aid, "id": int(child_aid), "name": name, "parent": parent_path, "avatar_color": cfg["avatar_color"]})

    async def handle_list_children(self, request):
        """GET /api/agents/{parent}/children - 列出子 agent（通过 parent 字段）"""
        parent_path = request.match_info["name"]
        parent_cfg = self._read_agent_config(parent_path)
        if not parent_cfg:
            return web.json_response({"error": f"Agent '{parent_path}' not found"}, status=404)

        all_agents = self._scan_agents_flat()
        children = [a for a in all_agents if a.get("parent") == parent_path]
        return web.json_response(children)

    async def handle_get_agent(self, request):
        """GET /api/agents/{path} - 获取 agent 详情"""
        path = request.match_info["name"]
        ad = self._agent_dir(path)
        if not (ad / "config.json").exists():
            return web.json_response({"error": "not found"}, status=404)
        try:
            cfg = json.loads((ad / "config.json").read_text(encoding="utf-8"))
        except (json.JSONDecodeError, ValueError):
            return web.json_response({"error": "config.json 解析失败"}, status=500)
        soul = (ad / "soul.md").read_text(encoding="utf-8") if (ad / "soul.md").exists() else ""
        identity = (ad / "identity.md").read_text(encoding="utf-8") if (ad / "identity.md").exists() else ""
        user = (ad / "user.md").read_text(encoding="utf-8") if (ad / "user.md").exists() else ""
        # 列出子 agent
        children = []
        for d in sorted(ad.iterdir()):
            if d.is_dir() and (d / "config.json").exists():
                children.append(d.name)
        # 从数据库读取部门信息（v1.34.4+）
        departments = []
        if self.core.agent_storage:
            try:
                departments = self.core.agent_storage.get_agent_departments(path)
            except Exception as e:
                logger.warning(f"获取 agent {path} 的部门信息失败: {e}")
        # 兼容旧前端：取第一个部门作为 department 字段
        department = departments[0] if departments else ""
        # 如果有 model_id，解析为完整模型信息
        model_info = None
        model_id = cfg.get("model_id", "")
        if model_id:
            for me in self.core.config.models_library:
                if me.id == model_id:
                    model_info = {"id": me.id, "name": me.name, "provider": me.provider,
                                 "api_type": getattr(me, 'api_type', ''),
                                 "model": me.model, "base_url": me.base_url, "enabled": me.enabled}
                    break
        result = {
            "path": path,
            **cfg,
            "soul": soul,
            "identity": identity,
            "user": user,
            "children": children,
            "department": department,  # 从数据库读取
            "departments": departments,  # 完整部门列表（供前端新功能使用）
            "model_info": model_info,
        }
        return web.json_response(result)

    # 系统 Agent 受保护的字段（不可修改）
    _SYSTEM_AGENT_PROTECTED_FIELDS = {
        "system_prompt", "description", "avatar_color", "avatar_emoji", "name",
        "execution_mode", "sandbox_image", "sandbox_network", "sandbox_memory",
    }

    async def handle_update_agent(self, request):
        """PUT /api/agents/{path} - 更新 agent 配置"""
        path = request.match_info["name"]
        data = await request.json()
        ad = self._agent_dir(path)
        if not (ad / "config.json").exists():
            return web.json_response({"error": "not found"}, status=404)
        try:
            cfg = json.loads((ad / "config.json").read_text(encoding="utf-8"))
        except (json.JSONDecodeError, ValueError):
            return web.json_response({"error": "config.json 解析失败"}, status=500)

        # 系统 Agent（内置 Agent）保护：核心字段不可修改
        is_system = cfg.get("system") or path in ("1", "2")
        if is_system:
            blocked = [k for k in data if k in self._SYSTEM_AGENT_PROTECTED_FIELDS]
            if blocked:
                return web.json_response({
                    "error": f"系统 Agent '{path}' 的以下字段受保护，不可修改: {', '.join(blocked)}",
                    "blocked_fields": blocked,
                }, status=403)
            # 系统 Agent 也不允许修改 soul.md / identity.md
            if any(k in data for k in ("soul", "identity")):
                return web.json_response({
                    "error": f"系统 Agent '{path}' 的灵魂文件和身份文件受保护，不可修改",
                }, status=403)
            # 系统 Agent 只允许修改有限字段
        allowed_fields = ("name", "description", "avatar_color", "avatar_emoji", "avatar_image", "model", "system_prompt",
                   "execution_mode", "enabled", "sandbox_image", "sandbox_network", "sandbox_memory",
                   "platform", "platform_token", "platform_app_id", "platform_app_secret",
                   "model_id", "backup_model_ids", "work_dir")
        # 更新允许的字段
        updated = False
        for k in allowed_fields:
            if k in data:
                if k == "backup_model_ids":
                    cfg[k] = [x for x in data[k] if isinstance(x, str) and x.strip()]
                else:
                    cfg[k] = data[k]
                updated = True
        # 不允许修改 system 标志
        if "system" in data:
            del data["system"]
        # 自动更新 updated_at
        cfg["updated_at"] = _now_iso()
        (ad / "config.json").write_text(json.dumps(cfg, indent=2, ensure_ascii=False), encoding="utf-8")
        
        # [v1.34.0] 同步到数据库
        if self.core.agent_storage:
            try:
                self.core.agent_storage.update(path, cfg)
                logger.debug(f"Agent '{path}' 已更新到数据库")
            except Exception as e:
                logger.error(f"Agent '{path}' 更新数据库失败: {e}")
        
        if "soul" in data and not is_system: (ad / "soul.md").write_text(data["soul"], encoding="utf-8")
        if "identity" in data and not is_system: (ad / "identity.md").write_text(data["identity"], encoding="utf-8")
        if "user" in data: (ad / "user.md").write_text(data["user"], encoding="utf-8")
        # 名字改变不再需要重命名目录（目录名是 agent ID，与名字无关）
        logger.info(f"更新 Agent: {path}")
        return web.json_response({"ok": True, "hot_reload": True})

    async def handle_delete_agent(self, request):
        """DELETE /api/agents/{path} - 删除 agent 及其所有子 agent"""
        path = request.match_info["name"]
        if path in ("1", "2"):
            return web.json_response({
                "error": "系统 Agent 不可删除",
                "agent_path": path,
            }, status=403)
        ad = self._agent_dir(path)
        if not (ad / "config.json").exists():
            return web.json_response({"error": "not found"}, status=404)
        # 检查是否是系统 agent（不可删除、不可重命名）
        cfg = self._read_agent_config(path)
        if cfg and cfg.get("system"):
            return web.json_response({
                "error": f"系统 Agent '{path}' 是内置 Agent，不可删除也不可重命名",
                "agent_path": path,
            }, status=403)
        if ad.exists():
            # 清理该 Agent 相关的会话和记忆数据
            if self.core.memory:
                try:
                    # 删除该 agent 的所有会话（通过 agent_id 精确匹配）
                    conn = self.core.memory._get_conn()
                    target_aid = self.core.memory.get_agent_id(path)
                    # 从 session_messages 表查询该 agent 的所有会话
                    rows = conn.execute(
                        "SELECT DISTINCT session_id FROM session_messages WHERE agent_id = ?",
                        (target_aid,),
                    ).fetchall()
                    for row in rows:
                        sid = row["session_id"]
                        # 删除该会话的所有消息和记忆
                        conn.execute("DELETE FROM session_messages WHERE session_id = ?", (sid,))
                        conn.execute("DELETE FROM memories WHERE session_id = ?", (sid,))
                        conn.execute("DELETE FROM session_names WHERE session_id = ?", (sid,))
                        logger.info(f"  清理会话数据: {sid}")
                        # 清理该会话的后端运行状态
                        self._cleanup_session_state(sid)
                    conn.commit()
                    logger.info(f"  已清理 Agent '{path}' 的 {len(rows)} 个会话")
                except Exception as e:
                    logger.warning(f"清理会话数据失败: {e}")
            shutil.rmtree(ad)
            # [v1.34.0] 同时删除数据库记录
            if self.core.agent_storage:
                try:
                    self.core.agent_storage.delete(path)
                    logger.info(f"Agent '{path}' 已从数据库删除")
                except Exception as e:
                    logger.error(f"Agent '{path}' 从数据库删除失败: {e}")
        logger.info(f"删除 Agent: {path}")
        return web.json_response({"ok": True})

    async def handle_get_soul(self, request):
        path = request.match_info.get("name", "")
        p = self._agent_dir(path) / "soul.md"
        if not p.parent.exists() or not (p.parent / "config.json").exists():
            return web.json_response({"error": "not found"}, status=404)
        return web.json_response({"soul": p.read_text(encoding="utf-8") if p.exists() else ""})

    async def handle_set_soul(self, request):
        data = await request.json(); ad = self._agent_dir(request.match_info["name"])
        ad.mkdir(parents=True, exist_ok=True)
        (ad / "soul.md").write_text(data.get("soul", ""), encoding="utf-8")
        return web.json_response({"ok": True, "hot_reload": True})

    async def handle_get_identity(self, request):
        path = request.match_info["name"]
        p = self._agent_dir(path) / "identity.md"
        if not p.parent.exists() or not (p.parent / "config.json").exists():
            return web.json_response({"error": "not found"}, status=404)
        return web.json_response({"identity": p.read_text(encoding="utf-8") if p.exists() else ""})

    async def handle_set_identity(self, request):
        data = await request.json(); ad = self._agent_dir(request.match_info["name"])
        ad.mkdir(parents=True, exist_ok=True)
        (ad / "identity.md").write_text(data.get("identity", ""), encoding="utf-8")
        return web.json_response({"ok": True, "hot_reload": True})

    async def handle_get_user(self, request):
        path = request.match_info["name"]
        p = self._agent_dir(path) / "user.md"
        if not p.parent.exists() or not (p.parent / "config.json").exists():
            return web.json_response({"error": "not found"}, status=404)
        return web.json_response({"user": p.read_text(encoding="utf-8") if p.exists() else ""})

    async def handle_set_user(self, request):
        data = await request.json(); ad = self._agent_dir(request.match_info["name"])
        ad.mkdir(parents=True, exist_ok=True)
        (ad / "user.md").write_text(data.get("user", ""), encoding="utf-8")
        return web.json_response({"ok": True, "hot_reload": True})

    # --- Executor ---
    async def handle_get_executor(self, request):
        info = self.core.executor.get_execution_info() if self.core.executor else {}
        cfg = self.core.config.executor if self.core.config else None
        # [v1.18.7] 附加执行锁状态和沙盒说明
        lock = self._execution_lock
        return web.json_response({
            **info,
            "timeout": cfg.timeout if cfg else 300,
            "auto_fix": cfg.auto_fix if cfg else True,
            "max_output_length": cfg.max_output_length if cfg else 50000,
            # 执行锁信息
            "lock": {
                "locked": lock["locked"],
                "locked_by": lock["locked_by"],
                "locked_at": lock["locked_at"],
            },
            # 沙盒模式说明
            "sandbox_desc": self._sandbox_description(info.get("mode"), info.get("sandbox_type"), info.get("docker_available")),
        })

    def _sandbox_description(self, mode, sandbox_type, docker_available):
        """生成沙盒模式说明文本"""
        if mode != "sandbox":
            return "本机模式: 代码直接在宿主机运行，拥有完整系统权限。多个 Agent 共享全局执行锁，同一时间只有一个 Agent 可以执行代码。"
        if docker_available:
            return "Docker 沙盒: 代码在 Docker 容器中运行，通过网络隔离保证安全性。容器使用指定镜像，执行完毕后自动销毁。不走全局锁，支持多 Agent 并发。"
        return "轻量级进程沙盒: Docker 不可用时的降级方案。代码在临时工作目录中通过子进程执行，提供目录隔离。不走全局锁，支持多 Agent 并发。安全性低于 Docker 沙盒。"

    async def handle_update_executor(self, request):
        data = await request.json()
        cfg_path = self.core.config_mgr._config_file
        cfg_data = _safe_load_json(cfg_path) if cfg_path.exists() else {}
        exe = cfg_data.setdefault("executor", {})
        for k in ("execution_mode", "timeout", "auto_fix", "max_output_length",
                   "sandbox_image", "sandbox_network", "sandbox_memory"):
            if k in data:
                exe[k] = data[k]
        cfg_path.write_text(json.dumps(cfg_data, indent=2, ensure_ascii=False), encoding="utf-8")
        # 热更新内存配置
        update_keys = {k: v for k, v in data.items() if hasattr(self.core.config_mgr.config.executor, k)}
        self.core.config_mgr.update_executor(**update_keys)
        # 统一热更新执行引擎
        if self.core.executor:
            # 先检查 execution_mode 切换是否成功
            if "execution_mode" in data:
                mode = data["execution_mode"]
                ok = self.core.executor.set_execution_mode(mode)
                if not ok:
                    return web.json_response({"ok": False, "error": f"切换到 {mode} 失败(Docker 不可用)"})
            self._hot_reload_executor()
        logger.info(f"执行引擎配置已热更新: mode={data.get('execution_mode')}")
        return web.json_response({"ok": True, "hot_reload": True})

    # --- Agent Bindings ---
    async def handle_agent_bindings(self, request):
        """GET /api/agents/{name}/bindings - 获取 Agent 的聊天平台绑定"""
        path = request.match_info["name"]
        cfg = self._read_agent_config(path)
        if not cfg:
            return web.json_response({"error": "not found"}, status=404)
        bindings = {}
        if cfg.get("platform"):
            bindings["platform"] = cfg["platform"]
        if cfg.get("platform_token"):
            bindings["platform_token"] = cfg["platform_token"]
        if cfg.get("platform_app_id"):
            bindings["platform_app_id"] = cfg["platform_app_id"]
        if cfg.get("platform_app_secret"):
            bindings["platform_app_secret"] = cfg["platform_app_secret"]
        # 模型绑定（含备用模型）
        if cfg.get("model_id"):
            model_info = None
            for me in self.core.config.models_library:
                if me.id == cfg["model_id"]:
                    model_info = {"id": me.id, "name": me.name, "provider": me.provider,
                                 "model": me.model, "base_url": me.base_url, "enabled": me.enabled}
                    break
            if model_info:
                bindings["model"] = model_info
            else:
                bindings["model_id"] = cfg["model_id"]
                bindings["model"] = None
        elif cfg.get("model"):
            bindings["model"] = {"model": cfg["model"]}

        # 备用模型列表
        backup_ids = cfg.get("backup_model_ids", [])
        if backup_ids:
            backup_models = []
            for bid in backup_ids:
                for me in self.core.config.models_library:
                    if me.id == bid:
                        backup_models.append({
                            "id": me.id, "name": me.name, "provider": me.provider,
                            "model": me.model, "base_url": me.base_url, "enabled": me.enabled
                        })
                        break
                else:
                    backup_models.append({"id": bid, "name": bid, "not_found": True})
            bindings["backup_models"] = backup_models
        else:
            bindings["backup_models"] = []

        return web.json_response(bindings)

    # --- Platforms ---
    async def handle_list_platforms(self, request):
        """列出所有聊天平台配置"""
        platforms = []
        for cp in self.core.config_mgr.config.chat_platforms:
            platforms.append({
                "id": cp.id,
                "platform": cp.platform,
                "display_name": cp.display_name,
                "enabled": cp.enabled,
                "token": cp.token[:8] + "****" if cp.token else "",
                "app_id": cp.app_id,
                "has_secret": bool(cp.app_secret),
                "webhook_url": cp.webhook_url,
                "allowed_users": cp.allowed_users,
                "extra": cp.extra,
                "bind_agent": cp.bind_agent,
                "bind_agents": cp.bind_agents,
            })
        return web.json_response(platforms)

    async def handle_get_platform(self, request):
        """获取单个平台配置详情（支持按 id 或 platform 查找）"""
        name = request.match_info["name"]
        # 优先按 id 查找，兼容按 platform 查找
        cp = self.core.config_mgr.get_chat_platform_by_id(name)
        if not cp:
            cp = self.core.config_mgr.get_chat_platform(name)
        if not cp:
            return web.json_response({"error": f"平台 {name} 不存在"}, status=404)
        return web.json_response({
            "id": cp.id,
            "platform": cp.platform,
            "display_name": cp.display_name,
            "enabled": cp.enabled,
            "token": cp.token,
            "app_id": cp.app_id,
            "app_secret": cp.app_secret,
            "webhook_url": cp.webhook_url,
            "allowed_users": cp.allowed_users,
            "extra": cp.extra,
            "bind_agent": cp.bind_agent,
            "bind_agents": cp.bind_agents,
        })

    async def handle_add_platform(self, request):
        """新增聊天平台配置（支持多实例）"""
        data = await request.json()
        platform_name = data.get("platform", "")
        if not platform_name:
            return web.json_response({"error": "缺少 platform 字段"}, status=400)
        # 不再限制同类型只能有一个，支持多 token 实例
        cp = ChatPlatformConfig(
            platform=platform_name,
            enabled=data.get("enabled", True),
            token=data.get("token", ""),
            app_id=data.get("app_id", ""),
            app_secret=data.get("app_secret", ""),
            webhook_url=data.get("webhook_url", ""),
            allowed_users=data.get("allowed_users", []),
            extra=data.get("extra", {}),
            bind_agent=data.get("bind_agent", ""),
            bind_agents=data.get("bind_agents", []),
        )
        # 自动生成唯一 id 和 display_name
        self.core.config_mgr._auto_platform_id(cp)
        # 检查 id 唯一性
        existing_ids = {p.id for p in self.core.config_mgr.config.chat_platforms}
        if cp.id in existing_ids:
            return web.json_response({"error": f"平台实例 {cp.display_name} 已存在（ID: {cp.id}）"}, status=409)
        self.core.config_mgr.config.chat_platforms.append(cp)
        self.core.config_mgr.save()
        # [v1.23.57] 热更新加异常保护，防止配置已保存但热更新失败导致前端报错
        try:
            await self._hot_reload_chat_platforms()
        except Exception as e:
            logger.error(f"热更新聊天平台失败(新增): {e}", exc_info=True)
        logger.info(f"新增聊天平台: {cp.display_name} (id={cp.id})")
        return web.json_response({"ok": True, "id": cp.id, "platform": platform_name, "display_name": cp.display_name, "hot_reload": True})

    async def handle_update_platform(self, request):
        """更新聊天平台配置"""
        name = request.match_info["name"]
        data = await request.json()
        # 优先按 id 查找
        cp = self.core.config_mgr.get_chat_platform_by_id(name)
        if not cp:
            cp = self.core.config_mgr.get_chat_platform(name)
        if not cp:
            return web.json_response({"error": f"平台 {name} 不存在"}, status=404)
        if "enabled" in data:
            cp.enabled = data["enabled"]
        if "token" in data:
            cp.token = data["token"]
        if "app_id" in data:
            cp.app_id = data["app_id"]
        if "app_secret" in data:
            cp.app_secret = data["app_secret"]
        if "webhook_url" in data:
            cp.webhook_url = data["webhook_url"]
        if "allowed_users" in data:
            cp.allowed_users = data["allowed_users"]
        if "extra" in data:
            cp.extra = data["extra"]
        if "bind_agent" in data:
            cp.bind_agent = data["bind_agent"]
        if "bind_agents" in data:
            cp.bind_agents = data["bind_agents"]
        if "display_name" in data:
            cp.display_name = data["display_name"]
        # 如果 token 变了，重新生成 id/display_name
        if "token" in data and data["token"]:
            old_id = cp.id
            self.core.config_mgr._auto_platform_id(cp)
            if cp.id != old_id:
                logger.info(f"平台 ID 变更: {old_id} -> {cp.id}")
        self.core.config_mgr.save()
        # [v1.23.57] 热更新加异常保护，防止配置已保存但热更新失败导致前端报错
        try:
            await self._hot_reload_chat_platforms()
        except Exception as e:
            logger.error(f"热更新聊天平台失败(更新): {e}", exc_info=True)
        logger.info(f"聊天平台配置已更新: {cp.display_name} (id={cp.id})")
        return web.json_response({"ok": True, "id": cp.id, "hot_reload": True})

    async def handle_delete_platform(self, request):
        """删除聊天平台配置"""
        name = request.match_info["name"]
        platforms = self.core.config_mgr.config.chat_platforms
        for i, cp in enumerate(platforms):
            if cp.id == name or cp.platform == name:
                platforms.pop(i)
                self.core.config_mgr.save()
                # [v1.23.57] 热更新加异常保护
                try:
                    await self._hot_reload_chat_platforms()
                except Exception as e:
                    logger.error(f"热更新聊天平台失败(删除): {e}", exc_info=True)
                logger.info(f"已删除聊天平台: {cp.display_name} (id={cp.id})")
                return web.json_response({"ok": True, "hot_reload": True})
        return web.json_response({"error": f"平台 {name} 不存在"}, status=404)

    async def handle_toggle_platform(self, request):
        """切换平台启用/禁用"""
        name = request.match_info["name"]
        cp = self.core.config_mgr.get_chat_platform_by_id(name)
        if not cp:
            cp = self.core.config_mgr.get_chat_platform(name)
        if not cp:
            return web.json_response({"error": f"平台 {name} 不存在"}, status=404)
        data = await request.json()
        cp.enabled = data.get("enabled", not cp.enabled)
        self.core.config_mgr.save()
        # [v1.23.53] 热更新聊天平台（加异常保护，防止热更新失败导致配置无法保存）
        try:
            await self._hot_reload_chat_platforms()
        except Exception as e:
            logger.error(f"热更新聊天平台失败: {e}", exc_info=True)
        logger.info(f"聊天平台 {cp.display_name} 已{'启用' if cp.enabled else '禁用'}")
        return web.json_response({"ok": True, "enabled": cp.enabled, "id": cp.id, "hot_reload": True})

    async def handle_restart_platform(self, request):
        """重启指定平台Bot"""
        name = request.match_info["name"]
        cp = self.core.config_mgr.get_chat_platform_by_id(name)
        if not cp:
            cp = self.core.config_mgr.get_chat_platform(name)
        display = cp.display_name if cp else name
        # TODO: 实现平台 Bot 热重启
        return web.json_response({"ok": True, "message": f"平台 {display} 重启请求已发送（重启需服务端支持）"})

    # [v1.20.3] QR 码绑定相关
    async def handle_get_platform_qr(self, request):
        """GET /api/platforms/{name}/qr - 获取平台当前 QR 码和连接状态"""
        name = request.match_info["name"]
        cp = self.core.config_mgr.get_chat_platform_by_id(name)
        if not cp:
            cp = self.core.config_mgr.get_chat_platform(name)
        if not cp:
            return web.json_response({"error": "平台不存在"}, status=404)

        qr_code = ""
        connected = False
        status = "unknown"

        # 从 bot 实例获取 QR 码
        bot = self.core.chat_manager.get_bot(cp.id or cp.platform) if self.core.chat_manager else None
        if bot and hasattr(bot, 'get_qr_code'):
            qr_code = bot.get_qr_code() or ""
        if bot and hasattr(bot, '_connected'):
            connected = bot._connected

        # 从 extra 读取持久化的连接状态
        conn_status = cp.extra.get("connection_status", "")
        if connected:
            status = "connected"
        elif qr_code:
            status = "waiting_scan"
        elif conn_status:
            status = conn_status
        else:
            status = "not_started"

        return web.json_response({
            "platform": cp.platform,
            "id": cp.id,
            "status": status,
            "connected": connected,
            "qr_code": qr_code,
        })

    async def handle_start_platform_qr(self, request):
        """POST /api/platforms/{name}/qr - 请求生成 QR 码（开始绑定流程）"""
        name = request.match_info["name"]
        cp = self.core.config_mgr.get_chat_platform_by_id(name)
        if not cp:
            cp = self.core.config_mgr.get_chat_platform(name)
        if not cp:
            return web.json_response({"error": "平台不存在"}, status=404)

        if cp.platform not in ("whatsapp", "wechat"):
            return web.json_response({"error": f"{cp.platform} 不支持 QR 码绑定"}, status=400)

        bot = self.core.chat_manager.get_bot(cp.id or cp.platform) if self.core.chat_manager else None
        if not bot:
            return web.json_response({"error": "Bot 实例未创建"}, status=400)

        if not hasattr(bot, 'generate_qr_code'):
            return web.json_response({"error": "该 Bot 不支持 QR 码绑定"}, status=400)

        try:
            qr_code = await bot.generate_qr_code()
            if qr_code:
                # 保存 QR 码到 extra
                cp.extra["connection_status"] = "waiting_scan"
                self.core.config_mgr.save()
                return web.json_response({
                    "ok": True,
                    "qr_code": qr_code,
                    "status": "waiting_scan",
                    "message": "QR 码已生成，请使用手机扫码",
                })
            else:
                return web.json_response({
                    "ok": False,
                    "status": "failed",
                    "error": "QR 码生成失败，请检查配置",
                })
        except Exception as e:
            logger.error(f"QR 码生成异常 ({name}): {e}")
            return web.json_response({"error": f"QR 码生成异常: {e}"}, status=500)

    async def handle_platform_agents(self, request):
        """获取绑定到指定平台的所有 Agent"""
        name = request.match_info["name"]
        agents = self.core.config_mgr.list_agents()
        bound = []
        for agent in agents:
            cfg = self.core.config_mgr.get_agent_config(agent)
            if cfg and cfg.get("platform") == name:
                bound.append({
                    "name": agent,
                    "nickname": cfg.get("nickname", agent),
                })
        return web.json_response(bound)

    # --- Sessions ---
    async def handle_session_status_query(self, request):
        """GET /api/session/status?sid=... - 查询会话的运行状态（支持断线重连恢复）"""
        try:
            sid = request.query.get("sid", "")
            if not sid:
                # 返回所有正在运行的会话
                running = []
                for session_id, info in self._running_sessions.items():
                    if info.get("running") and not info.get("done"):
                        running.append({
                            "sid": session_id,
                            "running": True,
                            "started_at": info.get("started_at"),
                            "agent_path": info.get("agent_path"),
                            "message": info.get("message", ""),
                        })
                return web.json_response({"running_sessions": running})
            
            info = self._running_sessions.get(sid)
            if not info:
                return web.json_response({"running": False, "done": True, "found": False})
            
            return web.json_response({
                "running": info.get("running", False) and not info.get("done", False),
                "done": info.get("done", False),
                "started_at": info.get("started_at"),
                "completed_at": info.get("completed_at"),
                "agent_path": info.get("agent_path"),
                "result": info.get("result", ""),
                "error": info.get("error"),
            })
        except Exception as e:
            logger.error(f"Error getting session status: {e}")
            return web.json_response({"error": str(e)}, status=500)

    async def handle_list_sessions(self, request):
        """GET /api/sessions - 获取所有有用户参与的会话列表（排除无用户的纯Agent会话）"""
        if not self.core.memory: return web.json_response([])
        agent = request.query.get("agent", "")
        conn = self.core.memory._get_conn()

        # [v1.27.2] 构建 agent_id → agent_name 的映射（数字 ID → 路径名）
        _agent_id_map = {}  # {int_id: str_name}
        try:
            for ar in conn.execute("SELECT id, name FROM agents").fetchall():
                _agent_id_map[int(ar["id"])] = ar["name"]
        except Exception:
            pass

        _hidden = '(' + ','.join(["'llm_output'", "'llm_input'", "'tool_result_raw'", "'conversation_insight'"]) + ')'
        # 只返回有用户参与（role='user'）的会话
        if agent:
            # 将 agent 路径转为数字 ID，精确过滤
            target_aid = self.core.memory.get_agent_id(agent)
            rows = conn.execute(
                f"""SELECT DISTINCT sm.session_id, sm.cnt, sm.last, sm.raw_agent_id
                   FROM (
                       SELECT session_id, COUNT(*) as cnt, MAX(created_at) as last,
                              agent_id as raw_agent_id FROM session_messages
                       WHERE role != '' AND key NOT IN {_hidden}
                       AND agent_id = ?
                       GROUP BY session_id
                   ) sm
                   WHERE sm.session_id IN (
                       SELECT DISTINCT session_id FROM session_messages WHERE role = 'user'
                   )
                   ORDER BY sm.last DESC LIMIT 100""",
                (target_aid,)).fetchall()
        else:
            rows = conn.execute(
                f"""SELECT DISTINCT sm.session_id, sm.cnt, sm.last, sm.raw_agent_id
                   FROM (
                       SELECT session_id, COUNT(*) as cnt, MAX(created_at) as last,
                              agent_id as raw_agent_id FROM session_messages
                       WHERE role != '' AND key NOT IN {_hidden}
                       GROUP BY session_id
                   ) sm
                   WHERE sm.session_id IN (
                       SELECT DISTINCT session_id FROM session_messages WHERE role = 'user'
                   )
                   ORDER BY sm.last DESC LIMIT 100""").fetchall()
        # [v1.27.2] 将数字 agent_id 转为 agent 名称
        sessions = []
        for r in rows:
            raw_aid = r["raw_agent_id"]
            agent_name = ""
            if raw_aid is not None:
                agent_name = _agent_id_map.get(int(raw_aid), "")
            sessions.append({"id": r["session_id"], "messages": r["cnt"], "last": r["last"],
                          "agent_id": agent_name,  # 返回 agent 名称（非数字 ID）
                          "agent_db_id": int(raw_aid),  # 数字 ID 供 URL 使用
                          "display_name": "", "preview": ""})
        # 批量获取自定义会话名称
        sids = [s["id"] for s in sessions]
        name_map = self.core.memory.list_session_names(sids) if sids else {}
        # 批量获取每个会话最后一条用户消息作为预览（从 session_messages 表）
        preview_map = {}
        if sids:
            conn = self.core.memory._get_conn()
            placeholders = ",".join(["?"] * len(sids))
            preview_rows = conn.execute(
                f"SELECT m1.session_id, m1.content FROM session_messages m1 "
                f"INNER JOIN (SELECT session_id, MAX(created_at) as max_t FROM session_messages "
                f"WHERE session_id IN ({placeholders}) AND role='user' "
                f"GROUP BY session_id) m2 ON m1.session_id = m2.session_id AND m1.created_at = m2.max_t "
                f"WHERE m1.role='user'",
                sids
            ).fetchall()
            for pr in preview_rows:
                preview_map[pr["session_id"]] = (pr["content"] or "")[:100]
        for s in sessions:
            s["display_name"] = name_map.get(s["id"], "")
            s["preview"] = preview_map.get(s["id"], "")
        return web.json_response(sessions)

    async def handle_agent_sessions(self, request):
        """GET /api/agents/{name}/sessions - Convenience endpoint for agent-scoped sessions."""
        name = request.match_info["name"]
        if not self.core.memory:
            return web.json_response({"agent": name, "sessions": []})
        # 使用数字 agent_id 精确过滤
        target_aid = self.core.memory.get_agent_id(name)
        rows = self.core.memory._get_conn().execute(
            """SELECT DISTINCT session_id, COUNT(*) as cnt, MAX(created_at) as last FROM session_messages
               WHERE agent_id = ?
               GROUP BY session_id ORDER BY last DESC LIMIT 100""",
            (target_aid,)).fetchall()
        sessions = [{"id": r["session_id"], "messages": r["cnt"], "last": r["last"]} for r in rows]
        # 批量获取自定义会话名称
        sids = [s["id"] for s in sessions]
        name_map = self.core.memory.list_session_names(sids) if sids else {}
        for s in sessions:
            s["display_name"] = name_map.get(s["id"], "")
        # Agent info
        ad = self._agent_dir(name)
        agent_info = {"name": name, "avatar_color": _agent_color(name)}
        if (ad / "config.json").exists():
            try:
                agent_info.update(json.loads((ad / "config.json").read_text(encoding="utf-8")))
            except (json.JSONDecodeError, ValueError):
                pass
        return web.json_response({**agent_info, "sessions": sessions})

    # Internal keys that should not appear in chat history UI
    _HIDDEN_KEYS = {"llm_output", "llm_input", "tool_result_raw", "llm_callback"}

    async def handle_get_messages(self, request):
        sid = request.match_info["sid"]
        if not self.core.memory: return web.json_response([])
        try:
            limit = min(int(request.query.get("limit", 500)), 500)
            offset = int(request.query.get("offset", 0))
            # [FIX] 支持 agent_id 过滤
            agent_name = request.query.get("agent", "")
            agent_id = None
            if agent_name:
                try:
                    agent_id = self.core.memory.get_agent_id(agent_name)
                except Exception:
                    agent_id = None
            entries = self.core.memory.get_conversation(sid, limit=limit + offset, agent_id=agent_id)
            entries = entries[offset:]
            # Filter out internal entries (LLM raw output only)
            entries = [e for e in entries if (e.key or "") not in self._HIDDEN_KEYS]
            result = []
            for e in entries:
                msg = {"role": e.role, "content": e.content, "time": e.created_at, "key": e.key or ""}
                # [v1.16.17] 附加附件元数据
                meta = e.metadata or {}
                if meta.get("images"):
                    msg["images"] = meta["images"]
                # [v1.23.19] 分离 _files 和 _media（前端用 msg._files 和 msg._media）
                _raw_files = meta.get("files") or []
                if _raw_files:
                    _file_items = [f for f in _raw_files if f.get("_type") != "media"]
                    _media_items = [f for f in _raw_files if f.get("_type") == "media"]
                    if _file_items:
                        msg["_files"] = _file_items
                    if _media_items:
                        msg["_media"] = _media_items
                # [v1.35] 附加 tool_result 到 tool_call 消息的 metadata
                if (e.key or "") == "tool_call" and meta.get("tool_result"):
                    msg["metadata"] = {"tool_result": meta["tool_result"]}
                result.append(msg)
            return web.json_response(result)
        except Exception as ex:
            logger.error(f"加载会话消息失败 sid={sid}: {ex}")
            return web.json_response({"error": f"加载消息失败: {str(ex)[:200]}"}, status=500)

    async def handle_get_raw_messages(self, request):
        """GET /api/sessions/{sid}/raw - 获取会话全部原始消息（含 llm_output 等）"""
        sid = request.match_info["sid"]
        if not self.core.memory:
            return web.json_response([])
        limit = int(request.query.get("limit", 0))
        offset = int(request.query.get("offset", 0))
        entries = self.core.memory.get_conversation_all(sid, limit=limit + offset if limit else 0)
        entries = entries[offset:]
        return web.json_response([{
            "role": e.role, "content": e.content, "time": e.created_at,
            "key": e.key or "", "id": e.id,
        } for e in entries])

    async def handle_get_raw_messages_query(self, request):
        """GET /api/session/raw?sid=... - 通过 query 参数获取原始消息（支持 session_id 中包含 /）"""
        sid = request.query.get("sid", "")
        if not sid:
            return web.json_response([])
        if not self.core.memory:
            return web.json_response([])
        limit = int(request.query.get("limit", 0))
        offset = int(request.query.get("offset", 0))
        entries = self.core.memory.get_conversation_all(sid, limit=limit + offset if limit else 0)
        entries = entries[offset:]
        return web.json_response([{
            "role": e.role, "content": e.content, "time": e.created_at,
            "key": e.key or "", "id": e.id,
        } for e in entries])

    async def handle_get_messages_query(self, request):
        """GET /api/session/messages?sid=...&agent=... - 通过 query 参数获取会话消息（支持 session_id 中包含 /）

        [FIX] 新增 agent 参数：当指定时，按 agent_id 过滤消息，
        防止跨 Agent 消息泄漏。
        """
        sid = request.query.get("sid", "")
        if not sid:
            return web.json_response([])
        if not self.core.memory: return web.json_response([])
        try:
            limit = min(int(request.query.get("limit", 500)), 500)
            offset = int(request.query.get("offset", 0))
            # [FIX] 支持 agent_id 过滤，防止跨 Agent 消息泄漏
            agent_name = request.query.get("agent", "")
            agent_id = None
            if agent_name:
                try:
                    agent_id = self.core.memory.get_agent_id(agent_name)
                except Exception:
                    agent_id = None
            entries = self.core.memory.get_conversation(sid, limit=limit + offset, agent_id=agent_id)
            entries = entries[offset:]
            # Filter out internal entries (LLM raw output only)
            entries = [e for e in entries if (e.key or "") not in self._HIDDEN_KEYS]
            result = []
            for e in entries:
                msg = {"role": e.role, "content": e.content, "time": e.created_at, "key": e.key or ""}
                # [v1.16.17] 附加附件元数据
                meta = e.metadata or {}
                if meta.get("images"):
                    msg["images"] = meta["images"]
                # [v1.23.19] 分离 _files 和 _media（前端用 msg._files 和 msg._media）
                _raw_files = meta.get("files") or []
                if _raw_files:
                    _file_items = [f for f in _raw_files if f.get("_type") != "media"]
                    _media_items = [f for f in _raw_files if f.get("_type") == "media"]
                    if _file_items:
                        msg["_files"] = _file_items
                    if _media_items:
                        msg["_media"] = _media_items
                # [v1.35] 附加 tool_result 到 tool_call 消息的 metadata
                if (e.key or "") == "tool_call" and meta.get("tool_result"):
                    msg["metadata"] = {"tool_result": meta["tool_result"]}
                result.append(msg)
            return web.json_response(result)
        except Exception as ex:
            logger.error(f"加载会话消息失败 sid={sid}: {ex}")
            return web.json_response({"error": f"加载消息失败: {str(ex)[:200]}"}, status=500)

    def _cleanup_session_state(self, sid: str):
        """删除会话时清理所有关联的后端状态"""
        cleaned = []
        # 1. 运行中的会话
        if sid in self._running_sessions:
            self._running_sessions[sid]["running"] = False
            self._running_sessions[sid]["done"] = True
            del self._running_sessions[sid]
            cleaned.append("running_session")
        # 2. 消息队列
        if sid in self._msg_queues:
            del self._msg_queues[sid]
            cleaned.append("msg_queue")
        # 3. 任务列表
        if sid in self._task_list_store:
            del self._task_list_store[sid]
            cleaned.append("task_list")
        # 4. 执行进度
        if sid in self._exec_progress:
            del self._exec_progress[sid]
            cleaned.append("exec_progress")
        # 5. Agent 活跃上下文
        if self.core.main_agent and sid in self.core.main_agent.active_contexts:
            self.core.main_agent.active_contexts.pop(sid, None)
            cleaned.append("active_context")
        # 6. 自动知识库文件
        try:
            cfg = self.core.config_mgr.config
            kb_dir = getattr(cfg, 'knowledge_base_dir', '') or ''
            if kb_dir:
                safe_session = sid.replace("-", "").replace("/", "_")[:12]
                kb_file = Path(kb_dir) / "auto_knowledge" / f"{safe_session}.md"
                if kb_file.exists():
                    kb_file.unlink()
                    cleaned.append("knowledge")
        except Exception:
            pass
        if cleaned:
            logger.info(f"会话 {sid} 后端状态已清理: {', '.join(cleaned)}")

    async def handle_delete_session(self, request):
        """DELETE /api/sessions/{sid} - 彻底删除会话（记忆 + 后端状态）"""
        sid = request.match_info["sid"]
        logger.info(f"删除会话: {sid}")
        self._cleanup_session_state(sid)
        if self.core.memory:
            count = self.core.memory.delete_session(sid)
            logger.info(f"会话 {sid} 已删除，共清除 {count} 条记忆")
        return web.json_response({"ok": True, "deleted": True})

    async def handle_delete_session_query(self, request):
        """DELETE /api/session?sid=... - 通过 query 参数删除会话（支持 session_id 中包含 /）"""
        sid = request.query.get("sid", "")
        if not sid:
            return web.json_response({"ok": False, "error": "missing sid"}, status=400)
        logger.info(f"删除会话: {sid}")
        self._cleanup_session_state(sid)
        if self.core.memory:
            count = self.core.memory.delete_session(sid)
            logger.info(f"会话 {sid} 已删除，共清除 {count} 条记忆")
        return web.json_response({"ok": True, "deleted": True})

    async def handle_clear_session_messages(self, request):
        """DELETE /api/sessions/{sid}/messages - 仅清空会话对话历史（保留会话本身）"""
        sid = request.match_info["sid"]
        logger.info(f"清空会话消息: {sid}")
        if self.core.memory:
            count = self.core.memory.clear_conversation(sid)
            logger.info(f"会话 {sid} 消息已清空，共清除 {count} 条消息")
        return web.json_response({"ok": True, "cleared": True})

    async def handle_clear_session_messages_query(self, request):
        """DELETE /api/session/messages?sid=... - 通过 query 参数清空会话消息"""
        sid = request.query.get("sid", "")
        if not sid:
            return web.json_response({"ok": False, "error": "missing sid"}, status=400)
        logger.info(f"清空会话消息: {sid}")
        if self.core.memory:
            count = self.core.memory.clear_conversation(sid)
            logger.info(f"会话 {sid} 消息已清空，共清除 {count} 条消息")
        return web.json_response({"ok": True, "cleared": True})

    async def handle_rename_session(self, request):
        """PUT /api/sessions/{sid}/rename - 重命名会话"""
        sid = request.match_info["sid"]
        try:
            body = await request.json()
        except Exception:
            body = {}
        new_name = (body.get("name") or "").strip()
        if not new_name:
            return web.json_response({"ok": False, "error": "名称不能为空"}, status=400)
        if len(new_name) > 100:
            return web.json_response({"ok": False, "error": "名称不能超过100个字符"}, status=400)
        if self.core.memory:
            self.core.memory.rename_session(sid, new_name)
        return web.json_response({"ok": True, "name": new_name})

    async def handle_rename_session_query(self, request):
        """PUT /api/session/rename?sid=... - 通过 query 参数重命名会话"""
        sid = request.query.get("sid", "")
        if not sid:
            return web.json_response({"ok": False, "error": "missing sid"}, status=400)
        try:
            body = await request.json()
        except Exception:
            body = {}
        new_name = (body.get("name") or "").strip()
        if not new_name:
            return web.json_response({"ok": False, "error": "名称不能为空"}, status=400)
        if len(new_name) > 100:
            return web.json_response({"ok": False, "error": "名称不能超过100个字符"}, status=400)
        if self.core.memory:
            self.core.memory.rename_session(sid, new_name)
        return web.json_response({"ok": True, "name": new_name})

    # --- Memory ---
    async def handle_memory_stats(self, request):
        return web.json_response(self.core.memory.get_stats() if self.core.memory else {})

    async def handle_memory_search(self, request):
        q = request.query.get("q", ""); cat = request.query.get("category", "")
        if not self.core.memory: return web.json_response([])
        results = self.core.memory.search(q, category=cat, limit=30)
        return web.json_response([{
            "id": e.id, "key": e.key, "content": e.content[:500],
            "category": e.category, "importance": e.importance,
            "role": e.role, "sid": e.session_id, "created_at": e.created_at,
        } for e in results])

    async def handle_memory_list(self, request):
        if not self.core.memory: return web.json_response([])
        # category 参数已废弃， memories 表只存储全局记忆
        limit = int(request.query.get("limit", "50"))
        session_id = request.query.get("session_id", "")
        # 使用 _query_memories 替代已删除的 _query
        entries = self.core.memory._query_memories(session_id=session_id, limit=limit, order_by="created_at DESC")
        return web.json_response([{
            "id": e.id, "key": e.key, "content": e.content[:500],
            "summary": e.summary, "importance": e.importance,
            "sid": e.session_id, "created_at": e.created_at,
        } for e in entries])

    async def handle_delete_memory(self, request):
        if self.core.memory:
            self.core.memory._get_conn().execute("DELETE FROM memories WHERE id=?", (request.match_info["mid"],))
            self.core.memory._get_conn().commit()
        return web.json_response({"ok": True})

    async def handle_memory_cleanup(self, request):
        return web.json_response({"cleaned": self.core.memory.cleanup_expired() if self.core.memory else 0})

    # --- 权限管理 ---
    async def handle_get_permissions(self, request):
        """GET /api/permissions - 获取所有权限配置"""
        pm = self.core.permission_manager
        if not pm:
            return web.json_response({"error": "权限管理器未初始化"}, status=500)
        try:
            result = pm.get_config_summary()
            # 前端期望 labels 键名，映射 permission_labels
            if "permission_labels" in result and "labels" not in result:
                result["labels"] = result["permission_labels"]
        except Exception as e:
            logger.error(f"获取权限配置失败: {e}")
            return web.json_response({"error": str(e)}, status=500)
        return web.json_response(result)

    async def handle_set_default_permissions(self, request):
        """PUT /api/permissions/defaults - 更新全局默认权限"""
        pm = self.core.permission_manager
        if not pm:
            return web.json_response({"error": "权限管理器未初始化"}, status=500)
        data = await request.json()
        try:
            # data 格式: {"execution": true, "file_read": false, ...}
            if isinstance(data, dict):
                for perm, value in data.items():
                    if isinstance(value, bool) and perm in pm.ALL_PERMISSIONS:
                        pm.set_default_permission(perm, value)
            pm.save()
            return web.json_response({"ok": True})
        except Exception as e:
            return web.json_response({"error": str(e)}, status=500)

    async def handle_get_agent_permissions(self, request):
        """GET /api/permissions/{agent} - 获取单个Agent权限"""
        pm = self.core.permission_manager
        if not pm:
            return web.json_response({"error": "权限管理器未初始化"}, status=500)
        agent = request.match_info["agent"]
        try:
            perms = pm.get_permissions(agent)
            return web.json_response({"agent": agent, "permissions": perms.to_dict()})
        except Exception as e:
            return web.json_response({"error": str(e)}, status=500)

    async def handle_set_agent_permissions(self, request):
        """PUT /api/permissions/{agent} - 设置Agent权限"""
        pm = self.core.permission_manager
        if not pm:
            return web.json_response({"error": "权限管理器未初始化"}, status=500)
        agent = request.match_info["agent"]
        data = await request.json()
        try:
            pm.set_permissions(agent, data)
            pm.save()
            return web.json_response({"ok": True})
        except Exception as e:
            return web.json_response({"error": str(e)}, status=500)

    async def handle_reset_agent_permissions(self, request):
        """DELETE /api/permissions/{agent} - 重置Agent权限为默认"""
        pm = self.core.permission_manager
        if not pm:
            return web.json_response({"error": "权限管理器未初始化"}, status=500)
        agent = request.match_info["agent"]
        try:
            pm.reset_agent_permissions(agent)
            pm.save()
            return web.json_response({"ok": True})
        except Exception as e:
            return web.json_response({"error": str(e)}, status=500)

    # --- LLM ---
    async def handle_get_llm(self, request):
        c = self.core.config.llm
        return web.json_response({"provider": c.provider, "api_type": c.api_type, "model": c.model, "base_url": c.base_url,
            "temperature": c.temperature, "max_tokens": c.max_tokens, "context_window": c.context_window,
            "input_modes": c.input_modes, "reasoning": c.reasoning,
            "timeout": c.timeout, "max_retries": c.max_retries, "api_key_set": bool(c.api_key)})

    async def handle_update_llm(self, request):
        data = await request.json()
        # 1. 写入文件
        cfg_path = self.core.config_mgr._config_file
        cfg_data = _safe_load_json(cfg_path) if cfg_path.exists() else {}
        llm = cfg_data.setdefault("llm", {})
        for k in ("provider", "api_type", "model", "base_url", "temperature", "max_tokens", "context_window", "input_modes", "reasoning", "timeout", "max_retries"):
            if k in data: llm[k] = data[k]
        if data.get("api_key"): llm["api_key"] = data["api_key"]
        cfg_path.write_text(json.dumps(cfg_data, indent=2, ensure_ascii=False), encoding="utf-8")
        # 2. 热更新内存中的配置
        update_keys = {k: v for k, v in data.items() if k != "api_key" or data.get("api_key")}
        self.core.config_mgr.update_llm(**update_keys)
        # 3. 统一热更新 LLM 客户端
        self._hot_reload_llm()
        logger.info(f"LLM 配置已热更新: provider={data.get('provider')}, model={data.get('model')}")
        return web.json_response({"ok": True, "hot_reload": True})

    async def handle_test_llm(self, request):
        """POST /api/llm/test - 独立测试指定模型连接，不使用任何兜底/fallback逻辑

        [v1.16.18] 根据 input_modes 测试多模态能力:
        - text: 始终测试（纯文本连接）
        - image: 发送一张带颜色的测试图片，验证模型能否识别
        - video/audio: 仅测试文本连接（视频/音频需要真实文件，暂不自动测试）
        """
        try:
            data = await request.json()
            api_key = data.get("api_key", "")
            base_url = data.get("base_url", "")
            model = data.get("model", "")
            api_type = data.get("api_type", "")
            temperature = data.get("temperature", 0.8)
            provider = data.get("provider", "")
            input_modes = data.get("input_modes", ["text"])

            if not model:
                return web.json_response({"ok": False, "error": "缺少 model 参数"})

            # 根据 api_type 或 base_url 推断 provider
            if not provider:
                if api_type == "anthropic":
                    provider = "anthropic"
                elif api_type == "ollama":
                    provider = "ollama"
                elif "zhipu" in base_url or "bigmodel" in base_url:
                    provider = "zhipu"
                elif "anthropic" in base_url:
                    provider = "anthropic"
                elif "ollama" in base_url or "localhost" in base_url or "127.0.0.1" in base_url:
                    provider = "ollama"
                elif "dashscope" in base_url or "qwen" in base_url.lower() or "modelscope" in base_url:
                    provider = "custom"
                else:
                    provider = "openai"

            # 纯粹使用请求体中的参数创建临时客户端，绝不 fallback 到全局配置
            from core.llm import LLMClient
            temp_client = LLMClient(
                provider=provider,
                api_key=api_key,
                base_url=base_url,
                model=model,
                temperature=temperature,
                timeout=30,
                max_retries=1,
            )

            # ── 1. 始终测试文本连接 ──
            msg = await temp_client.chat([Message(role="user", content="Hi, reply OK")])
            if not msg.success:
                return web.json_response({"ok": False, "error": msg.error or "LLM 调用失败"})
            text_ok = True
            text_response = (msg.content[:100] if msg.content else "")

            # ── 2. 根据 input_modes 测试多模态能力 ──
            test_results = {"text": True}
            multimodal_ok = True
            multimodal_errors = []

            if "image" in input_modes:
                try:
                    # 生成一张 8x8 红色方块测试图片（PNG）
                    import struct, zlib, base64 as _b64
                    def _make_test_png():
                        """生成一张 8x8 红色方块 PNG 图片（最小有效 PNG）"""
                        width, height = 8, 8
                        # 每个像素: R=255, G=0, B=0, A=255 (红色不透明)
                        raw_data = b""
                        for y in range(height):
                            raw_data += b"\x00"  # PNG filter: None
                            for x in range(width):
                                raw_data += struct.pack("BBBB", 255, 0, 0, 255)
                        compressed = zlib.compress(raw_data)
                        def _chunk(chunk_type, data):
                            c = chunk_type + data
                            crc = struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)
                            return struct.pack(">I", len(data)) + c + crc
                        png = b"\x89PNG\r\n\x1a\n"
                        png += _chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0))
                        png += _chunk(b"IDAT", compressed)
                        png += _chunk(b"IEND", b"")
                        return png

                    test_png_bytes = _make_test_png()
                    test_b64 = _b64.b64encode(test_png_bytes).decode("ascii")

                    # 构建 OpenAI Vision 格式的多模态消息
                    multimodal_content = [
                        {"type": "text", "text": "请描述这张图片的颜色和内容，简短回答。"},
                        {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{test_b64}"}}
                    ]
                    msg_img = await temp_client.chat([Message(role="user", content=multimodal_content)])
                    if msg_img.success:
                        test_results["image"] = True
                        test_results["image_response"] = (msg_img.content[:100] if msg_img.content else "")
                    else:
                        test_results["image"] = False
                        test_results["image_error"] = msg_img.error or "图片识别失败"
                        multimodal_errors.append(f"图片: {msg_img.error or '图片识别失败'}")
                        multimodal_ok = False
                except Exception as img_e:
                    test_results["image"] = False
                    test_results["image_error"] = str(img_e)
                    multimodal_errors.append(f"图片: {str(img_e)}")
                    multimodal_ok = False

            if "video" in input_modes:
                test_results["video"] = None  # 视频需要真实文件，暂不自动测试
                test_results["video_note"] = "视频测试需要上传真实视频文件，暂不支持自动测试"

            if "audio" in input_modes:
                test_results["audio"] = None  # 音频需要真实文件，暂不自动测试
                test_results["audio_note"] = "音频测试需要上传真实音频文件，暂不支持自动测试"

            # 构建结果
            all_ok = text_ok and multimodal_ok
            if multimodal_errors:
                detail = "; ".join(multimodal_errors)
                return web.json_response({
                    "ok": all_ok,
                    "text_ok": text_ok,
                    "text_response": text_response,
                    "tests": test_results,
                    "error": None if all_ok else f"部分测试未通过: {detail}",
                    "detail": detail if not all_ok else None,
                })
            return web.json_response({
                "ok": True,
                "text_ok": text_ok,
                "text_response": text_response,
                "tests": test_results,
            })
        except Exception as e:
            return web.json_response({"ok": False, "error": str(e)})

    async def handle_llm_usage(self, request):
        return web.json_response(self.core.llm.get_usage_stats() if self.core.llm else {})

    # --- Models Library ---
    async def handle_list_models(self, request):
        """GET /api/models - 列出模型库中所有模型"""
        models = []
        for m in self.core.config.models_library:
            models.append({
                "id": m.id, "name": m.name, "provider": m.provider,
                "api_type": m.api_type,
                "model": m.model, "base_url": m.base_url,
                "api_key": m.api_key or "",  # 返回明文 API Key
                "max_tokens": m.max_tokens, "temperature": m.temperature,
                "context_window": m.context_window,
                "input_modes": m.input_modes,
                "reasoning": m.reasoning,
                "enabled": m.enabled,
                "is_global_fallback": getattr(m, 'is_global_fallback', True),
                "has_api_key": bool(m.api_key),
            })
        return web.json_response(models)

    async def handle_add_model(self, request):
        """POST /api/models - 添加模型到库"""
        data = await request.json()
        model_id = data.get("id", "").strip()
        if not model_id:
            return web.json_response({"error": "模型 ID 不能为空"}, status=400)
        # 检查重复
        for m in self.core.config.models_library:
            if m.id == model_id:
                return web.json_response({"error": f"模型 '{model_id}' 已存在"}, status=409)
        entry = ModelEntry(
            id=model_id,
            name=data.get("name", model_id),
            provider=data.get("provider", "openai"),
            api_type=data.get("api_type", "openai-completions"),
            model=data.get("model", model_id),
            base_url=data.get("base_url", ""),
            api_key=data.get("api_key", ""),
            max_tokens=data.get("max_tokens", 10240),
            temperature=data.get("temperature", 0.8),
            context_window=data.get("context_window", 200000),
            input_modes=data.get("input_modes", ["text"]),
            reasoning=data.get("reasoning", False),
            enabled=data.get("enabled", True),
            is_global_fallback=data.get("is_global_fallback", True),
        )
        self.core.config.models_library.append(entry)
        self.core.config_mgr.save()
        # 模型配置变更，立即热更新 LLM 客户端
        self._hot_reload_llm()
        logger.info(f"添加模型到库: {model_id}")
        return web.json_response({"ok": True, "id": model_id, "hot_reload": True})

    async def handle_update_model(self, request):
        """PUT /api/models/{model_id} - 更新模型配置"""
        model_id = request.match_info["model_id"]
        data = await request.json()
        found = False
        for m in self.core.config.models_library:
            if m.id == model_id:
                for k in ("name", "provider", "api_type", "model", "base_url", "max_tokens", "temperature", "context_window", "input_modes", "reasoning", "enabled", "is_global_fallback"): 
                    if k in data:
                        setattr(m, k, data[k])
                # api_key: 如果请求中包含 api_key 字段（即使是空字符串），则更新
                if "api_key" in data:
                    m.api_key = data["api_key"]
                found = True
                break
        if not found:
            return web.json_response({"error": f"模型 '{model_id}' 未找到"}, status=404)
        self.core.config_mgr.save()
        # 模型配置变更，立即热更新 LLM 客户端（无论是否当前使用模型）
        self._hot_reload_llm()
        logger.info(f"更新模型库: {model_id}")
        return web.json_response({"ok": True, "hot_reload": True})

    async def handle_delete_model(self, request):
        """DELETE /api/models/{model_id} - 从库中删除模型"""
        model_id = request.match_info["model_id"]
        original_len = len(self.core.config.models_library)
        self.core.config.models_library = [
            m for m in self.core.config.models_library if m.id != model_id
        ]
        if len(self.core.config.models_library) == original_len:
            return web.json_response({"error": f"模型 '{model_id}' 未找到"}, status=404)
        self.core.config_mgr.save()
        # 模型配置变更，立即热更新 LLM 客户端（无论是否当前使用模型）
        self._hot_reload_llm()
        logger.info(f"从模型库删除: {model_id}")
        return web.json_response({"ok": True, "hot_reload": True})

    # --- Skills ---
    async def handle_list_skills(self, request):
        """GET /api/skills - 返回技能列表（支持 ?view=categorized 参数）

        默认返回扁平列表（向后兼容），传 ?view=categorized 返回四层分类结构。
        [v1.23.0] 四层: builtin_tools / cli_commands / python_skills / skill_guides
        """
        if not self.core.skill_registry:
            return web.json_response({"builtin_tools": [], "cli_commands": [], "python_skills": [], "skill_guides": []})

        view = request.query.get("view", "")
        try:
            if view == "categorized":
                return web.json_response(self.core.skill_registry.list_skills_by_category())
            # 默认：向后兼容的扁平列表
            return web.json_response(self.core.skill_registry.list_skills_info())
        except Exception as e:
            logger.error(f"list_skills 失败: {e}", exc_info=True)
            return web.json_response({"error": f"获取技能列表失败: {e}"}, status=500)

    async def handle_get_skill(self, request):
        """GET /api/skills/{name} - 获取单个技能/工具详情

        查找顺序: SkillRegistry → BUILTIN_TOOLS → CLI_COMMANDS
        """
        name = request.match_info["name"]

        try:
            # 1. 从 SkillRegistry 查找（Python 技能 + Markdown 技能指南 + 旧版存根）
            if self.core.skill_registry:
                s = self.core.skill_registry._skills.get(name)
                if s:
                    info = s.to_openclaw_format()
                    info["disabled"] = self.core.skill_registry._is_disabled(name)
                    return web.json_response(info)

            # 2. 从 BUILTIN_TOOLS 元数据查找（内置平台工具）
            from aiskills.registry import BUILTIN_TOOLS, CLI_COMMANDS
            for bt in BUILTIN_TOOLS:
                if bt["name"] == name:
                    return web.json_response({
                        "name": bt["name"],
                        "description": bt["description"],
                        "category": bt["category"],
                        "parameters": bt["parameters"],
                        "dangerous": False,
                        "disabled": False,
                        "skill_type": "builtin_platform",
                        "note": f"由 {bt['handler']} 处理，不可禁用",
                    })

            # 3. [v1.23.0] 从 CLI_COMMANDS 查找
            for cmd in CLI_COMMANDS:
                if cmd["name"] == name or name in cmd.get("aliases", []):
                    return web.json_response({
                        "name": cmd["name"],
                        "description": cmd["description"],
                        "category": cmd["category"],
                        "cli": cmd.get("cli", ""),
                        "aliases": cmd.get("aliases", []),
                        "disabled": False,
                        "skill_type": "cli_command",
                        "note": "通过 command 工具调用: myagent-ai <name>",
                    })

            return web.json_response({"error": "not found"}, status=404)
        except Exception as e:
            logger.error(f"get_skill 失败 ({name}): {e}", exc_info=True)
            return web.json_response({"error": f"获取技能 '{name}' 失败: {e}"}, status=500)

    async def handle_toggle_skill(self, request):
        """POST /api/skills/{name}/toggle - 启用/禁用技能

        内置平台工具（builtin_platform）和 CLI 命令（cli_command）不可禁用。
        """
        name = request.match_info["name"]
        try:
            try:
                data = await request.json()
            except Exception:
                data = {}
            enabled = data.get("enabled", True)

            # [v1.23.0] 内置平台工具和 CLI 命令不可禁用
            from aiskills.registry import BUILTIN_TOOLS, CLI_COMMANDS
            builtin_names = {bt["name"] for bt in BUILTIN_TOOLS}
            cli_names = {c["name"] for c in CLI_COMMANDS}
            if name in builtin_names:
                return web.json_response({"error": f"内置平台工具 '{name}' 不可禁用"}, status=400)
            if name in cli_names:
                return web.json_response({"error": f"CLI 命令 '{name}' 不可禁用（由 command 工具统一管理）"}, status=400)

            if self.core.skill_registry:
                self.core.skill_registry.toggle(name, enabled)
                # 持久化到配置文件
                self._save_disabled_skills()
            return web.json_response({"ok": True, "name": name, "enabled": enabled})
        except Exception as e:
            logger.error(f"toggle_skill 失败 ({name}): {e}", exc_info=True)
            return web.json_response({"error": f"切换技能 '{name}' 状态失败: {e}"}, status=500)

    def _save_disabled_skills(self):
        """将禁用技能列表持久化到配置文件"""
        if not self.core.skill_registry:
            return
        disabled = list(self.core.skill_registry.disabled_skills)
        cfg_path = self.core.config_mgr._config_file
        cfg_data = _safe_load_json(cfg_path) if cfg_path.exists() else {}
        cfg_data["disabled_skills"] = disabled
        cfg_path.write_text(json.dumps(cfg_data, indent=2, ensure_ascii=False), encoding="utf-8")

    def _load_disabled_skills(self):
        """从配置文件加载禁用技能列表"""
        if not self.core.skill_registry:
            return
        cfg_path = self.core.config_mgr._config_file
        if cfg_path.exists():
            cfg_data = _safe_load_json(cfg_path)
            disabled = cfg_data.get("disabled_skills", [])
            if isinstance(disabled, list):
                self.core.skill_registry.disabled_skills = set(disabled)

    # --- Site Management (v1.31.1) ---

    def _get_site_registry(self):
        """获取 SiteRegistry 单例（懒加载）"""
        if not hasattr(self, '_site_registry'):
            from core.site_registry import get_site_registry
            self._site_registry = get_site_registry()
        return self._site_registry

    def _get_browser_profile_manager(self):
        """获取 BrowserProfileManager 单例（懒加载）"""
        if not hasattr(self, '_browser_profile_mgr'):
            from core.browser_profile import get_browser_profile_manager
            self._browser_profile_mgr = get_browser_profile_manager()
        return self._browser_profile_mgr

    async def handle_list_sites(self, request):
        """GET /api/sites - 列出所有网站（支持 ?category=xxx 筛选）"""
        reg = self._get_site_registry()
        category = request.query.get("category", "")
        sites = reg.list_sites(category=category)
        summary = reg.to_summary()
        return web.json_response({
            "sites": sites,
            "summary": {
                "total": summary["total"],
                "builtin_count": summary["builtin_count"],
                "custom_count": summary["custom_count"],
            },
        })

    async def handle_site_categories(self, request):
        """GET /api/sites/categories - 获取分类统计"""
        reg = self._get_site_registry()
        cats = reg.get_categories()
        return web.json_response(cats)

    async def handle_get_site(self, request):
        """GET /api/sites/{name} - 获取单个网站详情"""
        name = request.match_info["name"]
        reg = self._get_site_registry()
        site = reg.get_site(name)
        if not site:
            return web.json_response({"error": f"网站 '{name}' 不存在"}, status=404)
        # 附加 Profile 信息
        from core.browser_profile import get_browser_profile_manager
        mgr = get_browser_profile_manager()
        profile = mgr.get_profile(name)
        site["profile"] = profile.to_dict()
        return web.json_response(site)

    async def handle_add_site(self, request):
        """POST /api/sites - 添加自定义网站"""
        try:
            data = await request.json()
        except Exception:
            return web.json_response({"error": "无效的 JSON 请求体"}, status=400)

        name = data.get("name", "").strip().lower().replace(" ", "_")
        if not name:
            return web.json_response({"error": "网站名称不能为空"}, status=400)
        if not all(c.isalnum() or c in "_-" for c in name):
            return web.json_response({"error": "网站名称只能包含字母、数字、下划线和连字符"}, status=400)

        reg = self._get_site_registry()
        try:
            site = reg.add_site(data)
            return web.json_response({"ok": True, "site": site})
        except ValueError as e:
            return web.json_response({"error": str(e)}, status=400)
        except Exception as e:
            logger.error(f"添加网站失败: {e}")
            return web.json_response({"error": f"添加网站失败: {e}"}, status=500)

    async def handle_update_site(self, request):
        """PUT /api/sites/{name} - 更新网站配置"""
        name = request.match_info["name"]
        try:
            data = await request.json()
        except Exception:
            return web.json_response({"error": "无效的 JSON 请求体"}, status=400)

        reg = self._get_site_registry()
        # 只允许更新部分字段
        allowed = {"display_name", "category", "login_url", "detect_url", "home_url",
                    "urls", "tips", "enabled"}
        updates = {k: v for k, v in data.items() if k in allowed}

        if not updates:
            return web.json_response({"error": "没有可更新的字段"}, status=400)

        site = reg.update_site(name, updates)
        if not site:
            return web.json_response({"error": f"网站 '{name}' 不存在"}, status=404)
        return web.json_response({"ok": True, "site": site})

    async def handle_remove_site(self, request):
        """DELETE /api/sites/{name} - 删除自定义网站"""
        name = request.match_info["name"]
        reg = self._get_site_registry()
        ok = reg.remove_site(name)
        if not ok:
            return web.json_response({"error": f"无法删除网站 '{name}'（内置网站不可删除）"}, status=400)
        # 同时删除 Profile
        from core.browser_profile import get_browser_profile_manager
        mgr = get_browser_profile_manager()
        mgr.delete_profile(name)
        return web.json_response({"ok": True})

    async def handle_init_site_profile(self, request):
        """POST /api/sites/{name}/init-profile - 初始化网站浏览器 Profile"""
        name = request.match_info["name"]
        reg = self._get_site_registry()
        site = reg.get_site(name)
        if not site:
            return web.json_response({"error": f"网站 '{name}' 不存在"}, status=404)
        try:
            initialized = reg.init_profiles([name])
            return web.json_response({"ok": True, "initialized": initialized})
        except Exception as e:
            logger.error(f"初始化 Profile 失败 ({name}): {e}")
            return web.json_response({"error": f"初始化失败: {e}"}, status=500)

    async def handle_list_profiles(self, request):
        """GET /api/sites/profiles - 列出所有已初始化的浏览器 Profile"""
        from core.browser_profile import get_browser_profile_manager
        mgr = get_browser_profile_manager()
        profiles = mgr.list_profiles()
        total_size = mgr.get_total_size_mb()
        return web.json_response({
            "profiles": profiles,
            "total_size_mb": round(total_size, 2),
        })

    async def handle_delete_profile(self, request):
        """DELETE /api/sites/profiles/{name} - 删除浏览器 Profile"""
        name = request.match_info["name"]
        from core.browser_profile import get_browser_profile_manager
        mgr = get_browser_profile_manager()
        ok = mgr.delete_profile(name)
        if not ok:
            return web.json_response({"error": f"Profile '{name}' 不存在或删除失败"}, status=404)
        return web.json_response({"ok": True})

    def _is_site_chrome_running(self, name: str) -> bool:
        """[v1.34.1] 检查指定网站的 Chrome 进程是否仍在运行。

        通过 _site_chrome_procs 追踪的 Popen 对象判断进程是否存活，
        同时清理已退出的进程记录。
        """
        proc = self._site_chrome_procs.get(name)
        if proc is None:
            return False
        # poll() 返回 None 表示进程仍在运行
        if proc.poll() is None:
            return True
        # 进程已退出，清理记录
        del self._site_chrome_procs[name]
        return False

    def _detect_chrome_path(self) -> str | None:
        """[v1.34.1] 检测系统已安装的 Chrome/Edge 路径，返回可执行文件路径或 None。"""
        import shutil
        import platform

        system = platform.system()
        chrome_path = None

        if system == "Windows":
            for p in (
                os.path.join(os.environ.get("PROGRAMFILES", "C:\\Program Files"), "Google", "Chrome", "Application", "chrome.exe"),
                os.path.join(os.environ.get("PROGRAMFILES(X86)", "C:\\Program Files (x86)"), "Google", "Chrome", "Application", "chrome.exe"),
                os.path.join(os.environ.get("LOCALAPPDATA", ""), "Google", "Chrome", "Application", "chrome.exe"),
                os.path.join(os.environ.get("PROGRAMFILES", "C:\\Program Files"), "Microsoft", "Edge", "Application", "msedge.exe"),
            ):
                if p and os.path.isfile(p):
                    chrome_path = p
                    break
        elif system == "Darwin":
            for p in (
                "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
                "/Applications/Chromium.app/Contents/MacOS/Chromium",
                "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
            ):
                if os.path.isfile(p):
                    chrome_path = p
                    break
        else:
            # Linux
            # [v1.38.0] 优先使用 myagent-browser 包装器（自带 proot 兼容参数）
            for cmd in ("myagent-browser", "firefox", "google-chrome", "google-chrome-stable", "chromium", "microsoft-edge"):
                found = shutil.which(cmd)
                if found:
                    # [v1.36.0] 跳过 snap 包装器
                    if "chromium" in cmd:
                        try:
                            with open(found, "r") as f:
                                first_kb = f.read(1024)
                            if "snap" in first_kb.lower():
                                continue
                        except Exception:
                            pass
                    chrome_path = found
                    break

        # 环境变量覆盖
        for key in ("CHROME_PATH", "BROWSER_PATH"):
            val = os.environ.get(key, "").strip()
            if val and os.path.isfile(val):
                chrome_path = val
                break

        return chrome_path

    async def handle_open_site_browser(self, request):
        """POST /api/sites/{name}/open-browser - 打开浏览器登录网站

        [v1.33.0] 自动启动 VNC 远程桌面（如果尚未运行），确保用户可通过 VNC 看到浏览器界面。
        [v1.33.3] 桌面环境 (Windows/Mac/Linux with real DISPLAY) 直接用系统 Chrome，
                  不启动 VNC 远程桌面。
        [v1.34.0] 桌面环境直接调用系统已安装的 Chrome（非 DrissionPage 控制），
                  用户手动登录后 cookie 自动保存在 profile 目录中。
        [v1.34.1] 桌面环境复用已有 Chrome 实例：同一 profile 的 Chrome 已在运行时，
                  仅在新标签页打开 URL，不再重复启动新窗口。
        """
        name = request.match_info["name"]
        reg = self._get_site_registry()
        site = reg.get_site(name)
        if not site:
            return web.json_response({"error": f"网站 '{name}' 不存在"}, status=404)

        # [v1.33.3] 桌面环境 vs 容器/Termux 环境分流
        try:
            from core.env_detect import is_desktop
            _is_desktop_env = is_desktop()
        except ImportError:
            _is_desktop_env = bool(os.environ.get("DISPLAY", "").strip())

        # Ensure profile is initialized
        from core.browser_profile import get_browser_profile_manager
        bprofile_mgr = get_browser_profile_manager()
        profile = bprofile_mgr.get_profile(name)
        if not profile.is_initialized():
            profile.ensure_dirs()

        login_url = site.get("login_url", "")

        # ═══════════════════════════════════════════════════════════
        # [v1.34.0] 桌面环境: 直接调用系统已安装的 Chrome
        # ═══════════════════════════════════════════════════════════
        if _is_desktop_env:
            try:
                import subprocess

                chrome_path = self._detect_chrome_path()
                user_data_dir = str(profile.profile_dir)

                if chrome_path:
                    # [v1.47.42] 区分 Firefox 和 Chrome，使用各自兼容的参数
                    is_firefox = "firefox" in chrome_path.lower() or "myagent-browser" in chrome_path.lower()

                    if is_firefox:
                        # Firefox 路径：用 --profile 替代 --user-data-dir
                        if self._is_site_chrome_running(name):
                            # Firefox 已在运行：传 URL + --new-window，在已有实例中开新窗口
                            # 桌面环境下远程通信正常，--new-window 可以可靠传递 URL
                            new_tab_cmd = [chrome_path, "--profile", user_data_dir, "--new-window"]
                            if login_url:
                                new_tab_cmd.append(login_url)
                            subprocess.Popen(new_tab_cmd, start_new_session=True)
                            logger.info(f"桌面环境: Firefox 已在运行，新窗口打开 {login_url} (profile={name})")
                        else:
                            cmd = [chrome_path, "--profile", user_data_dir]
                            if login_url:
                                cmd.append(login_url)
                            proc = subprocess.Popen(cmd, start_new_session=True)
                            self._site_chrome_procs[name] = proc
                            logger.info(f"桌面环境: 已启动 Firefox ({chrome_path})，URL: {login_url}")
                    else:
                        # Chrome/Chromium 路径
                        chrome_already_running = self._is_site_chrome_running(name)

                        if chrome_already_running:
                            # Chrome 已在运行：仅传递 URL，Chrome 会在已有实例中打开新标签页
                            new_tab_cmd = [chrome_path, f"--user-data-dir={user_data_dir}"]
                            if login_url:
                                new_tab_cmd.append(login_url)
                            subprocess.Popen(new_tab_cmd, start_new_session=True)
                            logger.info(f"桌面环境: Chrome 已在运行，新标签页打开 {login_url} (profile={name})")
                        else:
                            # Chrome 未运行：首次启动，带完整参数
                            cmd = [chrome_path, f"--user-data-dir={user_data_dir}"]
                            cmd.extend([
                                "--no-first-run",
                                "--no-default-browser-check",
                            ])
                            if login_url:
                                cmd.append(login_url)
                            proc = subprocess.Popen(cmd, start_new_session=True)
                            self._site_chrome_procs[name] = proc
                            logger.info(f"桌面环境: 已直接启动系统 Chrome ({chrome_path})，URL: {login_url}")

                    display_name = site.get('display_name', name)
                    browser_name = "Firefox" if is_firefox else "Chrome"
                    message = f"浏览器已打开，请在 {browser_name} 中完成 {display_name} 的登录"

                    return web.json_response({
                        "ok": True,
                        "site": name,
                        "login_url": login_url,
                        "is_desktop": True,
                        "vnc_started": False,
                        "vnc_running": False,
                        "novnc_url": None,
                        "chrome_path": chrome_path,
                        "message": message,
                    })
                else:
                    # 找不到 Chrome，降级使用系统浏览器
                    import subprocess as _sp
                    _opened = False
                    # [v1.47.12] Termux 环境: 优先使用 termux-open-url
                    termux_open = shutil.which("termux-open-url")
                    if termux_open and login_url:
                        try:
                            _sp.Popen([termux_open, login_url], start_new_session=True)
                            _opened = True
                        except Exception:
                            pass
                    if not _opened:
                        import webbrowser
                        if login_url:
                            webbrowser.open(login_url)
                    logger.info(f"桌面环境: 未找到 Chrome，使用系统默认浏览器打开 {login_url}")

                    display_name = site.get('display_name', name)
                    message = f"浏览器已打开，请在系统浏览器中完成 {display_name} 的登录"

                    return web.json_response({
                        "ok": True,
                        "site": name,
                        "login_url": login_url,
                        "is_desktop": True,
                        "vnc_started": False,
                        "vnc_running": False,
                        "novnc_url": None,
                        "chrome_path": None,
                        "message": message,
                    })

            except Exception as e:
                logger.error(f"桌面环境打开浏览器失败 ({name}): {e}")
                return web.json_response({"error": f"打开浏览器失败: {e}"}, status=500)

        # ═══════════════════════════════════════════════════════════
        # 容器/Termux 环境: 使用 VNC + Firefox（profile独立目录）
        # [v1.47.2] 不再使用 DrissionPage StealthBrowser（proot下不稳定）
        # 改用 VNC 内的 Firefox 直接打开登录页面，profile 隔离
        # ═══════════════════════════════════════════════════════════
        vnc_started = False
        vnc_running = False
        novnc_url = None

        # 容器/Termux 环境: 自动启动 VNC 远程桌面（有头浏览器需要虚拟显示）
        vnc_mgr = self._get_vnc_manager()
        vnc_was_started = vnc_mgr.is_running
        if not vnc_was_started:
            logger.info(f"网站登录需要 VNC 远程桌面，正在自动启动...")
            vnc_result = await vnc_mgr.start()
            if not vnc_result["success"]:
                return web.json_response({"error": f"启动远程桌面失败（登录浏览器需要远程桌面）: {vnc_result['message']}"}, status=500)
            # VNC 启动后，重建 MCP 客户端以使用有头浏览器
            try:
                from aiskills.chromedev_mcp import rebuild_mcp_client
                await rebuild_mcp_client()
            except Exception as e:
                logger.warning(f"VNC 启动后重建 MCP 客户端失败: {e}")
        vnc_started = not vnc_was_started
        vnc_running = True
        novnc_url = "/vnc/vnc.html?autoconnect=true&resize=remote"

        try:
            # [v1.47.2] 在VNC内使用Firefox打开登录页面
            # 使用 site name 作为 profile 目录名，实现登录状态隔离
            display_name = site.get('display_name', name)
            
            # 查找可用的浏览器
            browser_cmd = shutil.which("myagent-browser") or shutil.which("firefox")
            if not browser_cmd:
                browser_cmd = shutil.which("chromium") or shutil.which("google-chrome-stable")
            
            if browser_cmd:
                import subprocess
                # 构建 profile 目录路径
                from core.browser_profile import get_browser_profile_manager
                bprofile_mgr = get_browser_profile_manager()
                profile = bprofile_mgr.get_profile(name)
                profile.ensure_dirs()

                env = {**os.environ, "DISPLAY": vnc_mgr.display}

                # Firefox 使用 --profile 方式
                if "firefox" in browser_cmd.lower():
                    profile_dir = str(profile.profile_dir)

                    # ── 只杀掉使用相同 profile 的 Firefox ──
                    # proot 下远程通信不可靠，无法向已有实例传递 URL，
                    # 所以同 profile 的 Firefox 必须杀掉再重启。
                    # 但不同 profile 的 Firefox 可以共存，不影响其他网站窗口。
                    try:
                        # pgrep -f 匹配命令行中包含该 profile 目录的 Firefox 进程
                        result = subprocess.run(
                            ["pgrep", "-f", f"firefox.*{profile_dir}"],
                            capture_output=True, text=True, timeout=5,
                        )
                        if result.returncode == 0 and result.stdout.strip():
                            pids = result.stdout.strip().split("\n")
                            for pid_str in pids:
                                try:
                                    os.kill(int(pid_str.strip()), signal.SIGTERM)
                                    logger.info(f"已终止同 profile Firefox: PID {pid_str.strip()} (profile={name})")
                                except (ValueError, ProcessLookupError, PermissionError):
                                    pass
                            import time
                            time.sleep(0.5)  # 等待进程退出
                    except Exception:
                        pass

                    # 清理残留的锁文件
                    for _lock_name in (".parentlock", "lock"):
                        _lock_path = os.path.join(profile_dir, _lock_name)
                        if os.path.exists(_lock_path):
                            try:
                                os.remove(_lock_path)
                            except OSError:
                                pass

                    # proot 兼容环境变量
                    env["MOZ_FAKE_NO_SANDBOX"] = "1"

                    # 检查是否有其他 profile 的 Firefox 在运行
                    other_firefox_running = False
                    try:
                        result = subprocess.run(
                            ["pgrep", "-f", "(firefox)"],
                            capture_output=True, text=True, timeout=5,
                        )
                        if result.returncode == 0 and result.stdout.strip():
                            other_firefox_running = True
                    except Exception:
                        pass

                    # 启动 Firefox:
                    # - 有其他 profile 的 Firefox 在运行 → 加 --no-remote 允许多实例共存
                    # - 没有其他 Firefox → 直接启动，URL 在新窗口中打开
                    # - 直接传 URL，不用 --new-window（那是向已有实例发消息的标志）
                    cmd = [browser_cmd, "--profile", profile_dir]
                    if other_firefox_running:
                        cmd.append("--no-remote")
                    if login_url:
                        cmd.append(login_url)
                    logger.info(f"Firefox 启动命令: {cmd} (other_firefox_running={other_firefox_running})")
                else:
                    # Chromium 使用 --user-data-dir
                    cmd = [browser_cmd, f"--user-data-dir={profile.profile_dir}",
                           "--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu"]
                    if login_url:
                        cmd.append(login_url)

                subprocess.Popen(cmd, env=env, start_new_session=True,
                                stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
                logger.info(f"Termux: 已在VNC内启动浏览器, cmd={cmd}, DISPLAY={env.get('DISPLAY')}, profile: {name}")
            else:
                logger.warning(f"Termux: 未找到可用浏览器，无法在VNC内打开 {login_url}")

            message = f"浏览器已打开，请通过远程桌面完成 {display_name} 的登录"
            if vnc_started:
                message += "（已自动启动远程桌面）"

            return web.json_response({
                "ok": True,
                "site": name,
                "login_url": login_url,
                "is_desktop": False,
                "vnc_started": vnc_started,
                "vnc_running": vnc_running,
                "novnc_url": novnc_url,
                "message": message,
            })
        except Exception as e:
            logger.error(f"打开浏览器失败 ({name}): {e}")
            return web.json_response({"error": f"打开浏览器失败: {e}"}, status=500)

    async def handle_close_site_browser(self, request):
        """POST /api/sites/{name}/close-browser - 关闭网站浏览器
        
        [v1.34.0] 桌面环境: 关闭系统 Chrome 窗口（通过 profile user-data-dir 匹配）
        [v1.34.1] 同时清理 _site_chrome_procs 追踪记录
        """
        name = request.match_info["name"]

        # [v1.34.1] 清理进程追踪记录
        self._site_chrome_procs.pop(name, None)

        # [v1.34.0] 桌面环境: 尝试关闭使用对应 profile 的系统 Chrome 窗口
        try:
            from core.env_detect import is_desktop
            _is_desktop_env = is_desktop()
        except ImportError:
            _is_desktop_env = False

        if _is_desktop_env:
            try:
                import subprocess
                import signal
                import platform as pf
                # 通过 user-data-dir 查找对应的 Chrome 进程
                from core.browser_profile import get_browser_profile_manager
                bprofile_mgr = get_browser_profile_manager()
                profile = bprofile_mgr.get_profile(name)
                user_data_dir = str(profile.profile_dir)

                system = pf.system()

                if system == "Windows":
                    # Windows: 使用 taskkill 按命令行参数匹配
                    try:
                        subprocess.run(
                            ["wmic", "process", "where",
                             f"commandline like '%user-data-dir={user_data_dir}%'",
                             "call", "terminate"],
                            capture_output=True, text=True, timeout=10,
                        )
                    except Exception:
                        pass
                else:
                    # Linux/macOS: 使用 pgrep 查找
                    result = subprocess.run(
                        ["pgrep", "-f", f"user-data-dir={user_data_dir}"],
                        capture_output=True, text=True, timeout=5,
                    )
                    if result.returncode == 0 and result.stdout.strip():
                        for pid_str in result.stdout.strip().split("\n"):
                            try:
                                pid = int(pid_str.strip())
                                os.kill(pid, signal.SIGTERM)
                                logger.info(f"已关闭桌面 Chrome 进程: PID {pid} (profile={name})")
                            except (ValueError, ProcessLookupError, PermissionError):
                                pass

                return web.json_response({"ok": True, "site": name, "message": f"浏览器已关闭 ({name})"})
            except Exception as e:
                logger.warning(f"桌面环境关闭浏览器失败 ({name}): {e}")
                # 降级到 StealthBrowser 关闭
                pass

        # 容器/Termux 环境: 关闭 StealthBrowser 实例
        try:
            from aiskills.browser_stealth import close_stealth_browser
            close_stealth_browser(profile_name=name)
            return web.json_response({"ok": True, "site": name, "message": f"浏览器已关闭 ({name})"})
        except Exception as e:
            logger.error(f"关闭浏览器失败 ({name}): {e}")
            return web.json_response({"error": f"关闭浏览器失败: {e}"}, status=500)

    # --- Workdir ---
    async def handle_get_workdir(self, request):
        return web.json_response({"path": str(self.core.config_mgr.data_dir / "workspace")})

    async def handle_set_workdir(self, request):
        data = await request.json(); path = data.get("path", "")
        if not path:
            return web.json_response({"error": "path is required"}, status=400)
        # [v1.31.4] 安全修复: 路径规范化并验证，防止路径遍历
        try:
            resolved = Path(path).resolve()
            # 不允许设置为系统敏感目录
            blocked_prefixes = ["/etc", "/usr", "/bin", "/sbin", "/boot", "/proc", "/sys", "/dev", "/root"]
            for prefix in blocked_prefixes:
                if str(resolved).startswith(prefix + "/") or str(resolved) == prefix:
                    return web.json_response({"error": "不允许将工作目录设置为系统目录"}, status=403)
        except Exception as e:
            return web.json_response({"error": f"无效路径: {e}"}, status=400)
        if path: Path(path).mkdir(parents=True, exist_ok=True)
        cfg_path = self.core.config_mgr._config_file
        cfg_data = _safe_load_json(cfg_path) if cfg_path.exists() else {}
        cfg_data["workspace"] = path
        cfg_path.write_text(json.dumps(cfg_data, indent=2, ensure_ascii=False), encoding="utf-8")
        return web.json_response({"ok": True})

    async def handle_list_workdir(self, request):
        """GET /api/workdir/files?path=xxx&recursive=1 - 列出工作目录文件

        [v1.18.5] 支持:
        - path: 子目录相对路径（如 'userfiles/2026-04'）
        - recursive: 递归列出子目录
        """
        wd = self.core.config_mgr.data_dir / "workspace"
        sub_path = request.query.get("path", "").strip("/")
        if sub_path:
            target = wd / sub_path
        else:
            target = wd
        # 安全检查：防止路径遍历
        try:
            target = target.resolve()
            wd_resolved = wd.resolve()
            if not str(target).startswith(str(wd_resolved)):
                return web.json_response({"error": "非法路径"}, status=403)
        except Exception:
            return web.json_response({"error": "路径错误"}, status=400)

        if not target.exists(): return web.json_response([])
        recursive = request.query.get("recursive", "") in ("1", "true")
        items = []
        max_items = 500
        if recursive:
            for f in sorted(target.rglob("*")):
                if len(items) >= max_items: break
                try:
                    if f.is_file():
                        rel = str(f.relative_to(target))
                        items.append({"name": f.name, "path": sub_path + "/" + rel if sub_path else rel, "type": "file", "size": f.stat().st_size})
                except Exception:
                    pass
        else:
            for f in sorted(target.iterdir()):
                if len(items) >= max_items: break
                try:
                    items.append({
                        "name": f.name,
                        "path": (sub_path + "/" + f.name) if sub_path else f.name,
                        "type": "dir" if f.is_dir() else "file",
                        "size": f.stat().st_size if f.is_file() else 0,
                    })
                except Exception:
                    pass
        return web.json_response(items)

    async def handle_workdir_download(self, request):
        """GET /api/workdir/download/{path} - 下载工作目录文件"""
        import urllib.parse
        rel_path = urllib.parse.unquote(request.match_info["path"]).strip("/")
        if not rel_path:
            return web.json_response({"error": "未指定文件"}, status=400)
        wd = self.core.config_mgr.data_dir / "workspace"
        target = wd / rel_path
        # 安全检查
        try:
            target = target.resolve()
            wd_resolved = wd.resolve()
            if not str(target).startswith(str(wd_resolved)):
                return web.json_response({"error": "非法路径"}, status=403)
        except Exception:
            return web.json_response({"error": "路径错误"}, status=400)
        if not target.exists() or not target.is_file():
            return web.json_response({"error": "文件不存在"}, status=404)
        import mimetypes
        ctype = mimetypes.guess_type(str(target))[0] or "application/octet-stream"
        return web.Response(
            body=target.read_bytes(),
            content_type=ctype,
            headers={"Content-Disposition": f'attachment; filename="{target.name}"'},
        )

    # --- Logs ---
    @staticmethod
    def _iter_log_files(log_dir):
        """[v1.18.8+] 迭代所有日志文件（含轮转备份 myagent.log.1 等），按修改时间排序。"""
        try:
            return sorted(
                (f for f in log_dir.iterdir()
                 if f.is_file() and (f.suffix == ".log" or ".log." in f.name)),
                key=lambda f: f.stat().st_mtime,
            )
        except Exception:
            return []

    async def handle_get_logs(self, request):
        import collections
        log_dir = self.core.config_mgr.logs_dir
        lines = int(request.query.get("lines", "200"))
        level = request.query.get("level", "").upper()
        # [v1.18.8+] 读取所有日志文件（含轮转备份 myagent.log.1 等），按时间排序
        # 使用 deque 高效获取最后 N 行，避免加载整个文件到内存
        all_log_files = self._iter_log_files(log_dir)
        # 使用 deque 只保留最后 N 行，内存占用恒定
        result = collections.deque(maxlen=lines)
        for lf in all_log_files:
            try:
                with open(lf, "r", encoding="utf-8", errors="replace") as f:
                    for raw_line in f:
                        line = raw_line.rstrip("\n\r")
                        if not line.strip():
                            continue
                        # [Bug修复] 用位置匹配代替 in 搜索，避免日志内容包含级别关键字时误过滤
                        # 日志格式: [2025-01-01 12:00:00] INFO    myagent - message
                        if level:
                            # 查找 ] 后第一个单词（即级别字段）
                            bracket_end = line.find("] ")
                            if bracket_end >= 0:
                                level_part = line[bracket_end + 2:].split()[0].strip() if bracket_end + 2 < len(line) else ""
                                if level_part != level:
                                    continue
                            else:
                                # 格式不匹配，回退到 in 搜索
                                if level not in line:
                                    continue
                        result.append(line)
            except Exception:
                pass
        return web.json_response(list(result))

    async def handle_log_stream(self, request):
        resp = web.StreamResponse()
        resp.content_type = "text/event-stream"
        resp.headers["Cache-Control"] = "no-cache"
        resp.headers["Connection"] = "keep-alive"
        resp.headers["X-Accel-Buffering"] = "no"  # 防止 nginx 缓冲
        await resp.prepare(request)
        log_dir = self.core.config_mgr.logs_dir
        # [v1.18.8+] 跟踪所有日志文件（含轮转备份 myagent.log.1 等）
        file_positions = {}  # {file_path_str: last_pos}
        try:
            while True:
                try:
                    if log_dir.exists():
                        candidates = self._iter_log_files(log_dir)
                        current_keys = set()
                        for lf in candidates:
                            key = str(lf.resolve())
                            current_keys.add(key)
                            pos = file_positions.get(key, 0)
                            try:
                                size = lf.stat().st_size
                            except OSError:
                                continue
                            if size < pos:
                                # 文件被轮转/截断，从头读取
                                pos = 0
                            if size > pos:
                                try:
                                    with open(lf, "r", encoding="utf-8", errors="replace") as f:
                                        f.seek(pos)
                                        new_data = f.read()
                                        file_positions[key] = size
                                    if new_data:
                                        await resp.write(f"data: {json.dumps(new_data.strip())}\n\n")
                                except Exception:
                                    pass
                            else:
                                file_positions[key] = size
                        # [Bug修复] 清理已删除/轮转消失的旧文件位置，避免内存泄漏
                        stale = set(file_positions.keys()) - current_keys
                        for k in stale:
                            del file_positions[k]
                except Exception:
                    pass
                await asyncio.sleep(0.5)
        except asyncio.CancelledError:
            pass
        return resp

    # ── 配置管理 (热重载 / 导入 / 导出) ──
    # ── Agent 间通信 ──
    async def handle_get_communication(self, request):
        """GET /api/communication - 获取通信配置"""
        comm = self.core.config.communication
        return web.json_response({
            "enabled": comm.enabled,
            "server_url": comm.server_url,
            "agent_id": comm.agent_id,
            "max_friends": comm.max_friends,
            "auto_accept": comm.auto_accept,
        })

    async def handle_update_communication(self, request):
        """PUT /api/communication - 更新通信配置"""
        data = await request.json()
        cfg_path = self.core.config_mgr._config_file
        cfg_data = _safe_load_json(cfg_path) if cfg_path.exists() else {}
        comm = cfg_data.setdefault("communication", {})
        for k in ("enabled", "server_url", "max_friends", "auto_accept"):
            if k in data:
                comm[k] = data[k]
        cfg_path.write_text(json.dumps(cfg_data, indent=2, ensure_ascii=False), encoding="utf-8")
        # Hot-reload in memory
        if "enabled" in data:
            self.core.config.communication.enabled = data["enabled"]
        if "server_url" in data:
            self.core.config.communication.server_url = data["server_url"]
        if "max_friends" in data:
            self.core.config.communication.max_friends = data["max_friends"]
        if "auto_accept" in data:
            self.core.config.communication.auto_accept = data["auto_accept"]
        # 热更新通信管理器
        self._hot_reload_communication()
        logger.info(f"通信配置已更新: enabled={data.get('enabled')}")
        return web.json_response({"ok": True, "hot_reload": True})

    async def handle_comm_status(self, request):
        """GET /api/communication/status - 获取通信状态"""
        mgr = getattr(self.core, "communication_manager", None)
        if not mgr:
            return web.json_response({"enabled": False, "running": False})
        return web.json_response(mgr.get_status())

    async def handle_list_peers(self, request):
        """GET /api/communication/peers - 列出所有 peers"""
        mgr = getattr(self.core, "communication_manager", None)
        if not mgr:
            return web.json_response([])
        peers = mgr.get_peer_list()
        return web.json_response([p.to_dict() for p in peers])

    async def handle_add_peer(self, request):
        """POST /api/communication/peers - 添加 peer"""
        mgr = getattr(self.core, "communication_manager", None)
        if not mgr:
            return web.json_response({"error": "communication not enabled"}, status=400)
        data = await request.json()
        agent_id = data.get("agent_id", "").strip()
        public_key = data.get("public_key", "").strip()
        display_name = data.get("display_name", "")
        if not agent_id or not public_key:
            return web.json_response({"error": "agent_id and public_key are required"}, status=400)
        ok = mgr.add_peer(agent_id=agent_id, public_key=public_key, display_name=display_name)
        if ok:
            return web.json_response({"ok": True, "agent_id": agent_id})
        else:
            return web.json_response({"error": "peer already exists"}, status=409)

    async def handle_remove_peer(self, request):
        """DELETE /api/communication/peers/{agent_id} - 移除 peer"""
        mgr = getattr(self.core, "communication_manager", None)
        if not mgr:
            return web.json_response({"error": "communication not enabled"}, status=400)
        agent_id = request.match_info["agent_id"]
        ok = mgr.remove_peer(agent_id)
        if ok:
            return web.json_response({"ok": True})
        else:
            return web.json_response({"error": "peer not found"}, status=404)

    async def handle_comm_messages(self, request):
        """GET /api/communication/messages - 获取最近消息"""
        mgr = getattr(self.core, "communication_manager", None)
        if not mgr:
            return web.json_response([])
        limit = int(request.query.get("limit", "50"))
        from_agent = request.query.get("from_agent", "")
        to_agent = request.query.get("to_agent", "")
        messages = mgr.get_messages(limit=limit, from_agent=from_agent, to_agent=to_agent)
        return web.json_response(messages)

    async def handle_send_message(self, request):
        """POST /api/communication/messages - 发送消息"""
        mgr = getattr(self.core, "communication_manager", None)
        if not mgr:
            return web.json_response({"error": "communication not enabled"}, status=400)
        data = await request.json()
        to_agent = data.get("to_agent", "").strip()
        content = data.get("content", "").strip()
        msg_type = data.get("msg_type", "text")
        if not to_agent or not content:
            return web.json_response({"error": "to_agent and content are required"}, status=400)
        try:
            msg = await mgr.send_message(to_agent=to_agent, content=content, msg_type=msg_type)
            return web.json_response({"ok": True, "message": msg.to_dict()})
        except Exception as e:
            logger.error(f"Send message error: {e}")
            return web.json_response({"error": str(e)}, status=500)

    async def handle_ack_message(self, request):
        """POST /api/communication/messages/{msg_id}/ack - 确认消息"""
        msg_id = request.match_info["msg_id"]
        # For now, just return ok. In the future, this could update delivery status.
        return web.json_response({"ok": True, "msg_id": msg_id})

    # ==================================================================
    # DuckDNS 动态域名管理
    # ==================================================================

    @staticmethod
    def _duckdns_config_path():
        """DuckDNS 配置文件路径"""
        return Path.home() / ".myagent" / "duckdns_config.json"

    @staticmethod
    def _load_duckdns_config() -> dict:
        """加载 DuckDNS 配置"""
        import json as _json
        p = ApiServer._duckdns_config_path()
        try:
            if p.exists():
                return _json.loads(p.read_text(encoding="utf-8"))
        except Exception:
            pass
        return {"domain": "", "token": ""}

    @staticmethod
    def _save_duckdns_config(domain: str, token: str):
        """保存 DuckDNS 配置"""
        import json as _json
        p = ApiServer._duckdns_config_path()
        p.parent.mkdir(parents=True, exist_ok=True)
        p.write_text(_json.dumps({"domain": domain, "token": token}, indent=2, ensure_ascii=False), encoding="utf-8")

    @staticmethod
    def _get_global_ipv6_addresses() -> list:
        """检测本机所有全局 IPv6 地址"""
        import socket, re, subprocess, sys
        ipv6_addrs = []
        try:
            hostname = socket.gethostname()
            addrs = socket.getaddrinfo(hostname, None, socket.AF_INET6, socket.SOCK_STREAM)
            for addr_info in addrs:
                addr = addr_info[4][0]
                if addr == '::1' or addr.lower().startswith('fe80:') or addr.lower().startswith(('fc', 'fd')):
                    continue
                normalized = socket.inet_ntop(socket.AF_INET6, socket.inet_pton(socket.AF_INET6, addr))
                if normalized not in ipv6_addrs:
                    ipv6_addrs.append(normalized)
        except Exception:
            pass
        if not ipv6_addrs and sys.platform != 'win32':
            try:
                result = subprocess.run(["ip", "-6", "addr", "show", "scope", "global"], capture_output=True, text=True, timeout=5)
                for line in result.stdout.splitlines():
                    if line.strip().startswith("inet6 "):
                        ipv6_addrs.append(line.strip().split()[1].split("/")[0])
            except Exception:
                pass
        if not ipv6_addrs and sys.platform == 'win32':
            try:
                result = subprocess.run(["ipconfig"], capture_output=True, text=True, timeout=5)
                for match in re.finditer(r'IPv6[^:\n]*:\s*([0-9a-fA-F:]+)', result.stdout):
                    addr = match.group(1).strip()
                    if not addr or addr == '::1':
                        continue
                    try:
                        normalized = socket.inet_ntop(socket.AF_INET6, socket.inet_pton(socket.AF_INET6, addr))
                        if normalized != '::1' and not normalized.lower().startswith('fe80:'):
                            if normalized not in ipv6_addrs:
                                ipv6_addrs.append(normalized)
                    except Exception:
                        continue
            except Exception:
                pass
        return ipv6_addrs

    async def handle_get_duckdns(self, request):
        """GET /api/duckdns - 获取 DuckDNS 配置及当前 IP"""
        config = self._load_duckdns_config()
        ipv6_addrs = self._get_global_ipv6_addresses()
        public_ipv4 = ""
        try:
            import aiohttp
            async with aiohttp.ClientSession() as session:
                async with session.get("https://api.ipify.org", timeout=aiohttp.ClientTimeout(total=10)) as resp:
                    text = await resp.text()
                    import re as _re
                    if text and _re.match(r'^\d+\.\d+\.\d+\.\d+$', text.strip()):
                        public_ipv4 = text.strip()
        except Exception:
            pass
        return web.json_response({
            "code": 0,
            "data": {
                "domain": config.get("domain", ""),
                "token": config.get("token", ""),
                "full_domain": f"{config.get('domain', '')}.duckdns.org" if config.get("domain") else "",
                "current_ipv6": ipv6_addrs[0] if ipv6_addrs else "",
                "current_ipv4": public_ipv4,
            }
        })

    async def handle_save_duckdns(self, request):
        """POST /api/duckdns - 保存 DuckDNS 配置"""
        try:
            data = await request.json()
            domain = data.get("domain", "").strip()
            token = data.get("token", "").strip()
            if not domain:
                return web.json_response({"code": -1, "msg": "域名不能为空"})
            if not token:
                return web.json_response({"code": -1, "msg": "Token不能为空"})
            self._save_duckdns_config(domain, token)
            logger.info(f"DuckDNS config updated: {domain}.duckdns.org")
            return web.json_response({"code": 0, "msg": f"动态域名配置已保存：{domain}.duckdns.org"})
        except Exception as e:
            return web.json_response({"code": -1, "msg": str(e)})

    async def handle_update_duckdns(self, request):
        """POST /api/duckdns/update - 立即执行 DuckDNS 更新"""
        try:
            import aiohttp
            config = self._load_duckdns_config()
            domain = config.get("domain", "")
            token = config.get("token", "")
            if not domain or not token:
                return web.json_response({"code": -1, "msg": "域名或Token未配置"})
            ipv6_addrs = self._get_global_ipv6_addresses()
            ipv6 = ipv6_addrs[0] if ipv6_addrs else ""
            ipv4 = ""
            try:
                async with aiohttp.ClientSession() as session:
                    async with session.get("https://api.ipify.org", timeout=aiohttp.ClientTimeout(total=10)) as resp:
                        text = await resp.text()
                        import re as _re
                        if text and _re.match(r'^\d+\.\d+\.\d+\.\d+$', text.strip()):
                            ipv4 = text.strip()
            except Exception:
                pass
            if not ipv4 and not ipv6:
                return web.json_response({"code": -1, "msg": "未检测到任何公网IP地址"})
            url = f"https://www.duckdns.org/update?domains={domain}&token={token}&verbose=true"
            if ipv4:
                url += f"&ip={ipv4}"
            if ipv6:
                url += f"&ipv6={ipv6}"
            async with aiohttp.ClientSession() as session:
                async with session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as resp:
                    result_text = await resp.text()
            if result_text.startswith("OK"):
                parts = []
                if ipv4: parts.append(f"IPv4={ipv4}")
                if ipv6: parts.append(f"IPv6=[{ipv6}]")
                return web.json_response({
                    "code": 0,
                    "msg": f"更新成功：{domain}.duckdns.org → {', '.join(parts)}",
                    "data": {"ipv4": ipv4, "ipv6": ipv6, "detail": result_text.strip()}
                })
            else:
                return web.json_response({
                    "code": -1,
                    "msg": f"DuckDNS返回: {result_text.strip()}",
                    "data": {"ipv4": ipv4, "ipv6": ipv6, "detail": result_text.strip()}
                })
        except Exception as e:
            logger.error(f"DuckDNS manual update error: {e}")
            return web.json_response({"code": -1, "msg": f"更新出错：{str(e)}"})

    async def handle_get_config(self, request):
        """GET /api/config - 获取完整配置（敏感字段脱敏）"""
        cfg = self.core.config_mgr.get_full_config()
        return web.json_response(cfg)

    async def handle_get_config_key(self, request):
        """POST /api/config/get - 获取单个配置项"""
        data = await request.json()
        key = data.get("key", "")
        value = getattr(self.core.config, key, None)
        return web.json_response({"ok": True, "value": value or ""})

    async def handle_set_config_key(self, request):
        """POST /api/config/set - 设置单个配置项并保存"""
        data = await request.json()
        key = data.get("key", "")
        value = data.get("value", "")
        if not key:
            return web.json_response({"ok": False, "error": "缺少 key"})
        setattr(self.core.config, key, value)
        self.core.config_mgr.save()
        # 时区变更时清除缓存，下次调用 get_config_tz() 立即生效
        if key == "timezone":
            import core.utils as _u
            _u._tz_cache = None
        return web.json_response({"ok": True})

    def _build_model_chain(self, agent_cfg: dict | None, agent_path: str) -> list[dict]:
        """构建模型链: [主模型, 备用模型1, 备用模型2, ...]"""
        if not agent_cfg:
            return []

        chain = []
        llm_defaults = self.core.config.llm

        # 主模型
        model_id = agent_cfg.get("model_id")
        if model_id:
            for me in self.core.config.models_library:
                if me.id == model_id:
                    chain.append({
                        "id": me.id,
                        "name": me.name,
                        "provider": me.provider or llm_defaults.provider,
                        "api_type": getattr(me, 'api_type', '') or llm_defaults.api_type,
                        "model": me.model or model_id,
                        "base_url": me.base_url or llm_defaults.base_url,
                        "api_key": me.api_key or llm_defaults.api_key,
                        "temperature": me.temperature if me.temperature is not None else llm_defaults.temperature,
                        "max_tokens": me.max_tokens if me.max_tokens else llm_defaults.max_tokens,
                        "context_window": getattr(me, 'context_window', None) or llm_defaults.context_window,
                        "input_modes": list(getattr(me, 'input_modes', None) or ["text"]),
                        "is_backup": False,
                    })
                    break

        # 兼容旧的 model 字段
        if not chain and agent_cfg.get("model"):
            chain.append({
                "id": "",
                "name": agent_cfg["model"],
                "model": agent_cfg["model"],
                "is_backup": False,
            })

        # 如果没有找到任何主模型配置，尝试使用模型库中标记为全局兜底的模型
        if not chain:
            fallback_model = None
            for me in self.core.config.models_library:
                if getattr(me, 'is_global_fallback', True) and me.enabled:
                    fallback_model = me
                    break
            if fallback_model:
                chain.append({
                    "id": fallback_model.id,
                    "name": fallback_model.name,
                    "provider": fallback_model.provider or llm_defaults.provider,
                    "api_type": getattr(fallback_model, 'api_type', '') or llm_defaults.api_type,
                    "model": fallback_model.model or fallback_model.id,
                    "base_url": fallback_model.base_url or llm_defaults.base_url,
                    "api_key": fallback_model.api_key or llm_defaults.api_key,
                    "temperature": fallback_model.temperature if fallback_model.temperature is not None else llm_defaults.temperature,
                    "max_tokens": fallback_model.max_tokens if fallback_model.max_tokens else llm_defaults.max_tokens,
                    "context_window": getattr(fallback_model, 'context_window', None) or llm_defaults.context_window,
                    "input_modes": list(getattr(fallback_model, 'input_modes', None) or ["text"]),
                    "is_backup": False,
                })
            else:
                # [v1.33.7] 不再自动兜底到全局默认LLM配置，而是抛出明确错误
                # 避免在用户未配置模型时，无声无息地使用可能无效的默认OpenAI配置
                error_msg = (
                    f"Agent '{agent_path}' 未配置模型，且 models_library 中没有找到可用的全局兜底模型。\n"
                    f"请在配置文件中添加 models_library 配置，或为 agent 设置 model_id。\n"
                    f"参考文档: https://github.com/ctz168/myagent#%EF%B8%8F-%E9%85%8D%E7%BD%AE\n"
                    f"当前 models_library 长度: {len(self.core.config.models_library)}"
                )
                logger.error(error_msg)
                raise ValueError(error_msg)

        # 备用模型列表
        backup_ids = agent_cfg.get("backup_model_ids", [])
        for bid in backup_ids:
            if isinstance(bid, str) and bid.strip():
                for me in self.core.config.models_library:
                    if me.id == bid:
                        chain.append({
                            "id": me.id,
                            "name": me.name,
                            "provider": me.provider or llm_defaults.provider,
                            "api_type": getattr(me, 'api_type', '') or llm_defaults.api_type,
                            "model": me.model or bid,
                            "base_url": me.base_url or llm_defaults.base_url,
                            "api_key": me.api_key or llm_defaults.api_key,
                            "temperature": me.temperature if me.temperature is not None else llm_defaults.temperature,
                            "max_tokens": me.max_tokens if me.max_tokens else llm_defaults.max_tokens,
                            "context_window": getattr(me, 'context_window', None) or llm_defaults.context_window,
                            "input_modes": list(getattr(me, 'input_modes', None) or ["text"]),
                            "is_backup": True,
                        })
                        break

        return chain

    def _reorder_model_chain_for_images(self, model_chain: list[dict], has_images: bool) -> list[dict]:
        """[v1.16.14→18.2] 当消息包含图片时，将支持 vision 的模型优先排列

        优先从 model_chain 自带的 input_modes 读取（v1.16.18 改进），
        其次从 models_library 二次查找（兼容旧逻辑）。

        检查每个模型的 input_modes 字段，如果包含 "image"，则优先使用。
        如果 chain 中没有 vision 模型，自动从全局 models_library 查找 vision
        兜底模型追加到 chain 末尾（v1.18.2 改进）。
        """
        if not has_images or not model_chain:
            return model_chain

        # 从 model_chain 自带的 input_modes 或 models_library 获取
        vision_models = []
        text_only_models = []
        chain_ids = set()
        for mc in model_chain:
            mc_id = mc.get("id", "")
            model_name = mc.get("name", mc.get("model", "?"))
            chain_ids.add(mc_id)

            # [v1.16.18] 优先使用 chain 自带的 input_modes
            input_modes = mc.get("input_modes", None)

            # 如果 chain 中没有，从 models_library 查找
            if input_modes is None:
                input_modes = ["text"]  # 默认
                if mc_id:
                    for me in self.core.config.models_library:
                        if me.id == mc_id:
                            input_modes = me.input_modes or ["text"]
                            break

            logger.info(f"[reorder] 模型 {model_name} (id={mc_id}) input_modes={input_modes}")
            if "image" in input_modes:
                vision_models.append(mc)
            else:
                text_only_models.append(mc)

        if vision_models:
            logger.info(f"消息含图片，优先使用 vision 模型: {[m.get('name', m.get('model')) for m in vision_models]}")
            return vision_models + text_only_models

        # [v1.18.2] chain 中没有 vision 模型 → 从全局 models_library 自动找 vision 兜底
        llm_defaults = self.core.config.llm
        auto_vision = None
        for me in self.core.config.models_library:
            if me.id in chain_ids:
                continue  # 跳过已在 chain 中的
            modes = me.input_modes or ["text"]
            if "image" in modes and me.enabled:
                auto_vision = me
                break

        if auto_vision:
            vision_mc = {
                "id": auto_vision.id,
                "name": auto_vision.name,
                "provider": auto_vision.provider or llm_defaults.provider,
                "api_type": getattr(auto_vision, 'api_type', '') or llm_defaults.api_type,
                "model": auto_vision.model or auto_vision.id,
                "base_url": auto_vision.base_url or llm_defaults.base_url,
                "api_key": auto_vision.api_key or llm_defaults.api_key,
                "temperature": auto_vision.temperature if auto_vision.temperature is not None else llm_defaults.temperature,
                "max_tokens": auto_vision.max_tokens if auto_vision.max_tokens else llm_defaults.max_tokens,
                "context_window": getattr(auto_vision, 'context_window', None) or llm_defaults.context_window,
                "input_modes": list(auto_vision.input_modes or ["text"]),
                "is_backup": True,
                "_auto_vision": True,  # 标记：自动追加的 vision 兜底
            }
            logger.warning(f"消息含图片，agent 模型链无 vision 模型，自动追加全局 vision 兜底: {auto_vision.name} (id={auto_vision.id})")
            return [vision_mc] + text_only_models

        # 既没有 chain 内 vision，也没有全局兜底 → 保持原序，让 main_agent 降级纯文本
        chain_info = []
        for mc in model_chain:
            chain_info.append(f"{mc.get('name', mc.get('model', '?'))}(id={mc.get('id','')}, modes={mc.get('input_modes','?')})")
        logger.warning(f"消息含图片，但无可用 vision 模型，链详情: {chain_info}，将降级纯文本")
        return model_chain

    async def _try_model_chain(self, model_chain: list[dict], message: str, session_id: str,
                                 agent_path: str = None, agent_system_prompt: str = None,
                                 chat_mode: str = "") -> str:
        """依次尝试模型链中的模型，直到成功或全部失败

        [v1.33.5] 不再使用全局 _model_chain_lock。
        _try_model_chain_inner 为每次请求创建独立的 LLM 快照，天然线程安全。
        """
        if not model_chain:
            return await self.core.process_message(message, session_id)

        return await self._try_model_chain_inner(model_chain, message, session_id,
            agent_path=agent_path, agent_system_prompt=agent_system_prompt,
            chat_mode=chat_mode)

    async def _try_model_chain_inner(self, model_chain: list[dict], message: str, session_id: str,
                                      agent_path: str = None, agent_system_prompt: str = None,
                                      chat_mode: str = "") -> str:
        """_try_model_chain 的实际执行体

        [v1.33.5] 核心改造：不再修改共享 self.core.llm，而是为每次请求创建独立的 LLM 快照。
        这样多个请求可以安全并发执行，无需 _model_chain_lock。

        实现方式：
          1. 基于 self.core.llm 创建临时 LLMClient 副本
          2. 临时替换 main_agent.llm 为副本
          3. 执行完毕后恢复 main_agent.llm 为原始共享实例
          4. Agent 参数通过 main_agent 实例属性注入（在同 agent 排队保护下安全）
        """
        shared_llm = self.core.llm
        main_agent = self.core.main_agent
        last_error = ""
        used_model_name = ""

        for i, mc in enumerate(model_chain):
            is_backup = mc.get("is_backup", False)
            is_auto_vision = mc.get("_auto_vision", False)
            model_label = f"{'备用' if is_backup else '主'}模型 {mc.get('name', mc.get('model', '?'))}"
            logger.info(f"尝试 {model_label} ({i+1}/{len(model_chain)}): provider={mc.get('provider')}, model={mc.get('model')}")

            try:
                # [v1.33.5] 创建临时 LLM 快照 — 不修改共享 self.core.llm
                from core.llm import LLMClient
                snapshot_llm = LLMClient(
                    provider=mc.get("provider", shared_llm.provider),
                    model=mc.get("model", shared_llm.model),
                    base_url=mc.get("base_url", shared_llm.base_url),
                    api_key=mc.get("api_key", shared_llm.api_key),
                    temperature=mc.get("temperature", shared_llm.temperature),
                    max_tokens=mc.get("max_tokens", shared_llm.max_tokens),
                    timeout=shared_llm.timeout,
                    max_retries=shared_llm.max_retries,
                    reasoning=getattr(shared_llm, 'reasoning', False),
                    reasoning_effort=getattr(shared_llm, 'reasoning_effort', 'medium'),
                    context_window=mc.get("context_window", getattr(shared_llm, 'context_window', 200000)),
                    api_type=mc.get("api_type", getattr(shared_llm, 'api_type', '')),
                )

                # 临时替换 main_agent.llm 为快照实例
                _orig_llm = None
                _orig_exec_work_dir = None
                if main_agent:
                    _orig_llm = main_agent.llm
                    main_agent.llm = snapshot_llm

                    # 注入 Agent 专属参数
                    if agent_system_prompt:
                        main_agent._agent_override_prompt = agent_system_prompt
                        main_agent._agent_override_path = agent_path

                    # 独立工作目录
                    if agent_path and main_agent.executor:
                        from config import ConfigManager
                        _cm = ConfigManager()
                        _agent_wd = _cm.data_dir / "agents" / agent_path / "workspace"
                        _agent_wd.mkdir(parents=True, exist_ok=True)
                        (_agent_wd / "userfiles").mkdir(parents=True, exist_ok=True)
                        _orig_exec_work_dir = main_agent.executor.work_dir
                        main_agent.executor.work_dir = str(_agent_wd)

                    # Agent 名称和描述
                    if agent_path:
                        _acfg = self._read_agent_config(agent_path)
                        if _acfg:
                            main_agent._agent_override_name = _acfg.get("name", agent_path)
                            _adesc = _acfg.get("description", "")
                            if not _adesc:
                                _adesc = _acfg.get("system_prompt", "")[:200]
                            main_agent._agent_override_description = _adesc or None

                    # 执行模式
                    main_agent._chat_mode = chat_mode

                    # Agent 专属知识库目录
                    if agent_path and main_agent.context_builder:
                        agent_kb_dir = self._get_agent_knowledge_dir(agent_path)
                        agent_kb_dir.mkdir(parents=True, exist_ok=True)
                        main_agent.context_builder.agent_knowledge_dir = str(agent_kb_dir)

                    # 同步 context_window 到 context_builder
                    cw = mc.get("context_window")
                    if cw and main_agent.context_builder:
                        main_agent.context_builder.context_window = cw

                try:
                    response = await self.core.process_message(message, session_id)
                finally:
                    # 恢复 main_agent 状态
                    if main_agent:
                        if _orig_llm is not None:
                            main_agent.llm = _orig_llm
                        main_agent._agent_override_prompt = None
                        main_agent._agent_override_path = None
                        main_agent._agent_override_name = None
                        main_agent._agent_override_description = None
                        main_agent._chat_mode = ""
                        if _orig_exec_work_dir is not None and main_agent.executor:
                            main_agent.executor.work_dir = _orig_exec_work_dir
                        if main_agent.context_builder:
                            main_agent.context_builder.agent_knowledge_dir = None

                # 检查是否成功
                if response and not response.startswith("⚠️ LLM 调用失败") and not response.startswith("❌"):
                    used_model_name = model_label
                    if is_backup:
                        logger.warning(f"🔄 主模型失败，成功切换到 {model_label}")
                    if is_auto_vision:
                        _hint = f"💡 当前绑定的模型不支持图片，已自动切换到 {mc.get('name', mc.get('model', '?'))} 处理。\n\n"
                        return _hint + response
                    return response

                last_error = response
                logger.warning(f"{model_label} 返回无效响应，尝试下一个...")

            except Exception as e:
                last_error = str(e)
                logger.warning(f"{model_label} 调用异常: {e}，尝试下一个...")

        # 所有模型都失败
        return f"⚠️ 所有模型均调用失败 (共 {len(model_chain)} 个)。最后错误: {last_error}"

    async def _try_model_chain_stream(self, model_chain, message, session_id,
                                       agent_path=None, agent_system_prompt=None,
                                       chat_mode="", stream_response=None,
                                       voice_text="", user_images=None, user_files=None):
        """流式版本的模型链调用，逐token输出到SSE

        [v1.33.5] 不再使用全局 _model_chain_lock。
        _try_model_chain_stream_inner 为每次请求创建独立的 LLM 快照，天然线程安全。
        """
        if not model_chain:
            result = await self.core.process_message(message, session_id)
            await stream_response.write(("data: " + json.dumps({"type": "text", "content": result}) + "\n\n").encode())
            return result

        return await self._try_model_chain_stream_inner(
            model_chain, message, session_id,
            agent_path=agent_path, agent_system_prompt=agent_system_prompt,
            chat_mode=chat_mode, stream_response=stream_response,
            voice_text=voice_text,
            user_images=user_images, user_files=user_files,
        )

    async def _try_model_chain_stream_inner(self, model_chain, message, session_id,
                                             agent_path=None, agent_system_prompt=None,
                                             chat_mode="", stream_response=None,
                                             voice_text="", user_images=None, user_files=None):
        """_try_model_chain_stream 的实际执行体

        [v1.33.5] 核心改造：不再修改共享 self.core.llm，创建临时 LLM 快照。
        """
        shared_llm = self.core.llm
        main_agent = self.core.main_agent
        full_text = ""
        _auto_vision_switched = False
        _auto_vision_model_name = ""

        for i, mc in enumerate(model_chain):
            try:
                # [v1.33.5] 创建临时 LLM 快照
                from core.llm import LLMClient
                snapshot_llm = LLMClient(
                    provider=mc.get("provider", shared_llm.provider),
                    model=mc.get("model", shared_llm.model),
                    base_url=mc.get("base_url", shared_llm.base_url),
                    api_key=mc.get("api_key", shared_llm.api_key),
                    temperature=mc.get("temperature", shared_llm.temperature),
                    max_tokens=mc.get("max_tokens", shared_llm.max_tokens),
                    timeout=shared_llm.timeout,
                    max_retries=shared_llm.max_retries,
                    reasoning=getattr(shared_llm, 'reasoning', False),
                    reasoning_effort=getattr(shared_llm, 'reasoning_effort', 'medium'),
                    context_window=mc.get("context_window", getattr(shared_llm, 'context_window', 200000)),
                    api_type=mc.get("api_type", getattr(shared_llm, 'api_type', '')),
                )

                # 临时替换 main_agent.llm
                _orig_llm = None
                if main_agent:
                    _orig_llm = main_agent.llm
                    main_agent.llm = snapshot_llm
                    # 同步 context_window
                    cw = mc.get("context_window")
                    if cw and main_agent.context_builder:
                        main_agent.context_builder.context_window = cw

                try:
                    # 检测是否是自动追加的 vision 兜底模型
                    is_auto_vision = mc.get("_auto_vision", False)
                    if is_auto_vision:
                        _auto_vision_model_name = mc.get("name", mc.get("model", "?"))

                    result = await self._stream_process_message(
                        message, session_id, stream_response,
                        agent_path=agent_path, agent_system_prompt=agent_system_prompt,
                        chat_mode=chat_mode, voice_text=voice_text,
                        user_images=user_images, user_files=user_files,
                    )
                    if result and not result.startswith("⚠️") and not result.startswith("❌"):
                        if is_auto_vision and user_images:
                            _hint = f"💡 当前绑定的模型不支持图片，已自动切换到 {_auto_vision_model_name} 处理。\n\n"
                            logger.info(f"自动 vision 兜底成功: {_auto_vision_model_name}")
                            return _hint + result
                        return result
                    if result:
                        full_text = result
                finally:
                    if main_agent and _orig_llm is not None:
                        main_agent.llm = _orig_llm

            except Exception as e:
                logger.warning(f"模型 {i} 流式调用失败: {e}")
                full_text = f"⚠️ 模型 {i} 流式调用失败: {str(e)}"

        # [v1.18.2] 所有模型都失败，且包含图片，给出明确的配置提示
        if user_images and full_text and "不支持图片" in full_text:
            # 查找可用的 vision 模型名称
            vision_names = []
            for me in self.core.config.models_library:
                modes = me.input_modes or ["text"]
                if "image" in modes and me.enabled:
                    vision_names.append(me.name or me.id)
            if vision_names:
                return (f"⚠️ 当前绑定的模型不支持图片识别，自动切换也未找到可用的图片模型。\n\n"
                        f"📋 模型库中支持图片的模型: {', '.join(vision_names[:5])}\n\n"
                        f"请在 agent 设置中将其中一个绑定为模型或备用模型，以便识别图片。")
            else:
                return (f"⚠️ 当前绑定的模型不支持图片识别，且模型库中没有任何支持图片的模型。\n\n"
                        f"请在 models_library 中添加一个支持 vision 的模型（input_modes 包含 \"image\"），"
                        f"然后绑定到当前 agent。")

        return full_text

    async def _stream_text_chunked(self, text: str, write_sse, chunk_size: int = 4, delay: float = 0.015):
        """将文本分块流式发送到 SSE（模拟逐字输出效果）"""
        import asyncio
        for i in range(0, len(text), chunk_size):
            chunk = text[i:i + chunk_size]
            try:
                await write_sse({"type": "text_delta", "content": chunk})
            except Exception:
                break  # Client disconnected
            if delay > 0:
                await asyncio.sleep(delay)

    async def _stream_process_message(self, user_message, session_id, stream_response,
                                      agent_path=None, agent_system_prompt=None, chat_mode="",
                                      voice_text="", user_images=None, user_files=None):
        """使用流式LLM调用处理消息，支持完整的agent循环（工具调用/操作执行）+ 实时流式输出

        核心改进:
        - 每轮迭代结束后增量保存已流式输出的文本到会话记忆（即使客户端断开也不丢失）
        - 使用 frequency_penalty 减少大模型重复输出
        - 最终保存时使用累积文本而非 final_response，确保完整内容不丢失

        [v1.16.12] 新增 user_images/user_files 参数支持多模态消息
        """
        logger.info(f"[{session_id}] _stream_process_message 开始处理，chat_mode={chat_mode}")
        if not self.core.main_agent or not self.core.llm:
            result = await self.core.process_message(user_message, session_id)
            await stream_response.write(("data: " + json.dumps({"type": "text", "content": result}) + "\n\n").encode())
            return result

        agent = self.core.main_agent
        from agents.base import AgentContext
        from core.utils import safe_json_parse, truncate_str
        context = AgentContext(session_id=session_id, user_message=user_message)

        # Set agent context through context metadata instead of instance attributes
        context.metadata["agent_override_prompt"] = agent_system_prompt
        context.metadata["agent_override_path"] = agent_path
        context.metadata["chat_mode"] = chat_mode
        context.metadata["user_voice_text"] = voice_text  # 语音输入原始文本（用于 usersays_correct）

        # [v1.28] 传入正确的 agent_db_id，确保 V2 路径保存会话时使用正确的 agent_id
        agent_id = 1  # 默认为全权Agent
        if agent_path and self.core.memory:
            agent_id = self.core.memory.get_agent_id(agent_path)
            context.metadata["agent_db_id"] = agent_id

        # [v1.16.12→17] 处理用户图片附件 — 保存到磁盘 + data URI 传给 LLM Vision API
        if user_images:
            _processed_images = []
            _image_file_ids = []
            for img in user_images:
                mime = img.get("type", "image/png")
                b64 = img.get("data", "")
                if b64:
                    try:
                        import base64 as _b64mod
                        fbytes = _b64mod.b64decode(b64)
                        file_id = _save_upload_file(img.get("name", "image.png"), mime, fbytes)
                        _image_file_ids.append({"id": file_id, "name": img.get("name", ""), "type": mime, "size": len(fbytes)})
                    except Exception as _ie:
                        logger.warning(f"[{session_id}] 图片保存失败: {_ie}")
                    _processed_images.append({
                        "url": f"data:{mime};base64,{b64}",
                        "type": mime,
                        "name": img.get("name", ""),
                    })
            if _processed_images:
                context.metadata["user_images"] = _processed_images
                if _image_file_ids:
                    context.metadata["user_image_files"] = _image_file_ids
                    # [v1.16.18] 发送 file_ids 回前端，用于缩略图和点击打开
                    try:
                        await stream_response.write(("data: " + json.dumps({"type": "user_files", "images": _image_file_ids}) + "\n\n").encode())
                    except Exception:
                        pass
                # [v1.17.1] 调试日志
                logger.info(f"[{session_id}] 用户发送了 {len(_processed_images)} 张图片, data_uri_len={len(_processed_images[0].get('url',''))}")
                logger.info(f"[{session_id}] context.metadata['user_images'] 已设置, 将传给 main_agent")

        # [v1.16.12→17] 处理用户文件附件 — 保存到磁盘 + 提取文本附加到消息
        if user_files:
            _file_texts = []
            _file_file_ids = []
            _file_paths = []  # [v1.16.19] 记录文件实际磁盘路径，供 agent 使用 file_read
            for f in user_files:
                fname = f.get("name", "unknown")
                ftype = f.get("type", "")
                fdata_b64 = f.get("data", "")
                if not fdata_b64:
                    continue
                try:
                    import base64 as _b64mod
                    fbytes = _b64mod.b64decode(fdata_b64)
                    # Save to disk
                    _saved_path = None
                    try:
                        file_id = _save_upload_file(fname, ftype, fbytes)
                        _file_file_ids.append({"id": file_id, "name": fname, "type": ftype, "size": len(fbytes)})
                        # [v1.16.19] 查找实际磁盘路径
                        _saved_path, _ = _find_upload_file(file_id)
                        if _saved_path:
                            _saved_path = str(_saved_path)
                            _file_paths.append({"name": fname, "path": _saved_path})
                    except Exception as _se:
                        logger.warning(f"[{session_id}] 文件 {fname} 保存失败: {_se}")
                    # Extract text
                    _text = self._extract_text_from_file(fname, ftype, fbytes)
                    if _text:
                        _file_entry = f"--- 文件: {fname} ---"
                        # [v1.16.19] 附带磁盘路径，agent 可直接用 file_read 读取原始文件
                        if _saved_path:
                            _file_entry += f"\n文件路径: {_saved_path}"
                        _file_entry += f"\n{_text}"
                        _file_texts.append(_file_entry)
                except Exception as _fe:
                    logger.warning(f"[{session_id}] 文件 {fname} 提取失败: {_fe}")
            if _file_texts:
                file_context = "\n\n".join(_file_texts)
                context.metadata["user_file_texts"] = file_context
                user_message = f"{user_message}\n\n[附件内容]\n{file_context}" if user_message else f"[附件内容]\n{file_context}"
                context.user_message = user_message
            if _file_file_ids:
                context.metadata["user_file_files"] = _file_file_ids
                # [v1.16.18] 发送 file_ids 回前端，用于文件卡片点击打开
                try:
                    await stream_response.write(("data: " + json.dumps({"type": "user_files", "files": _file_file_ids}) + "\n\n").encode())
                except Exception:
                    pass
            # [v1.16.19] 记录文件路径列表到 metadata，agent 工具可引用
            if _file_paths:
                context.metadata["user_file_paths"] = _file_paths
            logger.info(f"[{session_id}] 用户发送了 {len(user_files)} 个文件，提取文本 {len(file_context) if _file_texts else 0} 字符")

        # ── 根据 Agent 配置设置执行引擎参数（execution_mode、work_dir 等）──
        agent_cfg_for_exec = self._read_agent_config(agent_path)
        _original_exec_mode = None
        _original_work_dir = None
        if agent_cfg_for_exec and agent.executor:
            _exec_mode = agent_cfg_for_exec.get("execution_mode")
            if _exec_mode:
                _original_exec_mode = agent.executor.execution_mode
                agent.executor.set_execution_mode(_exec_mode)
        # [v1.23.52] 为所有 Agent 设置独立工作目录（基于数字 aid）
        if agent_path and agent.executor:
            from config import ConfigManager
            cm = ConfigManager()
            agent_work_dir = cm.data_dir / "agents" / agent_path / "workspace"
            agent_work_dir.mkdir(parents=True, exist_ok=True)
            (agent_work_dir / "userfiles").mkdir(parents=True, exist_ok=True)
            _original_work_dir = agent.executor.work_dir
            agent.executor.work_dir = str(agent_work_dir)
            logger.info(f"[{session_id}] Agent [{agent_path}] 使用独立工作目录: {agent_work_dir}")

        # ── 设置 Agent 专属知识库目录（优先于组织知识库）──
        if agent_path and agent.context_builder:
            agent_kb_dir = self._get_agent_knowledge_dir(agent_path)
            # [v1.18.9] 始终设置 agent_knowledge_dir（即使目录为空），确保写入 Agent 专属目录
            agent_kb_dir.mkdir(parents=True, exist_ok=True)
            agent.context_builder.agent_knowledge_dir = str(agent_kb_dir)
            logger.debug(f"[{session_id}] 使用 Agent 专属知识库: {agent_kb_dir}")

        # Clear execution events from previous runs
        agent.clear_execution_events()

        # 注册活跃上下文（使 /api/chat/inject 能找到正在执行的任务）
        agent.active_contexts[session_id] = context

        async def _write_sse(data: dict):
            """Write SSE event, ignoring client disconnect errors"""
            try:
                await stream_response.write(
                    ("data: " + json.dumps(data, ensure_ascii=False) + "\n\n").encode()
                )
            except Exception as e:
                logger.debug(f"[{session_id}] SSE 发送失败: {e}")

        # ── V2 路由: 如果 context_builder 已初始化，使用 V2 结构化输出循环 ──
        if agent.context_builder is not None:
            logger.info(f"[{session_id}] 检测到 V2 Context Builder，使用 V2 流式执行循环")

            # 构建 Agent 身份信息
            agent_name = agent_path or agent.name
            agent_description = agent.description
            # 尝试从 Agent 配置中获取更详细的描述
            agent_cfg = self._read_agent_config(agent_path)
            if agent_cfg:
                agent_description = agent_cfg.get("description", agent_description)
                if not agent_description:
                    agent_description = agent_cfg.get("system_prompt", agent.description)[:200]

            # V2 SSE 事件回调 — 将 V2 事件转发为 SSE
            async def _v2_sse_callback(event: dict):
                await _write_sse(event)

            # 收集 V2 输出中的 reasoning 文本作为最终响应
            _v2_reasoning_parts = []
            # V2 模式下追踪最新 task_plan（Markdown 格式）
            _v2_latest_task_plan = ""

            async def _v2_collecting_callback(event: dict):
                """收集 reasoning 内容 + 同步 task_plan 到 _task_list_store，同时转发所有事件"""
                # ── 拦截 v2_task_plan 事件：解析 Markdown 并同步到 _task_list_store ──
                if event.get("type") == "v2_task_plan":
                    nonlocal _v2_latest_task_plan
                    plan_text = event.get("plan", "")
                    if plan_text:
                        _v2_latest_task_plan = plan_text
                        # 解析 Markdown 任务列表为 [{text, status}] 格式
                        parsed_tasks = self._parse_v2_task_plan(plan_text)
                        if parsed_tasks and len(parsed_tasks) >= 1:
                            # [FIX] 移除 >=3 的限制：即使1-2个任务也应同步到前端
                            # 用户反馈简单任务也需要显示任务列表
                            merged = self._merge_task_list(session_id, parsed_tasks)
                            self._task_list_store[session_id] = merged
                            # 额外发送 task_list_update 事件（前端侧边栏 task panel 依赖此事件）
                            try:
                                await _write_sse({"type": "task_list_update", "tasks": merged})
                            except Exception:
                                pass
                        elif not parsed_tasks or len(parsed_tasks) == 0:
                            # LLM 输出了空的 task_plan，清空任务列表
                            if session_id in self._task_list_store:
                                self._task_list_store[session_id] = []
                                try:
                                    await _write_sse({"type": "task_list_update", "tasks": []})
                                except Exception:
                                    pass

                # 转发所有事件到前端
                await _write_sse(event)
                if event.get("type") == "v2_reasoning":
                    content = event.get("content", "")
                    if content:
                        _v2_reasoning_parts.append(content)

            # 调用 V2 处理循环
            try:
                v2_context = await agent.process_v2(
                    context,
                    agent_name=agent_name,
                    agent_description=agent_description,
                    agent_override_prompt=agent_system_prompt,
                    stream_callback=_v2_collecting_callback,
                    stream_response=stream_response,
                    agent_path=agent_path,
                )
            finally:
                # 无论成功或异常，都清理 active_contexts
                agent.active_contexts.pop(session_id, None)
                # 恢复执行引擎原始模式（防止影响后续 Agent 请求）
                if _original_exec_mode is not None and agent.executor:
                    agent.executor.set_execution_mode(_original_exec_mode)
                # [v1.23.52] 恢复执行引擎原始工作目录
                if _original_work_dir is not None and agent.executor:
                    agent.executor.work_dir = _original_work_dir
                # 清理 Agent 专属知识库目录设置（防止影响其他 Agent 请求）
                if agent.context_builder:
                    agent.context_builder.agent_knowledge_dir = None

            # V2 结束后：如果 task_list_store 中有任务，确保最终推送一次
            if chat_mode == "exec" and session_id in self._task_list_store:
                final_tasks = self._task_list_store[session_id]
                if final_tasks:
                    try:
                        await _write_sse({"type": "task_list_update", "tasks": final_tasks})
                    except Exception:
                        pass

            # 返回最终响应
            final_response = v2_context.working_memory.get("final_response", "")
            # 优先使用 v2_reasoning 流式推送的文本（用户实际看到的内容），
            # 而不是后端设定的通用占位符如"已完成所有操作。"
            if _v2_reasoning_parts:
                # Smart join: only add separator when parts don't already end with newline
                joined_parts = []
                for i, part in enumerate(_v2_reasoning_parts):
                    if i > 0 and joined_parts and not joined_parts[-1].endswith('\n') and not part.startswith('\n'):
                        joined_parts.append('\n')
                    joined_parts.append(part)
                reasoning_text = ''.join(joined_parts)
                # 如果 final_response 是通用占位符或为空，使用 reasoning text
                if not final_response or final_response.startswith("已完成所有操作") or final_response.startswith("处理完毕"):
                    final_response = reasoning_text

            # ── V2 路由的助手回复已由 process_v2 内部保存，此处不再重复保存 ──
            # 仅在 process_v2 内部未保存时兜底（如 max_iter 耗尽但未 break）
            if agent.memory:
                # 检查最后一条记忆是否是本次会话的 assistant 消息
                recent = agent.memory.get_conversation(session_id, limit=3)
                _has_assistant = any(e.role == "assistant" for e in recent)
                if not _has_assistant and final_response and final_response.strip():
                    _fallback_agent_id = context.metadata.get("agent_db_id", 1)
                    agent.memory.add_session(agent_id=_fallback_agent_id,
                        session_id=session_id, role="assistant", content=final_response,
                    )

            # ── 保存工具调用过程到会话记忆（V2 路由） ──
            if agent.memory and agent._execution_events:
                try:
                    from core.utils import truncate_str
                    tool_summary_parts = []
                    for evt in agent._execution_events:
                        etype = evt.get("type", "")
                        if etype in ("tool_call", "skill_call"):
                            tool_name = evt.get("tool_name") or evt.get("skill_name", "")
                            args = evt.get("arguments") or evt.get("params", {})
                            tool_summary_parts.append(f"🔧 调用工具: {tool_name}({json.dumps(args, ensure_ascii=False)[:200]})")
                        elif etype in ("tool_result", "skill_result"):
                            tool_name = evt.get("tool_name") or evt.get("skill_name", "")
                            success = evt.get("success", False)
                            summary = evt.get("summary", "")
                            status = "✅" if success else "❌"
                            tool_summary_parts.append(f"  {status} 结果: {summary[:300]}")
                        elif etype == "code_exec":
                            lang = evt.get("language", "")
                            code_preview = evt.get("code_preview", "")
                            stdout = evt.get("stdout", "")
                            success = evt.get("success", False)
                            status = "✅" if success else "❌"
                            tool_summary_parts.append(f"💻 执行 {lang}: {code_preview[:150]}")
                            if stdout:
                                tool_summary_parts.append(f"  {status} 输出: {stdout[:300]}")
                        elif etype == "code_result":
                            pass  # code_result is duplicate of code_exec with final status
                    if tool_summary_parts:
                        exec_log = "\n".join(tool_summary_parts)
                        agent.memory.add_session(agent_id=agent_id, 
                            session_id=session_id,
                            role="tool",
                            content=exec_log,
                            importance=0.3,
                        )
                except Exception as e:
                    logger.warning(f"保存工具调用过程失败: {e}")

            return final_response

        # ── V1 路由: 标准 JSON action 格式 ──
        # Full agent loop (plan-execute-reflect) — mirrors MainAgent._process_inner
        max_iter = agent.config.agent.max_iterations if agent.config else 30
        final_response = ""
        iteration = 0
        # 追踪连续无 action 迭代次数，防止无限重新提示
        _consecutive_no_action = 0
        _MAX_NO_ACTION_RETRIES = 2  # 限制重试次数，避免重复循环规划
        # ── 追踪所有流式推送的纯文本（用于刷新后恢复） ──
        _all_streamed_text_parts = []  # 每轮迭代推送的纯文本片段
        # ── 追踪已保存到记忆的纯文本长度（避免多轮迭代间重复保存） ──
        _saved_text_length = 0

        while iteration < max_iter:
            iteration += 1

            # ── 执行模式：每轮迭代前刷新任务进度上下文 ──
            # 这样 LLM 在每次调用时都能看到最新的任务列表状态
            if chat_mode == "exec":
                fresh_task_context = self._build_task_plan_context(agent_path, chat_mode, user_message, session_id=session_id)
                if fresh_task_context:
                    # 替换掉之前的任务规划上下文（保留非任务规划部分）
                    base_prompt = context.metadata.get("agent_override_prompt", "")
                    task_section_marker = "\n\n## 任务规划\n"
                    idx = base_prompt.find(task_section_marker)
                    if idx >= 0:
                        # 替换旧的任务规划上下文
                        context.metadata["agent_override_prompt"] = base_prompt[:idx] + task_section_marker + fresh_task_context
                    else:
                        # 没有旧的任务规划上下文，追加
                        context.metadata["agent_override_prompt"] = base_prompt + task_section_marker + fresh_task_context

            # Build messages
            messages = agent._build_messages(context)
            tools = agent._get_tools()

            # ── 流式 text_delta 过滤器（支持文本+JSON混合输出） ──
            # 智能检测策略：
            # 1. 文本模式：正常流式推送每个 token
            # 2. 检测到 ```action 代码块：进入 action 模式，不推送（等待解析执行）
            # 3. 检测到 ```tasklist 代码块：进入 tasklist 模式，不推送（等待解析）
            # 4. 检测到裸 JSON（整个回复以 { 开头）：进入 action 模式，提取 thought
            # 5. 代码块结束后回到文本模式，继续流式推送
            _stream_state = {
                "mode": "text",         # "text" | "action_block" | "tasklist_block" | "bare_json"
                "processed_pos": 0,     # 已处理到的位置（用于去重 streaming）
                "thought_sent": 0,      # 已推送的 thought 长度
                "action_block_depth": 0, # ``` 嵌套深度
            }

            # 需要回退(hold back)的最大字符数，用于检测 ```action 或 ```tasklist 标记
            # 注意: 12 足够覆盖 ```tasklist\n (12字符) + 余量
            # 过大的值会导致短文本被 hold 住，流结束后一次性输出（看起来像非流式）
            _MAX_HOLD = 12

            async def _text_delta_callback(full_text_so_far: str, delta_text: str):
                """[v1.47.21] 智能流式过滤器：文本正常推送，JSON action 块拦截"""
                st = _stream_state
                remaining = full_text_so_far[st["processed_pos"]:]

                while remaining:
                    if st["mode"] == "text":
                        # ── 文本模式：寻找 ```action 或 ```tasklist 标记 ──
                        action_marker = remaining.find("```action")
                        tasklist_marker = remaining.find("```tasklist")

                        # 找最近的标记
                        markers = []
                        if action_marker >= 0:
                            markers.append(("action", action_marker))
                        if tasklist_marker >= 0:
                            markers.append(("tasklist", tasklist_marker))

                        if markers:
                            markers.sort(key=lambda x: x[1])
                            block_type, marker_pos = markers[0]
                            # 流式推送标记之前的文本
                            text_before = remaining[:marker_pos]
                            if text_before.strip():
                                await _write_sse({"type": "text_delta", "content": text_before})
                                _all_streamed_text_parts.append(text_before)
                            # 跳过整个开始标记（```action 或 ```tasklist），不要只跳到 ```
                            st["processed_pos"] += marker_pos + len(f"```{block_type}")
                            if block_type == "tasklist":
                                st["mode"] = "tasklist_block"
                            else:
                                st["mode"] = "action_block"
                            remaining = full_text_so_far[st["processed_pos"]:]
                            continue

                        # 没有找到标记，检查文本是否以 { 开头（裸 JSON 回退）
                        stripped_full = full_text_so_far.lstrip()
                        if stripped_full.startswith('{') and len(full_text_so_far.strip()) < 5:
                            # 整个回复以 { 开头 → 裸 JSON 模式
                            st["mode"] = "bare_json"
                            remaining = ""
                            # 尝试提取 thought（兼容旧格式）
                            import re as _re
                            try:
                                tm = _re.search(r'"thought"\s*:\s*"((?:[^"\\]|\\.)*)', full_text_so_far)
                                if tm:
                                    raw = tm.group(1)
                                    try:
                                        tv = raw.encode().decode('unicode_escape')
                                    except Exception:
                                        tv = raw
                                    new_part = tv[st["thought_sent"]:]
                                    if new_part:
                                        st["thought_sent"] = len(tv)
                                        await _write_sse({"type": "thought_delta", "content": new_part})
                            except Exception:
                                pass
                            continue

                        # 没有找到标记，流式推送（保留末尾可能的部分标记）
                        safe_end = len(remaining) - _MAX_HOLD
                        if safe_end > 0:
                            chunk = remaining[:safe_end]
                            await _write_sse({"type": "text_delta", "content": chunk})
                            _all_streamed_text_parts.append(chunk)
                            st["processed_pos"] += safe_end
                            remaining = full_text_so_far[st["processed_pos"]:]
                        else:
                            # 累积的文本还不够长，hold 住不推送（等待更多 token）
                            remaining = ""
                            break

                    elif st["mode"] == "action_block":
                        # ── Action 代码块模式：寻找结束标记 ``` ──
                        # 结束标记是独立的 ``` 行（前面有换行），不要匹配块开始标记内的 ```
                        end_marker = remaining.find("\n```")
                        if end_marker < 0 and remaining.startswith("```"):
                            end_marker = 0
                        if end_marker >= 0:
                            # 找到结束标记，跳过整个 action 块（不推送）
                            skip_len = (end_marker + 1 + 3) if end_marker > 0 else 3
                            st["processed_pos"] += skip_len
                            st["mode"] = "text"
                            remaining = full_text_so_far[st["processed_pos"]:]
                            continue
                        # 还没找到结束标记，继续等待
                        remaining = ""
                        break

                    elif st["mode"] == "tasklist_block":
                        # ── Tasklist 代码块模式：寻找结束标记 ``` ──
                        end_marker = remaining.find("\n```")
                        if end_marker < 0 and remaining.startswith("```"):
                            end_marker = 0
                        if end_marker >= 0:
                            # 找到结束标记，跳过整个 tasklist 块（不推送）
                            skip_len = (end_marker + 1 + 3) if end_marker > 0 else 3
                            st["processed_pos"] += skip_len
                            st["mode"] = "text"
                            remaining = full_text_so_far[st["processed_pos"]:]
                            continue
                        # 还没找到结束标记，继续等待
                        remaining = ""
                        break

                    elif st["mode"] == "bare_json":
                        # ── 裸 JSON 模式：提取 thought 字段流式推送 ──
                        import re as _re
                        try:
                            tm = _re.search(r'"thought"\s*:\s*"((?:[^"\\]|\\.)*)', full_text_so_far)
                            if tm:
                                raw = tm.group(1)
                                try:
                                    tv = raw.encode().decode('unicode_escape')
                                except Exception:
                                    tv = raw
                                new_part = tv[st["thought_sent"]:]
                                if new_part:
                                    st["thought_sent"] = len(tv)
                                    await _write_sse({"type": "thought_delta", "content": new_part})
                        except Exception:
                            pass
                        remaining = ""
                        break

            # Stream 结束后的 flush：推送所有 hold 住的文本
            async def _flush_remaining_text(full_text: str):
                """[v1.47.21] 流结束后，推送所有剩余的文本（处理 hold back 的部分）"""
                st = _stream_state
                remaining = full_text[st["processed_pos"]:]
                if remaining.strip() and st["mode"] == "text":
                    # 如果剩余文本较长，逐 token 推送以保持流式体验
                    if len(remaining) > 20:
                        await _stream_text_chunked(remaining, _write_sse, chunk_size=3, delay=0.01)
                    else:
                        await _write_sse({"type": "text_delta", "content": remaining})
                    _all_streamed_text_parts.append(remaining)
                    st["processed_pos"] = len(full_text)

            # Call LLM with streaming — tokens are filtered through _text_delta_callback
            # Call LLM with streaming + frequency_penalty to reduce repetition
            response = await agent._call_llm_stream(
                messages, tools=tools, stream_response=stream_response,
                text_delta_callback=_text_delta_callback,
                frequency_penalty=0.5,
                presence_penalty=0.3,
            )
            if not response.success:
                # 错误消息必须发送到前端，不能被忽略
                error_message = f"⚠️ LLM调用失败: {response.error}"
                logger.error(f"[{session_id}] LLM调用失败: {response.error}")
                try:
                    await stream_response.write(
                        ("data: " + json.dumps({"type": "text", "content": error_message}, ensure_ascii=False) + "\n\n").encode()
                    )
                    # 发送 done 事件确保前端结束等待状态
                    await stream_response.write(("data: " + json.dumps({"type": "done", "error": response.error}) + "\n\n").encode())
                except Exception as write_err:
                    logger.error(f"[{session_id}] 发送错误消息失败: {write_err}")
                return error_message

            content = response.content or ""
            
            # 检查是否有流式推送的内容
            streamed_text = "".join(_all_streamed_text_parts)
            
            # 如果 LLM 返回空内容但 success 为 True 且没有流式输出，发送提示信息
            if not content and not response.tool_calls and not streamed_text.strip():
                await _write_sse({"type": "text", "content": "⚠️ LLM 返回了空响应，请检查 API 配置或网络连接"})

            # ── 流结束后 flush 所有 hold 住的文本 ──
            await _flush_remaining_text(content)

            # ── 提取 tasklist JSON 并推送（exec 模式） ──
            if chat_mode == "exec":
                task_list = self._extract_task_list_json(content)
                if task_list is not None:
                    # 合并 LLM 输出的 tasklist 与服务端存储的状态
                    # 关键：服务端标记为 done 的任务不能被 LLM 覆盖回 pending
                    merged = self._merge_task_list(session_id, task_list)
                    self._task_list_store[session_id] = merged
                    await _write_sse({"type": "task_list_update", "tasks": merged})

            # ── Check for tool calls (OpenAI function calling) ──
            if response.tool_calls:
                # Send tool_call event to frontend
                agent._add_exec_event("tool_call", {
                    "title": f"调用 {len(response.tool_calls)} 个工具",
                    "tool_names": [tc["name"] for tc in response.tool_calls],
                })
                await _write_sse({"type": "exec_event", "data": {
                    "type": "tool_call",
                    "title": f"调用 {len(response.tool_calls)} 个工具",
                }})

                # Add assistant tool_calls message to history (OpenAI format requirement)
                context.conversation_history.append(
                    Message(role="assistant", content=response.content or "",
                           tool_calls=response.tool_calls)
                )

                # Execute tool calls
                tool_results = await agent._handle_tool_calls(response.tool_calls, context, "")

                # Send tool_result event to frontend
                for tc, result in tool_results:
                    success = result.get("success", False)
                    agent._add_exec_event("tool_result", {
                        "title": f"工具结果: {tc['name']}",
                        "tool_name": tc["name"],
                        "success": success,
                        "summary": truncate_str(result.get("output", result.get("error", "")), 500),
                    })
                    await _write_sse({"type": "exec_event", "data": {
                        "type": "tool_result",
                        "title": f"工具结果: {tc['name']}",
                        "success": success,
                    }})

                # Add tool results to history
                for tc, result in tool_results:
                    context.conversation_history.append(
                        Message(role="tool", content=json.dumps(result, ensure_ascii=False),
                               tool_call_id=tc["id"], name=tc["name"])
                    )
                continue  # Next iteration — let LLM process tool results

            # 注意：成功处理 tool_calls 或 actions 后，_consecutive_no_action 会被重置

            # ── 从混合内容中提取 JSON action 指令 ──
            # 支持格式：
            #   1. 纯 JSON（向后兼容）
            #   2. 文本 + ```action``` 代码块中的 JSON
            #   3. 文本 + 裸 JSON（safe_json_parse 自动提取）
            import re as _re_ext
            action_data = None

            # 优先从 ```action 代码块中提取 JSON
            action_block_pattern = r'```action\s*\n(.*?)\n```'
            action_blocks = _re_ext.findall(action_block_pattern, content, _re_ext.DOTALL)
            if action_blocks:
                # 取最后一个 action 块（LLM 可能有多个）
                for block in reversed(action_blocks):
                    parsed = safe_json_parse(block.strip())
                    if parsed and isinstance(parsed, dict):
                        action_data = parsed
                        break

            # 如果没有找到 ```action``` 块，回退到 safe_json_parse（支持裸 JSON 和 ```json 块）
            if not action_data:
                action_data = safe_json_parse(content)

            if action_data and isinstance(action_data, dict):
                # Has structured action instructions
                if "actions" in action_data:
                    thought_text = action_data.get("thought", "")
                    action_mode = action_data.get("mode", "step")  # step=逐步, batch=批量

                    # [v1.38] 执行模式不再强制单步 — 允许 LLM 一次调用多个工具（与系统提示词一致）
                    # if chat_mode == "exec":
                    #     action_mode = "step"

                    # thought 已经通过 ```action``` 前的文本或 thought_delta 流式推送了
                    # 不需要再额外发送 thought 事件（新格式下 thought 在纯文本中已展示）

                    # ── 根据 mode 决定执行方式 ──
                    if action_mode == "step" and len(action_data.get("actions", [])) > 1:
                        step_action_data = {
                            "thought": action_data.get("thought", ""),
                            "mode": "step",
                            "actions": [action_data["actions"][0]],
                        }
                        await _write_sse({
                            "type": "thought",
                            "content": "\n\n> ⚠️ step 模式下只执行第一个操作，其余操作将在后续步骤中执行"
                        })
                        results = await self._execute_actions_streaming(
                            agent, step_action_data, context, _write_sse
                        )
                    else:
                        results = await self._execute_actions_streaming(
                            agent, action_data, context, _write_sse
                        )

                    # Add assistant action message to conversation history
                    context.conversation_history.append(Message(role="assistant", content=content))
                    # ── 增量保存已流式输出的文本 ──
                    if agent.memory and _all_streamed_text_parts:
                        accumulated_text = "\n\n".join(p for p in _all_streamed_text_parts if p.strip())
                        if accumulated_text and len(accumulated_text) > _saved_text_length:
                            new_save_text = accumulated_text[_saved_text_length:]
                            if new_save_text.strip():
                                agent.memory.add_session(agent_id=agent_id, 
                                    session_id=session_id, role="assistant", content=new_save_text,
                                )
                                _saved_text_length = len(accumulated_text)

                    result_summary = agent._summarize_action_results(results)

                    # ── 自动更新任务状态：将 running 任务标记为 done ──
                    # 修复：执行成功后不依赖 LLM 下次输出才更新状态，避免重复执行同一任务
                    if chat_mode == "exec":
                        current_tasks = list(self._task_list_store.get(session_id, []))
                        updated = False
                        if results and results[0].get("success", False):
                            # 找到第一个 running 的任务，标记为 done
                            for t in current_tasks:
                                if t.get("status") == "running":
                                    t["status"] = "done"
                                    updated = True
                                    break
                            # 如果没有 running 的，找第一个 pending 的标记为 done（兼容 LLM 未设置 running 的情况）
                            if not updated:
                                for t in current_tasks:
                                    if t.get("status") == "pending":
                                        t["status"] = "done"
                                        updated = True
                                        break
                        if updated:
                            self._task_list_store[session_id] = current_tasks
                            await _write_sse({"type": "task_list_update", "tasks": current_tasks})

                    # Handle timeout diagnostics (same as _process_inner)
                    has_timeout = any(r.get("timed_out") for r in results)
                    timeout_detail = ""
                    if has_timeout:
                        timeout_details = []
                        for i, r in enumerate(results, 1):
                            if r.get("timed_out"):
                                diag = r.get("timeout_diagnosis", {})
                                timeout_details.append(
                                    f"### 命令 {i} 超时诊断\n"
                                    f"- 原因: {diag.get('diagnosis', '未知')}\n"
                                    f"- 进展: {diag.get('progress', '未知')}\n"
                                    f"- 是否建议重试: {'是' if diag.get('should_retry') else '否'}\n"
                                    f"- 重试策略: {diag.get('retry_strategy', '无')}\n"
                                )
                        timeout_detail = "\n\n## ⏰ 超时诊断详情\n" + "\n".join(timeout_details)

                    feedback_msg = f"[执行结果]\n{result_summary}\n\n请基于以上结果继续。"
                    if timeout_detail:
                        feedback_msg += timeout_detail + "\n\n请根据以上诊断信息决定下一步操作。"

                    context.conversation_history.append(
                        Message(role="user", content=feedback_msg)
                    )

                    # Check if all actions succeeded and no further steps needed
                    all_success = all(r.get("success", False) for r in results)
                    if action_mode == "batch":
                        no_more_actions = not action_data.get("actions") or len(action_data.get("actions", [])) == 0
                        if all_success and results and no_more_actions:
                            break

                    # Check if timeout diagnosis suggests aborting
                    if has_timeout:
                        should_abort = False
                        abort_reasons = []
                        for i, r in enumerate(results, 1):
                            if r.get("timed_out"):
                                diag = r.get("timeout_diagnosis", {})
                                if diag.get("should_retry") is False:
                                    should_abort = True
                                    abort_reasons.append(
                                        f"命令{i}: {diag.get('diagnosis', '不可恢复的超时')}"
                                    )
                        if should_abort:
                            abort_msg = (
                                "[系统通知] 以下命令因超时被终止，且超时诊断结果表明不应重试:\n"
                            )
                            for reason in abort_reasons:
                                abort_msg += f"- {reason}\n"
                            abort_msg += (
                                "\n请直接以纯文本回复，告知用户任务无法完成的原因和建议的替代方案。"
                            )
                            context.conversation_history.append(
                                Message(role="user", content=abort_msg)
                            )
                            continue

                    # Continue the loop — let LLM decide next step based on execution results
                    # tasklist 已在循环顶部 LLM 调用后提取并推送，此处无需重复
                    _consecutive_no_action = 0  # 成功执行 action，重置无 action 计数

                    continue

                # Single action — final_answer
                if action_data.get("type") == "final_answer":
                    final_response = action_data.get("content", content)
                    # 流式分块发送 final_answer 内容（而非一次性发送）
                    if final_response and final_response != content:
                        await _stream_text_chunked(final_response, _write_sse)
                    break

            # ── Pure text response (no actions/tool calls) ──
            # Content was already streamed token-by-token via _text_delta_callback

            # ── 执行模式多轮续命机制 ──
            # 问题：LLM 有时只输出文字总结而不输出 ```action``` 块，
            # 导致循环在此处 break，任务未完成就停止。
            # 修复：检查任务列表中是否还有未完成步骤，如果有则重新提示 LLM 继续。
            if chat_mode == "exec":
                current_tasks = self._task_list_store.get(session_id, [])
                pending_count = sum(
                    1 for t in current_tasks
                    if t.get("status") in ("pending", "running", "blocked")
                )
                if pending_count > 0 and _consecutive_no_action < _MAX_NO_ACTION_RETRIES and iteration < max_iter - 1:
                    # 还有未完成任务，LLM 忘记了输出 action 块，重新提示
                    _consecutive_no_action += 1
                    logger.info(
                        f"[{session_id}] Exec 模式: LLM 未输出 action，"
                        f"重新提示继续执行 ({_consecutive_no_action}/{_MAX_NO_ACTION_RETRIES})"
                    )
                    context.conversation_history.append(
                        Message(role="assistant", content=content)
                    )
                    context.conversation_history.append(
                        Message(
                            role="user",
                            content=(
                                f"[系统提示] 任务尚未完成，仍有 {pending_count} 个未执行步骤。"
                                "你必须继续执行操作：请调用相应的工具来完成下一步。"
                                "不要只输出文字总结或反馈，必须调用工具执行具体操作。"
                            ),
                        )
                    )
                    await _write_sse({
                        "type": "thought",
                        "content": (
                            f"\n\n⏳ 检测到 {pending_count} 个未完成任务，"
                            f"继续执行下一步操作..."
                        ),
                    })
                    # 清除上一轮的流式输出，为下一轮腾出空间
                    await _write_sse({"type": "clear_text"})
                    # ── 增量保存已流式输出的文本（每轮迭代都保存，防止中断丢失） ──
                    if agent.memory and _all_streamed_text_parts:
                        accumulated_text = "\n\n".join(p for p in _all_streamed_text_parts if p.strip())
                        if accumulated_text and len(accumulated_text) > _saved_text_length:
                            new_save_text = accumulated_text[_saved_text_length:]
                            if new_save_text.strip():
                                agent.memory.add_session(agent_id=agent_id, 
                                    session_id=session_id, role="assistant", content=new_save_text,
                                )
                                _saved_text_length = len(accumulated_text)
                    continue
                else:
                    # 所有任务完成，或已达到重试上限
                    if pending_count > 0 and _consecutive_no_action >= _MAX_NO_ACTION_RETRIES:
                        logger.warning(
                            f"[{session_id}] Exec 模式: 连续 {_MAX_NO_ACTION_RETRIES} 次"
                            f"无 action 输出，强制结束循环"
                        )
                        # 在回复末尾追加提示
                        final_response = content + (
                            f"\n\n⚠️ 注意：仍有 {pending_count} 个任务未完成，"
                            "但 Agent 已达到最大重试次数。你可以要求我继续完成这些任务。"
                        )
                    break
            else:
                # 非 exec 模式或无待办任务，正常结束
                # ── 增量保存：纯文本响应时也要保存 ──
                if agent.memory and content and len(content) > _saved_text_length:
                    new_save_text = content[_saved_text_length:]
                    if new_save_text.strip():
                        agent.memory.add_session(agent_id=agent_id, 
                            session_id=session_id, role="assistant", content=new_save_text,
                        )
                        _saved_text_length = len(content)
                final_response = content
                break

        # Save assistant response to memory
        # ── 优先使用流式累积文本（包含所有迭代的纯文本），回退到 final_response ──
        # 注意：即使客户端中途断开（刷新页面），也要保存已有内容
        saved_response = final_response
        if not saved_response and _all_streamed_text_parts:
            saved_response = "\n\n".join(p for p in _all_streamed_text_parts if p.strip())
        if not saved_response and content:
            saved_response = content  # 兜底：使用最后一轮的完整输出

        # ── 增量保存：只保存新增的文本（避免多轮迭代间重复保存到记忆） ──
        if agent.memory and saved_response and len(saved_response) > _saved_text_length:
            new_text = saved_response[_saved_text_length:]
            if new_text.strip():
                agent.memory.add_session(agent_id=agent_id, 
                    session_id=session_id, role="assistant", content=new_text,
                )
                _saved_text_length = len(saved_response)
        elif agent.memory and not saved_response:
            agent.memory.add_session(agent_id=agent_id, session_id=session_id, role="assistant", content="(执行完成，无文本回复)")

        # ── 保存工具调用过程到会话记忆（供历史记录查看） ──
        if agent.memory and agent._execution_events:
            try:
                from core.utils import truncate_str
                tool_summary_parts = []
                for evt in agent._execution_events:
                    etype = evt.get("type", "")
                    if etype in ("tool_call", "skill_call"):
                        tool_name = evt.get("tool_name") or evt.get("skill_name", "")
                        args = evt.get("arguments") or evt.get("params", {})
                        tool_summary_parts.append(f"🔧 调用工具: {tool_name}({json.dumps(args, ensure_ascii=False)[:200]})")
                    elif etype in ("tool_result", "skill_result"):
                        tool_name = evt.get("tool_name") or evt.get("skill_name", "")
                        success = evt.get("success", False)
                        summary = evt.get("summary", "")
                        status = "✅" if success else "❌"
                        tool_summary_parts.append(f"  {status} 结果: {summary[:300]}")
                    elif etype == "code_exec":
                        lang = evt.get("language", "")
                        code_preview = evt.get("code_preview", "")
                        stdout = evt.get("stdout", "")
                        success = evt.get("success", False)
                        status = "✅" if success else "❌"
                        tool_summary_parts.append(f"💻 执行 {lang}: {code_preview[:150]}")
                        if stdout:
                            tool_summary_parts.append(f"  {status} 输出: {stdout[:300]}")
                    elif etype == "code_result":
                        pass  # code_result is duplicate of code_exec with final status
                if tool_summary_parts:
                    exec_log = "\n".join(tool_summary_parts)
                    agent.memory.add_session(agent_id=agent_id, 
                        session_id=session_id,
                        role="tool",
                        content=exec_log,
                        importance=0.3,
                    )
            except Exception as e:
                logger.warning(f"保存工具调用过程失败: {e}")

        return saved_response or final_response or content or ""

    async def _execute_actions_streaming(
        self, agent, action_data: dict, context, write_sse
    ) -> list:
        """逐个执行 actions，每执行完一个立即通过 SSE 推送 exec_event。
        
        与 MainAgent._execute_actions 逻辑一致，但改为逐个执行 + 实时推送。
        """
        from core.utils import truncate_str
        results = []
        actions = action_data.get("actions", [])
        last_event_count = 0

        for action_idx, action in enumerate(actions):
            action_type = action.get("type", "")

            if action_type == "skill" and agent.skills:
                skill_name = action.get("name", "")
                skill_perm = agent._get_skill_permission(skill_name)
                if skill_perm and not agent.check_permission(skill_perm):
                    from core.permissions import PermissionManager
                    label = PermissionManager.PERMISSION_LABELS.get(skill_perm, skill_perm)
                    results.append({
                        "success": False,
                        "error": f"[权限] 当前 Agent 没有'{label}'权限，操作被拒绝",
                        "metadata": {"permission_denied": skill_perm},
                    })
                    agent._add_exec_event("skill_call", {
                        "title": f"技能调用被拒: {skill_name}",
                        "skill_name": skill_name,
                        "success": False,
                        "error": f"权限不足: {label}",
                    })
                    # 推送新事件
                    await self._relay_new_events(agent, last_event_count, write_sse)
                    last_event_count = len(agent.get_execution_events())
                    continue

                agent._add_exec_event("skill_call", {
                    "title": f"调用技能: {skill_name}",
                    "skill_name": skill_name,
                    "params": action.get("params", {}),
                })
                await self._relay_new_events(agent, last_event_count, write_sse)
                last_event_count = len(agent.get_execution_events())

                result = await agent.skills.execute(
                    action.get("name", ""),
                    **action.get("params", {}),
                )
                result_dict = result.to_dict()
                results.append(result_dict)
                agent._add_exec_event("skill_result", {
                    "title": f"技能结果: {skill_name}",
                    "skill_name": skill_name,
                    "success": result_dict.get("success", False),
                    "summary": truncate_str(str(result_dict.get("output", result_dict.get("error", ""))), 500),
                })
                await self._relay_new_events(agent, last_event_count, write_sse)
                last_event_count = len(agent.get_execution_events())

            elif action_type == "code" and agent.executor:
                if not agent.check_permission("execution"):
                    results.append({
                        "success": False,
                        "error": "[权限] 当前 Agent 没有代码执行权限，操作被拒绝",
                        "metadata": {"permission_denied": "execution"},
                    })
                    agent._add_exec_event("code_exec", {
                        "title": f"代码执行被拒",
                        "language": action.get("language", "unknown"),
                        "code_preview": truncate_str(action.get("code", ""), 200),
                        "success": False,
                        "error": "代码执行权限不足",
                    })
                    await self._relay_new_events(agent, last_event_count, write_sse)
                    last_event_count = len(agent.get_execution_events())
                    continue

                code_lang = action.get("language", "python")
                code_text = action.get("code", "")

                # 提取超时时间
                timeout_seconds = action.get("timeout_seconds")
                if timeout_seconds is None:
                    timeout_seconds = agent.config.executor.timeout if hasattr(agent.config, 'executor') else 120
                else:
                    try:
                        timeout_seconds = int(timeout_seconds)
                        timeout_seconds = max(5, min(timeout_seconds, 3600))
                    except (ValueError, TypeError):
                        timeout_seconds = 120

                # 发送 code_exec 开始事件（含预计超时时间）
                agent._add_exec_event("code_exec", {
                    "title": f"执行 {code_lang} 代码",
                    "language": code_lang,
                    "code": code_text,
                    "code_preview": truncate_str(code_text, 200),
                    "status": "running",
                    "timeout": timeout_seconds,
                })
                await self._relay_new_events(agent, last_event_count, write_sse)
                last_event_count = len(agent.get_execution_events())

                # 注入权限检查器
                agent.executor.set_permission_checker(
                    agent.check_permission, agent.name
                )

                # 执行代码
                exec_result = await agent.executor.execute(
                    language=action.get("language", "python"),
                    code=action.get("code", ""),
                    timeout=timeout_seconds,
                )

                if not exec_result.timeout_limit:
                    exec_result.timeout_limit = timeout_seconds

                result_dict = exec_result.to_dict()

                # 发送 code_result 事件（含已执行时间和预计超时）
                agent._add_exec_event("code_result", {
                    "title": f"{'超时' if exec_result.timed_out else '成功' if exec_result.success else '失败'}: {code_lang}",
                    "language": code_lang,
                    "code_preview": truncate_str(action.get("code", ""), 200),
                    "success": exec_result.success,
                    "timed_out": exec_result.timed_out,
                    "exit_code": exec_result.exit_code,
                    "execution_time": round(exec_result.execution_time, 3),
                    "timeout": timeout_seconds,
                    "stdout": truncate_str(exec_result.stdout, 5000),
                    "stderr": truncate_str(exec_result.stderr, 3000),
                    "error": truncate_str(exec_result.error, 2000),
                    "result": result_dict,
                })
                await self._relay_new_events(agent, last_event_count, write_sse)
                last_event_count = len(agent.get_execution_events())

                # 超时后自动触发 LLM 诊断分析
                if exec_result.timed_out:
                    logger.info(f"⏰ 命令执行超时 ({timeout_seconds}s)，自动触发 LLM 诊断分析...")
                    diagnosis = await agent._diagnose_timeout(
                        action, exec_result, context, ""
                    )
                    result_dict["timeout_diagnosis"] = diagnosis

                results.append(result_dict)

            elif action_type == "memory" and agent.memory_agent:
                from agents.base import AgentContext
                mem_ctx = AgentContext(
                    task_id="",
                    session_id=context.session_id,
                    metadata={
                        "memory_action": action.get("action", ""),
                        **action.get("data", {}),
                    },
                )
                await agent.memory_agent.process(mem_ctx)
                results.append({"success": True, "action": "memory"})

            elif action_type == "final":
                break

            else:
                results.append({
                    "success": False,
                    "error": f"未知操作类型: {action_type}",
                })

        return results

    async def _relay_new_events(self, agent, last_count: int, write_sse) -> int:
        """将 agent 中新增的执行事件通过 SSE 推送到前端，返回新的事件总数"""
        all_events = agent.get_execution_events()
        new_events = all_events[last_count:]
        for evt in new_events:
            await write_sse({"type": "exec_event", "data": evt})
        return len(all_events)

    async def handle_reload_config(self, request):
        """POST /api/config/reload - 从配置文件热重载（无需重启）

        增强功能:
        - 广播通知所有正在执行任务的 Agent 暂停
        - 等待 Agent 安全暂停后执行配置更新
        - 更新完成后通知 Agent 恢复执行
        """
        try:
            old_provider = self.core.config_mgr.config.llm.provider
            old_model = self.core.config_mgr.config.llm.model

            # ── 广播通知：让所有活跃 Agent 暂停 ──
            broadcaster = self.core.config_broadcaster
            paused_count = 0
            if broadcaster:
                paused_count = await broadcaster.request_reload()
                logger.info(f"热重载: 已暂停 {paused_count} 个活跃任务")

            # ── 执行配置重载 ──
            new_config = self.core.config_mgr.reload()
            logger.info("配置已热重载")

            # ── 更新运行中的组件 ──
            changes = []
            if self.core.llm:
                self._hot_reload_llm()
                if new_config.llm.provider != old_provider or new_config.llm.model != old_model:
                    changes.append(f"LLM: {old_provider}/{old_model} -> {new_config.llm.provider}/{new_config.llm.model}")

                # 模型库变化
                old_models = set(m.id for m in self.core.config.models_library)
                new_models = set(m.id for m in new_config.models_library)
                if old_models != new_models:
                    added = new_models - old_models
                    removed = old_models - new_models
                    parts = []
                    if added:
                        parts.append(f"新增模型: {', '.join(added)}")
                    if removed:
                        parts.append(f"移除模型: {', '.join(removed)}")
                    if parts:
                        changes.append("模型库: " + "; ".join(parts))

            self._hot_reload_executor()

            # 更新 app 引用
            self.core.config = new_config

            # ── 广播通知：让 Agent 恢复执行 ──
            if broadcaster:
                await broadcaster.complete_reload()
                logger.info(f"热重载: {paused_count} 个任务已恢复")

            logger.info(f"热重载完成，变更: {changes or '无显著变更'}")
            return web.json_response({
                "ok": True,
                "message": "配置已热重载",
                "changes": changes,
                "paused_tasks": paused_count,
                "broadcaster_stats": broadcaster.get_stats() if broadcaster else {},
                "config": self.core.config_mgr.get_full_config(),
            })
        except Exception as e:
            logger.error(f"热重载失败: {e}", exc_info=True)
            # 确保恢复广播器状态
            if self.core.config_broadcaster:
                self.core.config_broadcaster.force_reset()
            return web.json_response({"ok": False, "error": str(e)}, status=500)

    async def handle_export_config(self, request):
        """POST /api/config/export - 导出配置为 JSON 文件下载"""
        try:
            data = await request.json()
        except Exception:
            data = {}
        include_secrets = data.get("include_secrets", False)

        try:
            from core.utils import get_config_tz
            export_data = self.core.config_mgr.export_config(include_secrets=include_secrets)
            filename = f"myagent_config_{datetime.datetime.now(get_config_tz()).strftime('%Y%m%d_%H%M%S')}.json"

            resp = web.Response(
                body=json.dumps(export_data, ensure_ascii=False, indent=2),
                content_type="application/json",
                headers={
                    "Content-Disposition": f"attachment; filename=\"{filename}\"",
                },
            )
            logger.info(f"配置已导出: {filename} (secrets={include_secrets})")
            return resp
        except Exception as e:
            logger.error(f"导出配置失败: {e}", exc_info=True)
            return web.json_response({"ok": False, "error": str(e)}, status=500)

    async def handle_import_config(self, request):
        """POST /api/config/import - 从上传的 JSON 导入配置"""
        try:
            data = await request.json()
        except Exception:
            return web.json_response({"ok": False, "error": "无效的 JSON 数据"}, status=400)

        overwrite = data.get("_overwrite", False) if isinstance(data, dict) else False

        try:
            result = self.core.config_mgr.import_config(data, overwrite=overwrite)
            if not result["ok"]:
                return web.json_response(result, status=400)

            # 热重载运行中的组件
            new_config = self.core.config_mgr.config
            if self.core.llm:
                llm_cfg = new_config.llm
                self.core.llm.provider = llm_cfg.provider
                self.core.llm.model = llm_cfg.model
                self.core.llm.base_url = llm_cfg.base_url
                self.core.llm.api_key = llm_cfg.api_key
                self.core.llm.temperature = llm_cfg.temperature
                self.core.llm.max_tokens = llm_cfg.max_tokens
                self.core.llm.timeout = llm_cfg.timeout
                self.core.llm.max_retries = llm_cfg.max_retries
            self.core.config = new_config

            logger.info(f"配置已导入: {result['message']}")
            return web.json_response(result)
        except Exception as e:
            logger.error(f"导入配置失败: {e}", exc_info=True)
            return web.json_response({"ok": False, "error": str(e)}, status=500)

    # ── 配置安全保存与差异比较 ──
    async def handle_safe_save_config(self, request):
        """POST /api/config/safe-save - 安全保存配置（带备份+校验）"""
        from core.config_validator import ConfigValidator
        data = await request.json()

        validator = ConfigValidator(
            config_file_path=str(self.core.config_mgr._config_file)
        )

        # 如果是部分更新，先读取当前配置再合并
        current = {}
        cfg_path = self.core.config_mgr._config_file
        if cfg_path.exists():
            current = _safe_load_json(cfg_path)

        if data.get("merge", False):
            # 深度合并
            self._deep_merge(current, data.get("config", {}))
            new_config = current
        else:
            new_config = data.get("config", {})

        result = validator.safe_save(new_config)

        if result["ok"]:
            # 重新加载配置并热更新所有组件
            self.core.config_mgr.reload()
            self._hot_reload_llm()
            self._hot_reload_executor()
            self._hot_reload_communication()
            await self._hot_reload_chat_platforms()

        logger.info(f"安全保存配置: ok={result.get('ok')}")
        return web.json_response(result)

    async def handle_config_diff(self, request):
        """POST /api/config/diff - 查看配置差异"""
        from core.config_validator import ConfigValidator
        data = await request.json()

        validator = ConfigValidator(
            config_file_path=str(self.core.config_mgr._config_file)
        )

        diff = validator.get_diff(data.get("config", {}))
        return web.json_response(diff)

    # ── 首次启动检测 ──
    async def handle_setup_status(self, request):
        """GET /api/setup/status - 检测是否需要首次设置"""
        config = self.core.config
        # 检查是否已配置有效的 LLM API Key
        needs_setup = not config.llm.api_key
        # 确保系统 Agent 已初始化（使用异步锁防重复）
        await self._ensure_agents_initialized()
        helper_exists = (self._agent_dir("2") / "config.json").exists()
        return web.json_response({
            "needs_setup": needs_setup,
            "helper_exists": helper_exists,
            "has_api_key": bool(config.llm.api_key),
            "provider": config.llm.provider,
            "model": config.llm.model,
            "default_agent": "1",
        })

    async def handle_setup_complete(self, request):
        """POST /api/setup/complete - 标记首次设置完成"""
        # 确保系统 Agent 已初始化
        await self._ensure_agents_initialized()
        return web.json_response({"ok": True})

    # ── 自动更新管理 ──
    def _get_update_manager(self):
        """获取 UpdateManager 实例"""
        return self.core.update_manager

    async def handle_update_status(self, request):
        """GET /api/update/status - 获取更新管理器状态"""
        mgr = self._get_update_manager()
        if not mgr:
            return web.json_response({"error": "更新管理器未初始化"}, status=503)
        status = mgr.get_status()
        # 附加上广播器和队列状态
        if self.core.config_broadcaster:
            status["broadcaster"] = self.core.config_broadcaster.get_stats()
        if self.core.task_queue:
            stats = self.core.task_queue.get_stats()
            stats["draining"] = self.core.task_queue.draining
            status["task_queue"] = stats
        return web.json_response(status)

    async def handle_update_check(self, request):
        """POST /api/update/check - 检查远程版本更新"""
        mgr = self._get_update_manager()
        if not mgr:
            return web.json_response({"error": "更新管理器未初始化"}, status=503)
        if mgr.is_updating:
            return web.json_response({"error": "更新正在进行中"}, status=409)
        try:
            info = await mgr.check_for_update()
            return web.json_response({
                "current_version": info.current_version,
                "latest_version": info.latest_version,
                "has_update": info.has_update,
                "update_type": info.update_type,
                "changelog": info.changelog,
            })
        except Exception as e:
            logger.error(f"版本检查失败: {e}", exc_info=True)
            return web.json_response({"error": str(e)}, status=500)

    async def handle_update_apply(self, request):
        """POST /api/update/apply - 手动触发更新

        Body:
            type: "config" | "code" | "dependency" | "full"
            target_version: "1.2.0" (optional)
        """
        mgr = self._get_update_manager()
        if not mgr:
            return web.json_response({"error": "更新管理器未初始化"}, status=503)
        if mgr.is_updating:
            return web.json_response({"error": f"更新正在进行中 (状态: {mgr.status})"}, status=409)

        try:
            data = await request.json()
        except Exception:
            data = {}

        type_str = data.get("type", "full")
        try:
            from core.update_manager import UpdateType
            update_type = UpdateType(type_str)
        except ValueError:
            return web.json_response(
                {"error": f"无效的更新类型: {type_str}，可选: config/code/dependency/full"},
                status=400,
            )

        target = data.get("target_version", "")

        logger.info(f"手动触发更新: type={update_type.value}, target={target}")

        # 在后台任务中执行更新（避免阻塞 HTTP 响应）
        async def _do_update():
            try:
                record = await mgr.apply_update(update_type, target_version=target)
                logger.info(f"更新完成: {record.status} (v{record.from_version} → v{record.to_version})")
            except Exception as e:
                logger.error(f"更新异常: {e}", exc_info=True)

        asyncio.create_task(_do_update())

        return web.json_response({
            "ok": True,
            "message": f"已开始{update_type.value}更新",
            "update_type": update_type.value,
            "target_version": target or "latest",
        })

    async def handle_update_history(self, request):
        """GET /api/update/history?limit=20 - 获取更新历史"""
        mgr = self._get_update_manager()
        if not mgr:
            return web.json_response([])
        limit = int(request.query.get("limit", "20"))
        return web.json_response(mgr.get_history(limit=limit))

    # ── 组织管理 ──

    def _get_org_manager(self):
        """获取组织管理器实例（懒加载）"""
        if not hasattr(self, '_org_manager'):
            from organization.manager import OrganizationManager
            self._org_manager = OrganizationManager(
                data_dir=self.core.config_mgr.data_dir
            )
            self._org_manager.initialize()
        return self._org_manager

    def _get_org_rag(self):
        """获取组织知识库 RAG 实例（懒加载）"""
        if not hasattr(self, '_org_rag'):
            from knowledge.rag import KnowledgeRAG
            org_mgr = self._get_org_manager()
            self._org_rag = KnowledgeRAG(
                kb_dir=org_mgr._knowledge_dir
            )
            self._org_rag.build_index()
        return self._org_rag

    def _get_agent_knowledge_dir(self, agent_path: str) -> Path:
        """获取 Agent 的知识库目录"""
        return self._agent_dir(agent_path) / "knowledge"

    def _get_agent_rag(self, agent_path: str):
        """获取 Agent 知识库 RAG 实例"""
        if not hasattr(self, '_agent_rags'):
            self._agent_rags = {}
        if agent_path not in self._agent_rags:
            from knowledge.rag import KnowledgeRAG
            kb_dir = self._get_agent_knowledge_dir(agent_path)
            kb_dir.mkdir(parents=True, exist_ok=True)
            rag = KnowledgeRAG(kb_dir=kb_dir)
            rag.build_index()
            self._agent_rags[agent_path] = rag
        return self._agent_rags[agent_path]

    def _check_org_admin(self, agent_path: str = "") -> bool:
        """检查 agent 是否是组织知识库管理员"""
        org_cfg = self.core.config.organization
        admin = org_cfg.knowledge_admin or "1"
        return agent_path == admin

    async def handle_get_organization(self, request):
        """GET /api/organization - 获取组织配置和组织信息"""
        org_cfg = self.core.config.organization
        org_mgr = self._get_org_manager()
        org_info = org_mgr.get_org_info()
        return web.json_response({
            "enabled": org_cfg.enabled,
            "knowledge_admin": org_cfg.knowledge_admin,
            "info": org_info,
        })

    async def handle_update_organization(self, request):
        """PUT /api/organization - 更新组织配置"""
        data = await request.json()
        # 使用 ConfigManager.save() 确保序列化一致性
        if "enabled" in data:
            self.core.config.organization.enabled = bool(data["enabled"])
        if "knowledge_admin" in data:
            self.core.config.organization.knowledge_admin = str(data["knowledge_admin"])
        try:
            self.core.config_mgr.save()
        except Exception as e:
            logger.error(f"保存组织配置失败: {e}")
            return web.json_response({"ok": False, "error": f"保存失败: {e}"}, status=500)
        logger.info(f"组织配置已更新: enabled={self.core.config.organization.enabled}, admin={self.core.config.organization.knowledge_admin}")
        return web.json_response({"ok": True})

    async def handle_get_org_info(self, request):
        """GET /api/organization/info - 获取组织信息"""
        org_mgr = self._get_org_manager()
        content = org_mgr.get_org_info()
        return web.json_response({"content": content})

    async def handle_update_org_info(self, request):
        """PUT /api/organization/info - 更新组织信息"""
        data = await request.json()
        content = data.get("content", "")
        org_mgr = self._get_org_manager()
        ok = org_mgr.update_org_info(content)
        if ok:
            # 通知主 Agent 刷新组织上下文缓存
            if hasattr(self.core, 'main_agent') and self.core.main_agent:
                self.core.main_agent._org_context_mtime = 0
            return web.json_response({"ok": True})
        return web.json_response({"ok": False, "error": "更新失败"}, status=500)

    async def handle_list_org_knowledge(self, request):
        """GET /api/organization/knowledge - 列出组织知识库文件"""
        if not self.core.config.organization.enabled:
            return web.json_response([])
        org_mgr = self._get_org_manager()
        files = org_mgr.list_knowledge_files()
        return web.json_response(files)

    async def handle_upload_org_knowledge(self, request):
        """POST /api/organization/knowledge/upload - 上传文件到组织知识库（支持文件和文件夹上传）"""
        # 权限检查
        agent = request.query.get("agent", "1")
        if not self._check_org_admin(agent):
            return web.json_response(
                {"ok": False, "error": "权限不足：只有知识库管理员才能上传"},
                status=403,
            )

        files = await _read_multipart_files(request)
        if not files:
            return web.json_response({"ok": False, "error": "没有上传文件"}, status=400)

        org_mgr = self._get_org_manager()
        results = []
        for f in files:
            name = f.get("name", "")
            content = f.get("content", "")
            result = org_mgr.upload_file(name, content, uploaded_by=agent)
            results.append(result)

        # 刷新 RAG 索引
        if hasattr(self, '_org_rag'):
            self._org_rag.build_index()

        return web.json_response({"ok": True, "results": results})

    async def handle_delete_org_knowledge(self, request):
        """DELETE /api/organization/knowledge?path=xxx - 删除组织知识库文件"""
        # 权限检查
        agent = request.query.get("agent", "1")
        if not self._check_org_admin(agent):
            return web.json_response(
                {"ok": False, "error": "权限不足：只有知识库管理员才能删除"},
                status=403,
            )

        file_path = request.query.get("path", "").strip()
        if not file_path:
            return web.json_response({"ok": False, "error": "缺少 path 参数"}, status=400)
        # [v1.32.2] 安全修复: 路径遍历防护
        if ".." in file_path or file_path.startswith("/"):
            return web.json_response({"ok": False, "error": "非法路径"}, status=400)

        org_mgr = self._get_org_manager()
        result = org_mgr.delete_file(file_path)

        # 刷新 RAG 索引
        if result.get("ok") and hasattr(self, '_org_rag'):
            self._org_rag.build_index()

        return web.json_response(result)

    async def handle_read_org_knowledge(self, request):
        """GET /api/organization/knowledge/file?path=xxx - 读取知识库文件内容"""
        file_path = request.query.get("path", "").strip()
        download = request.query.get("download", "").strip() == "1"
        if not file_path:
            return web.json_response({"ok": False, "error": "缺少 path 参数"}, status=400)
        if ".." in file_path or file_path.startswith("/"):
            return web.json_response({"ok": False, "error": "非法路径"}, status=400)

        org_mgr = self._get_org_manager()
        kb_dir = org_mgr.get_knowledge_dir()
        target = kb_dir / file_path
        if not target.exists() or not target.is_file():
            return web.json_response({"ok": False, "error": f"文件不存在: {file_path}"}, status=404)

        # download=1 直接返回文件下载
        if download:
            resp = web.FileResponse(target)
            resp.headers['Content-Disposition'] = f'attachment; filename="{target.name}"'
            return resp

        content = org_mgr.read_knowledge_file(file_path)
        if content is None:
            return web.json_response({"ok": False, "error": f"文件不存在: {file_path}"}, status=404)
        return web.json_response({"ok": True, "path": file_path, "content": content})

    # ── Agent 知识库 ──

    async def handle_list_agent_knowledge(self, request):
        """GET /api/agents/{name}/knowledge - 列出 Agent 个人知识库文件"""
        agent_path = request.match_info["name"]
        kb_dir = self._get_agent_knowledge_dir(agent_path)
        if not kb_dir.exists():
            return web.json_response([])
        files = []
        for item in sorted(kb_dir.iterdir()):
            if item.is_file():
                try:
                    stat = item.stat()
                    files.append({
                        "name": item.name,
                        "path": item.name,
                        "type": "file",
                        "size": stat.st_size,
                    })
                except OSError:
                    pass
            elif item.is_dir():
                sub_files = [f for f in item.iterdir() if f.is_file()]
                files.append({
                    "name": item.name,
                    "path": item.name,
                    "type": "dir",
                    "size": 0,
                    "file_count": len(sub_files),
                })
        return web.json_response(files)

    async def handle_read_agent_knowledge(self, request):
        """GET /api/agents/{name}/knowledge/file?path=xxx - 读取 Agent 知识库文件内容"""
        agent_path = request.match_info["name"]
        file_path = request.query.get("path", "").strip()
        if not file_path:
            return web.json_response({"ok": False, "error": "缺少 path 参数"}, status=400)
        if ".." in file_path or file_path.startswith("/"):
            return web.json_response({"ok": False, "error": "非法路径"}, status=400)

        kb_dir = self._get_agent_knowledge_dir(agent_path)
        target = kb_dir / file_path
        if not target.exists() or not target.is_file():
            return web.json_response({"ok": False, "error": f"文件不存在: {file_path}"}, status=404)

        # 文本文件（< 1MB）返回内容，大文件或二进制文件返回下载链接
        size = target.stat().st_size
        ext = target.suffix.lower()
        text_exts = ('.txt', '.md', '.csv', '.json', '.xml', '.html', '.htm', '.css', '.js',
                      '.py', '.java', '.c', '.cpp', '.h', '.sh', '.yaml', '.yml', '.toml',
                      '.ini', '.cfg', '.conf', '.log', '.sql', '.go', '.rs', '.ts', '.tsx',
                      '.jsx', '.vue', '.svelte', '.rb', '.php', '.r', '.m', '.mm')
        if ext in text_exts and size < 1_000_000:
            try:
                content = target.read_text(encoding='utf-8', errors='replace')
                return web.json_response({"ok": True, "path": file_path, "name": target.name,
                                           "content": content, "size": size, "type": "text"})
            except Exception as e:
                return web.json_response({"ok": False, "error": f"读取失败: {e}"}, status=500)

        # 二进制或大文件 — 返回文件下载响应
        try:
            resp = web.FileResponse(target)
            resp.headers['Content-Disposition'] = f'attachment; filename="{target.name}"'
            return resp
        except Exception as e:
            return web.json_response({"ok": False, "error": f"下载失败: {e}"}, status=500)

    async def handle_upload_agent_knowledge(self, request):
        """POST /api/agents/{name}/knowledge/upload - 上传到 Agent 知识库（支持文件和文件夹上传）"""
        agent_path = request.match_info["name"]
        files = await _read_multipart_files(request)
        if not files:
            return web.json_response({"ok": False, "error": "没有上传文件"}, status=400)

        kb_dir = self._get_agent_knowledge_dir(agent_path)
        kb_dir.mkdir(parents=True, exist_ok=True)
        results = []
        for f in files:
            name = f.get("name", "")
            content = f.get("content", "")
            # 安全校验
            if ".." in name or name.startswith("/"):
                results.append({"ok": False, "message": "非法文件名"})
                continue
            if "/" in name:
                parts = name.split("/")
                parent = kb_dir / "/".join(parts[:-1])
                parent.mkdir(parents=True, exist_ok=True)
                target = parent / parts[-1]
            else:
                target = kb_dir / Path(name).name
            try:
                target.write_text(content, encoding="utf-8")
                results.append({"ok": True, "path": name})
            except Exception as e:
                results.append({"ok": False, "message": str(e)})

        # 刷新 RAG 索引
        if hasattr(self, '_agent_rags') and agent_path in self._agent_rags:
            self._agent_rags[agent_path].build_index()

        return web.json_response({"ok": True, "results": results})

    async def handle_delete_agent_knowledge(self, request):
        """DELETE /api/agents/{name}/knowledge?path=xxx - 删除 Agent 知识库文件"""
        agent_path = request.match_info["name"]
        file_path = request.query.get("path", "").strip()
        if not file_path:
            return web.json_response({"ok": False, "error": "缺少 path 参数"}, status=400)

        kb_dir = self._get_agent_knowledge_dir(agent_path)
        target = kb_dir / file_path

        if ".." in file_path or file_path.startswith("/"):
            return web.json_response({"ok": False, "error": "非法路径"}, status=400)

        if not target.exists():
            return web.json_response({"ok": False, "error": "文件不存在"}, status=404)

        try:
            if target.is_dir():
                shutil.rmtree(target)
            else:
                target.unlink()
        except Exception as e:
            return web.json_response({"ok": False, "message": str(e)})

        # 刷新 RAG 索引
        if hasattr(self, '_agent_rags') and agent_path in self._agent_rags:
            self._agent_rags[agent_path].build_index()

        return web.json_response({"ok": True, "message": "已删除"})

    async def handle_upload_agent_avatar(self, request):
        """POST /api/agents/{name}/avatar - 上传 Agent 头像图片（支持裁剪）

        请求格式: multipart/form-data
        - file: 图片文件 (jpg/png/webp)
        - crop_x, crop_y, crop_w, crop_h: 裁剪区域（像素，可选，不传则不裁剪）
        - size: 输出尺寸（像素，默认128，最大512）

        返回: {"ok": True, "url": "/api/agents/{name}/avatar.png"}
        """
        agent_path = request.match_info["name"]
        ad = self._agent_dir(agent_path)
        if not (ad / "config.json").exists():
            return web.json_response({"error": "Agent 不存在"}, status=404)

        try:
            reader = await request.multipart()
            field = await reader.next()

            if not field or field.name != "file":
                return web.json_response({"error": "缺少 file 字段"}, status=400)

            # 读取上传的图片数据
            image_data = bytearray()
            while True:
                chunk = await field.read_chunk()
                if not chunk:
                    break
                image_data.extend(chunk)

            # 解析裁剪参数（[v1.18.7] 防止 NaN 传入导致 ValueError）
            try:
                crop_x = int(float(request.query.get("crop_x", 0)))
                crop_y = int(float(request.query.get("crop_y", 0)))
                crop_w = int(float(request.query.get("crop_w", 0)))
                crop_h = int(float(request.query.get("crop_h", 0)))
            except (ValueError, TypeError):
                crop_x = crop_y = crop_w = crop_h = 0
            out_size = min(int(request.query.get("size", 128)), 512)

            # 使用 Pillow 处理图片
            from PIL import Image
            import io

            img = Image.open(io.BytesIO(bytes(image_data)))

            # 如果有 Alpha 通道，转为 RGB（JPEG 不支持透明）
            if img.mode in ("RGBA", "P"):
                # 创建白色背景
                bg = Image.new("RGB", img.size, (255, 255, 255))
                if img.mode == "P":
                    img = img.convert("RGBA")
                bg.paste(img, mask=img.split()[3] if img.mode == "RGBA" else None)
                img = bg
            elif img.mode != "RGB":
                img = img.convert("RGB")

            # 裁剪
            if crop_w > 0 and crop_h > 0:
                img = img.crop((crop_x, crop_y, crop_x + crop_w, crop_y + crop_h))

            # 居中裁剪为正方形（如果还不是正方形）
            w, h = img.size
            min_side = min(w, h)
            left = (w - min_side) // 2
            top = (h - min_side) // 2
            img = img.crop((left, top, left + min_side, top + min_side))

            # 缩放到目标尺寸
            img = img.resize((out_size, out_size), Image.LANCZOS)

            # 保存为 PNG
            avatar_path = ad / "avatar.png"
            img.save(str(avatar_path), "PNG", optimize=True)

            # 更新 config.json 中的 avatar_image 字段
            cfg = self._read_agent_config(agent_path)
            if cfg:
                cfg["avatar_image"] = f"/api/agents/{agent_path}/avatar.png"
                (ad / "config.json").write_text(
                    json.dumps(cfg, indent=2, ensure_ascii=False), encoding="utf-8"
                )

            # 确保头像文件可被静态访问：注册 avatar 路由
            avatar_url = f"/api/agents/{agent_path}/avatar.png"

            return web.json_response({
                "ok": True,
                "url": avatar_url,
                "size": out_size,
            })

        except ImportError:
            return web.json_response({"error": "需要 Pillow 库: pip install Pillow"}, status=500)
        except Exception as e:
            logger.error(f"头像上传失败 ({agent_path}): {e}", exc_info=True)
            return web.json_response({"error": f"头像上传失败: {e}"}, status=500)

    async def handle_get_agent_avatar(self, request):
        """GET /api/agents/{name}/avatar.png - 获取 Agent 头像图片"""
        agent_path = request.match_info["name"]
        ad = self._agent_dir(agent_path)
        avatar_file = ad / "avatar.png"
        if not avatar_file.exists():
            return web.json_response({"error": "头像不存在"}, status=404)
        # [v1.20.2] 添加缓存控制，防止浏览器缓存旧的 404 响应
        resp = web.FileResponse(str(avatar_file))
        resp.headers["Cache-Control"] = "no-cache"
        return resp

    # ── 知识库 RAG 搜索 ──

    async def handle_knowledge_search(self, request):
        """POST /api/knowledge/search - RAG 知识搜索

        请求体:
        {
            "query": "搜索关键词",
            "scope": "org",           # "org" 或 "agent:{path}"
            "top_k": 5
        }
        """
        data = await request.json()
        query = data.get("query", "").strip()
        if not query:
            return web.json_response({"error": "query 不能为空"}, status=400)

        scope = data.get("scope", "org")
        top_k = int(data.get("top_k", 5))

        try:
            if scope.startswith("agent:"):
                agent_path = scope[6:]
                rag = self._get_agent_rag(agent_path)
            else:
                rag = self._get_org_rag()

            results = rag.search(query, top_k=top_k)
            return web.json_response({
                "query": query,
                "scope": scope,
                "total_chunks": rag.total_chunks,
                "results": [r.to_dict() for r in results],
            })
        except Exception as e:
            logger.error(f"知识库搜索失败: {e}", exc_info=True)
            return web.json_response({"error": str(e)}, status=500)

    # ── 群聊管理 ──
    async def handle_list_groups(self, request):
        """GET /api/groups - 列出所有群"""
        mgr = self._get_group_manager()
        groups = [self._enrich_group_dict(g.to_dict()) for g in mgr.list_groups()]
        return web.json_response(groups)

    async def handle_create_group(self, request):
        """POST /api/groups - 创建群"""
        data = await request.json()
        name = data.get("name", "").strip()
        if not name:
            return web.json_response({"error": "群名不能为空"}, status=400)
        owner = data.get("owner", "1")
        description = data.get("description", "")
        avatar_emoji = data.get("avatar_emoji", "👥")
        avatar_color = data.get("avatar_color", "")
        member_paths = data.get("members", [])
        mgr = self._get_group_manager()
        group = mgr.create_group(
            name=name, owner=owner, description=description,
            avatar_emoji=avatar_emoji, avatar_color=avatar_color,
            member_paths=member_paths,
        )
        return web.json_response({"ok": True, "group": group.to_dict()})

    async def handle_get_group(self, request):
        """GET /api/groups/{gid} - 获取群详情"""
        gid = request.match_info["gid"]
        mgr = self._get_group_manager()
        group = mgr.get_group(gid)
        if not group:
            return web.json_response({"error": "群不存在"}, status=404)
        return web.json_response(self._enrich_group_dict(group.to_dict()))

    async def handle_update_group(self, request):
        """PUT /api/groups/{gid} - 更新群信息"""
        gid = request.match_info["gid"]
        data = await request.json()
        mgr = self._get_group_manager()
        group = mgr.update_group(
            gid,
            name=data.get("name", ""),
            description=data.get("description", ""),
            avatar_emoji=data.get("avatar_emoji", ""),
            avatar_color=data.get("avatar_color", ""),
        )
        if not group:
            return web.json_response({"error": "群不存在"}, status=404)
        return web.json_response({"ok": True, "group": group.to_dict()})

    async def handle_delete_group(self, request):
        """DELETE /api/groups/{gid} - 解散群"""
        gid = request.match_info["gid"]
        data = {}
        try:
            data = await request.json()
        except Exception:
            pass
        operator = data.get("operator", "")
        mgr = self._get_group_manager()
        ok = mgr.delete_group(gid, operator=operator)
        if not ok:
            return web.json_response({"error": "解散失败（群不存在或权限不足）"}, status=400)
        return web.json_response({"ok": True})

    async def handle_group_stats(self, request):
        """GET /api/groups/{gid}/stats - 获取群统计"""
        gid = request.match_info["gid"]
        mgr = self._get_group_manager()
        stats = mgr.get_group_stats(gid)
        if not stats:
            return web.json_response({"error": "群不存在"}, status=404)
        return web.json_response(stats)

    async def handle_get_group_session(self, request):
        """GET /api/groups/{gid}/session - 获取或创建群聊 session ID"""
        gid = request.match_info["gid"]
        mgr = self._get_group_manager()
        group = mgr.get_group(gid)
        if not group:
            return web.json_response({"error": "群不存在"}, status=404)
        sid = mgr.get_or_create_group_session(gid)
        return web.json_response({"ok": True, "sid": sid, "group_id": gid})

    async def handle_group_status(self, request):
        """GET /api/groups/{gid}/status - 检查群聊任务状态"""
        gid = request.match_info["gid"]
        tp = self._get_task_persistence()
        running_tasks = tp.get_all_tasks(status_filter=("running",))
        group_running = any(
            t.get("group_id", "") == gid or t.get("session_id", "").startswith(f"group_{gid}")
            for t in running_tasks
        )
        return web.json_response({"running": group_running, "done": not group_running})

    # ── 群成员管理 ──
    async def handle_add_member(self, request):
        """POST /api/groups/{gid}/members - 添加成员"""
        gid = request.match_info["gid"]
        data = await request.json()
        agent_path = data.get("agent_path", "").strip()
        if not agent_path:
            return web.json_response({"error": "agent_path 不能为空"}, status=400)
        role = data.get("role", "member")
        nickname = data.get("nickname", "")
        operator = data.get("operator", "")
        mgr = self._get_group_manager()
        group = mgr.add_member(gid, agent_path, role=role, nickname=nickname, operator=operator)
        if not group:
            return web.json_response({"error": "添加失败（群不存在、已存在或权限不足）"}, status=400)
        return web.json_response({"ok": True, "group": group.to_dict()})

    async def handle_remove_member(self, request):
        """DELETE /api/groups/{gid}/members/{agent_path} - 移除成员"""
        gid = request.match_info["gid"]
        agent_path = request.match_info["agent_path"]
        data = {}
        try:
            data = await request.json()
        except Exception:
            pass
        operator = data.get("operator", "")
        mgr = self._get_group_manager()
        group = mgr.remove_member(gid, agent_path, operator=operator)
        if not group:
            return web.json_response({"error": "移除失败"}, status=400)
        return web.json_response({"ok": True, "group": group.to_dict()})

    async def handle_set_member_role(self, request):
        """PUT /api/groups/{gid}/members/{agent_path}/role - 设置成员角色"""
        gid = request.match_info["gid"]
        agent_path = request.match_info["agent_path"]
        data = await request.json()
        role = data.get("role", "member")
        operator = data.get("operator", "")
        mgr = self._get_group_manager()
        group = mgr.set_member_role(gid, agent_path, role=role, operator=operator)
        if not group:
            return web.json_response({"error": "设置失败（群不存在、无权限或目标不存在）"}, status=400)
        return web.json_response({"ok": True, "group": group.to_dict()})

    async def handle_set_member_muted(self, request):
        """PUT /api/groups/{gid}/members/{agent_path}/mute - 设置禁言"""
        gid = request.match_info["gid"]
        agent_path = request.match_info["agent_path"]
        data = await request.json()
        muted = data.get("muted", False)
        operator = data.get("operator", "")
        mgr = self._get_group_manager()
        group = mgr.set_member_muted(gid, agent_path, muted=muted, operator=operator)
        if not group:
            return web.json_response({"error": "设置失败"}, status=400)
        return web.json_response({"ok": True, "group": group.to_dict()})

    # ── 群消息 ──
    async def handle_get_group_messages(self, request):
        """GET /api/groups/{gid}/messages - 获取群消息"""
        gid = request.match_info["gid"]
        mgr = self._get_group_manager()
        limit = int(request.query.get("limit", "100"))
        before = float(request.query.get("before", "0"))
        messages = mgr.get_messages(gid, limit=limit, before=before)
        return web.json_response([m.to_dict() for m in messages])

    async def handle_send_group_message(self, request):
        """POST /api/groups/{gid}/messages - 发送群消息（广播到所有成员agent）"""
        import re as _re
        import time as _time

        gid = request.match_info["gid"]
        data = await request.json()
        content = data.get("message", "").strip()
        if not content:
            return web.json_response({"error": "消息不能为空"}, status=400)

        mgr = self._get_group_manager()
        group = mgr.get_group(gid)
        if not group:
            return web.json_response({"error": "群不存在"}, status=404)

        # 1. 保存用户消息
        from groups.manager import GroupMessage
        user_msg = GroupMessage(
            group_id=gid,
            sender="user",
            sender_name="用户",
            sender_avatar="👤",
            content=content,
        )
        mgr.add_message(user_msg)

        # 2. 保存任务到持久化存储
        tp = self._get_task_persistence()
        from core.utils import generate_id
        task_id = generate_id("task")
        tp.save_task(
            task_id=task_id,
            description=content[:500],
            session_id=f"group_{gid}",
            group_id=gid,
            agent_path=", ".join(m.agent_path for m in group.members if not m.muted),
            status="running",
            metadata={"source": "group_chat", "group_name": group.name},
        )

        # 3. [v1.23.29] 解析 @提及
        at_pattern = _re.compile(r'@(\S+)')
        at_targets = at_pattern.findall(content)
        mentioned_agents = set()
        mentioned_all = False

        for target in at_targets:
            target_lower = target.lower()
            if target_lower in ('all', '所有人', '大家'):
                mentioned_all = True
                break
            for m in group.members:
                agent_cfg = self._read_agent_config(m.agent_path)
                agent_name = agent_cfg.get("name", "") if agent_cfg else ""
                if (target_lower == m.agent_path.lower()
                    or target_lower == agent_name.lower()
                    or (m.nickname and target_lower == m.nickname.lower())):
                    mentioned_agents.add(m.agent_path)

        # 4. 广播到所有非禁言成员agent，并行处理
        active_members = [m for m in group.members if not m.muted]

        # [v1.23.29] 确定哪些 Agent 应该回复
        if mentioned_all:
            target_members = active_members
        elif mentioned_agents:
            target_members = [m for m in active_members if m.agent_path in mentioned_agents]
        else:
            target_members = active_members  # 无@=广播给所有（向后兼容）

        import asyncio
        # Build a member_order map for deterministic sorting after gather
        member_order = {m.agent_path: i for i, m in enumerate(target_members)}

        async def process_agent_member(member):
            """Process a single member's response (DO NOT save messages here)"""
            try:
                agent_path = member.agent_path
                agent_cfg = self._read_agent_config(agent_path)
                model_chain = self._build_model_chain(agent_cfg, agent_path)
                session_id = f"group_{gid}_{agent_path}"

                # [v1.15.18] 构建 Agent 专属系统提示词（与1:1聊天一致）
                _, agent_system_prompt = self._build_agent_chat_context(agent_path, agent_cfg, content)

                # [v1.15.18] 构建群聊上下文（统一方法，减少代码重复）
                recent_msgs = mgr.get_messages(gid, limit=10)
                history_lines = []
                for rm in recent_msgs:
                    rm_name = rm.sender_name or ("用户" if rm.sender == "user" else rm.agent_path)
                    rm_time_str = _time.strftime("%H:%M", _time.localtime(rm.timestamp)) if rm.timestamp else ""
                    history_lines.append(f"[{rm_time_str}] {rm_name}: {rm.content[:200]}")
                chat_history = "\n".join(reversed(history_lines))

                group_context = self._build_group_context(
                    group, agent_path, agent_cfg, member,
                    mentioned_all=mentioned_all,
                    mentioned_agents=mentioned_agents,
                    chat_history=chat_history,
                )

                # 将群聊上下文追加到 agent_system_prompt
                if agent_system_prompt:
                    agent_system_prompt += "\n\n" + group_context
                else:
                    agent_system_prompt = group_context

                # 构建部门上下文（如果此群属于某个部门）
                dept_context = self._build_dept_context(gid, agent_path)
                if dept_context:
                    agent_system_prompt += "\n\n" + dept_context

                # 构建最终消息（用户原文不包含任何注入内容）
                agent_content = content

                # [v1.23.34] 群聊场景直接调用 _try_model_chain_inner（绕过全局锁）
                # 全局锁 _model_chain_lock 在并行场景下会导致死锁/阻塞
                if model_chain and self.core.llm:
                    response = await self._try_model_chain_inner(
                        model_chain, agent_content, session_id,
                        agent_path=agent_path, agent_system_prompt=agent_system_prompt,
                    )
                else:
                    response = await self.core.process_message(agent_content, session_id)

                # 获取agent的显示信息
                avatar = "🤖"
                display_name = agent_path
                agent_color = "var(--accent)"
                if agent_cfg:
                    avatar = agent_cfg.get("avatar_emoji", "🤖") or "🤖"
                    display_name = agent_cfg.get("name", agent_path)

                return {
                    "ok": True,
                    "agent_path": agent_path,
                    "name": display_name,
                    "avatar": avatar,
                    "agent_color": agent_color,
                    "response": response,
                }
            except Exception as e:
                logger.error(f"群消息处理失败 ({member.agent_path}): {e}")
                return {
                    "ok": False,
                    "agent_path": member.agent_path,
                    "name": member.agent_path,
                    "avatar": "❌",
                    "response": f"处理失败: {str(e)}",
                }

        # [v1.33.5] 并发调用目标成员 agent（不再串行！）
        # 旧版串行原因: _try_model_chain_inner 会修改共享 self.core.llm
        # 新版: 为每个请求创建独立的 LLM 快照，互不干扰，可安全并发
        raw_responses = []
        if len(target_members) <= 1:
            # 单成员，直接调用
            for member in target_members:
                try:
                    result = await process_agent_member(member)
                    raw_responses.append(result)
                except Exception as e:
                    logger.error(f"群消息处理异常 ({member.agent_path}): {e}")
                    raw_responses.append({
                        "ok": False, "agent_path": member.agent_path,
                        "name": member.agent_path, "avatar": "❌",
                        "response": f"异常: {str(e)}",
                    })
        else:
            # 多成员，并发执行
            async def _safe_process(m):
                try:
                    return await process_agent_member(m)
                except Exception as e:
                    logger.error(f"群消息处理异常 ({m.agent_path}): {e}")
                    return {
                        "ok": False, "agent_path": m.agent_path,
                        "name": m.agent_path, "avatar": "❌",
                        "response": f"异常: {str(e)}",
                    }

            tasks = [_safe_process(m) for m in target_members]
            raw_responses = await asyncio.gather(*tasks)

        # Sort by original member order to ensure deterministic message ordering
        final_responses = sorted(
            raw_responses,
            key=lambda r: member_order.get(r.get("agent_path", ""), 999999)
        )

        # [v1.23.29] 从 Agent 响应中提取 __CHAT_AGENT__ 标记，清理并保存跨Agent通信消息
        # [v1.23.37] 同时写入独立的 agent_chat 私聊记录表，便于管理后台查看
        # [v1.35.0] 同时支持3段和4段格式（4段包含files信息）
        # [v1.23.58] 修复4段格式解包为3变量导致的 ValueError 崩溃
        _chat_msg_all = _re.compile(r'__CHAT_AGENT__(.+?)\|(.+?)\|(.+?)(?:\|(.+?))?__END__')
        for resp in final_responses:
            resp_text = resp.get("response", "")
            chat_matches = _chat_msg_all.findall(resp_text)
            if chat_matches:
                # 清理标记文本
                resp["response"] = _re.sub(r'__CHAT_AGENT__.+?__END__', '', resp_text).strip()
                # 保存跨Agent通信消息（兼容3段和4段格式）
                for match in chat_matches:
                    c_path_s = match[0].strip()
                    c_name_s = match[1].strip()
                    c_msg_s = match[2].strip()
                    # match[3] 是 files（4段格式才有），此处不需要使用
                    # 群聊中显示简要提示
                    chat_sys_msg = GroupMessage(
                        group_id=gid,
                        sender=resp.get("agent_path", ""),
                        sender_name=resp.get("name", ""),
                        sender_avatar=resp.get("avatar", "🤖"),
                        content=f"💬 私下与 {c_name_s} 沟通中...",
                        msg_type="text",
                    )
                    mgr.add_message(chat_sys_msg)
                    # [v1.23.37] 写入独立的私聊记录表
                    try:
                        mgr.add_agent_chat(
                            group_id=gid,
                            from_agent=resp.get("agent_path", ""),
                            from_name=resp.get("name", ""),
                            to_agent=c_path_s,
                            to_name=c_name_s,
                            content=c_msg_s,
                        )
                    except Exception as ce:
                        logger.debug(f"保存私聊记录失败: {ce}")

        # Save agent messages sequentially in sorted order
        for resp in final_responses:
            agent_msg = GroupMessage(
                group_id=gid,
                sender="agent",
                sender_name=resp["name"],
                sender_avatar=resp["avatar"],
                content=resp["response"],
                agent_path=resp["agent_path"],
            )
            mgr.add_message(agent_msg)

        # 更新任务状态
        has_failure = any(not r.get("ok") for r in final_responses)
        tp.update_task_status(
            task_id,
            "failed" if has_failure else "completed",
            last_message=content[:500],
        )

        return web.json_response({
            "ok": True,
            "group_id": gid,
            "user_message": user_msg.to_dict(),
            "responses": final_responses,
            "task_id": task_id,
        })

    async def handle_send_group_message_stream(self, request):
        """POST /api/groups/{gid}/messages/stream - 群聊流式消息（SSE）"""
        import re as _re
        import time as _time
        import json as _json

        gid = request.match_info["gid"]
        data = await request.json()
        content = data.get("message", "").strip()
        if not content:
            return web.json_response({"error": "消息不能为空"}, status=400)

        mgr = self._get_group_manager()
        group = mgr.get_group(gid)
        if not group:
            return web.json_response({"error": "群不存在"}, status=404)

        # Setup SSE response
        resp = web.StreamResponse()
        resp.content_type = "text/event-stream"
        resp.headers["Cache-Control"] = "no-cache"
        resp.headers["Connection"] = "keep-alive"
        resp.headers["X-Accel-Buffering"] = "no"
        await resp.prepare(request)

        async def send_event(event_dict):
            """Send a single SSE event"""
            try:
                line = "data: " + _json.dumps(event_dict, ensure_ascii=False) + "\n\n"
                await resp.write(line.encode("utf-8"))
            except Exception:
                pass  # Client disconnected

        # 1. 保存用户消息
        from groups.manager import GroupMessage
        user_msg = GroupMessage(
            group_id=gid, sender="user", sender_name="用户",
            sender_avatar="👤", content=content,
        )
        mgr.add_message(user_msg)

        # 2. 保存任务
        tp = self._get_task_persistence()
        from core.utils import generate_id
        task_id = generate_id("task")
        tp.save_task(
            task_id=task_id, description=content[:500],
            session_id=f"group_{gid}", group_id=gid,
            agent_path=", ".join(m.agent_path for m in group.members if not m.muted),
            status="running",
            metadata={"source": "group_chat", "group_name": group.name},
        )

        # 3. 解析 @提及
        at_pattern = _re.compile(r'@(\S+)')
        at_targets = at_pattern.findall(content)
        mentioned_agents = set()
        mentioned_all = False
        for target in at_targets:
            target_lower = target.lower()
            if target_lower in ('all', '所有人', '大家'):
                mentioned_all = True
                break
            for m in group.members:
                agent_cfg = self._read_agent_config(m.agent_path)
                agent_name = agent_cfg.get("name", "") if agent_cfg else ""
                if (target_lower == m.agent_path.lower()
                    or target_lower == agent_name.lower()
                    or (m.nickname and target_lower == m.nickname.lower())):
                    mentioned_agents.add(m.agent_path)

        # 4. 确定目标成员
        active_members = [m for m in group.members if not m.muted]
        if mentioned_all:
            target_members = active_members
        elif mentioned_agents:
            target_members = [m for m in active_members if m.agent_path in mentioned_agents]
        else:
            target_members = active_members

        # 5. 串行处理每个成员，发送 SSE 事件
        try:
            for member in target_members:
                agent_path = member.agent_path
                agent_cfg = self._read_agent_config(agent_path)

                # 获取显示信息
                avatar = "🤖"
                display_name = agent_path
                agent_color = "var(--accent)"
                if agent_cfg:
                    avatar = agent_cfg.get("avatar_emoji", "🤖") or "🤖"
                    display_name = agent_cfg.get("name", agent_path)

                # 发送 agent_start 事件
                await send_event({
                    "type": "agent_start",
                    "agent_path": agent_path,
                    "name": display_name,
                    "avatar": avatar,
                    "agent_color": agent_color,
                })

                try:
                    model_chain = self._build_model_chain(agent_cfg, agent_path)
                    session_id = f"group_{gid}_{agent_path}"

                    _, agent_system_prompt = self._build_agent_chat_context(agent_path, agent_cfg, content)

                    recent_msgs = mgr.get_messages(gid, limit=10)
                    history_lines = []
                    for rm in recent_msgs:
                        rm_name = rm.sender_name or ("用户" if rm.sender == "user" else rm.agent_path)
                        rm_time_str = _time.strftime("%H:%M", _time.localtime(rm.timestamp)) if rm.timestamp else ""
                        history_lines.append(f"[{rm_time_str}] {rm_name}: {rm.content[:200]}")
                    chat_history = "\n".join(reversed(history_lines))

                    group_context = self._build_group_context(
                        group, agent_path, agent_cfg, member,
                        mentioned_all=mentioned_all,
                        mentioned_agents=mentioned_agents,
                        chat_history=chat_history,
                    )

                    if agent_system_prompt:
                        agent_system_prompt += "\n\n" + group_context
                    else:
                        agent_system_prompt = group_context

                    dept_context = self._build_dept_context(gid, agent_path)
                    if dept_context:
                        agent_system_prompt += "\n\n" + dept_context

                    # 处理 agent 响应
                    if model_chain and self.core.llm:
                        response = await self._try_model_chain_inner(
                            model_chain, content, session_id,
                            agent_path=agent_path,
                            agent_system_prompt=agent_system_prompt,
                        )
                    else:
                        response = await self.core.process_message(content, session_id)

                    # 发送 text_delta 事件
                    await send_event({
                        "type": "text_delta",
                        "content": response or "",
                    })

                    # 提取 __CHAT_AGENT__ 标记并清理响应
                    # [v1.23.58] 修复4段格式解包为3变量导致的 ValueError 崩溃
                    _chat_msg_all_s = _re.compile(r'__CHAT_AGENT__(.+?)\|(.+?)\|(.+?)(?:\|(.+?))?__END__')
                    clean_response = response
                    chat_matches = _chat_msg_all_s.findall(response or "")
                    if chat_matches:
                        clean_response = _re.sub(r'__CHAT_AGENT__.+?__END__', '', response or "").strip()
                        for match in chat_matches:
                            c_path_s = match[0].strip()
                            c_name_s = match[1].strip()
                            c_msg_s = match[2].strip()
                            chat_sys_msg = GroupMessage(
                                group_id=gid, sender=agent_path,
                                sender_name=display_name, sender_avatar=avatar,
                                content=f"💬 私下与 {c_name_s} 沟通中...",
                                msg_type="text",
                            )
                            mgr.add_message(chat_sys_msg)
                            try:
                                mgr.add_agent_chat(
                                    group_id=gid, from_agent=agent_path,
                                    from_name=display_name, to_agent=c_path_s,
                                    to_name=c_name_s, content=c_msg_s,
                                )
                            except Exception:
                                pass

                    # 保存 agent 消息
                    agent_msg = GroupMessage(
                        group_id=gid, sender="agent",
                        sender_name=display_name, sender_avatar=avatar,
                        content=clean_response or "",
                        agent_path=agent_path,
                    )
                    mgr.add_message(agent_msg)

                except Exception as e:
                    logger.error(f"群消息流式处理失败 ({agent_path}): {e}")
                    await send_event({
                        "type": "error",
                        "content": f"{display_name} 回复失败: {str(e)}",
                    })

                # 发送 agent_done 事件
                await send_event({"type": "agent_done"})

            # 更新任务状态
            tp.update_task_status(task_id, "completed", last_message=content[:500])

            # 发送 done 事件
            await send_event({"type": "done"})

        except Exception as e:
            logger.error(f"群聊流式处理异常: {e}")
            await send_event({"type": "error", "content": str(e)})
            tp.update_task_status(task_id, "failed", last_message=content[:500])

        await resp.write_eof()
        return resp

    async def handle_clear_group_messages(self, request):
        """DELETE /api/groups/{gid}/messages - 清空群消息"""
        gid = request.match_info["gid"]
        mgr = self._get_group_manager()
        ok = mgr.clear_messages(gid)
        return web.json_response({"ok": ok})

    # ── [v1.23.37] Agent间私聊记录 API ──

    # ── [v1.35.0] 用户-Agent 私聊会话 API（供后台管理面板使用） ──

    async def handle_get_private_chat_sessions(self, request):
        """GET /api/private-chats/sessions - 获取无用户参与的会话（纯Agent间聊天）

        数据来源合并两部分:
        1. session_messages 表 — 无用户参与的对话会话
        2. agent_chat 表 — Agent 间私聊记录（groups/messages.db）
        """
        agent_filter = request.query.get("agent", "").strip()
        sessions = []
        try:
            # ═══════════════════════════════════════════
            # 第一部分：session_messages 中无用户参与的会话
            # ═══════════════════════════════════════════
            if self.core.memory:
                conn = self.core.memory._get_conn()

                # 构建 agent_id → agent_name 的映射
                _agent_id_map = {}
                try:
                    for ar in conn.execute("SELECT id, name FROM agents").fetchall():
                        _agent_id_map[int(ar["id"])] = ar["name"]
                except Exception:
                    pass

                _hidden = '(' + ','.join(["'llm_output'", "'llm_input'", "'tool_result_raw'", "'conversation_insight'"]) + ')'

                if agent_filter:
                    target_aid = self.core.memory.get_agent_id(agent_filter)
                    rows = conn.execute(
                        f"""SELECT DISTINCT sm.session_id, sm.cnt, sm.last, sm.raw_agent_id
                           FROM (
                               SELECT session_id, COUNT(*) as cnt, MAX(created_at) as last,
                                      agent_id as raw_agent_id FROM session_messages
                               WHERE role != '' AND key NOT IN {_hidden}
                               AND agent_id = ?
                               GROUP BY session_id
                           ) sm
                           WHERE sm.session_id NOT IN (
                               SELECT DISTINCT session_id FROM session_messages WHERE role = 'user'
                           )
                           ORDER BY sm.last DESC LIMIT 200""",
                        (target_aid,)).fetchall()
                else:
                    rows = conn.execute(
                        f"""SELECT DISTINCT sm.session_id, sm.cnt, sm.last, sm.raw_agent_id
                           FROM (
                               SELECT session_id, COUNT(*) as cnt, MAX(created_at) as last,
                                      agent_id as raw_agent_id FROM session_messages
                               WHERE role != '' AND key NOT IN {_hidden}
                               GROUP BY session_id
                           ) sm
                           WHERE sm.session_id NOT IN (
                               SELECT DISTINCT session_id FROM session_messages WHERE role = 'user'
                           )
                           ORDER BY sm.last DESC LIMIT 200""").fetchall()

                sm_sessions = []
                for r in rows:
                    raw_aid = r["raw_agent_id"]
                    agent_name = _agent_id_map.get(int(raw_aid), "") if raw_aid else ""
                    sm_sessions.append({
                        "id": r["session_id"],
                        "session_id": r["session_id"],
                        "agent_path": agent_name,
                        "agent_name": agent_name,
                        "display_name": "",
                        "preview": "",
                        "messages": r["cnt"],
                        "last": r["last"],
                        "status": "",
                        "_source": "session_messages",
                    })

                # 批量获取自定义会话名称
                sids = [s["id"] for s in sm_sessions]
                name_map = self.core.memory.list_session_names(sids) if sids else {}

                # 批量获取每个会话最后一条 assistant 消息作为预览
                preview_map = {}
                if sids:
                    placeholders = ",".join(["?"] * len(sids))
                    preview_rows = conn.execute(
                        f"SELECT m1.session_id, m1.content FROM session_messages m1 "
                        f"INNER JOIN (SELECT session_id, MAX(created_at) as max_t FROM session_messages "
                        f"WHERE session_id IN ({placeholders}) AND role='assistant' "
                        f"GROUP BY session_id) m2 ON m1.session_id = m2.session_id AND m1.created_at = m2.max_t "
                        f"WHERE m1.role='assistant'",
                        sids
                    ).fetchall()
                    for pr in preview_rows:
                        preview_map[pr["session_id"]] = (pr["content"] or "")[:100]

                for s in sm_sessions:
                    s["display_name"] = name_map.get(s["id"], "")
                    s["preview"] = preview_map.get(s["id"], "")

                sessions.extend(sm_sessions)

            # ═══════════════════════════════════════════
            # 第二部分：agent_chat 表中的 Agent 间私聊记录
            # ═══════════════════════════════════════════
            try:
                mgr = self._get_group_manager()
                pairs = mgr.get_agent_chat_pairs(group_id="")
                for p in pairs:
                    pair_key = f"{p.get('from_agent','')}->{p.get('to_agent','')}"
                    # 避免重复：如果 session_messages 已有同一条记录则跳过
                    display = f"{p.get('from_name','')} <-> {p.get('to_name','')}"
                    sessions.append({
                        "id": pair_key,
                        "session_id": pair_key,
                        "agent_path": p.get("from_agent", "") or p.get("to_agent", ""),
                        "agent_name": display,
                        "display_name": display,
                        "preview": "",  # 消息详情需要点击后通过 agent-chat/messages 获取
                        "messages": p.get("cnt", p.get("count", 0)),
                        "last": p.get("last_ts", 0),
                        "status": "",
                        "_source": "agent_chat",
                        "_from_agent": p.get("from_agent", ""),
                        "_to_agent": p.get("to_agent", ""),
                    })
            except Exception as ac_err:
                logger.debug(f"合并 agent_chat 数据失败: {ac_err}")

            # 按 last 时间排序
            sessions.sort(key=lambda s: s.get("last", 0) or 0, reverse=True)

            # 限制总数
            sessions = sessions[:200]

            return web.json_response(sessions)
        except Exception as e:
            logger.debug(f"获取私聊会话失败: {e}")
            return web.json_response([])

    async def handle_get_agent_chat_pairs(self, request):
        """GET /api/agent-chat/pairs - 获取所有私聊Agent对"""
        gid = request.query.get("group_id", "")
        mgr = self._get_group_manager()
        pairs = mgr.get_agent_chat_pairs(group_id=gid)
        return web.json_response(pairs)

    async def handle_get_agent_chat_messages(self, request):
        """GET /api/agent-chat/messages - 查询私聊记录详情"""
        gid = request.query.get("group_id", "")
        from_agent = request.query.get("from_agent", "")
        to_agent = request.query.get("to_agent", "")
        limit = min(int(request.query.get("limit", "200")), 500)
        mgr = self._get_group_manager()
        messages = mgr.get_agent_chats(
            group_id=gid, from_agent=from_agent, to_agent=to_agent, limit=limit
        )
        return web.json_response(messages)

    async def handle_clear_agent_chat_messages(self, request):
        """DELETE /api/agent-chat/messages - 清空私聊记录"""
        gid = request.query.get("group_id", "")
        mgr = self._get_group_manager()
        deleted = mgr.clear_agent_chats(group_id=gid)
        return web.json_response({"ok": True, "deleted": deleted})

    @staticmethod
    def _get_lan_ipv4_addresses() -> list[str]:
        """获取所有局域网 IPv4 地址（排除 127.0.0.1 和公网地址）
        
        返回私有地址：10.x.x.x, 172.16-31.x.x, 192.168.x.x
        """
        import socket
        import subprocess
        import sys
        import re
        lan_addrs = []
        
        def _is_private_ipv4(addr: str) -> bool:
            """检查 IPv4 地址是否为私有/局域网地址"""
            if addr.startswith("10."):
                return True
            if addr.startswith("192.168."):
                return True
            if addr.startswith("172."):
                try:
                    second_octet = int(addr.split(".")[1])
                    return 16 <= second_octet <= 31
                except (ValueError, IndexError):
                    return False
            return False
        
        # 方法1：使用 socket.getaddrinfo（跨平台）
        try:
            hostname = socket.gethostname()
            addrs = socket.getaddrinfo(hostname, None, socket.AF_INET, socket.SOCK_STREAM)
            for addr_info in addrs:
                addr = addr_info[4][0]
                if _is_private_ipv4(addr) and addr not in lan_addrs:
                    lan_addrs.append(addr)
        except Exception:
            pass
        
        # 方法2：Linux 使用 ip 命令
        if not lan_addrs and sys.platform != 'win32':
            try:
                result = subprocess.run(
                    ["ip", "-4", "addr", "show"],
                    capture_output=True, text=True, timeout=5
                )
                for line in result.stdout.splitlines():
                    line = line.strip()
                    if line.startswith("inet "):
                        addr_part = line.split()[1]  # "192.168.1.100/24"
                        addr = addr_part.split("/")[0]  # "192.168.1.100"
                        if _is_private_ipv4(addr) and addr not in lan_addrs:
                            lan_addrs.append(addr)
            except Exception:
                pass
        
        # 方法3：Windows 使用 ipconfig
        if not lan_addrs and sys.platform == 'win32':
            try:
                result = subprocess.run(
                    ["ipconfig"],
                    capture_output=True, text=True, timeout=5
                )
                for match in re.finditer(r'IPv4[^:]*:\s*(\d+\.\d+\.\d+\.\d+)', result.stdout):
                    addr = match.group(1)
                    if _is_private_ipv4(addr) and addr not in lan_addrs:
                        lan_addrs.append(addr)
            except Exception:
                pass
        
        return lan_addrs

    @staticmethod
    def _get_public_ipv6_addresses() -> list[str]:
        """获取所有公网 IPv6 地址
        
        排除本地回环 (::1)、链路本地 (fe80::) 和 ULA (fc00::/7)
        """
        import socket
        import subprocess
        import sys
        import re
        ipv6_addrs = []
        
        # 方法1：使用 socket.getaddrinfo（跨平台）
        try:
            hostname = socket.gethostname()
            addrs = socket.getaddrinfo(hostname, None, socket.AF_INET6, socket.SOCK_STREAM)
            for addr_info in addrs:
                addr = addr_info[4][0]
                # 排除本地回环、链路本地和 ULA
                if addr == '::1':
                    continue
                if addr.lower().startswith('fe80:'):
                    continue
                if addr.lower().startswith('fc') or addr.lower().startswith('fd'):
                    continue  # ULA fc00::/7
                # 标准化地址
                try:
                    normalized = socket.inet_ntop(socket.AF_INET6, socket.inet_pton(socket.AF_INET6, addr))
                    if normalized not in ipv6_addrs:
                        ipv6_addrs.append(normalized)
                except Exception:
                    continue
        except Exception:
            pass
        
        # 方法2：Linux 使用 ip 命令
        if not ipv6_addrs and sys.platform != 'win32':
            try:
                result = subprocess.run(
                    ["ip", "-6", "addr", "show", "scope", "global"],
                    capture_output=True, text=True, timeout=5
                )
                for line in result.stdout.splitlines():
                    line = line.strip()
                    if line.startswith("inet6 "):
                        addr_part = line.split()[1]  # "2001:db8::1/64"
                        addr = addr_part.split("/")[0]  # "2001:db8::1"
                        if addr not in ipv6_addrs:
                            ipv6_addrs.append(addr)
            except Exception:
                pass
        
        # 方法3：Windows 使用 ipconfig
        if not ipv6_addrs and sys.platform == 'win32':
            try:
                result = subprocess.run(
                    ["ipconfig"],
                    capture_output=True, text=True, timeout=5
                )
                for match in re.finditer(r'IPv6[^:\n]*:\s*([0-9a-fA-F:]+)', result.stdout):
                    addr = match.group(1).strip()
                    if not addr or addr == '::1':
                        continue
                    try:
                        normalized = socket.inet_ntop(socket.AF_INET6, socket.inet_pton(socket.AF_INET6, addr))
                        if normalized == '::1':
                            continue
                        if normalized.lower().startswith('fe80:'):
                            continue
                        if normalized.lower().startswith('fc') or normalized.lower().startswith('fd'):
                            continue
                        if normalized not in ipv6_addrs:
                            ipv6_addrs.append(normalized)
                    except Exception:
                        continue
            except Exception:
                pass
        
        return ipv6_addrs

    # 保留旧方法以兼容现有代码
    @staticmethod
    def _get_local_ipv4() -> Optional[str]:
        """获取本地局域网 IPv4 地址（兼容旧代码）"""
        addrs = ApiServer._get_lan_ipv4_addresses()
        return addrs[0] if addrs else None

    @staticmethod
    def _get_public_ipv6() -> Optional[str]:
        """获取公网 IPv6 地址（兼容旧代码）"""
        addrs = ApiServer._get_public_ipv6_addresses()
        return addrs[0] if addrs else None

    async def start(self, port: int = 8767, host: str = "127.0.0.1"):
        # 加载禁用技能列表
        self._load_disabled_skills()

        # [v1.16.19] 后台自动安装文档处理依赖（openpyxl/python-docx/python-pptx/xlrd）
        # 确保上传 Excel/Word/PPT 文件时能直接提取内容，无需 agent 手动安装
        try:
            import threading
            def _install_doc_deps():
                try:
                    from core.deps_checker import check_and_install_deps
                    result = check_and_install_deps(categories={"doc", "pdf"}, auto_fix=True, silent=True)
                    installed = result.get("installed", 0)
                    if installed > 0:
                        logger.info(f"自动安装了 {installed} 个文档处理依赖（openpyxl/python-docx/python-pptx/xlrd/PyPDF2）")
                except Exception as e:
                    logger.debug(f"文档依赖自动安装检查失败（非关键）: {e}")
            threading.Thread(target=_install_doc_deps, daemon=True).start()
        except Exception:
            pass

        # 后台预加载 STT 模型，避免首次语音识别时等待数秒
        # 仅加载 SenseVoice（中文识别最佳）
        try:
            import threading
            def _preload_stt():
                try:
                    import warnings
                    warnings.filterwarnings("ignore")
                    os.environ.setdefault("HF_HUB_DISABLE_TELEMETRY", "1")
                    os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
                    os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")

                    try:
                        from funasr import AutoModel
                        model_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'models', 'sensevoice')
                        self._sensevoice_model = AutoModel(model="iic/SenseVoiceSmall", model_dir=model_dir,
                                                             device="cpu", disable_pbar=True, disable_update=True)
                        logger.info("STT SenseVoice 模型预加载完成")
                    except ImportError:
                        logger.debug("SenseVoice (funasr) 未安装，跳过 STT 预加载")
                    except Exception as e:
                        logger.debug(f"SenseVoice 预加载失败（不影响使用）: {e}")
                except Exception as e:
                    logger.debug(f"STT 预加载异常（不影响使用）: {e}")
            threading.Thread(target=_preload_stt, daemon=True).start()
        except Exception:
            pass

        # [v1.23.47] 先启动 Web 服务器，再恢复中断任务（避免任务恢复阻塞 Web 启动）
        # [v1.18.8+] 配置 aiohttp access log：
        # - access_log=None 禁用 aiohttp 内置的访问日志（每个 HTTP 请求一行，过于嘈杂）
        # - aiohttp.access / aiohttp.server 已在 _suppress_verbose_loggers() 中
        #   被提升到 WARNING 级别，避免日志文件被大量 HTTP 请求日志淹没
        try:
            logging.getLogger("aiohttp.access").setLevel(logging.WARNING)
            logging.getLogger("aiohttp.server").setLevel(logging.WARNING)
        except Exception:
            pass
        self._runner = web.AppRunner(self.app, access_log=None)
        await self._runner.setup()
        
        # [v1.48.2] 根据 host 参数决定监听策略
        # - "127.0.0.1": 只监听本地回环（IPv4 和 IPv6）
        # - "0.0.0.0": 监听所有接口（IPv4 和 IPv6）
        # - "mixed": 监听本地回环 + 局域网 IPv4 + 公网 IPv6（不监听公网 IPv4）
        # - 其他: 使用指定的 host
        # 
        # 参考 fund 项目的实现，使用 SockSite (pre-bound sockets) 来更好地控制 socket 选项
        sites = []
        listen_addresses = []
        import socket
        
        if host == "0.0.0.0":
            # 监听所有接口：IPv4 和 IPv6
            # IPv4 所有接口
            sites.append(web.TCPSite(self._runner, "0.0.0.0", port))
            listen_addresses.append(f"0.0.0.0:{port}")
            # IPv6 所有接口
            sites.append(web.TCPSite(self._runner, "::", port))
            listen_addresses.append(f"[::]:{port}")
        elif host == "127.0.0.1":
            # 只监听本地回环
            sites.append(web.TCPSite(self._runner, "127.0.0.1", port))
            listen_addresses.append(f"127.0.0.1:{port}")
            # 同时监听 IPv6 本地回环
            sites.append(web.TCPSite(self._runner, "::1", port))
            listen_addresses.append(f"[::1]:{port}")
        elif host == "mixed":
            # [v1.48.2] 混合模式：本地回环 + 局域网 IPv4 + 公网 IPv6（不监听公网 IPv4）
            # 参考 fund 项目的实现方式
            
            # 1. 监听所有 IPv6 接口（使用 IPV6_V6ONLY 避免 IPv4 映射）
            try:
                sock_v6 = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
                sock_v6.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
                sock_v6.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
                sock_v6.bind(("::", port))
                sock_v6.listen(128)
                sock_v6.setblocking(False)
                site_v6 = web.SockSite(self._runner, sock_v6)
                sites.append(site_v6)
                listen_addresses.append(f"[::]:{port}")
            except Exception as e:
                logger.warning(f"无法创建 IPv6 监听: {e}")
            
            # 2. 监听 IPv4 本地回环
            try:
                sock_lo = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
                sock_lo.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
                sock_lo.bind(("127.0.0.1", port))
                sock_lo.listen(128)
                sock_lo.setblocking(False)
                site_lo = web.SockSite(self._runner, sock_lo)
                sites.append(site_lo)
                listen_addresses.append(f"127.0.0.1:{port}")
            except Exception as e:
                logger.warning(f"无法创建本地回环监听: {e}")
            
            # 3. 监听所有局域网 IPv4 地址
            lan_addrs = self._get_lan_ipv4_addresses()
            for addr in lan_addrs:
                try:
                    sock_lan = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
                    sock_lan.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
                    sock_lan.bind((addr, port))
                    sock_lan.listen(128)
                    sock_lan.setblocking(False)
                    site_lan = web.SockSite(self._runner, sock_lan)
                    sites.append(site_lan)
                    listen_addresses.append(f"{addr}:{port}")
                except Exception as e:
                    logger.warning(f"无法创建局域网 IPv4 监听 ({addr}): {e}")
            
            if not lan_addrs:
                logger.warning("未找到局域网 IPv4 地址")
        else:
            # 使用指定的 host
            sites.append(web.TCPSite(self._runner, host, port))
            listen_addresses.append(f"{host}:{port}")
        
        # 启动所有站点
        for site in sites:
            try:
                await site.start()
            except OSError as e:
                if e.errno == 98 or "address already in use" in str(e).lower():
                    logger.error(f"端口 {port} 已被占用！")
                    logger.error(f"  可能是上次的 MyAgent 还在运行，请先关闭后再启动")
                    logger.error(f"  或者使用其他端口: myagent-ai web --port 8768")
                    logger.error(f"  查看占用端口的进程: lsof -i :{port}  或  netstat -tlnp | grep {port}")
                    raise SystemExit(1)
                raise
        
        logger.info(f"管理后台已启动，监听地址: {', '.join(listen_addresses)}")

        # [v1.23.47] 恢复被中断的任务：后台异步执行，不阻塞 Web 服务器
        async def _background_task_recovery():
            try:
                tp = self._get_task_persistence()
                interrupted = tp.get_all_tasks(status_filter=("running",))
                if interrupted:
                    logger.warning(f"检测到 {len(interrupted)} 个上次未完成的任务，正在后台恢复...")
                    for task in interrupted:
                        try:
                            await self._retry_interrupted_task(tp, task)
                        except Exception as e:
                            logger.error(f"自动恢复任务失败 ({task.get('task_id', '')}): {e}")
                            tp.update_task_status(
                                task.get("task_id", ""), "failed",
                                metadata={"interrupted": True, "interrupt_reason": "自动恢复失败"},
                                last_message=task.get("description", ""),
                            )
                # 清理超过 7 天的旧已完成任务
                tp.cleanup_old_tasks(days=7)
            except Exception as e:
                logger.warning(f"任务持久化恢复检查失败（非关键）: {e}")

        asyncio.create_task(_background_task_recovery())

    async def stop(self):
        if self._runner: await self._runner.cleanup()

    # ── 任务持久化 API ──

    async def handle_list_tasks(self, request):
        """GET /api/tasks - 列出所有任务（支持 ?status=pending 过滤）"""
        tp = self._get_task_persistence()
        status = request.query.get("status", "").strip()
        if status:
            # 支持逗号分隔的多个状态
            status_filter = tuple(s.strip() for s in status.split(",") if s.strip())
            tasks = tp.get_all_tasks(status_filter=status_filter)
        else:
            tasks = tp.get_all_tasks()
        return web.json_response({"tasks": tasks, "total": len(tasks)})

    async def handle_retry_task(self, request):
        """POST /api/tasks/{task_id}/retry - 重试失败/待处理的任务"""
        task_id = request.match_info["task_id"]
        tp = self._get_task_persistence()
        task = tp.get_task(task_id)
        if not task:
            return web.json_response({"error": f"任务不存在: {task_id}"}, status=404)

        status = task.get("status", "")
        if status not in ("failed", "pending"):
            return web.json_response(
                {"error": f"只能重试 failed/pending 状态的任务，当前状态: {status}"},
                status=400,
            )

        group_id = task.get("group_id", "")
        description = task.get("description", "")

        if not group_id:
            return web.json_response({"error": "该任务没有关联群聊，无法重试"}, status=400)

        # 验证群聊仍然存在
        mgr = self._get_group_manager()
        group = mgr.get_group(group_id)
        if not group:
            return web.json_response({"error": f"群聊已不存在: {group_id}"}, status=404)

        # 将任务重新标记为 running 并发送消息到群聊
        tp.update_task_status(task_id, "running")

        try:
            from groups.manager import GroupMessage
            # 保存用户消息
            user_msg = GroupMessage(
                group_id=group_id,
                sender="user",
                sender_name="用户（重试）",
                sender_avatar="🔄",
                content=f"[重试任务 {task_id}]\n{description}",
            )
            mgr.add_message(user_msg)

            # 广播到所有非禁言成员
            active_members = [m for m in group.members if not m.muted]
            import asyncio, time as _time

            async def process_retry_member(member):
                try:
                    agent_path = member.agent_path
                    agent_cfg = self._read_agent_config(agent_path)
                    model_chain = self._build_model_chain(agent_cfg, agent_path)
                    session_id = f"group_{group_id}_{agent_path}"

                    # [v1.15.18] 构建 Agent 专属系统提示词 + 群聊上下文（与发送群消息一致）
                    _, agent_system_prompt = self._build_agent_chat_context(agent_path, agent_cfg, description)

                    # [修复] 使用统一方法构建群聊上下文（与 handle_send_group_message 一致）
                    recent_msgs = mgr.get_messages(group_id, limit=10)
                    history_lines = []
                    for rm in recent_msgs:
                        rm_name = rm.sender_name or ("用户" if rm.sender == "user" else rm.agent_path)
                        rm_time_str = _time.strftime("%H:%M", _time.localtime(rm.timestamp)) if rm.timestamp else ""
                        history_lines.append(f"[{rm_time_str}] {rm_name}: {rm.content[:200]}")
                    chat_history = "\n".join(reversed(history_lines))

                    group_context = self._build_group_context(
                        group, agent_path, agent_cfg, member,
                        chat_history=chat_history,
                        speaker="用户（重试任务）",
                    )

                    if agent_system_prompt:
                        agent_system_prompt += "\n\n" + group_context
                    else:
                        agent_system_prompt = group_context

                    dept_context = self._build_dept_context(group_id, agent_path)
                    if dept_context:
                        agent_system_prompt += "\n\n" + dept_context

                    agent_content = description

                    if model_chain and self.core.llm:
                        # [修复] 使用 _try_model_chain_inner 绕过全局锁，避免死锁
                        # 与 handle_send_group_message 保持一致（串行处理避免 LLM 全局状态互相覆盖）
                        response = await self._try_model_chain_inner(
                            model_chain, agent_content, session_id,
                            agent_path=agent_path, agent_system_prompt=agent_system_prompt,
                        )
                    else:
                        response = await self.core.process_message(agent_content, session_id)

                    avatar = "🤖"
                    display_name = agent_path
                    if agent_cfg:
                        avatar = agent_cfg.get("avatar_emoji", "🤖") or "🤖"
                        display_name = agent_cfg.get("name", agent_path)

                    agent_msg = GroupMessage(
                        group_id=group_id,
                        sender="agent",
                        sender_name=display_name,
                        sender_avatar=avatar,
                        content=response,
                        agent_path=agent_path,
                    )
                    mgr.add_message(agent_msg)
                    return {"ok": True, "agent_path": agent_path, "response": response}
                except Exception as e:
                    logger.error(f"重试任务处理失败 ({member.agent_path}): {e}")
                    return {"ok": False, "agent_path": member.agent_path, "response": str(e)}

            # [v1.33.5] 并发调用目标成员 agent（不再串行）
            retry_results = []
            if len(active_members) <= 1:
                for member in active_members:
                    try:
                        result = await process_retry_member(member)
                        retry_results.append(result)
                    except Exception as e:
                        logger.error(f"重试任务处理异常 ({member.agent_path}): {e}")
                        retry_results.append({"ok": False, "agent_path": member.agent_path, "response": str(e)})
            else:
                async def _safe_retry(m):
                    try:
                        return await process_retry_member(m)
                    except Exception as e:
                        logger.error(f"重试任务处理异常 ({m.agent_path}): {e}")
                        return {"ok": False, "agent_path": m.agent_path, "response": str(e)}
                retry_results = await asyncio.gather(*[_safe_retry(m) for m in active_members])

            final_results = retry_results

            has_failure = any(not r.get("ok") for r in final_results)
            tp.update_task_status(
                task_id,
                "failed" if has_failure else "completed",
                last_message=description[:500],
            )

            return web.json_response({
                "ok": True,
                "task_id": task_id,
                "group_id": group_id,
                "responses": final_results,
            })
        except Exception as e:
            tp.update_task_status(task_id, "failed", last_message=f"重试异常: {str(e)}")
            logger.error(f"重试任务异常 ({task_id}): {e}")
            return web.json_response({"error": f"重试失败: {str(e)}"}, status=500)

    async def handle_delete_task(self, request):
        """DELETE /api/tasks/{task_id} - 删除任务记录"""
        task_id = request.match_info["task_id"]
        tp = self._get_task_persistence()
        task = tp.get_task(task_id)
        if not task:
            return web.json_response({"error": f"任务不存在: {task_id}"}, status=404)

        tp.delete_task(task_id)
        logger.info(f"任务已删除: {task_id}")
        return web.json_response({"ok": True, "task_id": task_id})

    async def _retry_interrupted_task(self, tp, task: dict):
        """
        自动恢复被中断的群聊任务。
        重新投递消息到群聊，让 agent 重新处理。
        """
        task_id = task.get("task_id", "")
        group_id = task.get("group_id", "")
        description = task.get("description", "")
        metadata = task.get("metadata", {})

        if not group_id:
            logger.info(f"任务 {task_id} 无关联群聊，标记为 failed")
            tp.update_task_status(task_id, "failed", metadata={"interrupted": True})
            return

        # 验证群聊仍然存在
        mgr = self._get_group_manager()
        group = mgr.get_group(group_id)
        if not group:
            logger.warning(f"任务 {task_id} 的群聊 {group_id} 已不存在，标记为 failed")
            tp.update_task_status(task_id, "failed", metadata={"interrupted": True})
            return

        active_members = [m for m in group.members if not m.muted]
        if not active_members:
            logger.warning(f"任务 {task_id} 的群聊 {group_id} 无活跃成员，标记为 failed")
            tp.update_task_status(task_id, "failed", metadata={"interrupted": True})
            return

        logger.info(f"正在恢复任务 {task_id}: 群聊[{group.name}] 描述[{description[:50]}...]")

        # 保存恢复消息到群聊
        from groups.manager import GroupMessage
        import asyncio, time as _time

        recover_msg = GroupMessage(
            group_id=group_id,
            sender="user",
            sender_name="系统（任务恢复）",
            sender_avatar="🔄",
            content=f"[自动恢复中断任务]\n{description}",
        )
        mgr.add_message(recover_msg)

        # 并行投递到所有成员 agent
        async def process_member(member):
            try:
                agent_path = member.agent_path
                agent_cfg = self._read_agent_config(agent_path)
                model_chain = self._build_model_chain(agent_cfg, agent_path)
                session_id = f"group_{group_id}_{agent_path}"

                _, agent_system_prompt = self._build_agent_chat_context(agent_path, agent_cfg, description)

                # [修复] 使用统一方法构建群聊上下文（与 handle_send_group_message 一致）
                recent_msgs = mgr.get_messages(group_id, limit=10)
                history_lines = []
                for rm in recent_msgs:
                    rm_name = rm.sender_name or ("用户" if rm.sender == "user" else rm.agent_path)
                    rm_time_str = _time.strftime("%H:%M", _time.localtime(rm.timestamp)) if rm.timestamp else ""
                    history_lines.append(f"[{rm_time_str}] {rm_name}: {rm.content[:200]}")
                chat_history = "\n".join(reversed(history_lines))

                group_context = self._build_group_context(
                    group, agent_path, agent_cfg, member,
                    chat_history=chat_history,
                    speaker="系统（任务恢复）",
                )

                if agent_system_prompt:
                    agent_system_prompt += "\n\n" + group_context
                else:
                    agent_system_prompt = group_context

                dept_context = self._build_dept_context(group_id, agent_path)
                if dept_context:
                    agent_system_prompt += "\n\n" + dept_context

                if model_chain and self.core.llm:
                    # [修复] 使用 _try_model_chain_inner 绕过全局锁，避免死锁
                    response = await self._try_model_chain_inner(
                        model_chain, description, session_id,
                        agent_path=agent_path, agent_system_prompt=agent_system_prompt,
                    )
                else:
                    response = await self.core.process_message(description, session_id)

                avatar = "🤖"
                display_name = agent_path
                if agent_cfg:
                    avatar = agent_cfg.get("avatar_emoji", "🤖") or "🤖"
                    display_name = agent_cfg.get("name", agent_path)

                agent_msg = GroupMessage(
                    group_id=group_id,
                    sender="agent",
                    sender_name=display_name,
                    sender_avatar=avatar,
                    content=response,
                    agent_path=agent_path,
                )
                mgr.add_message(agent_msg)
                return {"ok": True, "agent_path": agent_path}
            except Exception as e:
                logger.error(f"恢复任务处理失败 ({member.agent_path}): {e}")
                return {"ok": False, "agent_path": member.agent_path, "error": str(e)}

        # [v1.33.5] 并发调用目标成员 agent（不再串行）
        retry_results = []
        if len(active_members) <= 1:
            for member in active_members:
                try:
                    result = await process_member(member)
                    retry_results.append(result)
                except Exception as e:
                    logger.error(f"恢复任务处理异常 ({member.agent_path}): {e}")
                    retry_results.append({"ok": False, "agent_path": member.agent_path, "error": str(e)})
        else:
            async def _safe_process_member(m):
                try:
                    return await process_member(m)
                except Exception as e:
                    logger.error(f"恢复任务处理异常 ({m.agent_path}): {e}")
                    return {"ok": False, "agent_path": m.agent_path, "error": str(e)}
            retry_results = await asyncio.gather(*[_safe_process_member(m) for m in active_members])

        final_results = retry_results

        has_failure = any(not r.get("ok") for r in final_results)
        tp.update_task_status(
            task_id,
            "failed" if has_failure else "completed",
            metadata={"interrupted": True, "recovered": True},
            last_message=description[:500],
        )
        logger.info(f"任务 {task_id} 恢复完成: {'成功' if not has_failure else '部分失败'}")

    # ── 部门上下文构建（部长角色注入） ──

    def _find_dept_by_group_id(self, tree, group_id):
        """递归查找拥有指定群聊ID的部门"""
        for dept in (tree or []):
            if dept.get("chat_group_id") == group_id:
                return dept
            children = dept.get("children", [])
            if children:
                found = self._find_dept_by_group_id(children, group_id)
                if found:
                    return found
        return None

    def _build_group_context(self, group, agent_path, agent_cfg, member,
                              mentioned_all=False, mentioned_agents=None,
                              chat_history="", speaker="用户（群聊中的真人用户）"):
        """构建群聊上下文提示（统一方法，供所有群聊场景复用）"""
        mentioned_agents = mentioned_agents or set()
        import re as _re

        # ── [v1.35.0] 注入未读私聊消息（实现真正的私聊投递） ──
        private_chat_inbox = ""
        try:
            from groups.manager import GroupManager as _GM
            mgr = self._get_group_manager()
            if mgr._db_conn:
                unread = mgr.get_agent_chats(
                    group_id=group.id,
                    to_agent=agent_path,
                    limit=50,
                )
                if unread:
                    private_chat_inbox = "\n\n### 📬 你收到的私信（其他Agent发给你的）\n"
                    private_chat_inbox += "以下是其他Agent私下发给你的消息，请在回复中适当参考或回应：\n\n"
                    for msg in unread:
                        ts_str = ""
                        if msg.get("timestamp"):
                            ts_str = time.strftime("%H:%M:%S", time.localtime(msg["timestamp"]))
                        private_chat_inbox += f"- [{ts_str}] **{msg.get('from_name', msg.get('from_agent', '?'))}** 说: {msg.get('content', '')}\n"
                    private_chat_inbox += "\n注意：以上私信内容仅供参考，请根据群聊上下文决定是否需要在回复中提及。"
        except Exception as _e:
            logger.debug(f"注入私聊收件箱失败: {_e}")

        # 成员列表
        member_lines = []
        for m in group.members:
            mc = self._read_agent_config(m.agent_path)
            m_name = mc.get("name", m.agent_path) if mc else m.agent_path
            m_desc = mc.get("description", "") if mc else ""
            role_label = {"owner": "群主", "admin": "管理员"}.get(m.role, "成员")
            nick = f"（昵称: {m.nickname}）" if m.nickname else ""
            line = f"  - {m_name} [{m.agent_path}] ({role_label})"
            if m_desc:
                line += f" — {m_desc}"
            line += nick
            if m.muted:
                line += "（已禁言）"
            member_lines.append(line)

        my_name = agent_cfg.get("name", agent_path) if agent_cfg else agent_path
        my_role = {"owner": "群主", "admin": "管理员"}.get(member.role, "成员")

        # @提及信息
        at_info = ""
        if mentioned_all:
            at_info = "- 本次消息使用了 @所有人，请务必回复。\n"
        elif mentioned_agents:
            is_mentioned = member.agent_path in mentioned_agents
            if is_mentioned:
                at_info = f"- 本次消息使用了 @{my_name}，请务必回复。\n"
            else:
                at_info = "- 本次消息使用了 @其他成员，与你无关，请不要回复。\n"
        else:
            at_info = "- 本次消息没有 @任何人（广播消息），你可以根据内容决定是否回复。\n"

        group_context = f"""## 群聊上下文

你正在参与一个群聊，以下是群的详细信息：

### 群信息
- 群名称: {group.name}
- 群描述: {group.description or '无'}
- 当前发言者: {speaker}

### 你的身份
- 名称: {my_name}
- 路径: {agent_path}
- 角色: {my_role}{"（已禁言，但仍可接收消息）" if member.muted else ""}

### 群成员列表（共{len(group.members)}人）
{chr(10).join(member_lines)}

### 沟通规则（重要）
1. **@提及机制**: 用户发送消息时可以使用 @名称 来指定回复者
   - @某个Agent: 只有被@的Agent需要回复
   - @所有人 / @all: 所有成员都需要回复
   - 不@任何人（广播）: 你自行判断是否需要回复
2. **跨Agent私下沟通**: 你可以使用 `myagent-ai chat --agent <数字ID> --message "消息"` 命令向群内其他Agent发送私下消息。对方会在自己下次处理群聊消息时收到。
   - **⚠️ 必须使用 Agent 的数字 ID，不能用名字！** 例如群成员列表中显示 `薇纸 [3] (成员)`，则 ID 是 `3`，不是 `薇纸`
   - 从上方「群成员列表」中找到目标Agent的 `[方括号]` 内容就是它的数字 ID
   - 正确示例: `myagent-ai chat --agent 3 --message "你好"`
   - 错误示例: `myagent-ai chat --agent 薇纸 --message "你好"`（名字可能重复或找不到）
   - 私下沟通的内容不会直接显示给用户，适合讨论细节、交换数据、协调方案
   - 当任务需要多个Agent协作时，应该先在群里讨论分工，然后私下沟通具体细节
3. **协作分工模式**（复杂任务）:
   - 部长/管理员应主动分析任务，在群里提出分工方案（谁负责什么）
   - 其他成员应积极响应，认领自己擅长的部分
   - 分工确定后，各自私下沟通需要协调的细节
   - 部长/管理员负责在群里汇总进展，向用户汇报阶段性成果和最终结果
   - 私下沟通的详细过程不需要在群里展示，只汇报关键进展和最终结论
{at_info}
### 近期群聊记录（最近10条）
{chat_history if chat_history else '（暂无历史消息）'}

### 重要提醒
- 你只代表你自己发言，使用第一人称
- 不要假装是其他Agent或代替其他Agent回答
- 如果问题超出你的能力范围，建议用户@相关专家Agent
- 如果需要其他Agent的信息，使用 `myagent-ai chat --agent <路径>` 命令私下沟通
  **注意：--agent 参数必须使用路径（ID），不是名字！从群成员列表的[方括号]中获取路径**

### 能力提醒（关键）
- 你拥有完整的工具调用能力（搜索、文件操作、代码执行、图片生成等）
- 在群聊中，你应该像在1对1对话中一样主动使用工具来完成用户的请求
- 不要因为身处群聊就只做简单文字回复，该用工具时一定要用"""

        # 拼接私聊收件箱（如果有未读私信）
        if private_chat_inbox:
            group_context += private_chat_inbox

        return group_context

    def _build_dept_context(self, group_id: str, agent_path: str) -> str:
        """为部门群聊构建上下文提示。仅当群属于某个部门时才返回非空字符串。"""
        try:
            dm = self._get_dept_manager()
            tree = dm.get_dept_tree()
            dept = self._find_dept_by_group_id(tree, group_id)
            if not dept:
                return ""

            dept_name = dept.get("name", "")
            dept_desc = dept.get("description", "")
            head = dept.get("head", "")
            dept_path = dept.get("path", "")
            members = dept.get("agents", [])
            other_members = [m for m in members if m != agent_path]

            # 加载部门介绍信息 (dept.md)
            try:
                dept_info = dm.get_dept_info(dept_path)
            except Exception:
                dept_info = ""

            if agent_path == head:
                # 部长模式：规划+组织，不亲自执行
                return f"""[系统提示：你是「{dept_name}」部门的部长（负责人）]

## 你的职责
作为部门部长，你的核心职责是**规划和组织**，而不是亲自执行具体任务。

## 工作方式
1. **分析任务**：仔细理解任务需求，评估复杂度和工作量
2. **制定计划**：将任务分解为清晰的步骤，分配给合适的团队成员
3. **组织协调**：指导团队成员（{', '.join(other_members) if other_members else '暂无其他成员'}）按计划执行
4. **审核把关**：检查团队成员的产出质量，提出改进意见
5. **总结汇报**：汇总团队成果，给出最终结论

## 重要规则
- 不要亲自编写代码、执行命令或直接完成任务
- 应该分析需求 → 制定方案 → 分配给团队成员 → 跟踪进度
- 可以给出技术方向和方案建议，但具体实现交给团队成员
- 如果任务简单且只有一个成员，可以给出明确指令让成员执行

## 部门信息
{dept_desc}
{dept_info if dept_info else ''}

请以部长身份开始工作。"""
            else:
                # 普通成员模式：接收指令，执行任务
                head_name = head
                return f"""[系统提示：你是「{dept_name}」部门的成员]

## 部门信息
- 部门名称：{dept_name}
- 部门描述：{dept_desc}
- 部门部长：{head_name}
- 团队成员：{', '.join(members)}

## 工作方式
- 认真执行部长分配的任务
- 主动报告工作进展和遇到的问题
- 与团队成员协作配合

{dept_info if dept_info else ''}"""

        except Exception as e:
            logger.debug(f"构建部门上下文失败 (group={group_id}, agent={agent_path}): {e}")
            return ""

    # ── 部门管理 ──

    async def handle_dept_tree(self, request):
        """GET /api/departments - 获取完整部门树"""
        dm = self._get_dept_manager()
        tree = dm.get_dept_tree()
        return web.json_response(tree)

    async def handle_create_dept(self, request):
        """POST /api/departments - 创建部门"""
        try:
            data = await request.json()
        except Exception:
            return web.json_response({"error": "invalid JSON"}, status=400)
        name = data.get("name", "").strip()
        emoji = data.get("emoji", "")
        description = data.get("description", "")
        parent = data.get("parent", "").strip()
        if not name:
            return web.json_response({"error": "name is required"}, status=400)
        dm = self._get_dept_manager()
        result = dm.create_dept(
            name=name,
            emoji=emoji,
            description=description,
            parent=parent,
        )
        if result.get("ok"):
            return web.json_response(result)
        return web.json_response(result, status=400)

    async def handle_get_dept(self, request):
        """GET /api/departments/{path} - 获取部门详情"""
        path = unquote(request.match_info["path"])
        dm = self._get_dept_manager()
        dept = dm.get_dept(path)
        if not dept:
            return web.json_response({"error": f"部门不存在: {path}"}, status=404)
        return web.json_response(dept)

    async def handle_update_dept(self, request):
        """PUT /api/departments/{path} - 更新部门元数据"""
        path = unquote(request.match_info["path"])
        try:
            data = await request.json()
        except Exception:
            return web.json_response({"error": "invalid JSON"}, status=400)
        dm = self._get_dept_manager()
        result = dm.update_dept(
            path=path,
            name=data.get("name", ""),
            emoji=data.get("emoji", None),
            description=data.get("description", None),
        )
        if result.get("ok"):
            return web.json_response(result)
        return web.json_response(result, status=400)

    async def handle_delete_dept(self, request):
        """DELETE /api/departments/{path} - 删除部门"""
        path = unquote(request.match_info["path"])
        dm = self._get_dept_manager()
        result = dm.delete_dept(path)
        if result.get("ok"):
            return web.json_response(result)
        return web.json_response(result, status=400)

    # ── 部门介绍 ──

    async def handle_get_dept_info(self, request):
        """GET /api/departments/{path}/info - 获取部门介绍"""
        path = unquote(request.match_info["path"])
        dm = self._get_dept_manager()
        content = dm.get_dept_info(path)
        if content is None:
            return web.json_response({"ok": False, "error": f"部门不存在或无介绍: {path}"}, status=404)
        return web.json_response({"ok": True, "path": path, "content": content})

    async def handle_update_dept_info(self, request):
        """PUT /api/departments/{path}/info - 更新部门介绍及元数据"""
        path = unquote(request.match_info["path"])
        try:
            data = await request.json()
        except Exception:
            return web.json_response({"error": "invalid JSON"}, status=400)
        dm = self._get_dept_manager()
        # 更新 dept.md 内容（如果提供了 content 字段）
        content = data.get("content")
        if content is not None:
            result = dm.update_dept_info(path, content)
            if not result.get("ok"):
                return web.json_response(result, status=400)
        # 更新元数据字段：description、head
        description = data.get("description")
        head_name = data.get("head_name")
        if description is not None or head_name is not None:
            meta_result = dm.update_dept_meta(path, description=description, head=head_name)
            if not meta_result.get("ok"):
                return web.json_response(meta_result, status=400)
        return web.json_response({"ok": True, "message": "已保存"})

    # ── 部门知识库 ──

    async def handle_list_dept_knowledge(self, request):
        """GET /api/departments/{path}/knowledge - 列出部门知识库文件"""
        path = unquote(request.match_info["path"])
        dm = self._get_dept_manager()
        files = dm.list_knowledge(path)
        return web.json_response(files)

    async def handle_upload_dept_knowledge(self, request):
        """POST /api/departments/{path}/knowledge/upload - 上传到部门知识库（支持文件和文件夹上传）"""
        path = unquote(request.match_info["path"])
        files = await _read_multipart_files(request)
        if not files:
            return web.json_response({"ok": False, "error": "没有上传文件"}, status=400)
        dm = self._get_dept_manager()
        results = []
        for f in files:
            name = f.get("name", "")
            content = f.get("content", "")
            result = dm.upload_knowledge(path, name, content)
            results.append(result)
        ok = all(r.get("ok") for r in results)
        return web.json_response({"ok": ok, "results": results})

    async def handle_read_dept_knowledge(self, request):
        """GET /api/departments/{path}/knowledge/file?path=xxx - 读取知识库文件内容"""
        path = unquote(request.match_info["path"])
        file_path = request.query.get("path", "").strip()
        if not file_path:
            return web.json_response({"ok": False, "error": "缺少 path 参数"}, status=400)
        if ".." in file_path or file_path.startswith("/"):
            return web.json_response({"ok": False, "error": "非法路径"}, status=400)
        dm = self._get_dept_manager()
        content = dm.read_knowledge(path, file_path)
        if content is None:
            return web.json_response({"ok": False, "error": f"文件不存在: {file_path}"}, status=404)
        return web.json_response({"ok": True, "path": file_path, "content": content})

    async def handle_delete_dept_knowledge(self, request):
        """DELETE /api/departments/{path}/knowledge?path=xxx - 删除部门知识库文件"""
        path = unquote(request.match_info["path"])
        file_path = request.query.get("path", "").strip()
        if not file_path:
            return web.json_response({"ok": False, "error": "缺少 path 参数"}, status=400)
        # [v1.32.2] 安全修复: 路径遍历防护
        if ".." in file_path or file_path.startswith("/"):
            return web.json_response({"ok": False, "error": "非法路径"}, status=400)
        dm = self._get_dept_manager()
        result = dm.delete_knowledge(path, file_path)
        return web.json_response(result)

    # ── 部门成员管理 ──

    async def handle_dept_agents(self, request):
        """PUT /api/departments/{path}/agents - 管理部门成员"""
        path = unquote(request.match_info["path"])
        try:
            data = await request.json()
        except Exception:
            return web.json_response({"error": "invalid JSON"}, status=400)
        agents = data.get("agents", [])
        action = data.get("action", "add")
        if not isinstance(agents, list):
            return web.json_response({"error": "agents must be a list"}, status=400)
        dm = self._get_dept_manager()
        result = dm.assign_agent(path, agents, action)
        if result.get("ok"):
            return web.json_response(result)
        return web.json_response(result, status=400)

    async def handle_dept_head(self, request):
        """PUT /api/departments/{path}/head - 设置部门负责人"""
        path = unquote(request.match_info["path"])
        try:
            data = await request.json()
        except Exception:
            return web.json_response({"error": "invalid JSON"}, status=400)
        head = data.get("head", "")
        dm = self._get_dept_manager()
        result = dm.set_head(path, head)
        if result.get("ok"):
            return web.json_response(result)
        return web.json_response(result, status=400)

    # --- TTS 语音合成 ---
    async def handle_tts(self, request):
        """POST /api/tts - 文本转语音 (返回 MP3)"""
        try:
            data = await request.json()
        except Exception:
            return web.json_response({"error": "invalid JSON"}, status=400)

        text = data.get("text", "").strip()
        if not text:
            return web.json_response({"error": "text is required"}, status=400)

        voice = data.get("voice", "zh-CN-XiaoxiaoNeural")
        speed = data.get("speed", "+25%")

        try:
            audio_bytes = await synthesize(text, voice=voice, speed=speed)
            return web.Response(
                body=audio_bytes,
                content_type="audio/mpeg",
                headers={
                    "Content-Length": str(len(audio_bytes)),
                    "Cache-Control": "public, max-age=86400",
                },
            )
        except ValueError as e:
            return web.json_response({"error": str(e)}, status=400)
        except RuntimeError as e:
            logger.error(f"TTS synthesis error: {e}")
            return web.json_response({"error": str(e)}, status=500)
        except Exception as e:
            logger.error(f"TTS unexpected error: {e}", exc_info=True)
            return web.json_response({"error": "语音合成失败"}, status=500)

    async def handle_tts_voices(self, request):
        """GET /api/tts/voices - 获取可用语音列表"""
        return web.json_response(AVAILABLE_VOICES)

    # ── 全局执行锁 ──

    async def handle_get_execution_lock(self, request):
        """GET /api/execution-lock - 获取全局执行锁状态"""
        lock = self._execution_lock
        msg = "无锁，所有 Agent 可自由运行"
        if lock["locked"]:
            locked_at_raw = lock.get("locked_at_ts") or lock.get("locked_at")
            # locked_at 可能是 ISO 字符串（旧数据）或 float 时间戳
            if isinstance(locked_at_raw, (int, float)):
                elapsed = time.time() - locked_at_raw
            elif isinstance(locked_at_raw, str):
                try:
                    from core.utils import get_config_tz
                    dt = datetime.datetime.fromisoformat(locked_at_raw)
                    if dt.tzinfo is None:
                        dt = dt.replace(tzinfo=get_config_tz())
                    elapsed = time.time() - dt.timestamp()
                except Exception:
                    elapsed = 0.0
            else:
                elapsed = 0.0
            msg = f"锁被 {lock['locked_by']} 持有（已持 {elapsed:.1f} 秒）"
        return web.json_response({
            "locked": lock["locked"],
            "locked_by": lock["locked_by"],
            "locked_at": lock["locked_at"],
            "message": msg,
            "waiters": len(self._execution_lock_waiters),
        })

    # ── [v1.35.0] 浏览器锁状态 API ──

    async def handle_get_browser_lock_status(self, request):
        """GET /api/browser-lock/status - 获取所有浏览器实例的锁状态"""
        try:
            from aiskills.browser_stealth import get_all_browser_lock_status
            browsers = get_all_browser_lock_status()
        except Exception as e:
            browsers = []
        return web.json_response({
            "browsers": browsers,
            "total": len(browsers),
        })

    async def handle_execution_lock(self, request):
        """POST /api/execution-lock - 获取(acquire)或释放(release)全局执行锁"""
        try:
            data = await request.json()
        except Exception:
            return web.json_response({"error": "invalid JSON"}, status=400)

        action = data.get("action", "")
        agent_path = data.get("agent_path", "").strip()

        if action == "acquire":
            if not agent_path:
                return web.json_response({"error": "agent_path is required"}, status=400)
            if self._execution_lock["locked"] and self._execution_lock["locked_by"] != agent_path:
                return web.json_response({
                    "error": f"全局执行锁被 {self._execution_lock['locked_by']} 持有，无法获取",
                    "locked": True,
                    "locked_by": self._execution_lock["locked_by"],
                    "locked_at": self._execution_lock["locked_at"],
                }, status=409)
            # 获取锁（含幂等：同一 agent 重复获取返回成功）
            self._execution_lock = {
                "locked": True,
                "locked_by": agent_path,
                "locked_at": _now_iso(),
                "locked_at_ts": time.time(),
            }
            logger.info(f"全局执行锁已获取: {agent_path}")
            return web.json_response({
                "locked": True, "locked_by": agent_path,
                "locked_at": self._execution_lock["locked_at"],
                "message": f"锁已授予 {agent_path}",
            })

        elif action == "release":
            if not agent_path:
                return web.json_response({"error": "agent_path is required"}, status=400)
            if self._execution_lock["locked"] and self._execution_lock["locked_by"] != agent_path:
                return web.json_response({
                    "error": f"锁由 {self._execution_lock['locked_by']} 持有，你无权释放",
                    "locked": True,
                    "locked_by": self._execution_lock["locked_by"],
                }, status=403)
            prev = self._execution_lock["locked_by"]
            self._execution_lock = {"locked": False, "locked_by": None, "locked_at": None, "locked_at_ts": None}
            logger.info(f"全局执行锁已释放: {prev} -> free")
            return web.json_response({
                "locked": False, "locked_by": None, "locked_at": None,
                "message": "锁已释放",
            })

        else:
            return web.json_response({"error": "action must be 'acquire' or 'release'"}, status=400)

    # ── 大文本检测与知识库导入 ──

    async def handle_check_text_size(self, request):
        """POST /api/chat/check-text-size - 检测文本大小是否超出 LLM 安全上下文"""
        try:
            data = await request.json()
        except Exception:
            return web.json_response({"error": "invalid JSON"}, status=400)

        text = data.get("text", "")
        char_count = len(text)
        # 粗略估算 token：中文约 1 字符 = 0.7 token，英文约 4 字符 = 1 token
        chinese_chars = sum(1 for c in text if '\u4e00' <= c <= '\u9fff')
        other_chars = char_count - chinese_chars
        estimated_tokens = int(chinese_chars * 0.7 + other_chars / 4)
        max_context_chars = 24000  # 粗略安全线（约 8k tokens 中文）
        is_large = char_count > 8000

        recommendations = []
        if is_large:
            recommendations = [
                {
                    "id": "knowledge_base",
                    "label": "📥 导入知识库",
                    "desc": "将文本导入当前Agent的知识库，后续通过RAG搜索进行智能检索处理",
                },
                {
                    "id": "split_process",
                    "label": "✂️ 分段处理",
                    "desc": "将文本分成多段，逐段发送给Agent处理，最后汇总结果",
                },
            ]

        return web.json_response({
            "char_count": char_count,
            "is_large": is_large,
            "estimated_tokens": estimated_tokens,
            "max_context_chars": max_context_chars,
            "recommendations": recommendations,
        })

    async def handle_save_to_knowledge(self, request):
        """POST /api/chat/save-to-knowledge - 将大文本保存到 Agent 知识库"""
        try:
            data = await request.json()
        except Exception:
            return web.json_response({"error": "invalid JSON"}, status=400)

        text = data.get("text", "")
        agent_path = data.get("agent_path", "1") or "1"
        filename = data.get("filename", "").strip()

        if not text:
            return web.json_response({"error": "text is required"}, status=400)

        if not filename:
            ts = int(time.time())
            filename = f"large_text_{ts}.txt"

        # 安全校验
        safe_name = Path(filename).name
        if ".." in filename or filename.startswith("/"):
            return web.json_response({"error": "非法文件名"}, status=400)

        kb_dir = self._get_agent_knowledge_dir(agent_path)
        kb_dir.mkdir(parents=True, exist_ok=True)
        target = kb_dir / safe_name

        # 避免覆盖已有文件
        if target.exists():
            stem = safe_name.rsplit('.', 1)[0] if '.' in safe_name else safe_name
            ext = '.' + safe_name.rsplit('.', 1)[1] if '.' in safe_name else '.txt'
            filename = f"{stem}_{int(time.time())}{ext}"
            target = kb_dir / filename

        try:
            target.write_text(text, encoding="utf-8")
        except Exception as e:
            return web.json_response({"error": f"写入失败: {e}"}, status=500)

        # 刷新 RAG 索引
        try:
            rag = self._get_agent_rag(agent_path)
            rag.build_index()
        except Exception as e:
            logger.warning(f"RAG 索引刷新失败: {e}")

        logger.info(f"大文本已保存到知识库: {agent_path}/{filename} ({len(text)} 字符)")
        return web.json_response({
            "ok": True,
            "filename": filename,
            "path": str(target),
            "char_count": len(text),
        })

    # ── 工作流 API ──

    def _get_workflow_engine(self):
        from core.workflow_engine import WorkflowEngine
        return WorkflowEngine()

    async def handle_list_workflows(self, request):
        """GET /api/workflows - List all workflows across all agents"""
        try:
            engine = self._get_workflow_engine()
            workflows = engine.list_workflows()
            return web.json_response({"workflows": workflows})
        except Exception as e:
            logger.error(f"list_workflows failed: {e}")
            return web.json_response({"error": str(e)}, status=500)

    async def handle_workflow_stats(self, request):
        """GET /api/workflows/stats - Get workflow statistics"""
        try:
            engine = self._get_workflow_engine()
            stats = engine.get_stats()
            return web.json_response(stats)
        except Exception as e:
            logger.error(f"workflow_stats failed: {e}")
            return web.json_response({"error": str(e)}, status=500)

    async def handle_list_agent_workflows(self, request):
        """GET /api/workflows/{agent} - List workflows for an agent"""
        agent_path = request.match_info["agent"]
        try:
            engine = self._get_workflow_engine()
            workflows = engine.list_workflows(agent_path)
            return web.json_response({"agent_path": agent_path, "workflows": workflows})
        except Exception as e:
            logger.error(f"list_agent_workflows failed: {e}")
            return web.json_response({"error": str(e)}, status=500)

    async def handle_create_workflow(self, request):
        """POST /api/workflows/{agent} - Create a new workflow"""
        agent_path = request.match_info["agent"]
        try:
            data = await request.json()
        except Exception:
            return web.json_response({"error": "Invalid JSON body"}, status=400)
        try:
            engine = self._get_workflow_engine()
            wf = engine.create_workflow(agent_path, data)
            return web.json_response({"ok": True, "workflow": wf})
        except Exception as e:
            logger.error(f"create_workflow failed: {e}")
            return web.json_response({"error": str(e)}, status=500)

    async def handle_get_workflow(self, request):
        """GET /api/workflows/{agent}/{wid} - Get a single workflow"""
        agent_path = request.match_info["agent"]
        wid = request.match_info["wid"]
        try:
            engine = self._get_workflow_engine()
            wf = engine.get_workflow(agent_path, wid)
            if not wf:
                return web.json_response({"error": f"Workflow '{wid}' not found"}, status=404)
            return web.json_response(wf)
        except Exception as e:
            logger.error(f"get_workflow failed: {e}")
            return web.json_response({"error": str(e)}, status=500)

    async def handle_update_workflow(self, request):
        """PUT /api/workflows/{agent}/{wid} - Update a workflow"""
        agent_path = request.match_info["agent"]
        wid = request.match_info["wid"]
        try:
            data = await request.json()
        except Exception:
            return web.json_response({"error": "Invalid JSON body"}, status=400)
        try:
            engine = self._get_workflow_engine()
            wf = engine.update_workflow(agent_path, wid, data)
            return web.json_response({"ok": True, "workflow": wf})
        except Exception as e:
            logger.error(f"update_workflow failed: {e}")
            return web.json_response({"error": str(e)}, status=500)

    async def handle_delete_workflow(self, request):
        """DELETE /api/workflows/{agent}/{wid} - Delete a workflow"""
        agent_path = request.match_info["agent"]
        wid = request.match_info["wid"]
        try:
            engine = self._get_workflow_engine()
            ok = engine.delete_workflow(agent_path, wid)
            if not ok:
                return web.json_response({"error": f"Workflow '{wid}' not found"}, status=404)
            return web.json_response({"ok": True})
        except Exception as e:
            logger.error(f"delete_workflow failed: {e}")
            return web.json_response({"error": str(e)}, status=500)

    async def handle_run_workflow(self, request):
        """POST /api/workflows/{agent}/{wid}/run - Execute a workflow"""
        agent_path = request.match_info["agent"]
        wid = request.match_info["wid"]
        try:
            data = await request.json()
        except Exception:
            data = {}
        variables = data.get("variables", {})
        try:
            engine = self._get_workflow_engine()
            result = await engine.run_workflow(agent_path, wid, variables)
            return web.json_response(result)
        except Exception as e:
            logger.error(f"run_workflow failed: {e}")
            return web.json_response({"error": str(e)}, status=500)

    async def handle_toggle_workflow(self, request):
        """POST /api/workflows/{agent}/{wid}/toggle - Enable/disable workflow"""
        agent_path = request.match_info["agent"]
        wid = request.match_info["wid"]
        try:
            engine = self._get_workflow_engine()
            wf = engine.toggle_workflow(agent_path, wid)
            return web.json_response({"ok": True, "workflow": wf})
        except Exception as e:
            logger.error(f"toggle_workflow failed: {e}")
            return web.json_response({"error": str(e)}, status=500)

    async def handle_duplicate_workflow(self, request):
        """POST /api/workflows/{agent}/{wid}/duplicate - Duplicate a workflow"""
        agent_path = request.match_info["agent"]
        wid = request.match_info["wid"]
        try:
            data = await request.json()
        except Exception:
            data = {}
        new_name = data.get("name", "")
        try:
            engine = self._get_workflow_engine()
            wf = engine.duplicate_workflow(agent_path, wid, new_name)
            return web.json_response({"ok": True, "workflow": wf})
        except Exception as e:
            logger.error(f"duplicate_workflow failed: {e}")
            return web.json_response({"error": str(e)}, status=500)

    async def handle_list_workflow_runs(self, request):
        """GET /api/workflows/{agent}/{wid}/runs - List execution runs"""
        agent_path = request.match_info["agent"]
        wid = request.match_info["wid"]
        try:
            engine = self._get_workflow_engine()
            runs = engine.list_runs(agent_path, wid)
            return web.json_response({"runs": runs})
        except Exception as e:
            logger.error(f"list_workflow_runs failed: {e}")
            return web.json_response({"error": str(e)}, status=500)

    async def handle_get_workflow_run(self, request):
        """GET /api/workflows/{agent}/runs/{rid} - Get a single run"""
        agent_path = request.match_info["agent"]
        rid = request.match_info["rid"]
        try:
            engine = self._get_workflow_engine()
            run = engine.get_run(agent_path, rid)
            if not run:
                return web.json_response({"error": f"Run '{rid}' not found"}, status=404)
            return web.json_response(run)
        except Exception as e:
            logger.error(f"get_workflow_run failed: {e}")
            return web.json_response({"error": str(e)}, status=500)
