641 lines
22 KiB
Python
641 lines
22 KiB
Python
# modules/terminal_manager.py - 终端会话管理器
|
||
|
||
import json
|
||
from typing import Dict, List, Optional, Callable, TYPE_CHECKING
|
||
from pathlib import Path
|
||
from datetime import datetime
|
||
try:
|
||
from config import (
|
||
OUTPUT_FORMATS,
|
||
MAX_TERMINALS,
|
||
TERMINAL_BUFFER_SIZE,
|
||
TERMINAL_DISPLAY_SIZE,
|
||
TERMINAL_SNAPSHOT_DEFAULT_LINES,
|
||
TERMINAL_SNAPSHOT_MAX_LINES,
|
||
TERMINAL_SNAPSHOT_MAX_CHARS,
|
||
TERMINAL_SANDBOX_MODE,
|
||
TERMINAL_SANDBOX_IMAGE,
|
||
TERMINAL_SANDBOX_MOUNT_PATH,
|
||
TERMINAL_SANDBOX_SHELL,
|
||
TERMINAL_SANDBOX_NETWORK,
|
||
TERMINAL_SANDBOX_CPUS,
|
||
TERMINAL_SANDBOX_MEMORY,
|
||
TERMINAL_SANDBOX_BINDS,
|
||
TERMINAL_SANDBOX_BIN,
|
||
TERMINAL_SANDBOX_NAME_PREFIX,
|
||
TERMINAL_SANDBOX_ENV,
|
||
TERMINAL_SANDBOX_REQUIRE,
|
||
)
|
||
except ImportError:
|
||
import sys
|
||
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 (
|
||
OUTPUT_FORMATS,
|
||
MAX_TERMINALS,
|
||
TERMINAL_BUFFER_SIZE,
|
||
TERMINAL_DISPLAY_SIZE,
|
||
TERMINAL_SNAPSHOT_DEFAULT_LINES,
|
||
TERMINAL_SNAPSHOT_MAX_LINES,
|
||
TERMINAL_SNAPSHOT_MAX_CHARS,
|
||
TERMINAL_SANDBOX_MODE,
|
||
TERMINAL_SANDBOX_IMAGE,
|
||
TERMINAL_SANDBOX_MOUNT_PATH,
|
||
TERMINAL_SANDBOX_SHELL,
|
||
TERMINAL_SANDBOX_NETWORK,
|
||
TERMINAL_SANDBOX_CPUS,
|
||
TERMINAL_SANDBOX_MEMORY,
|
||
TERMINAL_SANDBOX_BINDS,
|
||
TERMINAL_SANDBOX_BIN,
|
||
TERMINAL_SANDBOX_NAME_PREFIX,
|
||
TERMINAL_SANDBOX_ENV,
|
||
TERMINAL_SANDBOX_REQUIRE,
|
||
)
|
||
|
||
from modules.persistent_terminal import PersistentTerminal
|
||
from utils.terminal_factory import TerminalFactory
|
||
|
||
if TYPE_CHECKING:
|
||
from modules.user_container_manager import ContainerHandle
|
||
|
||
class TerminalManager:
|
||
"""管理多个终端会话"""
|
||
|
||
def __init__(
|
||
self,
|
||
project_path: str,
|
||
max_terminals: int = None,
|
||
terminal_buffer_size: int = None,
|
||
terminal_display_size: int = None,
|
||
broadcast_callback: Callable = None,
|
||
sandbox_mode: Optional[str] = None,
|
||
sandbox_options: Optional[Dict] = None,
|
||
container_session: Optional["ContainerHandle"] = None,
|
||
):
|
||
"""
|
||
初始化终端管理器
|
||
|
||
Args:
|
||
project_path: 项目路径
|
||
max_terminals: 最大终端数量
|
||
terminal_buffer_size: 每个终端的缓冲区大小
|
||
terminal_display_size: 显示大小限制
|
||
broadcast_callback: WebSocket广播回调
|
||
"""
|
||
self.project_path = Path(project_path)
|
||
self.max_terminals = max_terminals or MAX_TERMINALS
|
||
self.terminal_buffer_size = terminal_buffer_size or TERMINAL_BUFFER_SIZE
|
||
self.terminal_display_size = terminal_display_size or TERMINAL_DISPLAY_SIZE
|
||
self.default_snapshot_lines = TERMINAL_SNAPSHOT_DEFAULT_LINES
|
||
self.max_snapshot_lines = TERMINAL_SNAPSHOT_MAX_LINES
|
||
self.max_snapshot_chars = TERMINAL_SNAPSHOT_MAX_CHARS
|
||
self.broadcast = broadcast_callback
|
||
self.default_sandbox_mode = (sandbox_mode or TERMINAL_SANDBOX_MODE or "host").lower()
|
||
self.sandbox_mode = self.default_sandbox_mode
|
||
default_sandbox_options = {
|
||
"image": TERMINAL_SANDBOX_IMAGE,
|
||
"mount_path": TERMINAL_SANDBOX_MOUNT_PATH,
|
||
"shell": TERMINAL_SANDBOX_SHELL,
|
||
"network": TERMINAL_SANDBOX_NETWORK,
|
||
"cpus": TERMINAL_SANDBOX_CPUS,
|
||
"memory": TERMINAL_SANDBOX_MEMORY,
|
||
"binds": list(TERMINAL_SANDBOX_BINDS),
|
||
"bin": TERMINAL_SANDBOX_BIN,
|
||
"name_prefix": TERMINAL_SANDBOX_NAME_PREFIX,
|
||
"env": dict(TERMINAL_SANDBOX_ENV),
|
||
"require": TERMINAL_SANDBOX_REQUIRE,
|
||
}
|
||
if sandbox_options:
|
||
# 深拷贝,确保不会影响默认值
|
||
for key, value in sandbox_options.items():
|
||
if key == "binds" and isinstance(value, list):
|
||
default_sandbox_options[key] = list(value)
|
||
elif key == "env" and isinstance(value, dict):
|
||
default_sandbox_options[key] = dict(value)
|
||
else:
|
||
default_sandbox_options[key] = value
|
||
self.sandbox_options = default_sandbox_options
|
||
self.container_session: Optional["ContainerHandle"] = None
|
||
if sandbox_options and sandbox_options.get("container_name"):
|
||
self.sandbox_mode = "docker"
|
||
self._apply_container_session(container_session)
|
||
|
||
# 终端会话字典
|
||
self.terminals: Dict[str, PersistentTerminal] = {}
|
||
|
||
# 当前活动终端
|
||
self.active_terminal: Optional[str] = None
|
||
|
||
# 终端工厂(跨平台支持)
|
||
self.factory = TerminalFactory()
|
||
|
||
def _apply_container_session(self, session: Optional["ContainerHandle"]):
|
||
"""根据容器句柄调整执行模式。"""
|
||
self.container_session = session
|
||
if session and session.mode == "docker":
|
||
self.sandbox_mode = "docker"
|
||
elif session:
|
||
self.sandbox_mode = "host"
|
||
else:
|
||
self.sandbox_mode = self.default_sandbox_mode
|
||
|
||
def _build_sandbox_options(self) -> Dict:
|
||
"""构造当前终端应使用的沙箱参数。"""
|
||
options = dict(self.sandbox_options)
|
||
if self.container_session and self.container_session.mode == "docker":
|
||
options["container_name"] = self.container_session.container_name
|
||
options["mount_path"] = self.container_session.mount_path
|
||
else:
|
||
options.pop("container_name", None)
|
||
return options
|
||
|
||
@staticmethod
|
||
def _same_container(a: Optional["ContainerHandle"], b: Optional["ContainerHandle"]) -> bool:
|
||
if a is b:
|
||
return True
|
||
if not a or not b:
|
||
return False
|
||
if a.mode != b.mode:
|
||
return False
|
||
if a.mode == "docker":
|
||
return a.container_id == b.container_id and a.container_name == b.container_name
|
||
return True
|
||
|
||
def update_container_session(self, session: Optional["ContainerHandle"]):
|
||
"""外部更新容器信息,必要时重置终端。"""
|
||
if self._same_container(self.container_session, session):
|
||
self._apply_container_session(session)
|
||
return
|
||
self._apply_container_session(session)
|
||
if self.terminals:
|
||
print(f"{OUTPUT_FORMATS['warning']} 容器已切换,正在关闭现有终端会话。")
|
||
self.close_all()
|
||
|
||
def open_terminal(
|
||
self,
|
||
session_name: str,
|
||
working_dir: str = None,
|
||
make_active: bool = True
|
||
) -> Dict:
|
||
"""
|
||
打开新终端会话
|
||
|
||
Args:
|
||
session_name: 会话名称
|
||
working_dir: 工作目录(相对于项目路径)
|
||
make_active: 是否设为活动终端
|
||
|
||
Returns:
|
||
操作结果
|
||
"""
|
||
# 检查是否已存在
|
||
if session_name in self.terminals:
|
||
return {
|
||
"success": False,
|
||
"error": f"终端会话 '{session_name}' 已存在",
|
||
"existing_sessions": list(self.terminals.keys())
|
||
}
|
||
|
||
# 检查数量限制
|
||
if len(self.terminals) >= self.max_terminals:
|
||
return {
|
||
"success": False,
|
||
"error": f"已达到最大终端数量限制 ({self.max_terminals})",
|
||
"existing_sessions": list(self.terminals.keys()),
|
||
"suggestion": "请先关闭一个终端会话"
|
||
}
|
||
|
||
# 确定工作目录
|
||
if working_dir:
|
||
work_path = self.project_path / working_dir
|
||
if not work_path.exists():
|
||
work_path.mkdir(parents=True, exist_ok=True)
|
||
else:
|
||
work_path = self.project_path
|
||
|
||
# 获取合适的shell命令(用于宿主机或回退模式)
|
||
shell_command = self.factory.get_shell_command()
|
||
|
||
# 创建终端实例
|
||
sandbox_options = self._build_sandbox_options()
|
||
terminal = PersistentTerminal(
|
||
session_name=session_name,
|
||
working_dir=str(work_path),
|
||
shell_command=shell_command,
|
||
broadcast_callback=self.broadcast,
|
||
max_buffer_size=self.terminal_buffer_size,
|
||
display_size=self.terminal_display_size,
|
||
project_path=str(self.project_path),
|
||
sandbox_mode=self.sandbox_mode,
|
||
sandbox_options=sandbox_options
|
||
)
|
||
|
||
# 启动终端
|
||
if not terminal.start():
|
||
return {
|
||
"success": False,
|
||
"error": "终端启动失败",
|
||
"session": session_name
|
||
}
|
||
|
||
# 保存终端实例
|
||
self.terminals[session_name] = terminal
|
||
|
||
# 设为活动终端
|
||
if make_active:
|
||
self.active_terminal = session_name
|
||
|
||
print(f"{OUTPUT_FORMATS['success']} 终端会话已打开: {session_name}")
|
||
|
||
# 广播终端列表更新
|
||
if self.broadcast:
|
||
self.broadcast('terminal_list_update', {
|
||
'terminals': self.get_terminal_list(),
|
||
'active': self.active_terminal
|
||
})
|
||
|
||
return {
|
||
"success": True,
|
||
"session": session_name,
|
||
"working_dir": str(work_path),
|
||
"shell": terminal.shell_command or shell_command,
|
||
"is_active": make_active,
|
||
"total_sessions": len(self.terminals)
|
||
}
|
||
|
||
def close_terminal(self, session_name: str) -> Dict:
|
||
"""
|
||
关闭终端会话
|
||
|
||
Args:
|
||
session_name: 会话名称
|
||
|
||
Returns:
|
||
操作结果
|
||
"""
|
||
if session_name not in self.terminals:
|
||
return {
|
||
"success": False,
|
||
"error": f"终端会话 '{session_name}' 不存在",
|
||
"existing_sessions": list(self.terminals.keys())
|
||
}
|
||
|
||
# 获取终端实例
|
||
terminal = self.terminals[session_name]
|
||
|
||
# 关闭终端
|
||
terminal.close()
|
||
|
||
# 从字典中移除
|
||
del self.terminals[session_name]
|
||
|
||
# 如果是活动终端,切换到另一个
|
||
if self.active_terminal == session_name:
|
||
if self.terminals:
|
||
self.active_terminal = list(self.terminals.keys())[0]
|
||
else:
|
||
self.active_terminal = None
|
||
|
||
print(f"{OUTPUT_FORMATS['info']} 终端会话已关闭: {session_name}")
|
||
|
||
# 广播终端列表更新
|
||
if self.broadcast:
|
||
self.broadcast('terminal_list_update', {
|
||
'terminals': self.get_terminal_list(),
|
||
'active': self.active_terminal
|
||
})
|
||
|
||
return {
|
||
"success": True,
|
||
"session": session_name,
|
||
"remaining_sessions": list(self.terminals.keys()),
|
||
"new_active": self.active_terminal
|
||
}
|
||
|
||
def reset_terminal(self, session_name: Optional[str]) -> Dict:
|
||
"""
|
||
重置终端会话:关闭并重新创建同名会话
|
||
|
||
Args:
|
||
session_name: 会话名称
|
||
|
||
Returns:
|
||
操作结果
|
||
"""
|
||
target_session = session_name or self.active_terminal
|
||
if not target_session:
|
||
return {
|
||
"success": False,
|
||
"error": "没有活动终端会话",
|
||
"suggestion": "请先使用 terminal_session 打开一个终端"
|
||
}
|
||
|
||
if target_session not in self.terminals:
|
||
return {
|
||
"success": False,
|
||
"error": f"终端会话 '{target_session}' 不存在",
|
||
"existing_sessions": list(self.terminals.keys())
|
||
}
|
||
|
||
terminal = self.terminals[target_session]
|
||
working_dir = str(terminal.working_dir)
|
||
shell_command = self.factory.get_shell_command()
|
||
|
||
terminal.close()
|
||
del self.terminals[target_session]
|
||
|
||
sandbox_options = self._build_sandbox_options()
|
||
new_terminal = PersistentTerminal(
|
||
session_name=target_session,
|
||
working_dir=working_dir,
|
||
shell_command=shell_command,
|
||
broadcast_callback=self.broadcast,
|
||
max_buffer_size=self.terminal_buffer_size,
|
||
display_size=self.terminal_display_size,
|
||
project_path=str(self.project_path),
|
||
sandbox_mode=self.sandbox_mode,
|
||
sandbox_options=sandbox_options
|
||
)
|
||
|
||
if not new_terminal.start():
|
||
if self.terminals:
|
||
self.active_terminal = next(iter(self.terminals.keys()))
|
||
else:
|
||
self.active_terminal = None
|
||
if self.broadcast:
|
||
self.broadcast('terminal_list_update', {
|
||
'terminals': self.get_terminal_list(),
|
||
'active': self.active_terminal
|
||
})
|
||
return {
|
||
"success": False,
|
||
"error": f"终端会话 '{target_session}' 重置失败:无法重新启动进程",
|
||
"working_dir": working_dir
|
||
}
|
||
|
||
self.terminals[target_session] = new_terminal
|
||
self.active_terminal = target_session
|
||
|
||
if self.broadcast:
|
||
self.broadcast('terminal_reset', {
|
||
'session': target_session,
|
||
'working_dir': working_dir,
|
||
'shell': new_terminal.shell_command or shell_command,
|
||
'time': datetime.now().isoformat()
|
||
})
|
||
self.broadcast('terminal_list_update', {
|
||
'terminals': self.get_terminal_list(),
|
||
'active': self.active_terminal
|
||
})
|
||
|
||
return {
|
||
"success": True,
|
||
"session": target_session,
|
||
"working_dir": working_dir,
|
||
"shell": new_terminal.shell_command or shell_command,
|
||
"message": "终端会话已重置并重新启动"
|
||
}
|
||
|
||
def switch_terminal(self, session_name: str) -> Dict:
|
||
"""
|
||
切换活动终端
|
||
|
||
Args:
|
||
session_name: 会话名称
|
||
|
||
Returns:
|
||
操作结果
|
||
"""
|
||
if session_name not in self.terminals:
|
||
return {
|
||
"success": False,
|
||
"error": f"终端会话 '{session_name}' 不存在",
|
||
"existing_sessions": list(self.terminals.keys())
|
||
}
|
||
|
||
previous_active = self.active_terminal
|
||
self.active_terminal = session_name
|
||
|
||
print(f"{OUTPUT_FORMATS['info']} 切换到终端: {session_name}")
|
||
|
||
# 广播切换事件
|
||
if self.broadcast:
|
||
self.broadcast('terminal_switched', {
|
||
'previous': previous_active,
|
||
'current': session_name
|
||
})
|
||
|
||
return {
|
||
"success": True,
|
||
"previous": previous_active,
|
||
"current": session_name,
|
||
"status": self.terminals[session_name].get_status()
|
||
}
|
||
|
||
def list_terminals(self) -> Dict:
|
||
"""
|
||
列出所有终端会话
|
||
|
||
Returns:
|
||
终端列表
|
||
"""
|
||
sessions = []
|
||
for name, terminal in self.terminals.items():
|
||
status = terminal.get_status()
|
||
status['is_active'] = (name == self.active_terminal)
|
||
sessions.append(status)
|
||
|
||
return {
|
||
"success": True,
|
||
"sessions": sessions,
|
||
"active": self.active_terminal,
|
||
"total": len(self.terminals),
|
||
"max_allowed": self.max_terminals
|
||
}
|
||
|
||
def send_to_terminal(
|
||
self,
|
||
command: str,
|
||
session_name: str = None,
|
||
wait_for_output: bool = True,
|
||
timeout: float = None
|
||
) -> Dict:
|
||
"""
|
||
向终端发送命令
|
||
|
||
Args:
|
||
command: 要执行的命令
|
||
session_name: 目标终端(None则使用活动终端)
|
||
wait_for_output: 是否等待输出
|
||
|
||
Returns:
|
||
执行结果
|
||
"""
|
||
# 确定目标终端
|
||
target_session = session_name or self.active_terminal
|
||
|
||
if not target_session:
|
||
return {
|
||
"success": False,
|
||
"error": "没有活动终端会话",
|
||
"suggestion": "请先使用 terminal_session 打开一个终端"
|
||
}
|
||
|
||
if target_session not in self.terminals:
|
||
return {
|
||
"success": False,
|
||
"error": f"终端会话 '{target_session}' 不存在",
|
||
"existing_sessions": list(self.terminals.keys())
|
||
}
|
||
|
||
# 发送命令
|
||
terminal = self.terminals[target_session]
|
||
result = terminal.send_command(command, wait_for_output, timeout=timeout)
|
||
|
||
return result
|
||
|
||
def get_terminal_output(
|
||
self,
|
||
session_name: str = None,
|
||
last_n_lines: int = 50
|
||
) -> Dict:
|
||
"""
|
||
获取终端输出
|
||
|
||
Args:
|
||
session_name: 终端名称(None则使用活动终端)
|
||
last_n_lines: 获取最后N行
|
||
|
||
Returns:
|
||
输出内容
|
||
"""
|
||
target_session = session_name or self.active_terminal
|
||
|
||
if not target_session:
|
||
return {
|
||
"success": False,
|
||
"error": "没有活动终端会话"
|
||
}
|
||
|
||
if target_session not in self.terminals:
|
||
return {
|
||
"success": False,
|
||
"error": f"终端会话 '{target_session}' 不存在"
|
||
}
|
||
|
||
terminal = self.terminals[target_session]
|
||
requested_lines = last_n_lines
|
||
if requested_lines is None:
|
||
snapshot_lines = self.default_snapshot_lines
|
||
elif requested_lines <= 0:
|
||
snapshot_lines = None
|
||
else:
|
||
snapshot_lines = min(requested_lines, self.max_snapshot_lines)
|
||
|
||
snapshot_char_limit = self.max_snapshot_chars if snapshot_lines is not None else None
|
||
snapshot = terminal.get_snapshot(
|
||
snapshot_lines,
|
||
snapshot_char_limit
|
||
)
|
||
|
||
fallback_lines = requested_lines
|
||
if fallback_lines is None:
|
||
fallback_lines = self.default_snapshot_lines
|
||
elif fallback_lines <= 0:
|
||
fallback_lines = 0
|
||
|
||
output = snapshot.get("output") if snapshot.get("success") else terminal.get_output(fallback_lines)
|
||
|
||
return {
|
||
"success": True,
|
||
"session": target_session,
|
||
"output": output,
|
||
"is_interactive": snapshot.get("is_interactive", terminal.is_interactive),
|
||
"last_command": snapshot.get("last_command", terminal.last_command),
|
||
"seconds_since_last_output": snapshot.get("seconds_since_last_output", terminal._seconds_since_last_output()),
|
||
"echo_loop_detected": snapshot.get("echo_loop_detected", terminal.echo_loop_detected),
|
||
"lines_returned": snapshot.get("lines_returned"),
|
||
"truncated": snapshot.get("truncated", False)
|
||
}
|
||
|
||
def get_terminal_snapshot(
|
||
self,
|
||
session_name: str = None,
|
||
lines: int = None,
|
||
max_chars: int = None
|
||
) -> Dict:
|
||
"""
|
||
获取终端输出快照
|
||
|
||
Args:
|
||
session_name: 指定会话(默认使用活动会话)
|
||
lines: 返回的最大行数
|
||
max_chars: 返回的最大字符数
|
||
|
||
Returns:
|
||
包含快照内容和状态的字典
|
||
"""
|
||
target_session = session_name or self.active_terminal
|
||
if not target_session:
|
||
return {
|
||
"success": False,
|
||
"error": "没有活动终端会话",
|
||
"suggestion": "请先使用 terminal_session 打开一个终端"
|
||
}
|
||
|
||
if target_session not in self.terminals:
|
||
return {
|
||
"success": False,
|
||
"error": f"终端会话 '{target_session}' 不存在",
|
||
"existing_sessions": list(self.terminals.keys())
|
||
}
|
||
|
||
if lines is None:
|
||
line_limit = self.default_snapshot_lines
|
||
elif lines <= 0:
|
||
line_limit = None
|
||
else:
|
||
line_limit = max(1, min(lines, self.max_snapshot_lines))
|
||
char_limit = max(100, min(max_chars if max_chars else self.max_snapshot_chars, self.max_snapshot_chars))
|
||
|
||
terminal = self.terminals[target_session]
|
||
char_limit = None if line_limit is None else char_limit
|
||
snapshot = terminal.get_snapshot(line_limit, char_limit)
|
||
snapshot.update({
|
||
"line_limit": line_limit,
|
||
"char_limit": char_limit,
|
||
"session": target_session
|
||
})
|
||
|
||
if snapshot.get("truncated"):
|
||
snapshot["note"] = f"输出已截断,仅返回了末尾的 {char_limit} 个字符"
|
||
|
||
return snapshot
|
||
|
||
def get_terminal_list(self) -> List[Dict]:
|
||
"""获取终端列表(简化版)"""
|
||
return [
|
||
{
|
||
"name": name,
|
||
"is_active": name == self.active_terminal,
|
||
"is_running": terminal.is_running,
|
||
"working_dir": str(terminal.working_dir)
|
||
}
|
||
for name, terminal in self.terminals.items()
|
||
]
|
||
|
||
def close_all(self):
|
||
"""关闭所有终端会话"""
|
||
print(f"{OUTPUT_FORMATS['info']} 关闭所有终端会话...")
|
||
|
||
for session_name in list(self.terminals.keys()):
|
||
self.close_terminal(session_name)
|
||
|
||
self.active_terminal = None
|
||
print(f"{OUTPUT_FORMATS['success']} 所有终端会话已关闭")
|
||
|
||
def __del__(self):
|
||
"""析构函数,确保所有终端被关闭"""
|
||
self.close_all()
|