feat: add host mode quick entry

This commit is contained in:
JOJO 2026-01-28 10:01:04 +08:00
parent 1c2ad9eb72
commit d8cffa30cc
7 changed files with 187 additions and 5 deletions

View File

@ -9,6 +9,8 @@ def _load_dotenv():
env_path = Path(__file__).resolve().parents[1] / ".env" env_path = Path(__file__).resolve().parents[1] / ".env"
if not env_path.exists(): if not env_path.exists():
return return
# 仅保护启动前已有的环境变量;.env 内重复键以后者为准
pre_existing_keys = set(os.environ.keys())
try: try:
for raw_line in env_path.read_text(encoding="utf-8").splitlines(): for raw_line in env_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip() line = raw_line.strip()
@ -19,8 +21,12 @@ def _load_dotenv():
key, value = line.split("=", 1) key, value = line.split("=", 1)
key = key.strip() key = key.strip()
value = value.strip().strip('"').strip("'") value = value.strip().strip('"').strip("'")
if key and key not in os.environ: if not key:
os.environ[key] = value continue
# 若在进程启动前已存在,则尊重外部环境;否则允许 .env 内后续行覆盖前面行
if key in pre_existing_keys:
continue
os.environ[key] = value
except Exception: except Exception:
# 加载失败时静默继续,保持兼容 # 加载失败时静默继续,保持兼容
pass pass

View File

@ -1,6 +1,11 @@
"""项目路径与目录配置。""" """项目路径与目录配置。"""
DEFAULT_PROJECT_PATH = "./project" import os
# 默认项目路径,可通过环境变量覆盖以指向宿主机任意目录
DEFAULT_PROJECT_PATH = os.environ.get("DEFAULT_PROJECT_PATH", "./project")
# 当终端运行在宿主机模式时,可显式指定工作目录;未设置时回退到 DEFAULT_PROJECT_PATH
HOST_PROJECT_PATH = os.environ.get("HOST_PROJECT_PATH", DEFAULT_PROJECT_PATH)
PROMPTS_DIR = "./prompts" PROMPTS_DIR = "./prompts"
DATA_DIR = "./data" DATA_DIR = "./data"
LOGS_DIR = "./logs" LOGS_DIR = "./logs"
@ -19,6 +24,7 @@ API_USAGE_FILE = f"{DATA_DIR}/api_usage.json"
__all__ = [ __all__ = [
"DEFAULT_PROJECT_PATH", "DEFAULT_PROJECT_PATH",
"HOST_PROJECT_PATH",
"PROMPTS_DIR", "PROMPTS_DIR",
"DATA_DIR", "DATA_DIR",
"LOGS_DIR", "LOGS_DIR",

View File

@ -114,7 +114,12 @@ class AgentSystem:
async def setup_project_path(self): async def setup_project_path(self):
"""设置项目路径""" """设置项目路径"""
path_input = os.path.expanduser(str(DEFAULT_PROJECT_PATH)) # 宿主机模式可通过 HOST_PROJECT_PATH 显式指定任意目录;否则使用默认
if (TERMINAL_SANDBOX_MODE or "").lower() == "host":
path_input = os.environ.get("HOST_PROJECT_PATH") or os.environ.get("DEFAULT_PROJECT_PATH") or str(DEFAULT_PROJECT_PATH)
else:
path_input = os.environ.get("DEFAULT_PROJECT_PATH") or str(DEFAULT_PROJECT_PATH)
path_input = os.path.expanduser(str(path_input))
project_path = Path(path_input).resolve() project_path = Path(path_input).resolve()
if self.is_unsafe_path(str(project_path)): if self.is_unsafe_path(str(project_path)):

View File

@ -5,6 +5,13 @@ from flask import Blueprint, request, jsonify, session, redirect, send_from_dire
from modules.personalization_manager import load_personalization_config from modules.personalization_manager import load_personalization_config
from modules.user_manager import UserWorkspace from modules.user_manager import UserWorkspace
from config import (
TERMINAL_SANDBOX_MODE,
HOST_PROJECT_PATH,
DATA_DIR,
LOGS_DIR,
UPLOAD_QUARANTINE_SUBDIR,
)
from .auth_helpers import login_required, api_login_required, get_current_user_record, get_current_username from .auth_helpers import login_required, api_login_required, get_current_user_record, get_current_username
from .security import ( from .security import (
@ -95,6 +102,49 @@ def login():
return jsonify({"success": True}) return jsonify({"success": True})
@auth_bp.route('/host-login', methods=['POST'])
def host_login():
"""宿主机模式一键进入(仅当 TERMINAL_SANDBOX_MODE=host 时可用)。"""
if (TERMINAL_SANDBOX_MODE or "").lower() != "host":
return jsonify({"success": False, "error": "宿主机模式未启用"}), 403
if not state.container_manager.has_capacity("host"):
return jsonify({"success": False, "error": "资源繁忙,请稍后再试"}), 503
host_path = Path(HOST_PROJECT_PATH).expanduser().resolve()
host_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 = host_path / "user_upload"
uploads_dir.mkdir(parents=True, exist_ok=True)
quarantine_root = Path(UPLOAD_QUARANTINE_SUBDIR).expanduser()
if not quarantine_root.is_absolute():
quarantine_root = (host_path.parent / UPLOAD_QUARANTINE_SUBDIR).resolve()
quarantine_root.mkdir(parents=True, exist_ok=True)
# 初始化 session跳过账号体系
session.clear()
session['logged_in'] = True
session['username'] = 'host'
session['role'] = 'admin'
session['host_mode'] = True
default_thinking = current_app.config.get('DEFAULT_THINKING_MODE', False)
session['thinking_mode'] = default_thinking
session['run_mode'] = current_app.config.get('DEFAULT_RUN_MODE', "deep" if default_thinking else "fast")
session.permanent = True
# 预先创建宿主机模式的终端/容器句柄host 模式不会启动 Docker
try:
state.container_manager.ensure_container("host", str(host_path), container_key="host")
except RuntimeError as exc:
session.clear()
return jsonify({"success": False, "error": str(exc)}), 503
get_csrf_token(force_new=True)
return jsonify({"success": True})
@auth_bp.route('/register', methods=['GET', 'POST']) @auth_bp.route('/register', methods=['GET', 'POST'])
def register(): def register():
if request.method == 'GET': if request.method == 'GET':

View File

@ -11,6 +11,13 @@ from modules.personalization_manager import load_personalization_config
import json import json
from pathlib import Path from pathlib import Path
from modules.usage_tracker import UsageTracker from modules.usage_tracker import UsageTracker
from config import (
HOST_PROJECT_PATH,
DATA_DIR,
LOGS_DIR,
TERMINAL_SANDBOX_MODE,
UPLOAD_QUARANTINE_SUBDIR,
)
from . import state from . import state
from .utils_common import debug_log from .utils_common import debug_log
@ -45,6 +52,75 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st
username = (username or get_current_username()) username = (username or get_current_username())
if not username: if not username:
return None, None return None, None
# 宿主机免登录模式:使用 HOST_PROJECT_PATH 直接进入,不创建 /users/<user>/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:
project_path = Path(HOST_PROJECT_PATH).expanduser().resolve()
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 / "user_upload"
uploads_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"
term_key = "host"
container_handle = state.container_manager.ensure_container("host", str(project_path), container_key=term_key)
usage_tracker = get_or_create_usage_tracker("host", 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"}:
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)
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)
return terminal, workspace
is_api_user = bool(session.get("is_api_user")) if has_request_context() else False is_api_user = bool(session.get("is_api_user")) if has_request_context() else False
# API 用户与网页用户使用不同的 manager # API 用户与网页用户使用不同的 manager
if is_api_user: if is_api_user:

View File

@ -51,7 +51,7 @@ THINKING_FAILURE_KEYWORDS = ["⚠️", "🛑", "失败", "错误", "异常", "
CSRF_HEADER_NAME = "X-CSRF-Token" CSRF_HEADER_NAME = "X-CSRF-Token"
CSRF_SESSION_KEY = "_csrf_token" CSRF_SESSION_KEY = "_csrf_token"
CSRF_SAFE_METHODS = {"GET", "HEAD", "OPTIONS", "TRACE"} CSRF_SAFE_METHODS = {"GET", "HEAD", "OPTIONS", "TRACE"}
CSRF_PROTECTED_PATHS = {"/login", "/register", "/logout"} CSRF_PROTECTED_PATHS = {"/login", "/register", "/logout", "/host-login"}
CSRF_PROTECTED_PREFIXES = ("/api/",) CSRF_PROTECTED_PREFIXES = ("/api/",)
CSRF_EXEMPT_PATHS = {"/api/csrf-token"} CSRF_EXEMPT_PATHS = {"/api/csrf-token"}
FAILED_LOGIN_LIMIT = 5 FAILED_LOGIN_LIMIT = 5

View File

@ -71,6 +71,21 @@
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
} }
.ghost-btn {
margin-top: 12px;
width: 100%;
padding: 11px;
border-radius: 999px;
border: 1px solid #d8894c;
background: #fffefc;
color: #d8894c;
font-size: 0.95rem;
cursor: pointer;
}
.ghost-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error { .error {
margin-top: 14px; margin-top: 14px;
color: #c0392b; color: #c0392b;
@ -100,6 +115,7 @@
<input type="password" id="password" autocomplete="current-password" /> <input type="password" id="password" autocomplete="current-password" />
</div> </div>
<button id="login-btn">登录</button> <button id="login-btn">登录</button>
<button id="host-btn" class="ghost-btn" title="仅当服务器配置为宿主机模式时可用">宿主机模式(免登录)</button>
<div class="error" id="error"></div> <div class="error" id="error"></div>
<div class="link"> <div class="link">
还没有账号?<a href="/register">点击注册</a> 还没有账号?<a href="/register">点击注册</a>
@ -153,6 +169,29 @@
btn.click(); btn.click();
} }
}); });
const hostBtn = document.getElementById('host-btn');
hostBtn.addEventListener('click', async () => {
hostBtn.disabled = true;
errorEl.textContent = '';
try {
const resp = await fetch('/host-login', { method: 'POST' });
if (resp.status === 503) {
window.location.href = '/resource_busy';
return;
}
const data = await resp.json();
if (data.success) {
window.location.href = '/';
} else {
errorEl.textContent = data.error || '宿主机模式不可用';
}
} catch (err) {
errorEl.textContent = '网络错误,请重试';
} finally {
hostBtn.disabled = false;
}
});
</script> </script>
</body> </body>
</html> </html>