agent-Specialization/utils/context_manager/project_mixin.py
JOJO f661dc6456 refactor(server,modules,utils,frontend): split oversized modules into packages and fix regressions
- Split server/chat.py, server/status.py, server/tasks.py into sub-packages
- Split utils/context_manager.py, utils/conversation_manager.py into mixin packages
- Split modules/file_manager.py, persistent_terminal.py, terminal_ops.py, mcp_client_manager.py into packages
- Split core/main_terminal_parts/context.py into base + mixins
- Split static/src/app/methods/{ui,message,upload,taskPolling,conversation} into sub-modules
- Fix flattened method exports, dynamic import paths, missing cross-module imports
- Add missing permission/network/execution mode constants and _PERMISSION_MODE_LABEL
- Update AGENTS.md, CLAUDE.md and project memories to reflect new structure
2026-06-20 15:54:48 +08:00

499 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
from utils.token_usage import normalize_usage_payload
AUTO_SHALLOW_PLACEHOLDER = "过早的工具结果已经被自动压缩"
AUTO_SHALLOW_TOOL_WHITELIST = {
"write_file",
"read_file",
"edit_file",
"terminal_input",
"terminal_snapshot",
"web_search",
"extract_webpage",
"run_command",
"view_image",
"view_video",
}
class ProjectMixin:
"""ContextManager project mixin 能力 mixin。"""
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)]
def add_entry(entry: Path, *, display_path: Optional[str] = None):
relative_path = str(entry.relative_to(self.project_path))
if display_path:
relative_path = display_path
if entry.is_dir():
structure["folders"].append({
"name": entry.name,
"path": relative_path
})
parts = relative_path.split("/")
parent = structure["tree"]
for idx, part in enumerate(parts):
is_leaf = idx == len(parts) - 1
parent.setdefault(part, {
"type": "folder",
"path": "/".join(parts[:idx + 1]),
"children": {}
})
if is_leaf:
parent[part]["type"] = "folder"
parent[part]["path"] = relative_path
parent[part].setdefault("children", {})
parent = parent[part].setdefault("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"]
}
for entry in selected:
add_entry(entry)
# 根目录浅扫会跳过点目录;内部文件迁移到 .agents 后,单独展示这几个对模型有意义的目录。
agents_dir = self.project_path / ".agents"
for internal_name in ("skills", "user_upload", "compact_result", "review"):
internal_dir = agents_dir / internal_name
if internal_dir.exists() and internal_dir.is_dir():
add_entry(internal_dir, display_path=f".agents/{internal_name}")
return structure
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 _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, '📄') # 默认文件图标