# utils/context_manager.py - 上下文管理器（集成对话持久化和Token统计）

import os
import json
import base64
import mimetypes
import io
import uuid
import platform
import shutil
import subprocess
from copy import deepcopy
from typing import Dict, List, Optional, Any
from pathlib import Path
from datetime import datetime
try:
    from config import (
        MAX_CONTEXT_SIZE,
        DATA_DIR,
        PROMPTS_DIR,
        TERMINAL_SANDBOX_MOUNT_PATH,
        TERMINAL_SANDBOX_CPUS,
        TERMINAL_SANDBOX_MEMORY,
        PROJECT_MAX_STORAGE_MB,
        TERMINAL_SANDBOX_MODE,
        LINUX_SAFETY,
    )
    from config.model_profiles import (
        get_model_prompt_replacements,
        get_registered_model_keys,
        model_supports_image,
        model_supports_video,
    )
except ImportError:
    import sys
    from pathlib import Path
    project_root = Path(__file__).resolve().parents[1]
    if str(project_root) not in sys.path:
        sys.path.insert(0, str(project_root))
    from config import (
        MAX_CONTEXT_SIZE,
        DATA_DIR,
        PROMPTS_DIR,
        TERMINAL_SANDBOX_MOUNT_PATH,
        TERMINAL_SANDBOX_CPUS,
        TERMINAL_SANDBOX_MEMORY,
        PROJECT_MAX_STORAGE_MB,
        TERMINAL_SANDBOX_MODE,
        LINUX_SAFETY,
    )
    from config.model_profiles import (
        get_model_prompt_replacements,
        get_registered_model_keys,
        model_supports_image,
        model_supports_video,
    )
from utils.conversation_manager import ConversationManager
from utils.host_workspace_debug import write_host_workspace_debug
from utils.media_store import MediaStore

AUTO_SHALLOW_PLACEHOLDER = "过早的工具结果已经被自动压缩"
AUTO_SHALLOW_TOOL_WHITELIST = {
    "write_file",
    "read_file",
    "edit_file",
    "terminal_input",
    "terminal_snapshot",
    "web_search",
    "extract_webpage",
    "run_python",
    "run_command",
    "view_image",
    "view_video",
}

class ContextManager:
    def __init__(self, project_path: str, data_dir: Optional[str] = None):
        self.project_path = Path(project_path).resolve()
        self.initial_project_path = self.project_path
        self.container_mount_path = TERMINAL_SANDBOX_MOUNT_PATH or "/workspace"
        self.container_cpu_limit = TERMINAL_SANDBOX_CPUS or "未限制"
        self.container_memory_limit = TERMINAL_SANDBOX_MEMORY or "未限制"
        self.project_storage_limit = f"{PROJECT_MAX_STORAGE_MB}MB" if PROJECT_MAX_STORAGE_MB else "未限制"
        self.workspace_root = Path(__file__).resolve().parents[1]
        self.data_dir = Path(data_dir).expanduser().resolve() if data_dir else Path(DATA_DIR).resolve()
        self.temp_files = {}  # 临时加载的文件内容
        self.file_annotations = {}  # 文件备注
        self.conversation_history = []  # 当前对话历史（内存中）
        self.todo_list: Optional[Dict[str, Any]] = None
        self.has_images: bool = False
        self.has_videos: bool = False
        self.image_compression_mode: str = "original"
        # 对话元数据与项目快照缓存
        self.conversation_metadata: Dict[str, Any] = {}
        self.project_snapshot: Optional[Dict[str, Any]] = None
        self._host_runtime_cache: Optional[Dict[str, str]] = None
        self._shallow_compact_round: int = 0
        
        # 新增：对话持久化管理器
        self.conversation_manager = ConversationManager(base_dir=self.data_dir)
        self.media_store = MediaStore(self.data_dir)
        self.current_conversation_id: Optional[str] = None
        self.auto_save_enabled = True
        self.main_terminal = None  # 由宿主终端在初始化后回填，用于工具定义访问
        
        # 用于接收Web终端的回调函数
        self._web_terminal_callback = None

        self.load_annotations()

    def _is_host_mode_without_safety(self) -> bool:
        """是否处于宿主机模式且未启用安全保护。"""
        return (TERMINAL_SANDBOX_MODE or "").lower() == "host" and not LINUX_SAFETY

    # ===========================================
    # 运行环境信息
    # ===========================================

    def _run_command(self, cmd: List[str], *, timeout: float = 1.5, cwd: Optional[Path] = None) -> str:
        """运行命令并返回标准输出 / Run command and return stdout."""
        try:
            completed = subprocess.run(
                cmd,
                capture_output=True,
                text=True,
                timeout=timeout,
                cwd=str(cwd) if cwd else None,
            )
        except (OSError, subprocess.TimeoutExpired):
            return ""
        if completed.returncode != 0:
            return ""
        return (completed.stdout or "").strip()

    def _read_first_line(self, path: Path) -> str:
        """读取文件首行并去除空白 / Read first line and strip."""
        try:
            with path.open("r", encoding="utf-8", errors="ignore") as fh:
                return fh.readline().strip()
        except OSError:
            return ""

    def _read_os_release_pretty(self) -> str:
        """读取 Linux 发行版信息 / Read Linux distro from os-release."""
        path = Path("/etc/os-release")
        if not path.exists():
            return ""
        try:
            content = path.read_text(encoding="utf-8", errors="ignore")
        except OSError:
            return ""
        for line in content.splitlines():
            if line.startswith("PRETTY_NAME="):
                value = line.split("=", 1)[1].strip().strip('"')
                return value
        return ""

    def _get_os_description(self) -> str:
        """获取 OS 描述 / Get OS description."""
        system = platform.system()
        if system == "Darwin":
            version = platform.mac_ver()[0] or platform.release()
            return f"macOS {version}".strip()
        if system == "Windows":
            release, version, _csd, _ptype = platform.win32_ver()
            if release and version and version not in release:
                return f"Windows {release} ({version})".strip()
            return f"Windows {release or version or platform.release()}".strip()
        if system == "Linux":
            pretty = self._read_os_release_pretty()
            if pretty:
                return f"Linux {pretty}".strip()
            version = platform.release() or platform.version()
            return f"Linux {version}".strip()
        version = platform.release() or platform.version()
        name = system or "Unknown"
        return f"{name} {version}".strip()

    def _parse_wmic_model(self, output: str) -> str:
        """解析 WMIC 输出 / Parse WMIC output."""
        if not output:
            return ""
        lines = [line.strip() for line in output.splitlines() if line.strip()]
        for line in lines:
            if line.lower() == "model":
                continue
            return line
        return ""

    def _get_device_model(self) -> str:
        """获取设备型号 / Get device model."""
        system = platform.system()
        if system == "Darwin":
            return self._run_command(["sysctl", "-n", "hw.model"])
        if system == "Windows":
            model = self._run_command(
                ["powershell", "-NoProfile", "-Command", "(Get-CimInstance -ClassName Win32_ComputerSystem).Model"]
            )
            if model:
                return model
            output = self._run_command(["wmic", "computersystem", "get", "model"])
            return self._parse_wmic_model(output)
        if system == "Linux":
            product = self._read_first_line(Path("/sys/devices/virtual/dmi/id/product_name"))
            vendor = self._read_first_line(Path("/sys/devices/virtual/dmi/id/sys_vendor"))
            if vendor and product and vendor not in product:
                return f"{vendor} {product}".strip()
            return product or vendor
        return ""

    def _get_python_info(self) -> str:
        """获取 Python 版本与可用命令 / Get Python version and commands."""
        version = platform.python_version()
        commands: List[str] = []
        if shutil.which("python"):
            commands.append("python")
        if shutil.which("python3"):
            commands.append("python3")
        if commands:
            return f"{version} ({', '.join(commands)})"
        return f"{version} (未在PATH)"

    def _get_node_info(self) -> str:
        """获取 Node 版本信息 / Get Node version info."""
        node_cmd = None
        if shutil.which("node"):
            node_cmd = "node"
        elif shutil.which("nodejs"):
            node_cmd = "nodejs"
        if not node_cmd:
            return "nodejs 未安装"
        version = self._run_command([node_cmd, "-v"])
        if version:
            if node_cmd == "nodejs":
                return f"{version} (nodejs)"
            return version
        return f"{node_cmd} 可用"

    def _get_git_info(self) -> str:
        """获取 Git 分支与状态 / Get git branch and status."""
        if not shutil.which("git"):
            return "无git环境"
        cwd = self.project_path if self.project_path.exists() else None
        if not cwd:
            return "未初始化"
        inside = self._run_command(["git", "rev-parse", "--is-inside-work-tree"], cwd=cwd)
        if inside.strip() != "true":
            return "未初始化"
        branch = self._run_command(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd)
        if not branch or branch == "HEAD":
            sha = self._run_command(["git", "rev-parse", "--short", "HEAD"], cwd=cwd)
            branch = f"detached@{sha}" if sha else "detached"
        status = self._run_command(["git", "status", "--porcelain"], cwd=cwd)
        dirty = bool(status.strip())
        return f"{branch} ({'dirty' if dirty else 'clean'})"

    def _get_host_runtime_cache(self) -> Dict[str, str]:
        """获取宿主机固定信息 / Get cached host info."""
        if self._host_runtime_cache:
            return self._host_runtime_cache
        os_desc = self._get_os_description() or "unknown"
        arch = platform.machine() or platform.processor() or "unknown"
        model = self._get_device_model() or "unknown"
        python_info = self._get_python_info() or "unknown"
        node_info = self._get_node_info() or "unknown"
        git_info = self._get_git_info() or "unknown"
        self._host_runtime_cache = {
            "os": os_desc,
            "arch": arch,
            "model": model,
            "python": python_info,
            "node": node_info,
            "git": git_info,
        }
        return self._host_runtime_cache

    def _build_host_runtime_environment(self) -> str:
        """构建宿主机运行环境提示 / Build host runtime environment text."""
        base = self._get_host_runtime_cache()
        lines = [
            "宿主机模式",
            f"  OS: {base.get('os', 'unknown')} | Arch: {base.get('arch', 'unknown')} | Model: {base.get('model', 'unknown')}",
            f"  Python: {base.get('python', 'unknown')}",
            f"  Node: {base.get('node', 'unknown')}",
            f"  Git: {base.get('git', 'unknown')}",
        ]
        return "\n".join(lines)

    # ===========================================
    # Token 累计文件工具
    # ===========================================

    def _token_totals_path(self) -> Path:
        return self.data_dir / "token_totals.json"

    def _load_token_totals(self) -> Dict[str, Any]:
        path = self._token_totals_path()
        if not path.exists():
            return {
                "input_tokens": 0,
                "output_tokens": 0,
                "total_tokens": 0,
                "updated_at": None,
            }
        try:
            with open(path, 'r', encoding='utf-8') as fh:
                payload = json.load(fh) or {}
            return {
                "input_tokens": int(payload.get("input_tokens") or payload.get("total_input_tokens") or 0),
                "output_tokens": int(payload.get("output_tokens") or payload.get("total_output_tokens") or 0),
                "total_tokens": int(payload.get("total_tokens") or 0),
                "updated_at": payload.get("updated_at"),
            }
        except (OSError, json.JSONDecodeError, ValueError) as exc:
            print(f"[TokenStats] 读取累计Token失败: {exc}")
            return {
                "input_tokens": 0,
                "output_tokens": 0,
                "total_tokens": 0,
                "updated_at": None,
            }

    def _save_token_totals(self, data: Dict[str, Any]):
        path = self._token_totals_path()
        path.parent.mkdir(parents=True, exist_ok=True)
        with open(path, 'w', encoding='utf-8') as fh:
            json.dump(data, fh, ensure_ascii=False, indent=2)

    def _increment_workspace_token_totals(self, input_tokens: int, output_tokens: int, total_tokens: int):
        if input_tokens <= 0 and output_tokens <= 0 and total_tokens <= 0:
            return
        snapshot = self._load_token_totals()
        snapshot["input_tokens"] = snapshot.get("input_tokens", 0) + max(0, int(input_tokens))
        snapshot["output_tokens"] = snapshot.get("output_tokens", 0) + max(0, int(output_tokens))
        snapshot["total_tokens"] = snapshot.get("total_tokens", 0) + max(0, int(total_tokens))
        snapshot["updated_at"] = datetime.now().isoformat()
        self._save_token_totals(snapshot)
    
    def set_web_terminal_callback(self, callback):
        """设置Web终端回调函数，用于广播事件"""
        self._web_terminal_callback = callback
    
    def load_annotations(self):
        """加载文件备注"""
        annotations_file = self.data_dir / "file_annotations.json"
        if annotations_file.exists():
            try:
                with open(annotations_file, 'r', encoding='utf-8') as f:
                    content = f.read()
                    if content.strip():
                        self.file_annotations = json.loads(content)
                    else:
                        self.file_annotations = {}
            except (json.JSONDecodeError, KeyError):
                print(f"⚠️ [警告] 文件备注格式错误，将重新初始化")
                self.file_annotations = {}
                self.save_annotations()
    
    def save_annotations(self):
        """保存文件备注"""
        annotations_file = self.data_dir / "file_annotations.json"
        with open(annotations_file, 'w', encoding='utf-8') as f:
            json.dump(self.file_annotations, f, ensure_ascii=False, indent=2)

    def _resolve_project_path_from_metadata(self, metadata: Dict[str, Any]) -> Path:
        """
        根据对话元数据解析项目路径，优先使用相对路径以提升可移植性
        """
        candidates = []

        relative_path = metadata.get("project_relative_path")
        if isinstance(relative_path, str) and relative_path.strip():
            rel_path_obj = Path(relative_path.strip())
            if rel_path_obj.is_absolute():
                candidates.append(rel_path_obj)
            else:
                candidates.append((self.workspace_root / rel_path_obj).resolve())

        stored_path = metadata.get("project_path")
        if isinstance(stored_path, str) and stored_path.strip():
            try:
                candidates.append(Path(stored_path.strip()).expanduser())
            except Exception:
                pass

        for candidate in candidates:
            try:
                if candidate.exists():
                    return candidate
            except Exception:
                continue

        # 最终回退到启动时指定的路径
        return self.initial_project_path

    # ===========================================
    # TODO 列表管理
    # ===========================================

    def get_todo_snapshot(self) -> Optional[Dict[str, Any]]:
        if not self.todo_list:
            return None
        snapshot = deepcopy(self.todo_list)
        snapshot["all_done"] = all(
            task.get("status") == "done" for task in snapshot.get("tasks", [])
        )
        snapshot["instruction"] = self._build_todo_instruction(snapshot)
        return snapshot

    def _build_todo_instruction(self, todo: Dict[str, Any]) -> str:
        status = todo.get("status", "active")
        all_done = all(task.get("status") == "done" for task in todo.get("tasks", []))
        if status == "closed":
            return "任务已结束，请在总结中说明未完成的事项。"
        if status == "completed" or all_done:
            return "所有任务已完成，可以结束任务并向用户汇报"
        return "请在确认完成某项任务后再勾选，然后继续下一步"

    def set_todo_list(self, todo_data: Optional[Dict[str, Any]]):
        if todo_data is not None:
            self.todo_list = deepcopy(todo_data)
        else:
            self.todo_list = None
        self.auto_save_conversation(force=True)
        self.broadcast_todo_update()

    def broadcast_todo_update(self):
        if not self._web_terminal_callback:
            return
        try:
            self._web_terminal_callback('todo_updated', {
                "conversation_id": self.current_conversation_id,
                "todo_list": self.get_todo_snapshot()
            })
        except Exception as e:
            print(f"[Debug] 广播todo更新失败: {e}")

    def render_todo_system_message(self) -> Optional[str]:
        snapshot = self.get_todo_snapshot()
        if not snapshot:
            return None

        lines = ["=== TODO_LIST ===", f"任务概述：{snapshot.get('overview', '')}"]
        for task in snapshot.get("tasks", []):
            status_icon = "✅已完成" if task.get("status") == "done" else "❌未完成"
            lines.append(f"task{task.get('index')}：{task.get('title')} [{status_icon}]")
        lines.append(snapshot.get("instruction", "请在确认完成某项任务后再勾选，然后继续下一步"))
        return "\n".join(lines)
    
    # ===========================================
    # 新增：Token统计相关方法
    # ===========================================
    
    def apply_usage_statistics(self, usage: Dict[str, Any]) -> bool:
        """
        根据模型返回的 usage 字段更新token统计
        """
        try:
            prompt_tokens = int(usage.get("prompt_tokens") or 0)
            completion_tokens = int(usage.get("completion_tokens") or 0)
            total_tokens = int(usage.get("total_tokens") or (prompt_tokens + completion_tokens))
            # 当前上下文长度优先取专用字段；缺失时回退到 prompt_tokens
            current_context_tokens = int(usage.get("current_context_tokens") or prompt_tokens)
        except (TypeError, ValueError):
            prompt_tokens = completion_tokens = total_tokens = current_context_tokens = 0

        try:
            self._increment_workspace_token_totals(prompt_tokens, completion_tokens, total_tokens)
        except Exception as exc:
            print(f"[TokenStats] 无法写入累计Token: {exc}")

        if not self.current_conversation_id:
            print("⚠️ 没有当前对话ID，跳过usage统计更新")
            return False
        
        try:
            success = self.conversation_manager.update_token_statistics(
                self.current_conversation_id,
                prompt_tokens,
                completion_tokens,
                total_tokens,
                current_context_tokens=current_context_tokens,
            )
            
            if success:
                self.safe_broadcast_token_update()
            
            return success
        except Exception as e:
            print(f"更新usage统计失败: {e}")
            return False
    
    def get_conversation_token_statistics(self, conversation_id: str = None) -> Optional[Dict]:
        """
        获取指定对话的token统计
        
        Args:
            conversation_id: 对话ID，默认为当前对话
        
        Returns:
            Dict: Token统计信息
        """
        target_id = conversation_id or self.current_conversation_id
        if not target_id:
            return None
        
        return self.conversation_manager.get_token_statistics(target_id)
    
    def get_current_context_tokens(self, conversation_id: str = None) -> int:
        """
        获取最近一次请求的上下文token数量
        """
        stats = self.get_conversation_token_statistics(conversation_id)
        if not stats:
            return 0
        return stats.get("current_context_tokens", 0)

    def _get_meta_flag(self, key: str, default: Any = None) -> Any:
        return (self.conversation_metadata or {}).get(key, default)

    def _set_meta_flag(self, key: str, value: Any, save: bool = True):
        self.conversation_metadata[key] = value
        if save and self.current_conversation_id:
            try:
                self.conversation_manager.update_conversation_metadata(
                    self.current_conversation_id,
                    {key: value}
                )
            except Exception as exc:
                print(f"[ContextCompression] 保存 metadata 失败 {key}: {exc}")

    def is_compression_in_progress(self) -> bool:
        return bool(self._get_meta_flag("compression_in_progress", False))

    def set_compression_state(
        self,
        *,
        in_progress: bool,
        mode: Optional[str] = None,
        stage: Optional[str] = None,
        error: Optional[str] = None,
        resume_payload: Optional[Dict[str, Any]] = None,
        job_id: Optional[str] = None,
    ):
        updates: Dict[str, Any] = {
            "compression_in_progress": bool(in_progress),
            "compression_mode": mode if in_progress else None,
            "compression_stage": stage if in_progress else None,
            "compression_error": error if in_progress else None,
            "compression_resume_payload": resume_payload if in_progress else None,
            "compression_job_id": job_id if in_progress else None,
        }
        for k, v in updates.items():
            self.conversation_metadata[k] = v
        if self.current_conversation_id:
            try:
                self.conversation_manager.update_conversation_metadata(self.current_conversation_id, updates)
            except Exception as exc:
                print(f"[ContextCompression] 更新压缩状态失败: {exc}")

    def on_tool_call_finished(
        self,
        tool_name: Optional[str] = None,
        *,
        enable_shallow: bool = True,
        shallow_trigger_tokens: int = 80_000,
        deep_trigger_tokens: int = 150_000,
        shallow_batch_size: int = 10,
        shallow_keep_recent_tools: int = 15,
        shallow_trigger_tool_calls_interval: int = 10,
        shallow_keep_user_turn_tools: int = 3,
    ) -> int:
        """每次工具调用完成后触发：更新计数并执行自动浅压缩。返回本轮浅压缩条数。"""
        if not self.current_conversation_id:
            return 0
        # 兼容历史对话：tool_call_count 可能从 0 开始，但历史里已存在大量 tool 结果。
        # 这里优先对齐到“当前历史中的 tool 消息数量”，避免长期无法触发浅压缩。
        meta_tool_count = int(self._get_meta_flag("tool_call_count", 0) or 0)
        history_tool_count = 0
        try:
            history_tool_count = sum(
                1 for msg in (self.conversation_history or [])
                if isinstance(msg, dict) and msg.get("role") == "tool"
            )
        except Exception:
            history_tool_count = 0
        tool_count = max(meta_tool_count + 1, history_tool_count)
        self._set_meta_flag("tool_call_count", tool_count, save=False)

        current_tokens = self.get_current_context_tokens(self.current_conversation_id)
        shallow_trigger_tokens = max(1, int(80_000 if shallow_trigger_tokens is None else shallow_trigger_tokens))
        deep_trigger_tokens = max(
            shallow_trigger_tokens + 1,
            int(150_000 if deep_trigger_tokens is None else deep_trigger_tokens),
        )
        shallow_batch_size = max(1, int(10 if shallow_batch_size is None else shallow_batch_size))
        shallow_keep_recent_tools = max(0, int(15 if shallow_keep_recent_tools is None else shallow_keep_recent_tools))
        shallow_keep_user_turn_tools = max(0, int(3 if shallow_keep_user_turn_tools is None else shallow_keep_user_turn_tools))
        shallow_trigger_tool_calls_interval = max(
            1,
            int(10 if shallow_trigger_tool_calls_interval is None else shallow_trigger_tool_calls_interval),
        )
        was_long_conversation = bool(self._get_meta_flag("is_long_conversation", False))
        if current_tokens > shallow_trigger_tokens and not was_long_conversation:
            self._set_meta_flag("is_long_conversation", True, save=False)
        just_marked_long = (not was_long_conversation) and bool(self._get_meta_flag("is_long_conversation", False))
        if current_tokens > deep_trigger_tokens and not self._get_meta_flag("is_ultra_long_conversation", False):
            self._set_meta_flag("is_ultra_long_conversation", True, save=False)

        # 每 10 次工具调用触发一次浅压缩（当 long 已标记）
        last_checkpoint = int(self._get_meta_flag("last_shallow_compress_tool_count", 0) or 0)
        should_try_shallow = (
            bool(enable_shallow)
            and bool(self._get_meta_flag("is_long_conversation", False))
            and (
                just_marked_long
                or (last_checkpoint <= 0 and tool_count > 0)
                or (tool_count - last_checkpoint >= shallow_trigger_tool_calls_interval)
            )
        )
        changed = False
        compressed_count = 0
        if should_try_shallow:
            compressed = self._run_auto_shallow_compression(
                batch_size=shallow_batch_size,
                keep_recent_tools=shallow_keep_recent_tools,
                keep_user_turn_tools=shallow_keep_user_turn_tools,
            )
            self._set_meta_flag("last_shallow_compress_tool_count", tool_count, save=False)
            compressed_count = max(0, int(compressed or 0))
            changed = compressed > 0

        if self.current_conversation_id:
            updates = {
                "tool_call_count": tool_count,
                "is_long_conversation": bool(self._get_meta_flag("is_long_conversation", False)),
                "is_ultra_long_conversation": bool(self._get_meta_flag("is_ultra_long_conversation", False)),
                "last_shallow_compress_tool_count": int(self._get_meta_flag("last_shallow_compress_tool_count", 0) or 0),
            }
            try:
                self.conversation_manager.update_conversation_metadata(self.current_conversation_id, updates)
            except Exception as exc:
                print(f"[ContextCompression] 写入计数失败: {exc}")
        if changed:
            self.auto_save_conversation(force=True)
        return compressed_count

    def _run_auto_shallow_compression(self, batch_size: int = 10, keep_recent_tools: int = 15, keep_user_turn_tools: int = 3) -> int:
        """浅压缩：仅打标记，不修改原文。返回本轮标记条数。
        
        Args:
            batch_size: 每轮最大压缩数量
            keep_recent_tools: 保留最近的N条工具消息不压缩
            keep_user_turn_tools: 保留最近N次用户输入后的工具消息不压缩
        """
        history = self.conversation_history or []
        if not history:
            return 0

        tool_indices = [idx for idx, msg in enumerate(history) if msg.get("role") == "tool"]
        protected_indices = set(tool_indices[-max(0, keep_recent_tools):]) if keep_recent_tools > 0 else set()
        
        # 新增机制：保留最近keep_user_turn_tools次用户输入后的工具消息
        if keep_user_turn_tools > 0:
            # 从后往前找真正的用户输入（排除系统自动发送的）
            user_turn_indices = []
            for idx in range(len(history) - 1, -1, -1):
                msg = history[idx]
                if msg.get("role") == "user":
                    # 检查是否是系统自动发送的消息（向后兼容：检查metadata或前缀文本）
                    metadata = msg.get("metadata") or {}
                    content = msg.get("content") or ""
                    is_auto = metadata.get("is_auto_generated") or \
                              content.startswith("这是一句系统自动发送的user消息，用于通知你")
                    if not is_auto:
                        user_turn_indices.append(idx)
                        if len(user_turn_indices) >= keep_user_turn_tools:
                            break
            
            # 保护这些用户输入之后的所有工具消息（直到下一个user消息）
            for user_idx in user_turn_indices:
                for idx in range(user_idx + 1, len(history)):
                    msg = history[idx]
                    if msg.get("role") == "user":
                        break  # 遇到下一个user消息，停止
                    if msg.get("role") == "tool":
                        protected_indices.add(idx)

        candidates: List[int] = []
        for idx, msg in enumerate(history):
            if msg.get("role") != "tool":
                continue
            if idx in protected_indices:
                continue
            tool_name = (msg.get("name") or "").strip()
            if tool_name not in AUTO_SHALLOW_TOOL_WHITELIST:
                continue
            metadata = msg.get("metadata") or {}
            if metadata.get("auto_shallow_compacted"):
                continue
            candidates.append(idx)
            if len(candidates) >= max(1, batch_size):
                break

        if not candidates:
            return 0

        self._shallow_compact_round += 1
        now = datetime.now().isoformat()
        for idx in candidates:
            msg = history[idx]
            metadata = msg.get("metadata") or {}
            metadata["auto_shallow_compacted"] = True
            metadata["auto_shallow_compacted_at"] = now
            metadata["auto_shallow_compact_round"] = self._shallow_compact_round
            msg["metadata"] = metadata
            history[idx] = msg
        self.conversation_history = history
        return len(candidates)
    
    # ===========================================
    # 新增：对话持久化相关方法
    # ===========================================
    
    def start_new_conversation(
        self,
        project_path: str = None,
        thinking_mode: bool = False,
        run_mode: Optional[str] = None,
        metadata_overrides: Optional[Dict[str, Any]] = None,
    ) -> str:
        """
        开始新对话
        
        Args:
            project_path: 项目路径，默认使用当前项目路径
            thinking_mode: 思考模式
        
        Returns:
            str: 新对话ID
        """
        if project_path is None:
            project_path = str(self.project_path)
        
        # 保存当前对话（如果有的话）
        if self.current_conversation_id and self.conversation_history:
            self.save_current_conversation()

        # 同步 skills（每次新对话覆盖镜像）
        try:
            from modules.personalization_manager import load_personalization_config
            from modules.skills_manager import sync_workspace_skills
            personalization_config = getattr(self, "custom_personalization_config", None) or load_personalization_config(self.data_dir)
            enabled_skills = None
            if isinstance(personalization_config, dict):
                enabled_skills = personalization_config.get("enabled_skills")
            sync_workspace_skills(self.project_path, enabled_skills)
        except Exception as exc:
            print(f"[Skills] 同步失败: {exc}")
        
        # 创建新对话
        conversation_id = self.conversation_manager.create_conversation(
            project_path=project_path,
            thinking_mode=thinking_mode,
            run_mode=run_mode or ("thinking" if thinking_mode else "fast"),
            initial_messages=[],
            model_key=getattr(self.main_terminal, "model_key", None),
            has_images=False,
            has_videos=False,
            metadata_overrides=metadata_overrides,
        )
        
        # 重置当前状态
        self.current_conversation_id = conversation_id
        self.conversation_history = []
        self.todo_list = None
        self.has_images = False
        self.has_videos = False
        self.conversation_metadata = {}
        self.project_snapshot = None
        
        print(f"📝 开始新对话: {conversation_id}")
        return conversation_id
    
    def load_conversation_by_id(self, conversation_id: str) -> bool:
        """
        加载指定对话
        
        Args:
            conversation_id: 对话ID
        
        Returns:
            bool: 加载是否成功
        """
        # 先保存当前对话
        if self.current_conversation_id and self.conversation_history:
            self.save_current_conversation()
        
        # 加载指定对话
        conversation_data = self.conversation_manager.load_conversation(conversation_id)
        if not conversation_data:
            print(f"⌘ 对话 {conversation_id} 不存在")
            return False
        
        # 更新当前状态
        self.current_conversation_id = conversation_id
        self.conversation_history = conversation_data.get("messages", [])
        todo_data = conversation_data.get("todo_list")
        self.todo_list = deepcopy(todo_data) if todo_data else None
        self.conversation_metadata = deepcopy(conversation_data.get("metadata", {}) or {})
        # 恢复项目文件树快照（如已存在）
        meta = self.conversation_metadata
        if meta.get("project_file_tree"):
            self.project_snapshot = {
                "file_tree": meta.get("project_file_tree"),
                "statistics": meta.get("project_statistics"),
                "snapshot_at": meta.get("project_snapshot_at")
            }
        else:
            self.project_snapshot = None
        
        # 更新项目路径（如果对话中有的话）
        metadata = conversation_data.get("metadata", {})
        resolved_project_path = self._resolve_project_path_from_metadata(metadata)

        stored_path = metadata.get("project_path")
        stored_path_obj = None
        if isinstance(stored_path, str) and stored_path.strip():
            try:
                stored_path_obj = Path(stored_path.strip()).expanduser().resolve()
            except Exception:
                stored_path_obj = None

        if stored_path_obj and stored_path_obj != resolved_project_path:
            print(f"⚠️ 对话记录中的项目路径不可用，已回退至: {resolved_project_path}")

        self.project_path = resolved_project_path
        write_host_workspace_debug(
            "context_manager.load_conversation_by_id.set_project_path",
            conversation_id=conversation_id,
            metadata_project_path=str(stored_path_obj) if stored_path_obj else None,
            resolved_project_path=str(resolved_project_path),
        )
        
        run_mode = metadata.get("run_mode")
        permission_mode = metadata.get("permission_mode")
        model_key = metadata.get("model_key")
        self.has_images = metadata.get("has_images", False)
        self.has_videos = metadata.get("has_videos", False)
        if not self.has_images or not self.has_videos:
            for msg in self.conversation_history:
                if not isinstance(msg, dict):
                    continue
                images = msg.get("images") or []
                videos = msg.get("videos") or []
                if images:
                    self.has_images = True
                if videos:
                    self.has_videos = True
                for ref in (msg.get("media_refs") or []):
                    kind = str((ref or {}).get("kind") or "").strip().lower()
                    if kind == "image":
                        self.has_images = True
                    elif kind == "video":
                        self.has_videos = True
                if self.has_images and self.has_videos:
                    break
        if self.main_terminal:
            try:
                if model_key:
                    self.main_terminal.set_model(model_key)
            except Exception:
                fallback_key = None
                for candidate in get_registered_model_keys(visible_only=True):
                    if self.has_images and not model_supports_image(candidate):
                        continue
                    if self.has_videos and not model_supports_video(candidate):
                        continue
                    fallback_key = candidate
                    break
                if fallback_key:
                    try:
                        self.main_terminal.set_model(fallback_key)
                        self.conversation_metadata["model_key"] = fallback_key
                    except Exception:
                        pass
            try:
                if run_mode:
                    self.main_terminal.set_run_mode(run_mode)
                elif metadata.get("thinking_mode"):
                    self.main_terminal.set_run_mode("thinking")
                else:
                    self.main_terminal.set_run_mode("fast")
            except Exception:
                pass
            try:
                fallback_mode = getattr(self.main_terminal, "default_permission_mode", "unrestricted")
                self.main_terminal.set_permission_mode(permission_mode or fallback_mode, persist=False)
            except Exception:
                pass
        
        print(f"📖 加载对话: {conversation_id} - {conversation_data.get('title', '未知标题')}")
        print(f"📊 包含 {len(self.conversation_history)} 条消息")
        
        return True
    
    def _ensure_project_snapshot(self) -> Dict[str, Any]:
        """
        确保当前对话拥有项目文件树快照：
        - 若已缓存/存档则直接返回；
        - 若不存在，则第一次扫描目录并存入对话文件，后续复用。
        """
        if self.project_snapshot:
            return self.project_snapshot
        
        meta = self.conversation_metadata or {}
        stored_tree = meta.get("project_file_tree")
        if stored_tree and stored_tree != "宿主机模式下文件树不可用":
            use_stored_snapshot = True
            stored_path = meta.get("project_path")
            if isinstance(stored_path, str) and stored_path.strip():
                try:
                    stored_path_obj = Path(stored_path.strip()).expanduser().resolve()
                    if stored_path_obj != Path(self.project_path).resolve():
                        use_stored_snapshot = False
                        write_host_workspace_debug(
                            "context_manager.project_snapshot.discard_stale_snapshot",
                            conversation_id=self.current_conversation_id,
                            metadata_project_path=str(stored_path_obj),
                            current_project_path=str(Path(self.project_path).resolve()),
                        )
                except Exception:
                    pass
            if use_stored_snapshot:
                self.project_snapshot = {
                    "file_tree": stored_tree,
                    "statistics": meta.get("project_statistics"),
                    "snapshot_at": meta.get("project_snapshot_at")
                }
                write_host_workspace_debug(
                    "context_manager.project_snapshot.use_metadata_snapshot",
                    conversation_id=self.current_conversation_id,
                    project_path=str(self.project_path),
                )
                return self.project_snapshot
        
        # 首次生成并缓存
        structure = self._get_project_structure_for_prompt()
        snapshot = {
            "file_tree": self._build_file_tree(structure),
            "statistics": {
                "total_files": structure["total_files"],
                "total_size": structure["total_size"]
            },
            "snapshot_at": datetime.now().isoformat()
        }
        self.project_snapshot = snapshot
        write_host_workspace_debug(
            "context_manager.project_snapshot.generated_new",
            conversation_id=self.current_conversation_id,
            project_path=str(self.project_path),
            total_files=snapshot.get("statistics", {}).get("total_files"),
        )
        if self.current_conversation_id:
            self.conversation_manager.update_project_snapshot(
                self.current_conversation_id,
                project_file_tree=snapshot["file_tree"],
                project_statistics=snapshot["statistics"],
                project_snapshot_at=snapshot["snapshot_at"]
            )
            # 同步内存元数据
            self.conversation_metadata["project_file_tree"] = snapshot["file_tree"]
            self.conversation_metadata["project_statistics"] = snapshot["statistics"]
            self.conversation_metadata["project_snapshot_at"] = snapshot["snapshot_at"]
        return snapshot

    def _get_project_structure_for_prompt(self, limit: int = 20) -> Dict:
        """获取用于 prompt 的浅层文件结构（仅根目录，优先文件夹）。"""
        structure = {
            "path": str(self.project_path),
            "files": [],
            "folders": [],
            "total_files": 0,
            "total_size": 0,
            "tree": {}
        }
        if not self.project_path.exists():
            return structure
        try:
            entries = [p for p in self.project_path.iterdir() if not p.name.startswith('.')]
        except PermissionError:
            return structure

        folders = [p for p in entries if p.is_dir()]
        files = [p for p in entries if p.is_file()]
        folders.sort(key=lambda p: p.name.lower())
        files.sort(key=lambda p: p.name.lower())
        selected = (folders + files)[:max(0, limit)]

        for entry in selected:
            relative_path = str(entry.relative_to(self.project_path))
            if entry.is_dir():
                structure["folders"].append({
                    "name": entry.name,
                    "path": relative_path
                })
                structure["tree"][entry.name] = {
                    "type": "folder",
                    "path": relative_path,
                    "children": {}
                }
            else:
                try:
                    size = entry.stat().st_size
                except OSError:
                    size = 0
                file_info = {
                    "name": entry.name,
                    "path": relative_path,
                    "size": size,
                    "annotation": self.file_annotations.get(relative_path, "")
                }
                structure["files"].append(file_info)
                structure["total_files"] += 1
                structure["total_size"] += size
                structure["tree"][entry.name] = {
                    "type": "file",
                    "path": relative_path,
                    "size": size,
                    "annotation": file_info["annotation"]
                }
        return structure
    
    def save_current_conversation(self) -> bool:
        """
        保存当前对话
        
        Returns:
            bool: 保存是否成功
        """
        if not self.current_conversation_id:
            print("⚠️ 没有当前对话ID，无法保存")
            return False
        
        if not self.auto_save_enabled:
            return False
        
        try:
            run_mode = getattr(self.main_terminal, "run_mode", None) if hasattr(self, "main_terminal") else None
            success = self.conversation_manager.save_conversation(
                conversation_id=self.current_conversation_id,
                messages=self.conversation_history,
                project_path=str(self.project_path),
                todo_list=self.todo_list,
                thinking_mode=getattr(self.main_terminal, "thinking_mode", None) if hasattr(self, "main_terminal") else None,
                run_mode=run_mode,
                model_key=getattr(self.main_terminal, "model_key", None) if hasattr(self, "main_terminal") else None,
                has_images=self.has_images,
                has_videos=self.has_videos
            )
            
            if success:
                print(f"💾 对话已自动保存: {self.current_conversation_id}")
            else:
                print(f"⌘ 对话保存失败: {self.current_conversation_id}")
            
            return success
        except Exception as e:
            print(f"⌘ 保存对话异常: {e}")
            return False
    
    def auto_save_conversation(self, force: bool = False):
        """自动保存对话（静默模式，减少日志输出）"""
        if not self.auto_save_enabled or not self.current_conversation_id:
            return
        if not force and not self.conversation_history:
            return
        try:
            run_mode = getattr(self.main_terminal, "run_mode", None) if hasattr(self, "main_terminal") else None
            model_key = getattr(self.main_terminal, "model_key", None) if hasattr(self, "main_terminal") else None
            self.conversation_manager.save_conversation(
                conversation_id=self.current_conversation_id,
                messages=self.conversation_history,
                project_path=str(self.project_path),
                todo_list=self.todo_list,
                thinking_mode=getattr(self.main_terminal, "thinking_mode", None) if hasattr(self, "main_terminal") else None,
                run_mode=run_mode,
                model_key=model_key,
                has_images=self.has_images,
                has_videos=self.has_videos
            )
            # 静默保存，不输出日志
        except Exception as e:
            print(f"⌘ 自动保存异常: {e}")
    
    def get_conversation_list(self, limit: int = 50, offset: int = 0) -> Dict:
        """获取对话列表"""
        return self.conversation_manager.get_conversation_list(limit=limit, offset=offset)
    
    def delete_conversation_by_id(self, conversation_id: str) -> bool:
        """删除指定对话"""
        # 如果是当前对话，清理状态
        if self.current_conversation_id == conversation_id:
            self.current_conversation_id = None
            self.conversation_history = []
            self.todo_list = None
        elif self.current_conversation_id and self.conversation_history:
            try:
                conversation_data = self.conversation_manager.load_conversation(self.current_conversation_id)
                if not conversation_data:
                    self.current_conversation_id = None
                    self.conversation_history = []
                    self.todo_list = None
                else:
                    todo_data = conversation_data.get("todo_list")
                    self.todo_list = deepcopy(todo_data) if todo_data else None
            except Exception as exc:
                print(f"⌘ 刷新待办列表失败: {exc}")
                self.todo_list = None
        
        return self.conversation_manager.delete_conversation(conversation_id)
    
    def search_conversations(self, query: str, limit: int = 20) -> List[Dict]:
        """搜索对话"""
        return self.conversation_manager.search_conversations(query, limit)
    
    def get_conversation_statistics(self) -> Dict:
        """获取对话统计"""
        return self.conversation_manager.get_statistics()

    def compress_conversation(self, conversation_id: str) -> Dict:
        """
        压缩指定对话：保留用户/助手原文（不含 reasoning），提取工具意图/名称，
        生成一条 system 消息作为新对话的压缩版历史。
        """
        conversation_data = self.conversation_manager.load_conversation(conversation_id)
        if not conversation_data:
            return {
                "success": False,
                "error": f"对话不存在: {conversation_id}"
            }

        original_messages = conversation_data.get("messages", []) or []
        if not original_messages:
            return {
                "success": False,
                "error": "当前对话没有可压缩的内容"
            }

        header_text = (
            f"系统提示：根据压缩后的工作记录继续这个任务。"
            f"如果信息不足，提示用户使用对话回顾功能。源对话：{conversation_id}"
        )

        lines: List[str] = []
        tool_buffer: List[str] = []
        seen_tool_call_ids = set()

        def add_spacing():
            if lines and lines[-1] != "":
                lines.append("")

        def flush_tools():
            if not tool_buffer:
                return
            add_spacing()
            lines.append("tool：")
            lines.extend(f"- {entry}" for entry in tool_buffer)
            tool_buffer.clear()

        for message in original_messages:
            role = message.get("role")

            if role == "user":
                flush_tools()
                content = message.get("content") or ""
                add_spacing()
                lines.append(f"user：{content}")
                continue

            if role == "assistant":
                content = message.get("content") or ""
                has_visible_content = bool(str(content).strip())
                if has_visible_content:
                    flush_tools()
                    add_spacing()
                    lines.append(f"assistant：{content}")

                tool_calls = message.get("tool_calls") or []
                for tc in tool_calls:
                    tc_id = tc.get("id") or tc.get("tool_call_id")
                    if tc_id:
                        seen_tool_call_ids.add(tc_id)

                    func = tc.get("function") or {}
                    arguments = func.get("arguments")
                    args_obj = {}
                    if isinstance(arguments, str):
                        try:
                            args_obj = json.loads(arguments)
                        except Exception:
                            args_obj = {}
                    elif isinstance(arguments, dict):
                        args_obj = arguments

                    intent = args_obj.get("intent") if isinstance(args_obj, dict) else None
                    name = func.get("name") or tc.get("name") or "unknown_tool"
                    entry = intent.strip() if isinstance(intent, str) and intent.strip() else name
                    tool_buffer.append(entry)
                continue

            if role == "tool":
                tc_id = message.get("tool_call_id") or message.get("id")
                if tc_id and tc_id in seen_tool_call_ids:
                    # 已经通过 intent 记录，无需重复
                    continue
                name = message.get("name") or "unknown_tool"
                tool_buffer.append(name)
                continue

            # 其他角色（如 system）原样保留
            flush_tools()
            content = message.get("content") or ""
            add_spacing()
            lines.append(f"{role}：{content}" if role else content)

        flush_tools()

        summary_text = header_text + "\n\n" + "\n".join(lines)

        system_message = {
            "role": "system",
            "content": summary_text,
            "timestamp": datetime.now().isoformat(),
            "metadata": {
                "compression": {
                    "source_conversation_id": conversation_id,
                    "types": ["intent_summary"],
                    "created_at": datetime.now().isoformat()
                }
            }
        }

        metadata = conversation_data.get("metadata", {})
        resolved_project_path = self._resolve_project_path_from_metadata(metadata)
        project_path = str(resolved_project_path)
        thinking_mode = metadata.get("thinking_mode", False)
        run_mode = metadata.get("run_mode") or ("thinking" if thinking_mode else "fast")
        model_key = metadata.get("model_key")
        has_images = metadata.get("has_images", False)
        original_title = conversation_data.get("title")

        compressed_conversation_id = self.conversation_manager.create_conversation(
            project_path=project_path,
            thinking_mode=thinking_mode,
            run_mode=run_mode,
            initial_messages=[system_message],
            model_key=model_key,
            has_images=has_images,
            metadata_overrides={
                "permission_mode": metadata.get("permission_mode", "unrestricted"),
            },
        )

        # 设置压缩后的对话标题
        if original_title:
            try:
                new_title = f"{original_title} 压缩后"
                self.conversation_manager.update_conversation_title(compressed_conversation_id, new_title)
            except Exception:
                pass

        return {
            "success": True,
            "compressed_conversation_id": compressed_conversation_id,
            "compressed_types": ["intent_summary"],
            "system_message": summary_text
        }

    def duplicate_conversation(self, conversation_id: str) -> Dict:
        """复制对话，生成新的对话副本"""
        conversation_data = self.conversation_manager.load_conversation(conversation_id)
        if not conversation_data:
            return {
                "success": False,
                "error": f"对话不存在: {conversation_id}"
            }

        original_messages = deepcopy(conversation_data.get("messages", []) or [])
        original_title = conversation_data.get("title")

        metadata = conversation_data.get("metadata", {})
        resolved_project_path = self._resolve_project_path_from_metadata(metadata)
        project_path = str(resolved_project_path)
        thinking_mode = metadata.get("thinking_mode", False)
        run_mode = metadata.get("run_mode") or ("thinking" if thinking_mode else "fast")
        model_key = metadata.get("model_key")
        has_images = metadata.get("has_images", False)

        duplicate_conversation_id = self.conversation_manager.create_conversation(
            project_path=project_path,
            thinking_mode=thinking_mode,
            run_mode=run_mode,
            initial_messages=original_messages,
            model_key=model_key,
            has_images=has_images,
            metadata_overrides={
                "permission_mode": metadata.get("permission_mode", "unrestricted"),
            },
        )

        token_stats = conversation_data.get("token_statistics")
        if token_stats:
            new_data = self.conversation_manager.load_conversation(duplicate_conversation_id)
            if new_data:
                new_data["token_statistics"] = deepcopy(token_stats)
                new_metadata = new_data.get("metadata", {})
                new_metadata["total_messages"] = metadata.get("total_messages", len(original_messages))
                new_metadata["total_tools"] = metadata.get("total_tools", 0)
                new_metadata["status"] = metadata.get("status", "active")
                new_data["metadata"] = new_metadata
                new_data["updated_at"] = datetime.now().isoformat()
                self.conversation_manager._save_conversation_file(duplicate_conversation_id, new_data)
                self.conversation_manager._update_index(duplicate_conversation_id, new_data)

        # 设置复制后的对话标题
        if original_title:
            try:
                new_title = f"{original_title} 的副本"
                self.conversation_manager.update_conversation_title(duplicate_conversation_id, new_title)
            except Exception:
                pass

        return {
            "success": True,
            "duplicate_conversation_id": duplicate_conversation_id
        }
    
    # ===========================================
    # 修改现有方法，集成自动保存和Token统计
    # ===========================================
    
    def safe_broadcast_token_update(self):
        """安全的token更新广播（只广播累计统计，不重新计算）"""
        try:
            print(f"[Debug] 尝试广播token更新")
            
            # 检查是否有回调函数
            if not hasattr(self, '_web_terminal_callback'):
                print(f"[Debug] 没有_web_terminal_callback属性")
                return
                
            if not self._web_terminal_callback:
                print(f"[Debug] _web_terminal_callback为None")
                return
            
            if not self.current_conversation_id:
                print(f"[Debug] 没有当前对话ID")
                return
                
            print(f"[Debug] 广播token统计，对话ID: {self.current_conversation_id}")
            
            # 只获取已有的累计token统计，不重新计算
            cumulative_stats = self.get_conversation_token_statistics()
            
            # 准备广播数据
            broadcast_data = {
                'conversation_id': self.current_conversation_id,
                'cumulative_input_tokens': cumulative_stats.get("total_input_tokens", 0) if cumulative_stats else 0,
                'cumulative_output_tokens': cumulative_stats.get("total_output_tokens", 0) if cumulative_stats else 0,
                'cumulative_total_tokens': cumulative_stats.get("total_tokens", 0) if cumulative_stats else 0,
                'current_context_tokens': cumulative_stats.get("current_context_tokens", 0) if cumulative_stats else 0,
                'updated_at': datetime.now().isoformat()
            }
            
            print(f"[Debug] Token统计: 累计输入={broadcast_data['cumulative_input_tokens']}, 累计输出={broadcast_data['cumulative_output_tokens']}")
            
            # 广播到前端
            self._web_terminal_callback('token_update', broadcast_data)
            
            print(f"[Debug] token更新已广播")
            
        except Exception as e:
            print(f"[Debug] 广播token更新失败: {e}")
            import traceback
            traceback.print_exc()

    def _generate_message_id(self) -> str:
        """生成消息唯一 ID（用于 media_store 映射）。"""
        return f"msg_{uuid.uuid4().hex}"

    def _resolve_media_input_path(self, raw_path: str) -> Optional[Path]:
        path_text = str(raw_path or "").strip()
        if not path_text:
            return None
        try:
            candidate = Path(path_text).expanduser()
            if candidate.is_absolute():
                return candidate.resolve()
            return (Path(self.project_path) / candidate).resolve()
        except Exception:
            return None

    def _store_media_item_from_candidate(self, candidate: Dict[str, Any]) -> Optional[Dict[str, Any]]:
        if not isinstance(candidate, dict):
            return None
        kind = str(candidate.get("kind") or "").strip().lower() or "binary"
        mime_type = candidate.get("mime_type")
        source_path = candidate.get("source_path")
        base_ref: Optional[Dict[str, Any]] = None
        try:
            media_id = str(candidate.get("media_id") or "").strip()
            if media_id:
                data_url = self.media_store.to_data_url(candidate)
                if data_url:
                    base_ref = {
                        "media_id": media_id,
                        "kind": kind,
                        "mime_type": mime_type,
                        "store_path": candidate.get("store_path"),
                        "source_path": source_path,
                    }
                else:
                    base_ref = None
            elif candidate.get("data_url"):
                stored = self.media_store.store_data_url(
                    str(candidate.get("data_url")),
                    kind=kind,
                    source_path=source_path,
                )
                base_ref = dict(stored)
            elif candidate.get("data_base64") or candidate.get("data"):
                payload = str(candidate.get("data_base64") or candidate.get("data") or "")
                if payload:
                    stored = self.media_store.store_base64_data(
                        payload,
                        kind=kind,
                        mime_type=mime_type,
                        source_path=source_path,
                    )
                    base_ref = dict(stored)
            elif candidate.get("path"):
                abs_path = self._resolve_media_input_path(str(candidate.get("path")))
                if abs_path and abs_path.exists() and abs_path.is_file():
                    stored = self.media_store.store_file(
                        abs_path,
                        kind=kind,
                        mime_type=mime_type,
                        source_path=source_path or str(candidate.get("path")),
                    )
                    base_ref = dict(stored)
        except Exception:
            base_ref = None
        if not base_ref:
            return None
        # 保留调用侧附加信息（例如视频 fps、MCP item 类型等）
        passthrough_keys = {
            "label",
            "fps",
            "source",
            "origin",
            "item_type",
            "name",
            "title",
            "uri",
            "url",
            "index",
        }
        for key in passthrough_keys:
            if key in candidate and candidate.get(key) is not None:
                base_ref[key] = candidate.get(key)
        if base_ref.get("source_path") is None and source_path:
            base_ref["source_path"] = source_path
        return base_ref

    def _prepare_media_candidates(
        self,
        images: Optional[List[Any]] = None,
        videos: Optional[List[Any]] = None,
        media_refs: Optional[List[Dict[str, Any]]] = None,
    ) -> List[Dict[str, Any]]:
        candidates: List[Dict[str, Any]] = []
        for item in media_refs or []:
            if not isinstance(item, dict):
                continue
            kind = str(item.get("kind") or "").strip().lower()
            if not kind:
                mime_hint = str(item.get("mime_type") or item.get("mimeType") or "").strip()
                if mime_hint.startswith("image/"):
                    kind = "image"
                elif mime_hint.startswith("video/"):
                    kind = "video"
                elif mime_hint.startswith("audio/"):
                    kind = "audio"
            candidate = dict(item)
            if kind:
                candidate["kind"] = kind
            if "mimeType" in candidate and "mime_type" not in candidate:
                candidate["mime_type"] = candidate.get("mimeType")
            candidates.append(candidate)

        for path in images or []:
            if isinstance(path, dict):
                candidate = dict(path)
                candidate.setdefault("kind", "image")
                if "mimeType" in candidate and "mime_type" not in candidate:
                    candidate["mime_type"] = candidate.get("mimeType")
                if "path" not in candidate and path.get("url"):
                    candidate["path"] = path.get("url")
                candidates.append(candidate)
                continue
            path_text = str(path or "").strip()
            if not path_text:
                continue
            candidates.append(
                {
                    "kind": "image",
                    "path": path_text,
                    "source_path": path_text,
                    "source": "message_images",
                }
            )

        for item in videos or []:
            if isinstance(item, dict):
                path_text = str(item.get("path") or "").strip()
                if not path_text:
                    continue
                candidate = {
                    "kind": "video",
                    "path": path_text,
                    "source_path": path_text,
                    "fps": item.get("fps"),
                    "source": item.get("source") or "message_videos",
                }
                if item.get("mime_type"):
                    candidate["mime_type"] = item.get("mime_type")
                elif item.get("mimeType"):
                    candidate["mime_type"] = item.get("mimeType")
                candidates.append(candidate)
            else:
                path_text = str(item or "").strip()
                if not path_text:
                    continue
                candidates.append(
                    {
                        "kind": "video",
                        "path": path_text,
                        "source_path": path_text,
                        "source": "message_videos",
                    }
                )
        return candidates

    @staticmethod
    def _dedupe_media_refs(media_refs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
        deduped: List[Dict[str, Any]] = []
        seen = set()
        for item in media_refs:
            if not isinstance(item, dict):
                continue
            media_id = str(item.get("media_id") or "").strip()
            source_path = str(item.get("source_path") or "").strip()
            key = (media_id, source_path, str(item.get("kind") or ""))
            if key in seen:
                continue
            seen.add(key)
            deduped.append(item)
        return deduped
    
    def add_conversation(
        self,
        role: str,
        content: str,
        tool_calls: Optional[List[Dict]] = None,
        tool_call_id: Optional[str] = None,
        name: Optional[str] = None,
        metadata: Optional[Dict[str, Any]] = None,
        reasoning_content: Optional[str] = None,
        images: Optional[List[Any]] = None,
        videos: Optional[List[Any]] = None,
        media_refs: Optional[List[Dict[str, Any]]] = None,
    ):
        """添加对话记录（改进版：集成自动保存 + 智能token统计）"""
        timestamp = datetime.now().isoformat()
        message_id = self._generate_message_id()
        if role == "assistant":
            message = {
                "role": role,
                "reasoning_content": reasoning_content if reasoning_content is not None else "",
                "content": content or "",
                "timestamp": timestamp,
                "message_id": message_id,
            }
        else:
            message = {
                "role": role,
                "content": content,
                "timestamp": timestamp,
                "message_id": message_id,
            }
        
        if metadata:
            message["metadata"] = dict(metadata)
        if images:
            message["images"] = images
            self.has_images = True
        if videos:
            message["videos"] = videos
            self.has_videos = True

        prepared_candidates = self._prepare_media_candidates(images=images, videos=videos, media_refs=media_refs)
        stored_media_refs: List[Dict[str, Any]] = []
        for candidate in prepared_candidates:
            stored_ref = self._store_media_item_from_candidate(candidate)
            if stored_ref:
                stored_media_refs.append(stored_ref)
        stored_media_refs = self._dedupe_media_refs(stored_media_refs)
        if stored_media_refs:
            message["media_refs"] = stored_media_refs
            message.setdefault("metadata", {})
            message["metadata"]["media_refs"] = stored_media_refs
            if any(str(item.get("kind") or "").strip().lower() == "image" for item in stored_media_refs):
                self.has_images = True
            if any(str(item.get("kind") or "").strip().lower() == "video" for item in stored_media_refs):
                self.has_videos = True
            try:
                self.media_store.link_message_media(
                    self.current_conversation_id,
                    message_id,
                    stored_media_refs,
                )
            except Exception:
                pass
        
        # 记录当前助手回复所用模型，便于回放时查看
        if role == "assistant":
            message.setdefault("metadata", {})
            if "model_key" not in message["metadata"]:
                model_key = getattr(self.main_terminal, "model_key", None) if self.main_terminal else None
                if model_key:
                    message["metadata"]["model_key"] = model_key

        # 如果是assistant消息且有工具调用，保存完整格式
        if role == "assistant" and tool_calls:
            # 确保工具调用格式完整
            formatted_tool_calls = []
            for tc in tool_calls:
                # 如果是简化格式，补全它
                if "function" in tc and not tc.get("id"):
                    formatted_tc = {
                        "id": f"call_{datetime.now().timestamp()}_{tc['function'].get('name', 'unknown')}",
                        "type": "function",
                        "function": tc["function"]
                    }
                else:
                    formatted_tc = tc
                formatted_tool_calls.append(formatted_tc)
            message["tool_calls"] = formatted_tool_calls
        
        # 如果是tool消息，保存必要信息
        if role == "tool":
            if tool_call_id:
                message["tool_call_id"] = tool_call_id
            if name:
                message["name"] = name
        
        self.conversation_history.append(message)
        
        # 自动保存
        self.auto_save_conversation()
        
        print(f"[Debug] 添加{role}消息后广播token更新")
        self.safe_broadcast_token_update()
        return message
    
    def add_tool_result(self, tool_call_id: str, function_name: str, result: str):
        """添加工具调用结果（保留方法以兼容）"""
        self.add_conversation(
            role="tool",
            content=result,
            tool_call_id=tool_call_id,
            name=function_name
        )
    
    # ===========================================
    # 废弃旧的保存/加载方法，保持兼容性
    # ===========================================
    
    def save_conversation(self):
        """保存对话历史（废弃，使用新的持久化系统）"""
        print("⚠️ save_conversation() 已废弃，使用新的持久化系统")
        return self.save_current_conversation()
    
    def load_conversation(self):
        """加载对话历史（废弃，使用新的持久化系统）"""
        print("⚠️ load_conversation() 已废弃，使用 load_conversation_by_id()")
        # 兼容性：尝试加载最近的对话
        conversations = self.get_conversation_list(limit=1)
        if conversations["conversations"]:
            latest_conv = conversations["conversations"][0]
            return self.load_conversation_by_id(latest_conv["id"])
        return False
    
    # ===========================================
    # 保持原有的其他方法不变
    # ===========================================
    
    def get_project_structure(self) -> Dict:
        """获取项目文件结构"""
        structure = {
            "path": str(self.project_path),
            "files": [],
            "folders": [],
            "total_files": 0,
            "total_size": 0,
            "tree": {}  # 新增：树形结构数据
        }

        if self._is_host_mode_without_safety():
            structure["unavailable"] = True
            structure["message"] = "宿主机模式下文件树不可用"
            return structure
        
        # 记录实际存在的文件
        existing_files = set()
        
        def scan_directory(path: Path, level: int = 0, max_level: int = 5, parent_tree: Dict = None):
            if level > max_level:
                return
            
            if parent_tree is None:
                parent_tree = structure["tree"]
            
            try:
                # 获取目录内容并排序（文件夹在前，文件在后）
                items = sorted(path.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))
                
                for item in items:
                    if item.name.startswith('.'):
                        continue
                    
                    relative_path = str(item.relative_to(self.project_path))
                    
                    if item.is_file():
                        existing_files.add(relative_path)  # 记录存在的文件
                        file_info = {
                            "name": item.name,
                            "path": relative_path,
                            "size": item.stat().st_size,
                            "modified": datetime.fromtimestamp(item.stat().st_mtime).isoformat(),
                            "annotation": self.file_annotations.get(relative_path, "")
                        }
                        structure["files"].append(file_info)
                        structure["total_files"] += 1
                        structure["total_size"] += file_info["size"]
                        
                        # 添加到树形结构
                        parent_tree[item.name] = {
                            "type": "file",
                            "path": relative_path,
                            "size": file_info["size"],
                            "annotation": file_info["annotation"]
                        }
                    
                    elif item.is_dir():
                        folder_info = {
                            "name": item.name,
                            "path": relative_path
                        }
                        structure["folders"].append(folder_info)
                        
                        # 创建文件夹节点
                        parent_tree[item.name] = {
                            "type": "folder",
                            "path": relative_path,
                            "children": {}
                        }
                        
                        # 递归扫描子目录
                        scan_directory(item, level + 1, max_level, parent_tree[item.name]["children"])
            except PermissionError:
                pass
        
        scan_directory(self.project_path)
        
        # 清理不存在文件的备注
        invalid_annotations = []
        for annotation_path in self.file_annotations.keys():
            if annotation_path not in existing_files:
                invalid_annotations.append(annotation_path)
        
        if invalid_annotations:
            for path in invalid_annotations:
                del self.file_annotations[path]
                print(f"🧹 清理无效备注: {path}")
            self.save_annotations()
        
        return structure
    
    def load_file(self, file_path: str) -> bool:
        """加载文件到临时上下文"""
        full_path = self.project_path / file_path
        
        if not full_path.exists():
            return False
        
        if not full_path.is_file():
            return False
        
        try:
            with open(full_path, 'r', encoding='utf-8') as f:
                content = f.read()
                self.temp_files[file_path] = content
                return True
        except Exception:
            return False
    
    def unload_file(self, file_path: str) -> bool:
        """从临时上下文移除文件"""
        if file_path in self.temp_files:
            del self.temp_files[file_path]
            return True
        return False
    
    def update_annotation(self, file_path: str, annotation: str):
        """更新文件备注"""
        self.file_annotations[file_path] = annotation
        self.save_annotations()
    
    def load_prompt(self, prompt_name: str) -> str:
        """加载prompt模板"""
        # 允许覆盖主系统提示（仅对 main_system 系列生效）
        if prompt_name.startswith("main_system"):
            override = getattr(self, "custom_system_prompt", None)
            if override:
                return override
        prompt_file = Path(PROMPTS_DIR) / f"{prompt_name}.txt"
        if prompt_file.exists():
            with open(prompt_file, 'r', encoding='utf-8') as f:
                return f.read()
        return ""
    
    def build_main_context(self, memory_content: str) -> Dict:
        """构建主终端上下文"""
        snapshot = self._ensure_project_snapshot()
        stats = snapshot.get("statistics") or {}
        total_files = stats.get("total_files", 0)
        total_size_bytes = stats.get("total_size", 0)
        
        context = {
            "project_info": {
                "path": str(self.project_path),
                "file_tree": snapshot.get("file_tree", ""),
                "file_annotations": self.file_annotations,
                "statistics": {
                    "total_files": total_files,
                    "total_size": f"{total_size_bytes / 1024 / 1024:.2f}MB"
                }
            },
            "memory": memory_content,
            "conversation": self.conversation_history,
            "todo_list": self.get_todo_snapshot()
        }
        
        return context
    
    def build_task_context(
        self,
        task_info: Dict,
        main_memory: str,
        task_memory: str,
        execution_results: List[Dict] = None
    ) -> Dict:
        """构建子任务上下文"""
        snapshot = self._ensure_project_snapshot()
        stats = snapshot.get("statistics") or {}
        total_files = stats.get("total_files", 0)
        total_size_bytes = stats.get("total_size", 0)
        
        context = {
            "task_info": task_info,
            "project_info": {
                "path": str(self.project_path),
                "file_tree": snapshot.get("file_tree", ""),
                "file_annotations": self.file_annotations,
                "statistics": {
                    "total_files": total_files,
                    "total_size": f"{total_size_bytes / 1024 / 1024:.2f}MB"
                }
            },
            "memory": {
                "main_memory": main_memory,
                "task_memory": task_memory
            },
            "temp_files": self.temp_files,
            "execution_results": execution_results or [],
            "conversation": {
                "main": self.conversation_history[-10:],  # 最近10条主对话
                "sub": []  # 子任务对话
            }
        }
        
        return context
    
    def _build_file_tree(self, structure: Dict) -> str:
        """构建文件树字符串（修复版：正确显示树形结构）"""
        if not structure.get("tree"):
            return f"📁 {structure['path']}/\n(空项目)"
        
        lines = []
        project_name = Path(structure['path']).name
        if self._is_host_mode_without_safety():
            root_label = f"{structure['path']} (项目根)"
        else:
            container_root = (self.container_mount_path or "").strip() or "/workspace"
            container_root = container_root.rstrip("/") or "/"
            if container_root == "/":
                root_label = f"/ (项目根)"
            elif container_root.endswith(project_name):
                root_label = f"{container_root} (项目根)"
            else:
                root_label = f"{container_root} (映射自 {project_name})"
        lines.append(f"📁 {root_label}/")
        
        ROOT_FOLDER_CHILD_LIMIT = 20

        def count_descendants(item: Dict) -> int:
            """计算某个文件夹下（含多层）所有子项数量。"""
            if item.get("type") != "folder":
                return 0
            children = item.get("children") or {}
            total = len(children)
            for child in children.values():
                if child.get("type") == "folder":
                    total += count_descendants(child)
            return total

        def build_tree_recursive(tree_dict: Dict, prefix: str = "", depth: int = 0):
            """递归构建树形结构"""
            if not tree_dict:
                return
                
            # 将项目按类型和名称排序：文件夹在前，文件在后，同类型按名称排序
            items = list(tree_dict.items())
            folders = [(name, info) for name, info in items if info["type"] == "folder"]
            files = [(name, info) for name, info in items if info["type"] == "file"]
            
            # 排序
            folders.sort(key=lambda x: x[0].lower())
            files.sort(key=lambda x: x[0].lower())
            
            # 合并列表
            sorted_items = folders + files
            
            for i, (name, info) in enumerate(sorted_items):
                is_last = (i == len(sorted_items) - 1)
                
                # 选择连接符
                if is_last:
                    current_connector = "└── "
                    next_prefix = prefix + "    "
                else:
                    current_connector = "├── "
                    next_prefix = prefix + "│   "
                
                if info["type"] == "folder":
                    # 文件夹
                    lines.append(f"{prefix}{current_connector}📁 {name}/")
                    
                    # 递归处理子项目
                    children = info.get("children") or {}
                    if depth == 0:
                        total_entries = count_descendants(info)
                    else:
                        total_entries = None
                    if depth == 0 and total_entries is not None and total_entries > ROOT_FOLDER_CHILD_LIMIT:
                        lines.append(
                            f"{next_prefix}… (该目录包含 {total_entries} 项，已省略以控制 prompt 体积)"
                        )
                    elif children:
                        build_tree_recursive(children, next_prefix, depth + 1)
                else:
                    # 文件
                    icon = self._get_file_icon(name)
                    size_info = self._format_file_size(info['size'])
                    
                    # 构建文件行
                    file_line = f"{prefix}{current_connector}{icon} {name}"
                    
                    # 添加大小信息（简化版）
                    if info['size'] > 1024:  # 只显示大于1KB的文件大小
                        file_line += f" {size_info}"
                    
                    # 添加备注
                    if info.get('annotation'):
                        file_line += f" # {info['annotation']}"
                    
                    lines.append(file_line)
        
        # 构建树形结构
        build_tree_recursive(structure["tree"])
        
        # 添加统计信息
        lines.append("")
        lines.append(f"📊 统计: {structure['total_files']} 个文件, {structure['total_size']/1024/1024:.2f}MB")
        
        return "\n".join(lines)
    
    def _format_file_size(self, size_bytes: int) -> str:
        """格式化文件大小"""
        if size_bytes < 1024:
            return f"({size_bytes}B)"
        elif size_bytes < 1024 * 1024:
            return f"({size_bytes/1024:.1f}KB)"
        else:
            return f"({size_bytes/1024/1024:.1f}MB)"
    
    def _get_file_icon(self, filename: str) -> str:
        """根据文件类型返回合适的图标"""
        ext = filename.split('.')[-1].lower() if '.' in filename else ''
        
        icon_map = {
            'py': '🐍',      # Python
            'js': '📜',      # JavaScript
            'ts': '📘',      # TypeScript
            'jsx': '⚛️',     # React JSX
            'tsx': '⚛️',     # React TSX
            'java': '☕',    # Java
            'cpp': '⚙️',     # C++
            'c': '⚙️',       # C
            'h': '📎',       # Header files
            'cs': '💷',      # C#
            'go': '🐹',      # Go
            'rs': '🦀',      # Rust
            'rb': '💎',      # Ruby
            'php': '🐘',     # PHP
            'swift': '🦉',   # Swift
            'kt': '🟣',      # Kotlin
            'md': '📝',      # Markdown
            'txt': '📄',     # Text
            'json': '📊',    # JSON
            'yaml': '📋',    # YAML
            'yml': '📋',     # YAML
            'toml': '📋',    # TOML
            'xml': '📰',     # XML
            'html': '🌐',    # HTML
            'css': '🎨',     # CSS
            'scss': '🎨',    # SCSS
            'less': '🎨',    # LESS
            'sql': '🗃️',     # SQL
            'db': '🗄️',      # Database
            'sh': '💻',      # Shell script
            'bash': '💻',    # Bash script
            'bat': '💻',     # Batch file
            'ps1': '💻',     # PowerShell
            'env': '🔧',     # Environment
            'gitignore': '🚫', # Gitignore
            'dockerfile': '🐳', # Docker
            'png': '🖼️',     # Image
            'jpg': '🖼️',     # Image
            'jpeg': '🖼️',    # Image
            'gif': '🖼️',     # Image
            'svg': '🖼️',     # Image
            'ico': '🖼️',     # Icon
            'mp4': '🎬',     # Video
            'mp3': '🎵',     # Audio
            'wav': '🎵',     # Audio
            'pdf': '📕',     # PDF
            'doc': '📘',     # Word
            'docx': '📘',    # Word
            'xls': '📗',     # Excel
            'xlsx': '📗',    # Excel
            'ppt': '📙',     # PowerPoint
            'pptx': '📙',    # PowerPoint
            'zip': '📦',     # Archive
            'rar': '📦',     # Archive
            'tar': '📦',     # Archive
            'gz': '📦',      # Archive
            'log': '📋',     # Log file
            'lock': '🔒',    # Lock file
        }
        
        return icon_map.get(ext, '📄')  # 默认文件图标
    
    def check_context_size(self) -> Dict:
        """检查上下文大小"""
        sizes = {
            "temp_files": sum(len(content) for content in self.temp_files.values()),
            "conversation": sum(len(json.dumps(msg, ensure_ascii=False)) for msg in self.conversation_history),
            "total": 0
        }
        sizes["total"] = sum(sizes.values())
        
        return {
            "sizes": sizes,
            "is_overflow": sizes["total"] > MAX_CONTEXT_SIZE,
            "usage_percent": (sizes["total"] / MAX_CONTEXT_SIZE) * 100
        }
    def _compress_image_if_needed(self, path: Path) -> Optional[str]:
        """根据个性化设置压缩图片，返回 data URL（若压缩失败则返回 None 表示使用原图）。"""
        mode = getattr(self, "image_compression_mode", "original") or "original"
        if mode == "original":
            return None
        target_map = {
            "1080p": (1920, 1080),
            "720p": (1280, 720),
            "540p": (960, 540),
        }
        target = target_map.get(mode)
        if not target:
            return None
        try:
            from PIL import Image
        except Exception:
            return None
        try:
            with Image.open(path) as im:
                w, h = im.size
                max_w, max_h = target
                if w <= max_w and h <= max_h:
                    return None  # 已经不超过目标，不压缩
                im_copy = im.copy()
                im_copy.thumbnail((max_w, max_h))
                buf = io.BytesIO()
                im_copy.save(buf, format="PNG", optimize=True)
                data = buf.getvalue()
                mime, _ = mimetypes.guess_type(path.name)
                if not mime:
                    mime = "image/png"
                b64 = base64.b64encode(data).decode("utf-8")
                return f"data:{mime};base64,{b64}"
        except Exception:
            return None

    def _build_content_with_images(
        self,
        text: str,
        images: List[Any],
        videos: Optional[List[Any]] = None,
        media_refs: Optional[List[Dict[str, Any]]] = None,
    ) -> Any:
        """将文本与媒体组合成多模态 content。

        优先使用 media_refs（来自 media_store），回退到历史路径字段（images/videos）。
        """
        videos = videos or []
        media_refs = media_refs or []
        if not images and not videos and not media_refs:
            return text

        parts: List[Dict[str, Any]] = []
        extra_videos: List[Any] = []
        extra_notes: List[str] = []
        current_model = getattr(getattr(self, "main_terminal", None), "model_key", None)
        supports_video_fps = current_model == "qwen3-vl-plus"
        qwen_video_fps = 2

        media_payload_attached = False

        if media_refs:
            for ref in media_refs:
                if not isinstance(ref, dict):
                    continue
                kind = str(ref.get("kind") or "").strip().lower()
                data_url = self.media_store.to_data_url(ref)
                if not data_url:
                    continue
                if kind == "image":
                    parts.append({"type": "image_url", "image_url": {"url": data_url}})
                    media_payload_attached = True
                    continue
                if kind == "video":
                    payload: Dict[str, Any] = {"type": "video_url", "video_url": {"url": data_url}}
                    fps_value = ref.get("fps")
                    if supports_video_fps and fps_value is None:
                        fps_value = qwen_video_fps
                    if fps_value is not None:
                        payload["fps"] = fps_value
                    parts.append(payload)
                    media_payload_attached = True
                    continue
                if kind == "audio":
                    mime = str(ref.get("mime_type") or "audio/*")
                    size = ref.get("size")
                    label = f"[已附加音频 {mime}"
                    if isinstance(size, int):
                        label += f", {size} bytes"
                    label += "]"
                    extra_notes.append(label)
                    media_payload_attached = True

        # 若已通过 media_refs 成功附加媒体，避免再次从路径重复注入
        should_use_legacy_paths = not media_payload_attached

        if should_use_legacy_paths:
            for item in images:
                try:
                    if isinstance(item, dict):
                        path = str(item.get("path") or "").strip()
                    else:
                        path = str(item or "").strip()
                    if not path:
                        continue
                    abs_path = self._resolve_media_input_path(path)
                    if not abs_path or not abs_path.exists() or not abs_path.is_file():
                        continue
                    mime, _ = mimetypes.guess_type(abs_path.name)
                    if mime and mime.startswith("video/"):
                        extra_videos.append(item)
                        continue
                    if mime and not mime.startswith("image/"):
                        continue
                    data_url = self._compress_image_if_needed(abs_path)
                    if not data_url:
                        if not mime:
                            mime = "image/png"
                        data = abs_path.read_bytes()
                        b64 = base64.b64encode(data).decode("utf-8")
                        data_url = f"data:{mime};base64,{b64}"
                    parts.append({"type": "image_url", "image_url": {"url": data_url}})
                except Exception:
                    continue

            for item in [*videos, *extra_videos]:
                try:
                    if isinstance(item, dict):
                        path = item.get("path") or ""
                    else:
                        path = item
                    path_text = str(path or "").strip()
                    if not path_text:
                        continue
                    abs_path = self._resolve_media_input_path(path_text)
                    if not abs_path or not abs_path.exists() or not abs_path.is_file():
                        continue
                    if abs_path.stat().st_size > 50 * 1024 * 1024:
                        continue
                    mime, _ = mimetypes.guess_type(abs_path.name)
                    if not mime:
                        mime = "video/mp4"
                    data = abs_path.read_bytes()
                    b64 = base64.b64encode(data).decode("utf-8")
                    data_url = f"data:{mime};base64,{b64}"
                    payload = {"type": "video_url", "video_url": {"url": data_url}}
                    fps_value = item.get("fps") if isinstance(item, dict) else None
                    if supports_video_fps and fps_value is None:
                        fps_value = qwen_video_fps
                    if fps_value is not None:
                        payload["fps"] = fps_value
                    parts.append(payload)
                except Exception:
                    continue

        text_segments = []
        if text:
            text_segments.append(text)
        if extra_notes:
            text_segments.extend(extra_notes)
        merged_text = "\n".join(segment for segment in text_segments if segment)
        if merged_text:
            parts.insert(0, {"type": "text", "text": merged_text})

        # 只有纯文本时保持字符串，避免无意义地返回 list[{"type":"text"}]
        if not parts:
            return text
        if len(parts) == 1 and parts[0].get("type") == "text":
            return parts[0].get("text", "")
        return parts

    def _build_workspace_system_message(self, context: Dict) -> Optional[str]:
        """构建独立的工作区系统消息，根据运行模式动态展示环境与资源信息。"""
        template = self.load_prompt("workspace_system")
        if not template:
            template = (
                "## 工作区信息\n"
                "- **运行环境**：{runtime_environment}\n"
                "- **资源限制**：{resource_limit}\n"
                "- **当前时间**：{current_time}\n"
                "- **项目结构**：\n\n{file_tree}\n\n"
                "- **长期记忆**：{memory}"
            )

        is_host = self._is_host_mode_without_safety()
        runtime_environment = (
            self._build_host_runtime_environment()
            if is_host
            else f"隔离容器中（挂载目录 {self.container_mount_path or '/workspace'}），宿主机路径已隐藏"
        )
        resource_limit = (
            "宿主机模式无限制"
            if is_host
            else f"CPU {self.container_cpu_limit} 核，内存 {self.container_memory_limit}，磁盘配额 {self.project_storage_limit}"
        )

        now = datetime.now()
        current_time_text = f"{now.year}年{now.month}月{now.day}日 {now.hour}点（24小时制）"

        content = template.format(
            runtime_environment=runtime_environment,
            resource_limit=resource_limit,
            container_path=self.container_mount_path or "/workspace",
            container_cpus=self.container_cpu_limit,
            container_memory=self.container_memory_limit,
            project_storage=self.project_storage_limit,
            current_time=current_time_text,
            file_tree=(
                "（以下为工作区根目录的部分文件和文件夹）\n" + context["project_info"]["file_tree"]
                if context["project_info"].get("file_tree")
                else ""
            ),
            memory=context["memory"],
        )

        return content
