"""用户终端与工作区相关的共享辅助函数。""" from __future__ import annotations from functools import wraps from typing import Optional, Tuple, Dict, Any from flask import session, jsonify, has_request_context from core.web_terminal import WebTerminal from modules.gui_file_manager import GuiFileManager from modules.upload_security import UploadQuarantineManager, UploadSecurityError from modules.personalization_manager import load_personalization_config from modules.skills_manager import infer_private_skills_dir, sync_workspace_skills from modules.host_workspace_manager import resolve_host_workspace from utils.host_workspace_debug import write_host_workspace_debug import json from pathlib import Path from modules.usage_tracker import UsageTracker from config import ( DATA_DIR, LOGS_DIR, TERMINAL_SANDBOX_MODE, UPLOAD_QUARANTINE_SUBDIR, ) from config.model_profiles import get_registered_model_keys from . import state from .utils_common import debug_log from .auth_helpers import get_current_username, get_current_user_record, get_current_user_role # will create helper module def make_terminal_callback(username: str): """生成面向指定用户的广播函数""" from .extensions import socketio def _callback(event_type, data): try: socketio.emit(event_type, data, room=f"user_{username}") except Exception as exc: debug_log(f"广播事件失败 ({username}): {event_type} - {exc}") return _callback def attach_user_broadcast(terminal: WebTerminal, username: str): """确保终端的广播函数指向当前用户的房间""" callback = make_terminal_callback(username) terminal.message_callback = callback if terminal.terminal_manager: terminal.terminal_manager.broadcast = callback def _make_terminal_key(username: str, workspace_id: Optional[str] = None) -> str: return f"{username}::{workspace_id}" if workspace_id else username def _set_terminal_workspace_label(terminal: WebTerminal, label: Optional[str]) -> None: label_text = str(label or "").strip() try: terminal.workspace_label = label_text except Exception: pass try: if getattr(terminal, "context_manager", None): terminal.context_manager.workspace_label = label_text except Exception: pass def _ensure_workspace_skills_synced(terminal: WebTerminal, workspace) -> None: """ 确保工作区 skills 已按当前个性化配置完成同步。 使用终端实例上的路径标记避免每次请求都重复全量拷贝。 """ try: project_path = str(Path(workspace.project_path).resolve()) except Exception: project_path = str(workspace.project_path) if getattr(terminal, "_skills_synced_project_path", None) == project_path: return try: config = load_personalization_config(workspace.data_dir) enabled_skills = config.get("enabled_skills") if isinstance(config, dict) else None result = sync_workspace_skills( workspace.project_path, enabled_skills, private_dir=infer_private_skills_dir(workspace.data_dir), ) if not result.get("success"): debug_log(f"[Skills] 工作区同步失败: {result.get('error')}") return terminal._skills_synced_project_path = project_path debug_log(f"[Skills] 工作区技能已同步: {project_path} ({result.get('copied', 0)} 项)") except Exception as exc: debug_log(f"[Skills] 工作区同步异常: {exc}") def get_user_resources(username: Optional[str] = None, workspace_id: Optional[str] = None) -> Tuple[Optional[WebTerminal], Optional['modules.user_manager.UserWorkspace']]: from modules.user_manager import UserWorkspace username = (username or get_current_username()) if not username: return None, None # 宿主机免登录模式:根据 host_workspaces.json 选择路径,不创建 /users//project host_mode_session = bool(session.get("host_mode")) if has_request_context() else False sandbox_is_host = (TERMINAL_SANDBOX_MODE or "host").lower() == "host" if host_mode_session and sandbox_is_host: # 宿主机多工作区并行:资源选择必须优先由显式 workspace_id / 当前请求 session 决定, # 不能被进程级 HOST_ACTIVE_WORKSPACE_ID 覆盖,否则后台任务会在用户切换视图后串到新工作区。 selected_workspace_id = ( workspace_id or (session.get("host_workspace_id") if has_request_context() else None) or (session.get("workspace_id") if has_request_context() else None) ) with state.HOST_ACTIVE_WORKSPACE_LOCK: active_workspace_id = state.HOST_ACTIVE_WORKSPACE_ID active_workspace_path = state.HOST_ACTIVE_WORKSPACE_PATH active_workspace_version = state.HOST_ACTIVE_WORKSPACE_VERSION if not selected_workspace_id and active_workspace_id: selected_workspace_id = active_workspace_id _, host_workspace = resolve_host_workspace(selected_workspace_id) if ( active_workspace_id and active_workspace_path and selected_workspace_id == active_workspace_id ): host_workspace = dict(host_workspace) host_workspace["workspace_id"] = active_workspace_id host_workspace["path"] = active_workspace_path project_path = Path(host_workspace.get("path") or "").expanduser().resolve() write_host_workspace_debug( "context.get_user_resources.host.selected_workspace", selected_workspace_id=selected_workspace_id, active_workspace_id=active_workspace_id, active_workspace_path=active_workspace_path, active_workspace_version=active_workspace_version, resolved_workspace_id=host_workspace.get("workspace_id"), project_path=str(project_path), username=username, ) project_path.mkdir(parents=True, exist_ok=True) data_dir = Path(DATA_DIR).expanduser().resolve() data_dir.mkdir(parents=True, exist_ok=True) logs_dir = Path(LOGS_DIR).expanduser().resolve() logs_dir.mkdir(parents=True, exist_ok=True) uploads_dir = project_path / ".agents" / "user_upload" uploads_dir.mkdir(parents=True, exist_ok=True) skills_dir = project_path / ".agents" / "skills" skills_dir.mkdir(parents=True, exist_ok=True) quarantine_root = Path(UPLOAD_QUARANTINE_SUBDIR).expanduser() if not quarantine_root.is_absolute(): quarantine_root = (project_path.parent / UPLOAD_QUARANTINE_SUBDIR).resolve() quarantine_root.mkdir(parents=True, exist_ok=True) workspace = UserWorkspace( username="host", root=project_path.parent, project_path=project_path, data_dir=data_dir, logs_dir=logs_dir, uploads_dir=uploads_dir, quarantine_dir=quarantine_root, ) if not hasattr(workspace, "workspace_id"): workspace.workspace_id = host_workspace.get("workspace_id") or "default" workspace_id_value = getattr(workspace, "workspace_id", None) or host_workspace.get("workspace_id") or "default" term_key = _make_terminal_key("host", workspace_id_value) container_handle = state.container_manager.ensure_container("host", str(project_path), container_key=term_key, preferred_mode="host") usage_tracker = None # 宿主机模式不计配额 terminal = state.user_terminals.get(term_key) target_project_path = str(project_path) if terminal: should_recreate_terminal = False try: current_project_path = str(Path(getattr(terminal, "project_path", "")).expanduser().resolve()) except Exception: current_project_path = str(getattr(terminal, "project_path", "")) try: current_context_project_path = str( Path(getattr(getattr(terminal, "context_manager", None), "project_path", "")).expanduser().resolve() ) except Exception: current_context_project_path = str( getattr(getattr(terminal, "context_manager", None), "project_path", "") ) if current_project_path != target_project_path or current_context_project_path != target_project_path: write_host_workspace_debug( "context.get_user_resources.host.path_mismatch", terminal_id=id(terminal), current_project_path=current_project_path, current_context_project_path=current_context_project_path, target_project_path=target_project_path, ) try: if hasattr(terminal, "update_project_path"): terminal.update_project_path(target_project_path) else: should_recreate_terminal = True except Exception as exc: debug_log(f"[HostWorkspace] update_project_path 失败,回退重建终端: {exc}") should_recreate_terminal = True if not should_recreate_terminal: try: updated_project_path = str(Path(getattr(terminal, "project_path", "")).expanduser().resolve()) except Exception: updated_project_path = str(getattr(terminal, "project_path", "")) if updated_project_path != target_project_path: should_recreate_terminal = True if should_recreate_terminal: write_host_workspace_debug( "context.get_user_resources.host.recreate_terminal", terminal_id=id(terminal), target_project_path=target_project_path, ) try: if getattr(terminal, "terminal_manager", None): terminal.terminal_manager.close_all() except Exception: pass state.user_terminals.pop(term_key, None) terminal = None if not terminal: run_mode = session.get('run_mode') if has_request_context() else None thinking_mode_flag = session.get('thinking_mode') if has_request_context() else None if run_mode not in {"fast", "thinking", "deep"}: run_mode = "fast" thinking_mode_flag = False thinking_mode = bool(thinking_mode_flag) if thinking_mode_flag is not None else (run_mode != "fast") terminal = WebTerminal( project_path=str(project_path), thinking_mode=thinking_mode, run_mode=run_mode, message_callback=make_terminal_callback("host"), data_dir=str(data_dir), container_session=container_handle, usage_tracker=usage_tracker ) if terminal.terminal_manager: terminal.terminal_manager.broadcast = terminal.message_callback state.user_terminals[term_key] = terminal terminal.username = "host" terminal.user_role = "admin" terminal.quota_update_callback = None if has_request_context(): session['run_mode'] = terminal.run_mode session['thinking_mode'] = terminal.thinking_mode session['workspace_id'] = getattr(workspace, "workspace_id", None) session['host_workspace_id'] = getattr(workspace, "workspace_id", None) else: terminal.update_container_session(container_handle) attach_user_broadcast(terminal, "host") terminal.username = "host" terminal.user_role = "admin" if has_request_context(): session['workspace_id'] = getattr(workspace, "workspace_id", None) session['host_workspace_id'] = getattr(workspace, "workspace_id", None) _set_terminal_workspace_label( terminal, host_workspace.get("label") or host_workspace.get("workspace_id") or getattr(workspace, "workspace_id", None), ) # 宿主机模式同样需要应用管理员策略(否则前端工具菜单会退化成静态基础分类) try: from core.tool_config import ToolCategory from modules import admin_policy_manager record = get_current_user_record() role = get_current_user_role(record) if record else "admin" invite_code = getattr(record, "invite_code", None) if record else None policy = admin_policy_manager.get_effective_policy( record.username if record else username, role, invite_code, ) categories_map = { cid: ToolCategory( label=cat.get("label") or cid, tools=list(cat.get("tools") or []), default_enabled=bool(cat.get("default_enabled", True)), silent_when_disabled=bool(cat.get("silent_when_disabled", False)), ) for cid, cat in policy.get("categories", {}).items() } forced_states = policy.get("forced_category_states") or {} disabled_models = policy.get("disabled_models") or [] terminal.set_admin_policy(categories_map, forced_states, disabled_models) terminal.admin_policy_ui_blocks = policy.get("ui_blocks") or {} terminal.admin_policy_version = policy.get("updated_at") if terminal.model_key in disabled_models: for candidate in get_registered_model_keys(visible_only=True): if candidate not in disabled_models: try: terminal.set_model(candidate) if has_request_context(): session["model_key"] = terminal.model_key break except Exception: continue except Exception as exc: debug_log(f"[admin_policy][host_mode] 应用失败: {exc}") _ensure_workspace_skills_synced(terminal, workspace) write_host_workspace_debug( "context.get_user_resources.host.return", terminal_id=id(terminal), terminal_project_path=str(getattr(terminal, "project_path", "")), context_project_path=str(getattr(getattr(terminal, "context_manager", None), "project_path", "")), workspace_project_path=str(getattr(workspace, "project_path", "")), workspace_id=getattr(workspace, "workspace_id", None), current_conversation_id=getattr(getattr(terminal, "context_manager", None), "current_conversation_id", None), ) return terminal, workspace is_api_user = bool(session.get("is_api_user")) if has_request_context() else False # API 用户与网页用户使用不同的 manager if is_api_user: record = None if workspace_id is None: raise RuntimeError("API 调用缺少 workspace_id") workspace = state.api_user_manager.ensure_workspace(username, workspace_id) else: record = get_current_user_record() selected_workspace_id = ( workspace_id or (session.get("workspace_id") if has_request_context() else None) or "default" ) workspace = state.user_manager.ensure_user_workspace(username, selected_workspace_id) # 为兼容后续逻辑,补充 workspace_id 属性 if not hasattr(workspace, "workspace_id"): try: workspace.workspace_id = selected_workspace_id or "default" except Exception: pass workspace_id_value = getattr(workspace, "workspace_id", None) or "default" term_key = _make_terminal_key(username, workspace_id_value) container_handle = state.container_manager.ensure_container(username, str(workspace.project_path), container_key=term_key, preferred_mode="docker") usage_tracker = None if is_api_user else get_or_create_usage_tracker(username, workspace) terminal = state.user_terminals.get(term_key) if not terminal: run_mode = session.get('run_mode') if has_request_context() else None thinking_mode_flag = session.get('thinking_mode') if has_request_context() else None if run_mode not in {"fast", "thinking", "deep"}: preferred_run_mode = None try: personal_config = load_personalization_config(workspace.data_dir) candidate_mode = (personal_config or {}).get('default_run_mode') if isinstance(candidate_mode, str) and candidate_mode.lower() in {"fast", "thinking", "deep"}: preferred_run_mode = candidate_mode.lower() except Exception as exc: debug_log(f"[UserInit] 加载个性化偏好失败: {exc}") if preferred_run_mode: run_mode = preferred_run_mode thinking_mode_flag = preferred_run_mode != "fast" elif thinking_mode_flag: run_mode = "deep" else: run_mode = "fast" thinking_mode = run_mode != "fast" terminal = WebTerminal( project_path=str(workspace.project_path), thinking_mode=thinking_mode, run_mode=run_mode, message_callback=make_terminal_callback(username), data_dir=str(workspace.data_dir), container_session=container_handle, usage_tracker=usage_tracker ) if terminal.terminal_manager: terminal.terminal_manager.broadcast = terminal.message_callback state.user_terminals[term_key] = terminal terminal.username = username terminal.user_role = "api" if is_api_user else get_current_user_role(record) terminal.quota_update_callback = (lambda metric=None: emit_user_quota_update(username)) if not is_api_user else None if has_request_context(): session['run_mode'] = terminal.run_mode session['thinking_mode'] = terminal.thinking_mode session['model_key'] = getattr(terminal, "model_key", None) session['workspace_id'] = getattr(workspace, "workspace_id", None) else: terminal.update_container_session(container_handle) attach_user_broadcast(terminal, username) terminal.username = username terminal.user_role = "api" if is_api_user else get_current_user_role(record) terminal.quota_update_callback = (lambda metric=None: emit_user_quota_update(username)) if not is_api_user else None if has_request_context(): session['workspace_id'] = getattr(workspace, "workspace_id", None) if is_api_user: workspace_label = workspace_id_value else: try: workspace_label = ( state.user_manager.list_user_workspaces(username) .get(workspace_id_value, {}) .get("label") ) or workspace_id_value except Exception: workspace_label = workspace_id_value _set_terminal_workspace_label(terminal, workspace_label) # 应用管理员策略 if not is_api_user: try: from core.tool_config import ToolCategory from modules import admin_policy_manager policy = admin_policy_manager.get_effective_policy( record.username if record else None, get_current_user_role(record), getattr(record, "invite_code", None), ) categories_map = { cid: ToolCategory( label=cat.get("label") or cid, tools=list(cat.get("tools") or []), default_enabled=bool(cat.get("default_enabled", True)), silent_when_disabled=bool(cat.get("silent_when_disabled", False)), ) for cid, cat in policy.get("categories", {}).items() } forced_states = policy.get("forced_category_states") or {} disabled_models = policy.get("disabled_models") or [] terminal.set_admin_policy(categories_map, forced_states, disabled_models) terminal.admin_policy_ui_blocks = policy.get("ui_blocks") or {} terminal.admin_policy_version = policy.get("updated_at") if terminal.model_key in disabled_models: for candidate in get_registered_model_keys(visible_only=True): if candidate not in disabled_models: try: terminal.set_model(candidate) session["model_key"] = terminal.model_key break except Exception: continue except Exception as exc: debug_log(f"[admin_policy] 应用失败: {exc}") _ensure_workspace_skills_synced(terminal, workspace) return terminal, workspace def get_or_create_usage_tracker(username: Optional[str], workspace: Optional['modules.user_manager.UserWorkspace'] = None) -> Optional[UsageTracker]: if not username: return None tracker = state.usage_trackers.get(username) if tracker: return tracker from modules.user_manager import UserWorkspace if workspace is None: workspace = state.user_manager.ensure_user_workspace(username) record = state.user_manager.get_user(username) role = getattr(record, "role", "user") if record else "user" tracker = UsageTracker(str(workspace.data_dir), role=role or "user") state.usage_trackers[username] = tracker return tracker def emit_user_quota_update(username: Optional[str]): from .extensions import socketio if not username: return tracker = get_or_create_usage_tracker(username) if not tracker: return try: snapshot = tracker.get_quota_snapshot() socketio.emit('quota_update', {'quotas': snapshot}, room=f"user_{username}") except Exception: pass def apply_conversation_overrides(terminal: WebTerminal, workspace, conversation_id: Optional[str]): """根据对话元数据应用自定义 prompt / personalization(仅 API 用途)。""" if not conversation_id: return conv_path = Path(workspace.data_dir) / "conversations" / f"{conversation_id}.json" if not conv_path.exists(): return try: data = json.loads(conv_path.read_text(encoding="utf-8")) meta = data.get("metadata") or {} prompt_name = meta.get("custom_prompt_name") personalization_name = meta.get("personalization_name") # prompt override if prompt_name: prompt_path = Path(workspace.data_dir) / "prompts" / f"{prompt_name}.txt" if prompt_path.exists(): terminal.context_manager.custom_system_prompt = prompt_path.read_text(encoding="utf-8") else: terminal.context_manager.custom_system_prompt = None else: terminal.context_manager.custom_system_prompt = None # personalization override if personalization_name: pers_path = Path(workspace.data_dir) / "personalization" / f"{personalization_name}.json" if pers_path.exists(): try: terminal.context_manager.custom_personalization_config = json.loads(pers_path.read_text(encoding="utf-8")) except Exception: terminal.context_manager.custom_personalization_config = None else: terminal.context_manager.custom_personalization_config = None else: terminal.context_manager.custom_personalization_config = None # 应用个性化偏好(含禁用工具分类)到当前终端 try: terminal.apply_personalization_preferences(terminal.context_manager.custom_personalization_config) except Exception as exc: debug_log(f"[apply_overrides] 应用个性化失败: {exc}") except Exception as exc: debug_log(f"[apply_overrides] 读取对话元数据失败: {exc}") def with_terminal(func): """注入用户专属终端和工作区""" @wraps(func) def wrapper(*args, **kwargs): username = get_current_username() try: terminal, workspace = get_user_resources(username) except RuntimeError as exc: return jsonify({"error": str(exc), "code": "resource_busy"}), 503 if not terminal or not workspace: return jsonify({"error": "System not initialized"}), 503 kwargs.update({ 'terminal': terminal, 'workspace': workspace, 'username': username }) return func(*args, **kwargs) return wrapper def get_terminal_for_sid(sid: str): username = state.connection_users.get(sid) if not username: return None, None, None try: terminal, workspace = get_user_resources(username) except RuntimeError: return username, None, None return username, terminal, workspace def get_gui_manager(workspace): return GuiFileManager(str(workspace.project_path)) def get_upload_guard(workspace): return UploadQuarantineManager(workspace) def build_upload_error_response(exc: UploadSecurityError): status = 400 if exc.code in {"scanner_missing", "scanner_unavailable"}: status = 500 return jsonify({ "success": False, "error": str(exc), "code": exc.code, }), status def ensure_conversation_loaded( terminal: WebTerminal, conversation_id: Optional[str], workspace=None, ): created_new = False if not conversation_id: result = terminal.create_new_conversation() if not result.get("success"): raise RuntimeError(result.get("message", "创建对话失败")) conversation_id = result["conversation_id"] if has_request_context(): session['run_mode'] = terminal.run_mode session['thinking_mode'] = terminal.thinking_mode created_new = True else: conversation_id = conversation_id if conversation_id.startswith('conv_') else f"conv_{conversation_id}" current_id = terminal.context_manager.current_conversation_id if current_id != conversation_id: load_result = terminal.load_conversation(conversation_id) if not load_result.get("success"): raise RuntimeError(load_result.get("message", "对话加载失败")) write_host_workspace_debug( "context.ensure_conversation_loaded.after_load", terminal_id=id(terminal), conversation_id=conversation_id, terminal_project_path=str(getattr(terminal, "project_path", "")), context_project_path=str(getattr(getattr(terminal, "context_manager", None), "project_path", "")), metadata_project_path=( getattr(getattr(terminal, "context_manager", None), "conversation_metadata", {}) or {} ).get("project_path"), ) try: conv_data = terminal.context_manager.conversation_manager.load_conversation(conversation_id) or {} meta = conv_data.get("metadata", {}) or {} run_mode_meta = meta.get("run_mode") if run_mode_meta: terminal.set_run_mode(run_mode_meta) elif meta.get("thinking_mode"): terminal.set_run_mode("thinking") else: terminal.set_run_mode("fast") if terminal.thinking_mode: terminal.api_client.start_new_task(force_deep=terminal.deep_thinking_mode) else: terminal.api_client.start_new_task() if has_request_context(): session['run_mode'] = terminal.run_mode session['thinking_mode'] = terminal.thinking_mode session['model_key'] = getattr(terminal, "model_key", None) except Exception: pass if workspace is not None: try: workspace_project_path = str(Path(workspace.project_path).expanduser().resolve()) terminal.update_project_path(workspace_project_path) write_host_workspace_debug( "context.ensure_conversation_loaded.reapply_workspace_path", terminal_id=id(terminal), conversation_id=conversation_id, workspace_project_path=workspace_project_path, terminal_project_path=str(getattr(terminal, "project_path", "")), context_project_path=str(getattr(getattr(terminal, "context_manager", None), "project_path", "")), ) except Exception as exc: write_host_workspace_debug( "context.ensure_conversation_loaded.reapply_workspace_path_failed", terminal_id=id(terminal), conversation_id=conversation_id, error=str(exc), ) # 应用对话级自定义 prompt / personalization(仅 API)。 # 注意:ensure_conversation_loaded 在 WebSocket/后台任务等多处复用,有些调用点拿不到 workspace; # 因此这里允许 workspace 为空(仅跳过 override,不影响正常对话加载)。 if workspace is not None: try: apply_conversation_overrides(terminal, workspace, conversation_id) except Exception as exc: debug_log(f"[apply_overrides] 失败: {exc}") return conversation_id, created_new def reset_system_state(terminal: Optional[WebTerminal]): """完整重置系统状态""" if not terminal: return try: if hasattr(terminal, 'api_client') and terminal.api_client: debug_log("重置API客户端状态") terminal.api_client.start_new_task(force_deep=getattr(terminal, "deep_thinking_mode", False)) if hasattr(terminal, 'current_session_id'): terminal.current_session_id += 1 debug_log(f"重置会话ID为: {terminal.current_session_id}") web_attrs = ['streamingMessage', 'currentMessageIndex', 'preparingTools', 'activeTools'] for attr in web_attrs: if hasattr(terminal, attr): if attr in ['streamingMessage']: setattr(terminal, attr, False) elif attr in ['currentMessageIndex']: setattr(terminal, attr, -1) elif attr in ['preparingTools', 'activeTools'] and hasattr(getattr(terminal, attr), 'clear'): getattr(terminal, attr).clear() debug_log("系统状态重置完成") except Exception as e: debug_log(f"状态重置过程中出现错误: {e}") import traceback debug_log(f"错误详情: {traceback.format_exc()}") __all__ = [ "get_user_resources", "with_terminal", "get_terminal_for_sid", "get_gui_manager", "get_upload_guard", "build_upload_error_response", "ensure_conversation_loaded", "reset_system_state", "get_or_create_usage_tracker", "emit_user_quota_update", "attach_user_broadcast", ]