"""
MindOS 3.0 SSE Gateway (mindos_sse.py)

继承 BaseSSEServer 基座，仅包含 MindOS NEXT 特有的：
  L4: _runChat — Agent 编排（积分 + wiki + 模型选择 + AIAgent）
  L5: 业务 API — 音频、连接器、积分、Admin、知识花园

共享逻辑（L1 认证 + L2 SSE + L3 翻译闭包）由 BaseSSEServer 提供。

工程约束（继承自 MindOS V2 的相对路径哲学）：
  /mindos-next/events       → SSE 长连接（不在 /api/ 下，符合 EventSource 惯例）
  /mindos-next/api/*        → 所有 HTTP API（与 Angular SPA 路由彻底隔离）
  /mindos-next/{其他}       → Angular SPA 路由（dev server 服务 index.html）
"""

# ── sys.path 修正（必须在所有 import 之前）──────────────────────────
# platforms/ 目录下有 signal.py / email.py 等与 stdlib 同名的本地模块，
# 会遮蔽 Python 标准库。将本目录从 sys.path[0] 移到末尾，确保 stdlib 优先。
import sys as _sys, os as _os
_this_dir = _os.path.dirname(_os.path.abspath(__file__))
if _this_dir in _sys.path:
    _sys.path.remove(_this_dir)
_sys.path.append(_this_dir)   # 降权到末尾：本地模块仍可 import，但不遮蔽 stdlib

# 将 hermes-overlay 加入 PYTHONPATH (BaseSSEServer 基座所在位置)
_overlay_dir = _os.path.join(_os.path.dirname(_os.path.dirname(_os.path.dirname(_os.path.dirname(__file__)))), "hermes-overlay")
if _os.path.isdir(_overlay_dir) and _overlay_dir not in _sys.path:
    _sys.path.insert(0, _overlay_dir)
# ──────────────────────────────────────────────────────────────────────

import asyncio
import json
import logging
import os
import time
import uuid
from typing import Any, Dict, Optional

try:
    from aiohttp import web
    AIOHTTP_AVAILABLE = True
except ImportError:
    AIOHTTP_AVAILABLE = False
    web = None  # type: ignore[assignment]

from platforms.sse_base.sse_server import BaseSSEServer, CORS_HEADERS
from platforms.sse_base import callback_atoms as atoms

logger = logging.getLogger(__name__)

# ── 兼容别名（dashscope_realtime.py 通过 from mindos_sse import 引用）──
_CORS_HEADERS = CORS_HEADERS


async def _verifyTokenAsync(token: str) -> dict | None:
    """验证 JWT token，返回 user dict 或 None。

    复用 MindPassAuth 策略（委托 MindPass 服务验证），
    与 BaseSSEServer._extractUser 完全一致。
    供 dashscope_realtime.py WS handler 复用。
    """
    from platforms.sse_base.auth_strategies import MindPassAuth
    strategy = MindPassAuth()
    user = await strategy.verify(token)
    if not user:
        return None
    # 兼容 dashscope_realtime.py 期望的 sub 字段
    return {"sub": user.get("userId", ""), **user}

# ── 延迟加载工具（避免 import 循环）──
try:
    import tools.feishu_tool  # noqa: F401 — 触发 registry.register() 副作用
except Exception as _fe:
    logger.warning("[MindOSSSE] feishu_tool 加载失败（非致命）: %s", _fe)

try:
    import tools.tencent_meeting_tool  # noqa: F401
except Exception as _tm:
    logger.warning("[MindOSSSE] tencent_meeting_tool 加载失败（非致命）: %s", _tm)

try:
    import tools.ima_tool  # noqa: F401
except Exception as _ima:
    logger.warning("[MindOSSSE] ima_tool 加载失败（非致命）: %s", _ima)


# ══════════════════════════════════════════════════
# Wiki 辅助函数（L4 _runChat 和 L5 _handleHistory 共用）
# ══════════════════════════════════════════════════

def normalize_wiki_path(raw_path: str, user_id: str) -> str:
    """
    将 Hermes write_file 的任意路径标准化为
    相对于 {WIKI_DIR}/{userId}/ 的路径。

    输入可能是：
      "wiki/{uid}/reports/xxx.md"   — Hermes 相对路径（含 wiki/{uid} 前缀）
      "/opt/.../wiki/{uid}/raw/xxx.md" — 绝对路径
      "reports/xxx.md"              — 已经标准化
      "raw/xxx.md"                  — MdConverter 输出

    输出统一为：
      "reports/xxx.md"
      "raw/xxx.md"

    ★ 这是全文件唯一允许的路径标准化点，不在其他地方重写 realpath/relpath 逻辑。
    """
    wiki_dir = os.getenv("MINDOS_WIKI_DIR", os.path.expanduser("~/.hermes/wiki"))
    user_wiki = os.path.join(wiki_dir, user_id)

    # 1. 去掉 wiki/{uid}/ 前缀（Hermes 最常见格式）
    prefix = f"wiki/{user_id}/"
    if raw_path.startswith(prefix):
        return raw_path[len(prefix):]

    # 2. 绝对路径 → os.path.relpath
    if os.path.isabs(raw_path):
        try:
            return os.path.relpath(raw_path, user_wiki)
        except ValueError:
            return os.path.basename(raw_path)

    # 3. 已经是 "reports/xxx.md" 或 "raw/xxx.md" → 直接返回
    return raw_path


def _read_wiki_file(rel_path: str, user_id: str) -> str:
    """读取 wiki 文件内容；失败返回空字符串（non-fatal）。

    当直接路径读不到时，按知识花园三层架构探查子目录：
      reports/ → wiki/ → raw/
    解决 Agent write_file 路径与磁盘实际位置错位的问题。
    """
    wiki_dir = os.getenv("MINDOS_WIKI_DIR", os.path.expanduser("~/.hermes/wiki"))
    user_wiki = os.path.join(wiki_dir, user_id)
    full_path = os.path.join(user_wiki, rel_path)

    # 1. 直接路径
    try:
        with open(full_path, encoding="utf-8") as f:
            return f.read()
    except Exception:
        pass

    # 2. 容错探查：文件名可能落在子目录中（Agent 路径漂移）
    basename = os.path.basename(rel_path)
    for subdir in ("reports", "wiki", "raw", "raw/transcripts", "raw/meetings"):
        fallback = os.path.join(user_wiki, subdir, basename)
        if fallback != full_path and os.path.isfile(fallback):
            try:
                with open(fallback, encoding="utf-8") as f:
                    logger.info(
                        "[MindOSSSE] _read_wiki_file fallback: %s → %s/%s",
                        rel_path, subdir, basename,
                    )
                    return f.read()
            except Exception:
                pass

    return ""


# ══════════════════════════════════════════════════
# MindOS NEXT Gateway — 继承 BaseSSEServer
# ══════════════════════════════════════════════════

class MindOSSSEServer(BaseSSEServer):
    """
    MindOS 3.0 前端的 SSE 服务器。

    继承 BaseSSEServer 共享基座，实现：
    - L4: _runChat（积分预检 + 模型选择 + wiki 注入 + AIAgent + 积分扣减）
    - L5: 业务 API（音频、连接器、积分、Admin、知识花园、User Profile）
    """

    # 单例引用（供 dashscope_realtime.py 访问 _pushEvent）
    instance: "MindOSSSEServer | None" = None

    # MindOS NEXT 特有的工具标签
    _EXTRA_TOOL_LABELS = {
        "feishu_list_profiles":          "📋 正在查询飞书账号",
        "feishu_init_profile":           "🔗 正在创建飞书 Profile",
        "feishu_auth_domain":            "🔐 正在获取飞书授权链接",
        "feishu_query":                  "📊 正在查询飞书数据",
        "tencent_meeting_list_profiles": "📋 正在查询腾讯会议账号",
        "tencent_meeting_query":         "📹 正在查询腾讯会议数据",
        "ima_list_profiles":             "📋 正在查询 IMA 账号",
        "ima_query":                     "📝 正在操作 IMA 笔记/知识库",
    }

    def __init__(self, host: str = "127.0.0.1", port: int = 8650):
        super().__init__(prefix="/mindos-next", host=host, port=port)
        MindOSSSEServer.instance = self

    # ── L3 覆写：追加 file:created 检测器 ──

    def get_callback_atoms(self) -> list:
        return [
            atoms.make_stream_delta,
            atoms.make_tool_thinking,
            atoms.make_file_created_detector,  # ★ MindOS 知识花园特有
        ]

    def get_tool_labels(self) -> dict:
        return self._EXTRA_TOOL_LABELS

    def _get_platform_source(self) -> str:
        return "mindos"

    def _get_storage_root(self) -> str:
        """覆写基座方法：三元域 storage 根目录。

        未设置 MINDOS_STORAGE_ROOT 时返回空字符串，
        基座的三元域装配自动跳过（向后兼容）。
        """
        return os.getenv("MINDOS_STORAGE_ROOT", "")

    def _extract_chat_params(self, body: dict, user: dict) -> dict:
        return {
            "model_id": body.get("modelId", "").strip(),
            "phone": user.get("phone", ""),
            "displayName": user.get("name", ""),
        }

    # ══════════════════════════════════════════════
    # L4: _runChat
    # ══════════════════════════════════════════════

    async def _runChat(self, userId: str, chatId: str, text: str, model_id: str = "", phone: str = "", displayName: str = "", **kwargs) -> None:
        """
        在后台执行 Hermes AIAgent 对话。
        全部结果通过 SSE 推送。

        model_id：前端传入的模型别名（对齐 LiteLLM model_group_alias），
        为空时回退到环境变量 MINDOS_MODEL。

        知识花园路径通过环境变量 MINDOS_WIKI_DIR 配置，
        默认使用当前 Hermes 的 wiki 目录。
        """
        try:
            # ── SSE 连接等待 ──
            await self._wait_for_sse(userId, timeout_seconds=30.0)

            from run_agent import AIAgent
            from hermes_state import SessionDB

            db = SessionDB()
            loop = asyncio.get_event_loop()

            # P1-6：积分预检（余额不足则拒绝）
            credit_check = db.check_credits(userId, phone=phone, display_name=displayName)
            if not credit_check["allowed"]:
                self._pushEvent(userId, "agent:error", {
                    "chatId": chatId,
                    "message": "今日免费额度已用完，请充值后继续使用。",
                    "errorType": "quota_exceeded",
                    "credits": credit_check,
                })
                return

            # P1-6 fix: 拍快照——session 的 tokens 是累积的，
            # Agent 结束后用 (当前 - 快照) 计算本轮增量
            _snap = db.get_session(chatId) or {}
            _snap_input = _snap.get("input_tokens") or 0
            _snap_output = _snap.get("output_tokens") or 0

            # P0修复D0：模型选择——回退链：model_id（前端选择）→ MINDOS_MODEL（环境变量）→ 默认
            effective_model = model_id or os.getenv("MINDOS_MODEL", "qwen-plus")

            # ── Fix D1：抢在 AIAgent 之前写入 user_id ──
            try:
                db.create_session(
                    session_id=chatId,
                    source="mindos",
                    user_id=userId,
                    model=effective_model,
                )
            except Exception as e:
                logger.warning("[MindOSSSE] pre-create_session failed (non-fatal): %s", e)

            # 知识花园路径（用户隔离：wiki/{userId}/）
            _wiki_root = os.getenv(
                "MINDOS_WIKI_DIR",
                os.path.expanduser("~/.hermes/wiki"),
            )
            wikiDir = os.path.join(_wiki_root, userId)

            # ── 使用基座的 _makeCallbacks 组合闭包 ──
            stream_cb, tool_cb, reasoning_cb = self._makeCallbacks(
                userId, chatId, loop,
                wiki_reader=_read_wiki_file,
                path_normalizer=normalize_wiki_path,
            )

            agent = AIAgent(
                model=effective_model,
                base_url=os.getenv("OPENAI_API_BASE", ""),
                api_key=os.getenv("OPENAI_API_KEY", ""),
                enabled_toolsets=["file", "web", "skills", "connectors"],
                stream_delta_callback=stream_cb,
                tool_progress_callback=tool_cb,
                reasoning_callback=reasoning_cb,
                quiet_mode=True,
                platform="mindos",
                session_id=chatId,
                session_db=db,
                user_id=userId,
                request_overrides={
                    "user": userId,
                    "extra_body": {
                        "thinking": {"type": "disabled"},
                    },
                },
            )

            # 推送"正在思考"事件给前端
            self._pushEvent(userId, "agent:thinking", {
                "chatId": chatId,
                "step": "reasoning",
                "message": "🧠 正在思考中…",
            })

            # 加载上下文履历
            history = db.get_messages_as_conversation(chatId)

            # 系统提示词：告知知识花园位置 + 用户身份
            systemPrompt = f"""你是 MindOS NEXT 智能助理。
当用户询问你的身份时，你必须回答"我是 MindOS NEXT"。

## 用户身份
MINDOS_USER_ID: {userId}
（调用任何连接器工具时，必须将此 ID 作为 user_id 参数传入）

## 可用连接器工具
- **飞书连接器**：查询飞书日历、会议、文档等。工具：`feishu_list_profiles`、`feishu_query`
- **腾讯会议连接器**：查询腾讯会议列表、AI 纪要、录制，或预约会议。工具：`tencent_meeting_list_profiles`、`tencent_meeting_query`
  - 调用前必须先调 `tencent_meeting_list_profiles` 确认已绑定 Token
  - 涉及时间的操作必须先用 `tencent_meeting_query(tool_name=convert_timestamp)` 获取当前时间
  - 若未绑定，告知用户在左侧边栏点击腾讯会议图标完成绑定
- **IMA 连接器**（QQ 笔记/知识库）：搜索、读取、新建笔记，操作知识库。工具：`ima_list_profiles`、`ima_query`
  - 调用前必须先调 `ima_list_profiles` 确认已绑定
  - 若未绑定，告知用户在左侧边栏点击 IMA 图标，填写 Client ID 和 API Key 完成绑定

## 知识花园（三层架构）
你的个人知识库位于：{wikiDir}

目录结构：
- `{wikiDir}/raw/`      — LV0 原始素材（会议转录、文章、上传文件）。只读参考，不要修改。
  - `raw/transcripts/`  — 会议转录原文
  - `raw/meetings/`     — 会议纪要
- `{wikiDir}/wiki/`     — LV1 知识提炼（实体、概念、对比分析等知识卡片）
  - `wiki/index.md`     — 全局索引（先读这个了解全貌）
  - `wiki/SCHEMA.md`    — 知识模型定义
  - `wiki/entities/`    — 人物/产品/机构实体卡片
  - `wiki/concepts/`    — 概念和方法论卡片
  - `wiki/cards/`       — 独立知识卡片
  - `wiki/comparisons/` — 对比分析
- `{wikiDir}/reports/`  — LV2 报告输出（你生成的分析报告写到这里）

使用方法：
1. 先读取 {wikiDir}/wiki/index.md 了解知识库全貌
2. 使用 search_files 在 {wikiDir}/ 下搜索相关内容
3. 基于知识库中的 [[wikilinks]] 追溯关联实体

## 文件写入规范（强制）
- 所有 write_file 调用必须使用**绝对路径**
- 生成报告 → 写入 `{wikiDir}/reports/报告名.md`
- 新增/更新知识卡片 → 写入 `{wikiDir}/wiki/` 对应子目录
- 严禁使用相对路径（如 "report.md"），这会导致文件丢失
- 严禁直接写入 `{wikiDir}/` 根目录

## 输出规范
- 引用知识库内容时，标注来源文件名
- 使用 Markdown 格式输出
- 中文回答
"""

            # 确保用户 wiki 目录存在（程序性保证，不依赖 LLM）
            os.makedirs(wikiDir, exist_ok=True)

            from tools.terminal_tool import register_task_env_overrides, clear_task_env_overrides
            register_task_env_overrides(chatId, {"cwd": wikiDir})

            try:
                result = await self._run_agent_task(
                    lambda: agent.run_conversation(
                        user_message=text,
                        system_message=systemPrompt,
                        conversation_history=history,
                    ),
                    userId, chatId,
                )
            finally:
                clear_task_env_overrides(chatId)

            finalResponse = result.get("final_response", "")
            if not finalResponse:
                finalResponse = result.get("error", "(未生成回答)")

            # P1-6：LLM 对话完成后扣减积分（增量计费）
            _MODEL_RATES = {
                "deepseek":           (1, 2),
                "qwen-plus":          (4, 12),
                "glm":                (5, 16),
                "gemini-pro-vertex":  (9, 36),
            }
            try:
                session_info = db.get_session(chatId)
                if session_info:
                    delta_input = max(0, (session_info.get("input_tokens") or 0) - _snap_input)
                    delta_output = max(0, (session_info.get("output_tokens") or 0) - _snap_output)
                    in_rate, out_rate = _MODEL_RATES.get(effective_model, (4, 12))
                    credits_used = max(1, (delta_input * in_rate + delta_output * out_rate) // 1000)
                    db.deduct_credits(
                        user_id=userId,
                        credits=credits_used,
                        tx_type="llm_chat",
                        session_id=chatId,
                        model=effective_model,
                        raw_metric=json.dumps({
                            "delta_input": delta_input,
                            "delta_output": delta_output,
                            "in_rate": in_rate,
                            "out_rate": out_rate,
                        }),
                    )
            except Exception as _ce:
                logger.warning("[MindOSSSE] credit deduction failed (non-fatal): %s", _ce)

            self._pushEvent(userId, "agent:done", {
                "chatId": chatId,
                "fullAnswer": finalResponse,
            })

        except Exception as e:
            logger.error("[MindOSSSE] Chat error for %s/%s: %s", userId, chatId, e, exc_info=True)
            self._pushEvent(userId, "agent:error", {
                "chatId": chatId,
                "message": f"处理失败：{str(e)}",
            })

    # ══════════════════════════════════════════════
    # L3.5: 水合原子覆写（MindOS 特有的产出物重建）
    # ══════════════════════════════════════════════

    def get_hydrate_atoms(self) -> list:
        """MindOS 水合原子：从 tool_calls 中 write_file 重建 files 列表。

        对齐 callback_atoms 的闭包组合范式：基座 _handleHistory → _hydrateMessages
        会对每条消息依次调用这些原子。

        兼容旧数据：V9 前的消息不含 attachments 列，通过 make_hydrate_files_from_tool_calls
        从 tool_calls JSON 中重建产出物。当 attachments 正式启用后，此原子作为 fallback。
        """
        from platforms.sse_base import hydrate_atoms as ha

        # 水合上下文中无法直接获取 userId（它在 request 中），
        # 因此 MindOS 需要覆写 _hydrateMessages 注入 userId。
        # 见下方 _hydrateMessages 覆写。
        return []

    def get_hydrate_post_processors(self) -> list:
        """MindOS 后处理：跨消息文件聚合 + 消息过滤。"""
        from platforms.sse_base import hydrate_atoms as ha

        return [ha.aggregate_files_across_messages]

    def _hydrateMessages(self, messages: list, **context) -> list:
        """MindOS 覆写：注入 userId 上下文后执行水合。

        基座 _handleHistory 传入 userId=... 和 chatId=...，
        此处利用 userId 构造 per-user 的 file reader/normalizer 闭包。
        """
        from platforms.sse_base import hydrate_atoms as ha

        userId = context.get("userId", "")

        # 构造绑定 userId 的兼容原子
        fileHydrator = ha.make_hydrate_files_from_tool_calls(
            userId=userId,
            wiki_reader=_read_wiki_file,
            path_normalizer=normalize_wiki_path,
        )

        # 单条消息原子管线
        for i, msg in enumerate(messages):
            msg = fileHydrator(msg)
            messages[i] = msg

        # 跨消息后处理（文件聚合 + 消息过滤）
        for processor in self.get_hydrate_post_processors():
            messages = processor(messages)

        return messages

    # ══════════════════════════════════════════════
    # L5: 覆写 _handleSessionsList（MindOS 特有的 get_sessions_for_user）
    # ══════════════════════════════════════════════

    async def _handleSessionsList(self, request: "web.Request") -> "web.Response":
        """GET /mindos-next/api/sessions/list — 返回当前用户的所有会话"""
        user = await self._extractUser(request)
        if not user:
            return web.json_response({"error": "Unauthorized"}, status=401, headers=CORS_HEADERS)

        userId = user["userId"]
        try:
            from hermes_state import SessionDB
            import datetime
            db = SessionDB()
            rows = db.get_sessions_for_user(userId)
            sessions = []
            for r in rows:
                ts = r.get("last_active") or r.get("started_at") or 0
                updated_at = datetime.datetime.fromtimestamp(
                    ts, tz=datetime.timezone.utc
                ).isoformat()
                title = r.get("title") or r.get("preview") or "对话"
                sessions.append({
                    "chatId":           r["id"],
                    "title":            title[:24],
                    "updatedAt":        updated_at,
                    "preview":          r.get("preview", ""),
                    "workProductCount": r.get("work_product_count", 0),
                    "messageCount":     r.get("message_count", 0),
                })
            return web.json_response({"sessions": sessions}, headers=CORS_HEADERS)
        except Exception as e:
            logger.error("Failed to list sessions for userId=%s: %s", userId, e)
            return web.json_response({"sessions": [], "error": str(e)}, headers=CORS_HEADERS)

    # ══════════════════════════════════════════════
    # L5: 音频管线
    # ══════════════════════════════════════════════

    async def _handleAudioPresign(self, request: "web.Request") -> "web.Response":
        """GET /mindos-next/api/audio/presign?ext=.m4a"""
        user = await self._extractUser(request)
        if not user:
            return web.json_response({"error": "Unauthorized"}, status=401, headers=CORS_HEADERS)

        ext = request.rel_url.query.get("ext", ".m4a")
        if not ext.startswith("."):
            ext = "." + ext

        try:
            from flash_asr import generate_oss_presign  # type: ignore
            result = generate_oss_presign(user["userId"], ext)
            return web.json_response(result, headers=CORS_HEADERS)
        except Exception as e:
            logger.error("[Presign] error: %s", e)
            return web.json_response({"error": str(e)}, status=500, headers=CORS_HEADERS)

    async def _handleAudioTranscribe(self, request: "web.Request") -> "web.Response":
        """POST /mindos-next/api/audio/transcribe"""
        user = await self._extractUser(request)
        if not user:
            return web.json_response({"error": "Unauthorized"}, status=401, headers=CORS_HEADERS)

        try:
            body = await request.json()
        except Exception:
            return web.json_response({"error": "Invalid JSON"}, status=400, headers=CORS_HEADERS)

        oss_key  = body.get("ossKey", "")
        read_url = body.get("readUrl", "")
        title    = body.get("title", "未命名") or "未命名"
        chat_id  = body.get("chatId", "")

        if not oss_key or not read_url:
            return web.json_response({"error": "ossKey 和 readUrl 必填"}, status=400, headers=CORS_HEADERS)

        asyncio.create_task(
            self._runOssTranscribePipeline(
                userId=user["userId"], chatId=chat_id,
                readUrl=read_url, ossKey=oss_key, title=title,
            )
        )
        return web.json_response(
            {"received": True, "message": "转写进行中，请通过 SSE 跟踪进度"},
            status=202, headers=CORS_HEADERS,
        )

    async def _runOssTranscribePipeline(self, userId, chatId, readUrl, ossKey, title):
        """后台任务：离线音频 → Markdown（委托给 voice_import 管线）。"""
        try:
            from voice_import import run as voice_import_run  # type: ignore
            await voice_import_run(
                sse_server=self,
                user_id=userId, chat_id=chatId,
                read_url=readUrl, oss_key=ossKey, title=title,
            )
        except Exception as e:
            logger.error("[OSSTranscribe] pipeline error: %s", e, exc_info=True)
            self._pushEvent(userId, "md:error", {"chatId": chatId, "message": f"转写失败：{e}"})

    async def _handleAudioUpload(self, request: "web.Request") -> "web.Response":
        """POST /mindos-next/api/audio/upload (封存保留)"""
        user = await self._extractUser(request)
        if not user:
            return web.json_response({"error": "Unauthorized"}, status=401, headers=CORS_HEADERS)

        userId = user["userId"]

        try:
            reader = await request.multipart()
        except Exception:
            return web.json_response({"error": "Invalid multipart"}, status=400, headers=CORS_HEADERS)

        import tempfile, pathlib
        tmp_path: Optional[str] = None
        title = "未命名"
        chat_id = ""
        original_filename = "upload"

        try:
            async for field in reader:
                if field.name == "title":
                    title = (await field.read(decode=True)).decode("utf-8", errors="replace").strip() or "未命名"
                elif field.name == "chatId":
                    chat_id = (await field.read(decode=True)).decode("utf-8", errors="replace").strip()
                elif field.name == "audio":
                    original_filename = getattr(field, "filename", "audio.m4a") or "audio.m4a"
                    suffix = pathlib.Path(original_filename).suffix or ".m4a"
                    with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
                        tmp_path = tmp.name
                        while True:
                            chunk = await field.read_chunk(8192)
                            if not chunk:
                                break
                            tmp.write(chunk)
        except Exception as e:
            logger.error("[AudioUpload] multipart read error: %s", e)
            return web.json_response({"error": f"读取上传文件失败: {e}"}, status=400, headers=CORS_HEADERS)

        if not tmp_path:
            return web.json_response({"error": "未找到 audio 字段"}, status=400, headers=CORS_HEADERS)

        asyncio.create_task(
            self._runAudioPipeline(userId, chat_id, tmp_path, original_filename, title)
        )

        return web.json_response(
            {"received": True, "message": "转写进行中，请通过 SSE 跟踪进度"},
            status=202, headers=CORS_HEADERS,
        )

    # ── 上传防重辅助 ──

    def _raw_index_path(self, wiki_dir: str) -> str:
        return os.path.join(wiki_dir, ".raw_index.json")

    def _lookup_raw_index(self, wiki_dir: str, sha256: str) -> Optional[str]:
        idx_path = self._raw_index_path(wiki_dir)
        if not os.path.exists(idx_path):
            return None
        try:
            with open(idx_path, encoding="utf-8") as f:
                idx = json.load(f)
            return idx.get(sha256)
        except Exception:
            return None

    def _update_raw_index(self, wiki_dir: str, sha256: str, rel_path: str) -> None:
        idx_path = self._raw_index_path(wiki_dir)
        try:
            with open(idx_path, encoding="utf-8") as f:
                idx = json.load(f)
        except Exception:
            idx = {}
        idx[sha256] = rel_path
        with open(idx_path, "w", encoding="utf-8") as f:
            json.dump(idx, f, ensure_ascii=False, indent=2)

    async def _runAudioPipeline(self, userId, chatId, tmp_path, original_filename, title):
        """后台任务：SHA-256 防重 → ASR → md_converter → SSE 进度推送。"""
        import hashlib as _hs
        try:
            from flash_asr import transcribe_file  # type: ignore
            from md_converter import MdConverter  # type: ignore

            _wiki_root = os.getenv("MINDOS_WIKI_DIR", os.path.expanduser("~/.hermes/wiki"))
            wiki_dir   = os.path.join(_wiki_root, userId)
            converter  = MdConverter(wiki_dir)

            with open(tmp_path, "rb") as fh:
                sha256 = _hs.sha256(fh.read()).hexdigest()

            existing_rel = self._lookup_raw_index(wiki_dir, sha256)
            if existing_rel:
                logger.info("[AudioUpload] 防重命中: %s → %s", original_filename, existing_rel)
                existing_content = _read_wiki_file(existing_rel, userId)
                self._pushEvent(userId, "md:appended", {
                    "chatId": chatId, "file": existing_rel, "chars": len(existing_content),
                    "provider": "dedup",
                    "message": f"✅ 文件已入库（自动去重）：{os.path.basename(existing_rel)}",
                })
                return

            self._pushEvent(userId, "md:progress", {
                "chatId": chatId, "stage": "asr_start", "filename": original_filename,
            })

            result   = await transcribe_file(tmp_path, api_key=os.getenv("DASHSCOPE_API_KEY", ""))
            text     = result["text"]
            provider = result["provider"]

            if not text:
                self._pushEvent(userId, "md:error", {
                    "chatId": chatId, "message": "转写结果为空，请检查音频文件",
                })
                return

            md_file     = converter.new_file(title=title, source_filename=original_filename)
            total_chars = converter.append_segment(md_file, text, offset_ms=0)
            converter.finalize(md_file, char_count=total_chars)
            rel_path    = converter.relative_path(md_file)

            self._update_raw_index(wiki_dir, sha256, rel_path)

            logger.info("[AudioUpload] ✅ %s → %s (%d 字, provider=%s)",
                        original_filename, rel_path, total_chars, provider)

            self._pushEvent(userId, "md:appended", {
                "chatId": chatId, "file": rel_path, "chars": total_chars,
                "provider": provider,
                "message": f"✅ 会议记录已入库：{md_file.name}（{total_chars} 字）",
            })

        except Exception as e:
            logger.error("[AudioUpload] pipeline error: %s", e, exc_info=True)
            self._pushEvent(userId, "md:error", {"chatId": chatId, "message": f"转写失败：{e}"})
        finally:
            try:
                os.unlink(tmp_path)
            except Exception:
                pass
    # ══════════════════════════════════════════════
    # L5: 通用文件管线（AnyFile2MD Phase C — 文档/图片解析）
    # ══════════════════════════════════════════════

    async def _handleFilePresign(self, request: "web.Request") -> "web.Response":
        """GET /mindos-next/api/file/presign?ext=.pdf

        复用 flash_asr.generate_oss_presign()，但使用不同的 OSS 路径前缀。
        """
        user = await self._extractUser(request)
        if not user:
            return web.json_response({"error": "Unauthorized"}, status=401, headers=CORS_HEADERS)

        ext = request.rel_url.query.get("ext", ".pdf")
        if not ext.startswith("."):
            ext = "." + ext

        try:
            from flash_asr import generate_oss_presign  # type: ignore
            result = generate_oss_presign(
                user["userId"], ext, prefix="mindos-next/docs",
            )
            return web.json_response(result, headers=CORS_HEADERS)
        except Exception as e:
            logger.error("[FilePresign] error: %s", e)
            return web.json_response({"error": str(e)}, status=500, headers=CORS_HEADERS)

    async def _handleFileParse(self, request: "web.Request") -> "web.Response":
        """POST /mindos-next/api/file/parse
        Body: { ossKey, readUrl, filename, title?, chatId }

        HTTP 只确认收到（戒律 2）。解析/写 MD/SSE 推送在后台 asyncio.create_task。
        """
        user = await self._extractUser(request)
        if not user:
            return web.json_response({"error": "Unauthorized"}, status=401, headers=CORS_HEADERS)

        try:
            body = await request.json()
        except Exception:
            return web.json_response({"error": "Invalid JSON"}, status=400, headers=CORS_HEADERS)

        oss_key  = body.get("ossKey", "")
        read_url = body.get("readUrl", "")
        filename = body.get("filename", "")
        title    = body.get("title", "") or filename or "未命名"
        chat_id  = body.get("chatId", "")

        if not oss_key or not read_url or not filename:
            return web.json_response(
                {"error": "ossKey、readUrl、filename 必填"},
                status=400, headers=CORS_HEADERS,
            )

        # 格式支持检查
        from infra.pipelines.anyfile2md import isSupported  # type: ignore
        if not isSupported(filename):
            return web.json_response(
                {"error": f"不支持的文件格式: {filename}"},
                status=400, headers=CORS_HEADERS,
            )

        asyncio.create_task(
            self._runDocParsePipeline(
                userId=user["userId"], chatId=chat_id,
                readUrl=read_url, filename=filename, title=title,
            )
        )
        return web.json_response(
            {"received": True, "message": "解析进行中，请通过 SSE 跟踪进度"},
            status=202, headers=CORS_HEADERS,
        )

    async def _runDocParsePipeline(self, userId, chatId, readUrl, filename, title):
        """后台任务：anyfile2md → md_converter → SSE 推送。

        进度通过 onProgress 回调注入管线，管线本身无状态。
        """
        import hashlib as _hs
        try:
            from infra.pipelines.anyfile2md import parse as parse_to_markdown  # type: ignore
            from md_converter import MdConverter       # type: ignore

            _wiki_root = os.getenv("MINDOS_WIKI_DIR", os.path.expanduser("~/.hermes/wiki"))
            wiki_dir   = os.path.join(_wiki_root, userId)
            converter  = MdConverter(wiki_dir)

            # ── 进度回调绑定（管线每个原子边界触发）──
            def _progress(stage: str, detail: dict):
                self._pushEvent(userId, "md:progress", {
                    "chatId": chatId, "stage": stage, "filename": filename, **detail,
                })

            # 调用原子化管线（带进度回调）
            result = await parse_to_markdown(readUrl, filename, onProgress=_progress)
            text     = result["text"]
            provider = result["provider"]
            vl_pages = result.get("vlPages", result.get("vl_pages", 0))

            if not text:
                self._pushEvent(userId, "md:error", {
                    "chatId": chatId, "message": "解析结果为空，请检查文件内容",
                })
                return

            # 写入 MD 文件（复用 md_converter）
            _progress("writing", {})
            md_file     = converter.new_file(title=title, source_filename=filename)
            total_chars = converter.append_segment(md_file, text, offset_ms=0)
            converter.finalize(md_file, char_count=total_chars)
            rel_path    = converter.relative_path(md_file)

            # SHA-256 防重索引
            sha256 = _hs.sha256(text.encode("utf-8")).hexdigest()
            self._update_raw_index(wiki_dir, sha256, rel_path)

            # VL 积分扣减（仅 VL 降级时）
            if vl_pages > 0:
                try:
                    from hermes_state import SessionDB as _CreditDB
                    _cdb = _CreditDB()
                    vl_credits = max(1, vl_pages * 5)  # ~5 credits/页
                    _cdb.deduct_credits(
                        user_id=userId, credits=vl_credits,
                        tx_type="vl_doc_parse", session_id=chatId,
                        model="qwen-vl",
                        raw_metric=json.dumps({
                            "pages": vl_pages, "chars": total_chars,
                            "filename": filename,
                        }),
                    )
                except Exception as _ce:
                    logger.warning("[DocParse] credit deduction failed: %s", _ce)

            logger.info(
                "[DocParse] ✅ %s → %s (%d 字, provider=%s, vl_pages=%d)",
                filename, rel_path, total_chars, provider, vl_pages,
            )

            # DB 持久化（只存元数据，不存 mdContent — 内容通过 wiki API 按需读取）
            try:
                from hermes_state import SessionDB as _SessionDB
                _db = _SessionDB()
                _db.create_session(
                    session_id=chatId, source="mindos",
                    user_id=userId, model="doc_parse",
                )
                _db.append_message(
                    session_id=chatId, role="assistant",
                    content=json.dumps({
                        "type": "document", "fileName": title,
                        "mdPath": rel_path, "chars": total_chars,
                        "provider": provider,
                    }, ensure_ascii=False),
                )
            except Exception as _pe:
                logger.warning("[DocParse] persist failed (non-fatal): %s", _pe)

            # SSE 完成通知（不含 mdContent，避免阻塞 SSE 通道）
            self._pushEvent(userId, "md:appended", {
                "chatId": chatId, "file": rel_path, "chars": total_chars,
                "provider": provider, "fileName": title,
                "message": f"✅ 文档已入库：{os.path.basename(str(md_file))}（{total_chars} 字）",
            })

        except Exception as e:
            logger.error("[DocParse] pipeline error: %s", e, exc_info=True)
            self._pushEvent(userId, "md:error", {
                "chatId": chatId, "message": f"文件解析失败：{e}",
            })

    # ══════════════════════════════════════════════
    # L4.5: CRX 纯文本直写通道（garden/clip）
    # ══════════════════════════════════════════════

    async def _handleGardenClip(self, request: "web.Request") -> "web.Response":
        """POST /mindos-next/api/garden/clip

        为 MindOS NEXT Clipper (CRX) 提供的极简落盘通道。
        CRX 端已将内容解析为带 YAML Frontmatter 的纯净 Markdown，
        此端点仅做 JWT 鉴权 + 直写文件 + SSE 通知，不经过 OSS/anyfile2md 管线。
        """
        user = await self._extractUser(request)
        if not user:
            return web.json_response({"error": "Unauthorized"}, status=401, headers=CORS_HEADERS)

        try:
            body = await request.json()
        except Exception:
            return web.json_response({"error": "Invalid JSON"}, status=400, headers=CORS_HEADERS)

        filename = body.get("filename", "").strip()
        content  = body.get("content", "")

        if not filename or not content:
            return web.json_response(
                {"error": "filename 和 content 必填"},
                status=400, headers=CORS_HEADERS,
            )

        # 路径遍历防护：强制取 basename，禁止 ../ 穿透
        filename = os.path.basename(filename)
        if not filename:
            return web.json_response(
                {"error": "无效的文件名"},
                status=400, headers=CORS_HEADERS,
            )

        # 确保 .md 扩展名
        if not filename.endswith(".md"):
            filename += ".md"

        userId = user["userId"]
        import hashlib as _hs

        # SHA-256 去重（剥离 YAML Frontmatter 再做哈希）
        # YAML 头中 clipped_at 每次都不同，必须排除才能正确去重
        import re as _re
        body_for_hash = _re.sub(r'^---\n.*?\n---\n*', '', content, count=1, flags=_re.DOTALL)
        sha256 = _hs.sha256(body_for_hash.encode("utf-8")).hexdigest()
        _wiki_root = os.getenv("MINDOS_WIKI_DIR", os.path.expanduser("~/.hermes/wiki"))
        wiki_dir   = os.path.join(_wiki_root, userId)
        clip_dir   = os.path.join(wiki_dir, "raw", "web_clips")
        os.makedirs(clip_dir, exist_ok=True)

        # 检查是否已存在相同内容
        existing = self._check_raw_index(wiki_dir, sha256)
        if existing:
            logger.info("[GardenClip] 重复内容，跳过写入: %s (sha=%s…)", filename, sha256[:12])
            return web.json_response(
                {"received": True, "duplicate": True, "existingFile": existing},
                headers=CORS_HEADERS,
            )

        # 直写 .md 文件
        file_path = os.path.join(clip_dir, filename)
        # 文件名冲突时自动加序号
        if os.path.exists(file_path):
            base, ext = os.path.splitext(filename)
            counter = 1
            while os.path.exists(file_path):
                file_path = os.path.join(clip_dir, f"{base}_{counter}{ext}")
                counter += 1

        try:
            with open(file_path, "w", encoding="utf-8") as f:
                f.write(content)
        except Exception as e:
            logger.error("[GardenClip] 写入失败: %s", e)
            return web.json_response(
                {"error": f"文件写入失败: {e}"},
                status=500, headers=CORS_HEADERS,
            )

        # 更新 SHA-256 防重索引
        rel_path = os.path.relpath(file_path, wiki_dir)
        self._update_raw_index(wiki_dir, sha256, rel_path)
        total_chars = len(content)

        logger.info(
            "[GardenClip] ✅ %s → %s (%d 字, sha=%s…)",
            filename, rel_path, total_chars, sha256[:12],
        )

        # SSE 通知前端刷新文件列表
        self._pushEvent(userId, "md:appended", {
            "file": rel_path, "chars": total_chars,
            "fileName": os.path.basename(file_path),
            "message": f"✅ 剪藏已入库：{os.path.basename(file_path)}（{total_chars} 字）",
        })

        return web.json_response(
            {"received": True, "file": rel_path, "chars": total_chars},
            headers=CORS_HEADERS,
        )

    def _check_raw_index(self, wiki_dir: str, sha256: str) -> str:
        """检查 SHA-256 是否已在 raw_index 中，返回已有文件的相对路径或空字符串。"""
        index_path = os.path.join(wiki_dir, ".raw_index.json")
        if not os.path.exists(index_path):
            return ""
        try:
            with open(index_path, "r", encoding="utf-8") as f:
                index = json.load(f)
            return index.get(sha256, "")
        except Exception:
            return ""

    # ══════════════════════════════════════════════
    # L5: 连接器管理 API
    # ══════════════════════════════════════════════

    async def _handleFeishuProfiles(self, request):
        """GET /mindos-next/api/feishu/profiles"""
        user = await self._extractUser(request)
        if not user:
            return web.json_response({"error": "Unauthorized"}, status=401)

        user_id = user.get("userId", "")
        if not user_id:
            return web.json_response({"error": "missing user_id"}, status=400)

        try:
            from tools.registry import registry
            raw = registry.dispatch("feishu_list_profiles", {"user_id": user_id})
            data = json.loads(raw)
            profiles = data.get("profiles", [])
            accounts = []
            for p in profiles:
                name  = p.get("name", "")
                alias = p.get("alias", "") or ""
                accounts.append({
                    "profileName": name,
                    "alias": alias,
                    "label": alias if alias else name,
                    "status": "connected",
                    "tokenStatus": p.get("tokenStatus", ""),
                    "expiresAt": p.get("expiresAt", ""),
                    "user": p.get("user", ""),
                })
            return web.json_response({"accounts": accounts, "count": len(accounts)})
        except Exception as e:
            logger.error("[MindOSSSE] feishu profiles error: %s", e)
            return web.json_response({"error": str(e)}, status=500)

    async def _handleFeishuSetAlias(self, request):
        """POST /mindos-next/api/feishu/profiles/alias"""
        user = await self._extractUser(request)
        if not user:
            return web.json_response({"error": "Unauthorized"}, status=401)

        user_id = user.get("userId", "")
        try:
            body = await request.json()
            profile_name = str(body.get("profile_name", "")).strip()
            alias        = str(body.get("alias", "")).strip()
            if not profile_name:
                return web.json_response({"error": "profile_name 必填"}, status=400)

            from tools.registry import registry
            raw = registry.dispatch("feishu_rename_profile", {
                "user_id": user_id, "profile_name": profile_name, "alias": alias,
            })
            data = json.loads(raw)
            return web.json_response(data)
        except Exception as e:
            logger.error("[MindOSSSE] feishu set alias error: %s", e)
            return web.json_response({"error": str(e)}, status=500)

    async def _handleTencentMeetingProfiles(self, request):
        """GET /mindos-next/api/tencent-meeting/profiles"""
        user = await self._extractUser(request)
        if not user:
            return web.json_response({"error": "Unauthorized"}, status=401)

        user_id = user.get("userId", "")
        if not user_id:
            return web.json_response({"error": "missing user_id"}, status=400)

        try:
            from tools.tencent_meeting_tool import has_user_token, TOKEN_OBTAIN_URL
            token_configured = has_user_token(user_id)

            if not token_configured:
                return web.json_response({
                    "accounts": [], "count": 0, "tokenConfigured": False,
                    "tokenObtainUrl": TOKEN_OBTAIN_URL,
                    "notice": "腾讯会议目前仅支持个人账号认证，请前往上述链接获取 Token 完成绑定。",
                })

            from tools.registry import registry
            raw  = registry.dispatch("tencent_meeting_list_profiles", {"user_id": user_id})
            data = json.loads(raw)
            profiles = data.get("profiles", [])
            accounts = []
            for p in profiles:
                accounts.append({
                    "profileName":    p.get("profileName", ""),
                    "label":          p.get("label", "腾讯会议"),
                    "status":         p.get("status", "unknown"),
                    "tokenConfigured": True,
                    "notice":         p.get("notice", "腾讯会议目前仅支持个人账号认证。"),
                })
            return web.json_response({
                "accounts": accounts, "count": len(accounts),
                "tokenConfigured": True, "tokenObtainUrl": TOKEN_OBTAIN_URL,
            })
        except Exception as e:
            logger.error("[MindOSSSE] tencent meeting profiles error: %s", e)
            return web.json_response({"error": str(e)}, status=500)

    async def _handleTencentMeetingBind(self, request):
        """POST /mindos-next/api/tencent-meeting/bind"""
        user = await self._extractUser(request)
        if not user:
            return web.json_response({"error": "Unauthorized"}, status=401)

        user_id = user.get("userId", "")
        try:
            body  = await request.json()
            token = str(body.get("token", "")).strip()

            from tools.tencent_meeting_tool import set_user_token
            set_user_token(user_id, token)

            if token:
                logger.info("[MindOSSSE] tencent meeting token saved for user %s", user_id[:8])
                return web.json_response({"success": True, "message": "腾讯会议账号绑定成功。"})
            else:
                logger.info("[MindOSSSE] tencent meeting token cleared for user %s", user_id[:8])
                return web.json_response({"success": True, "message": "已解绑腾讯会议账号。"})
        except Exception as e:
            logger.error("[MindOSSSE] tencent meeting bind error: %s", e)
            return web.json_response({"error": str(e)}, status=500)

    async def _handleImaProfiles(self, request):
        """GET /mindos-next/api/ima/profiles"""
        user = await self._extractUser(request)
        if not user:
            return web.json_response({"error": "Unauthorized"}, status=401, headers=CORS_HEADERS)

        user_id = user.get("userId", "")
        try:
            from tools.ima_tool import has_user_credentials, CREDENTIALS_URL
            if not has_user_credentials(user_id):
                return web.json_response({
                    "success": True, "profiles": [], "count": 0,
                    "credentialsConfigured": False, "credentialsUrl": CREDENTIALS_URL,
                }, headers=CORS_HEADERS)

            from tools.registry import registry
            raw = registry.dispatch("ima_list_profiles", {"user_id": user_id})
            data = json.loads(raw) if isinstance(raw, str) else raw
            return web.json_response(data, headers=CORS_HEADERS)
        except Exception as e:
            logger.error("[MindOSSSE] ima profiles error: %s", e)
            return web.json_response({"error": str(e)}, status=500, headers=CORS_HEADERS)

    async def _handleImaBind(self, request):
        """POST /mindos-next/api/ima/bind"""
        user = await self._extractUser(request)
        if not user:
            return web.json_response({"error": "Unauthorized"}, status=401, headers=CORS_HEADERS)

        user_id = user.get("userId", "")
        try:
            body      = await request.json()
            client_id = str(body.get("client_id", "")).strip()
            api_key   = str(body.get("api_key", "")).strip()

            from tools.ima_tool import set_user_credentials
            set_user_credentials(user_id, client_id, api_key)

            if client_id and api_key:
                logger.info("[MindOSSSE] ima credentials saved for user %s", user_id[:8])
                return web.json_response({"success": True, "message": "IMA 账号绑定成功。"}, headers=CORS_HEADERS)
            else:
                logger.info("[MindOSSSE] ima credentials cleared for user %s", user_id[:8])
                return web.json_response({"success": True, "message": "已解绑 IMA 账号。"}, headers=CORS_HEADERS)
        except Exception as e:
            logger.error("[MindOSSSE] ima bind error: %s", e)
            return web.json_response({"error": str(e)}, status=500, headers=CORS_HEADERS)

    # ══════════════════════════════════════════════
    # L5: 知识花园发布
    # ══════════════════════════════════════════════

    async def _handleGardenPublish(self, request):
        """POST /mindos-next/api/garden/publish"""
        import shutil as _sh, re as _re
        import pathlib as _pl

        user = await self._extractUser(request)
        if not user:
            return web.json_response({"error": "Unauthorized"}, status=401, headers=CORS_HEADERS)
        userId = user["userId"]

        try:
            body = await request.json()
            garden_name = body.get("gardenName", "").strip()
        except Exception:
            return web.json_response({"error": "invalid JSON, need gardenName"}, status=400, headers=CORS_HEADERS)

        if not garden_name:
            return web.json_response({"error": "gardenName required"}, status=400, headers=CORS_HEADERS)

        _wiki_root  = os.getenv("MINDOS_WIKI_DIR", os.path.expanduser("~/.hermes/wiki"))
        user_wiki   = _pl.Path(_wiki_root) / userId
        garden_dir  = user_wiki / "gardens" / garden_name
        files_md    = garden_dir / "files.md"

        if not files_md.exists():
            return web.json_response(
                {"error": f"gardens/{garden_name}/files.md 不存在"},
                status=404, headers=CORS_HEADERS,
            )

        files_text   = files_md.read_text(encoding="utf-8")
        source_files = set(_re.findall(r'(?:^|\s)- *(raw/[^\s]+\.md)', files_text, _re.MULTILINE))

        if not source_files:
            return web.json_response(
                {"error": "files.md 中未找到任何 raw/*.md 源文件"},
                status=400, headers=CORS_HEADERS,
            )

        wiki_pages_dirs = ["entities", "concepts", "comparisons", "queries"]
        matched_pages = []
        frontmatter_re = _re.compile(r'^---\n(.*?)\n---', _re.DOTALL)
        sources_re     = _re.compile(r'sources:\s*\[([^\]]*)\]')

        for subdir in wiki_pages_dirs:
            d = user_wiki / subdir
            if not d.is_dir():
                continue
            for md_file in d.glob("*.md"):
                text = md_file.read_text(encoding="utf-8", errors="replace")
                fm_match = frontmatter_re.search(text)
                if not fm_match:
                    continue
                fm_text = fm_match.group(1)
                src_match = sources_re.search(fm_text)
                if not src_match:
                    continue
                srcs = {s.strip().strip('"').strip("'") for s in src_match.group(1).split(",")}
                if srcs & source_files:
                    matched_pages.append(md_file)

        pub_dir = _pl.Path(_wiki_root) / "commons" / "published" / userId / garden_name
        pub_dir.mkdir(parents=True, exist_ok=True)

        copied = 0
        for page in matched_pages:
            rel = page.relative_to(user_wiki)
            dest = pub_dir / rel
            dest.parent.mkdir(parents=True, exist_ok=True)
            _sh.copy2(page, dest)
            copied += 1

        _sh.copy2(files_md, pub_dir / "files.md")

        readings_dir = garden_dir / "readings"
        if readings_dir.is_dir():
            _sh.copytree(readings_dir, pub_dir / "readings", dirs_exist_ok=True)

        commons_dir = _pl.Path(_wiki_root) / "commons"
        commons_dir.mkdir(parents=True, exist_ok=True)
        index_md = commons_dir / "index.md"
        entry = f"- [{userId}/{garden_name}]({userId}/{garden_name})  \n"
        if not index_md.exists():
            index_md.write_text(f"# MindOS 知识花园广场\n\n{entry}", encoding="utf-8")
        else:
            existing = index_md.read_text(encoding="utf-8")
            if f"{userId}/{garden_name}" not in existing:
                with open(index_md, "a", encoding="utf-8") as f:
                    f.write(entry)

        logger.info("[Garden] 发布 %s/%s: %d 个 wiki 页面", userId, garden_name, copied)
        return web.json_response({
            "published": True, "gardenName": garden_name,
            "wikiPagesCopied": copied,
            "path": f"commons/published/{userId}/{garden_name}/",
        }, headers=CORS_HEADERS)

    # ══════════════════════════════════════════════
    # L5: 文件读取（MD popWindow 用）
    # ══════════════════════════════════════════════

    async def _handleFileRaw(self, request):
        """GET /mindos-next/api/file/raw?path=raw/xxx.md"""
        import pathlib as _pl

        user = await self._extractUser(request)
        if not user:
            return web.json_response({"error": "Unauthorized"}, status=401, headers=CORS_HEADERS)
        userId = user["userId"]

        rel = request.rel_url.query.get("path", "").strip()
        if not rel:
            return web.json_response({"error": "path required"}, status=400, headers=CORS_HEADERS)

        _wiki_root = os.getenv("MINDOS_WIKI_DIR", os.path.expanduser("~/.hermes/wiki"))
        wiki_root_path = _pl.Path(_wiki_root)

        is_public = rel.startswith("commons/published/")
        if is_public:
            base   = wiki_root_path
            target = (base / rel).resolve()
            try:
                target.relative_to((base / "commons" / "published").resolve())
            except ValueError:
                return web.json_response({"error": "forbidden"}, status=403, headers=CORS_HEADERS)
        else:
            base   = wiki_root_path / userId
            target = (base / rel).resolve()
            try:
                target.relative_to(base.resolve())
            except ValueError:
                return web.json_response({"error": "forbidden"}, status=403, headers=CORS_HEADERS)

        if not target.exists() or not target.is_file():
            return web.json_response({"error": "not found", "path": rel}, status=404, headers=CORS_HEADERS)

        content = target.read_text(encoding="utf-8")
        return web.json_response({"content": content, "path": rel}, headers=CORS_HEADERS)

    # ══════════════════════════════════════════════
    # L5: 积分 & Admin & User Profile
    # ══════════════════════════════════════════════

    async def _handleCredits(self, request):
        """GET /mindos-next/api/credits"""
        user = await self._extractUser(request)
        if not user:
            return web.json_response({"error": "Unauthorized"}, status=401, headers=CORS_HEADERS)

        try:
            from hermes_state import SessionDB
            db = SessionDB()
            credits = db.check_credits(user["userId"])
            return web.json_response(credits, headers=CORS_HEADERS)
        except Exception as e:
            logger.error("Failed to check credits: %s", e)
            return web.json_response({"error": str(e)}, status=500, headers=CORS_HEADERS)

    _ADMIN_USER_IDS = {"11c1cece-2422-41e7-86f0-1f54b6162b95"}

    async def _requireAdmin(self, request):
        user = await self._extractUser(request)
        if not user:
            return web.json_response({"error": "Unauthorized"}, status=401, headers=CORS_HEADERS)
        if user["userId"] not in self._ADMIN_USER_IDS:
            return web.json_response({"error": "Forbidden"}, status=403, headers=CORS_HEADERS)
        return user

    async def _handleAdminDashboard(self, request):
        """GET /mindos-next/api/admin/dashboard?days=7"""
        result = await self._requireAdmin(request)
        if isinstance(result, web.Response):
            return result
        try:
            days = int(request.query.get("days", "7"))
            from hermes_state import SessionDB
            db = SessionDB()
            dashboard = db.admin_dashboard(days=days)
            return web.json_response(dashboard, headers=CORS_HEADERS)
        except Exception as e:
            logger.error("Admin dashboard error: %s", e)
            return web.json_response({"error": str(e)}, status=500, headers=CORS_HEADERS)

    async def _handleAdminTopup(self, request):
        """POST /mindos-next/api/admin/credits/topup"""
        result = await self._requireAdmin(request)
        if isinstance(result, web.Response):
            return result
        try:
            body = await request.json()
            target_user = body.get("userId")
            amount = int(body.get("amount", 0))
            reason = body.get("reason", "admin_topup")
            if not target_user or amount <= 0:
                return web.json_response(
                    {"error": "需要 userId 和正整数 amount"}, status=400, headers=CORS_HEADERS
                )
            from hermes_state import SessionDB
            db = SessionDB()
            updated = db.admin_topup(target_user, amount, reason)
            logger.info("[Admin] topup userId=%s amount=%d reason=%s", target_user, amount, reason)
            return web.json_response({"ok": True, "credits": updated}, headers=CORS_HEADERS)
        except Exception as e:
            logger.error("Admin topup error: %s", e)
            return web.json_response({"error": str(e)}, status=500, headers=CORS_HEADERS)

    async def _handleUpdateName(self, request):
        """POST /mindos-next/api/user/name"""
        user = await self._extractUser(request)
        if not user:
            return web.json_response({"error": "Unauthorized"}, status=401, headers=CORS_HEADERS)
        try:
            body = await request.json()
            new_name = (body.get("name") or "").strip()
            if not new_name:
                return web.json_response({"error": "name 不能为空"}, status=400, headers=CORS_HEADERS)
            from hermes_state import SessionDB
            db = SessionDB()
            with db._lock:
                db._conn.execute(
                    "UPDATE user_credits SET display_name = ?, updated_at = datetime('now') WHERE user_id = ?",
                    (new_name, user["userId"]),
                )
                db._conn.commit()
            logger.info("[Profile] userId=%s name → %s", user["userId"], new_name)
            return web.json_response({"ok": True, "name": new_name}, headers=CORS_HEADERS)
        except Exception as e:
            logger.error("Update name error: %s", e)
            return web.json_response({"error": str(e)}, status=500, headers=CORS_HEADERS)

    async def _handleUploadAvatar(self, request):
        """POST /mindos-next/api/user/avatar"""
        user = await self._extractUser(request)
        if not user:
            return web.json_response({"error": "Unauthorized"}, status=401, headers=CORS_HEADERS)
        try:
            reader = await request.multipart()
            field = await reader.next()
            if field is None or field.name != "avatar":
                return web.json_response({"error": "请上传 avatar 字段"}, status=400, headers=CORS_HEADERS)

            from pathlib import Path
            avatar_dir = Path(os.environ.get("MINDOS_STORAGE", ".")) / "storage" / "avatars"
            avatar_dir.mkdir(parents=True, exist_ok=True)

            ext = ".jpg"
            ct = field.headers.get("Content-Type", "")
            if "png" in ct:
                ext = ".png"
            elif "webp" in ct:
                ext = ".webp"

            filename = f"{user['userId'][:8]}_{uuid.uuid4().hex[:6]}{ext}"
            filepath = avatar_dir / filename

            data = await field.read(decode=False)
            if len(data) > 2 * 1024 * 1024:
                return web.json_response({"error": "文件超过 2MB"}, status=400, headers=CORS_HEADERS)

            filepath.write_bytes(data)

            avatar_url = f"/mindos-next/api/avatar/{filename}"
            logger.info("[Profile] userId=%s avatar → %s", user["userId"], avatar_url)
            return web.json_response({"ok": True, "avatarUrl": avatar_url}, headers=CORS_HEADERS)
        except Exception as e:
            logger.error("Upload avatar error: %s", e)
            return web.json_response({"error": str(e)}, status=500, headers=CORS_HEADERS)

    async def _handleServeAvatar(self, request):
        """GET /mindos-next/api/avatar/{filename}"""
        from pathlib import Path
        filename = request.match_info.get("filename", "")
        avatar_dir = Path(os.environ.get("MINDOS_STORAGE", ".")) / "storage" / "avatars"
        filepath = avatar_dir / filename
        if not filepath.exists() or not filepath.is_file():
            return web.json_response({"error": "not found"}, status=404, headers=CORS_HEADERS)

        ct = "image/jpeg"
        if filename.endswith(".png"):
            ct = "image/png"
        elif filename.endswith(".webp"):
            ct = "image/webp"

        return web.Response(
            body=filepath.read_bytes(),
            content_type=ct,
            headers={**CORS_HEADERS, "Cache-Control": "public, max-age=31536000"},
        )

    # ══════════════════════════════════════════════
    # L5: 路由注册（覆写 BaseSSEServer.register_routes）
    # ══════════════════════════════════════════════

    def register_routes(self, app) -> None:
        """注册 MindOS NEXT 特有的 HTTP 路由。"""
        p = self._prefix

        # 音频管线
        app.router.add_get(f"{p}/api/audio/presign", self._handleAudioPresign)
        app.router.add_post(f"{p}/api/audio/transcribe", self._handleAudioTranscribe)
        app.router.add_post(f"{p}/api/audio/upload", self._handleAudioUpload)
        app.router.add_get(f"{p}/api/file/raw", self._handleFileRaw)
        app.router.add_post(f"{p}/api/garden/publish", self._handleGardenPublish)
        app.router.add_post(f"{p}/api/garden/clip", self._handleGardenClip)

        # 通用文件管线（AnyFile2MD Phase C）
        app.router.add_get(f"{p}/api/file/presign", self._handleFilePresign)
        app.router.add_post(f"{p}/api/file/parse", self._handleFileParse)

        # 飞书连接器
        app.router.add_get(f"{p}/api/feishu/profiles", self._handleFeishuProfiles)
        app.router.add_post(f"{p}/api/feishu/profiles/alias", self._handleFeishuSetAlias)

        # 腾讯会议
        app.router.add_get(f"{p}/api/tencent-meeting/profiles", self._handleTencentMeetingProfiles)
        app.router.add_post(f"{p}/api/tencent-meeting/bind", self._handleTencentMeetingBind)

        # IMA
        app.router.add_get(f"{p}/api/ima/profiles", self._handleImaProfiles)
        app.router.add_post(f"{p}/api/ima/bind", self._handleImaBind)

        # 实时录音 WS
        try:
            from gateway.platforms.dashscope_realtime import handleWsRecord, register_server
            register_server(self)
            app.router.add_get(f"{p}/ws/record", handleWsRecord)
            logger.info("[MindOSSSE] 实时录音 WS 端点已注册: %s/ws/record", p)
        except ImportError as e:
            logger.warning("[MindOSSSE] dashscope_realtime 不可用，跳过 WS 路由: %s", e)

        # 积分 & Admin
        app.router.add_get(f"{p}/api/credits", self._handleCredits)
        app.router.add_get(f"{p}/api/admin/dashboard", self._handleAdminDashboard)
        app.router.add_post(f"{p}/api/admin/credits/topup", self._handleAdminTopup)

        # User Profile
        app.router.add_post(f"{p}/api/user/name", self._handleUpdateName)
        app.router.add_post(f"{p}/api/user/avatar", self._handleUploadAvatar)
        app.router.add_get(f"{p}/api/avatar/{{filename}}", self._handleServeAvatar)

        # ── CLI Tunnel ──────────────────────────────────────────
        try:
            from platforms.mindcli_bridge import MindCLIBridge
            self._cli_bridge = MindCLIBridge()
            app.router.add_get(f"{p}/ws/cli-tunnel", self._cli_bridge.handle_tunnel_connect)

            # 注入 bridge 到 cli_tunnel_tool，使工具 handler 能通过 Tunnel 派发调用
            try:
                from tools.cli_tunnel_tool import set_bridge
                set_bridge(self._cli_bridge)
                logger.info("[MindOSSSE] CLI Tunnel + Tool Registry 集成完成")
            except ImportError:
                logger.debug("[MindOSSSE] cli_tunnel_tool 未就绪（非阻塞）")

            logger.info("[MindOSSSE] CLI Tunnel 端点已注册: %s/ws/cli-tunnel", p)
        except ImportError as e:
            self._cli_bridge = None
            logger.warning("[MindOSSSE] mindcli_bridge 不可用，跳过 CLI Tunnel: %s", e)


# ── 独立启动入口 ──
async def main():
    """直接运行此文件来启动 MindOS SSE 服务器。"""
    import sys
    sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))

    logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(message)s")

    server = MindOSSSEServer(
        host=os.getenv("MINDOS_SSE_HOST", "127.0.0.1"),
        port=int(os.getenv("MINDOS_SSE_PORT", "8650")),
    )
    await server.start()

    try:
        while True:
            await asyncio.sleep(3600)
    except KeyboardInterrupt:
        pass
    finally:
        await server.stop()


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