diff --git a/android-webview-app/APP_CHANGELOG.md b/android-webview-app/APP_CHANGELOG.md
index 2114846..6d5768f 100644
--- a/android-webview-app/APP_CHANGELOG.md
+++ b/android-webview-app/APP_CHANGELOG.md
@@ -1,5 +1,11 @@
# App 更新说明
+# 1.0.20
+- 新增“新用户欢迎弹窗 + 新手教程”持久化机制:普通账号首次进入会提示是否开始教程,选择“开始吧”或“不再提示”后将状态写入 `data/users.json`
+- 新增后端教程状态接口(查询/更新),并排除宿主机模式用户,避免 host 模式触发教程弹窗
+- 修复宿主机模式切回普通账号登录时可能遗留 `host_mode` 状态,导致教程提示判定异常的问题
+- 优化新手教程欢迎弹窗视觉风格:按钮与系统现有主题风格统一,支持多主题配色
+
# 1.0.19
- 修复 Android APK 内「个人空间-新手教程」期间无法上下滚动的问题:教程遮罩层现在会正确转发触摸滑动到可滚动容器
- 优化教程滚动目标识别:优先命中当前可滚动页面区域,减少 WebView 场景下滑动失效
diff --git a/android-webview-app/app/build.gradle.kts b/android-webview-app/app/build.gradle.kts
index 1489eb8..6b1568d 100644
--- a/android-webview-app/app/build.gradle.kts
+++ b/android-webview-app/app/build.gradle.kts
@@ -16,8 +16,8 @@ android {
applicationId = "com.cyjai.agent"
minSdk = 24
targetSdk = 35
- versionCode = 21
- versionName = "1.0.19"
+ versionCode = 22
+ versionName = "1.0.20"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
diff --git a/modules/user_manager.py b/modules/user_manager.py
index 8b2f588..b7e04c7 100644
--- a/modules/user_manager.py
+++ b/modules/user_manager.py
@@ -28,6 +28,7 @@ class UserRecord:
created_at: str
invite_code: Optional[str] = None
role: str = "user"
+ tutorial_completed: bool = False
@dataclass
@@ -93,6 +94,7 @@ class UserManager:
password_hash=password_hash,
created_at=created_at,
invite_code=invite_entry["code"],
+ tutorial_completed=False,
)
self._users[username] = record
self._index_user(record)
@@ -189,6 +191,15 @@ class UserManager:
"""返回当前注册用户的浅拷贝字典,键为用户名。"""
return dict(self._users)
+ def set_tutorial_completed(self, username: str, completed: bool = True) -> Optional[UserRecord]:
+ key = (username or "").strip().lower()
+ record = self._users.get(key)
+ if not record:
+ return None
+ record.tutorial_completed = bool(completed)
+ self._save_users()
+ return record
+
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
@@ -215,6 +226,7 @@ class UserManager:
return
try:
+ migrated = False
with open(self.users_file, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
@@ -231,9 +243,14 @@ class UserManager:
created_at=payload.get("created_at", ""),
invite_code=payload.get("invite_code"),
role=payload.get("role", "user"),
+ tutorial_completed=bool(payload.get("tutorial_completed", False)),
)
+ if "tutorial_completed" not in payload:
+ migrated = True
self._users[username] = record
self._index_user(record)
+ if migrated:
+ self._save_users()
except json.JSONDecodeError:
raise RuntimeError(f"无法解析用户数据文件: {self.users_file}")
@@ -246,6 +263,7 @@ class UserManager:
"created_at": record.created_at,
"invite_code": record.invite_code,
"role": record.role,
+ "tutorial_completed": bool(record.tutorial_completed),
}
for username, record in self._users.items()
}
@@ -312,6 +330,7 @@ class UserManager:
created_at=datetime.utcnow().isoformat(),
invite_code=None,
role="admin",
+ tutorial_completed=False,
)
self._users[admin_name] = record
self._index_user(record)
diff --git a/server/auth.py b/server/auth.py
index 3fc34e6..8a16ab4 100644
--- a/server/auth.py
+++ b/server/auth.py
@@ -1,7 +1,9 @@
from __future__ import annotations
import mimetypes
+import secrets
from pathlib import Path
-from flask import Blueprint, request, jsonify, session, redirect, send_from_directory, abort, current_app
+from datetime import datetime
+from flask import Blueprint, request, jsonify, session, redirect, send_from_directory, abort, current_app, make_response
from modules.personalization_manager import load_personalization_config
from modules.user_manager import UserWorkspace
@@ -12,6 +14,7 @@ from config import (
LOGS_DIR,
UPLOAD_QUARANTINE_SUBDIR,
LINUX_SAFETY,
+ LOGS_DIR,
)
from .auth_helpers import login_required, api_login_required, get_current_user_record, get_current_username
@@ -27,6 +30,79 @@ from . import state
from .utils_common import debug_log
auth_bp = Blueprint("auth", __name__)
+AUTH_DEBUG_FILE = Path(LOGS_DIR).expanduser().resolve() / "auth_debug.log"
+
+
+def auth_debug_log(message: str):
+ AUTH_DEBUG_FILE.parent.mkdir(parents=True, exist_ok=True)
+ ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ line = f"[{ts}] {message}"
+ try:
+ with AUTH_DEBUG_FILE.open("a", encoding="utf-8") as f:
+ f.write(line + "\n")
+ except Exception:
+ pass
+ try:
+ debug_log(line)
+ except Exception:
+ pass
+
+
+def _session_debug_snapshot():
+ return {
+ "logged_in": bool(session.get("logged_in")),
+ "username": session.get("username"),
+ "role": session.get("role"),
+ "run_mode": session.get("run_mode"),
+ "thinking_mode": session.get("thinking_mode"),
+ "host_mode": bool(session.get("host_mode")),
+ }
+
+
+def _issue_login_nonce(username: str) -> str:
+ nonce = secrets.token_urlsafe(16)
+ user = (username or "").strip().lower()
+ if user:
+ state.active_login_nonces[user].add(nonce)
+ session["login_nonce"] = nonce
+ return nonce
+
+
+def _revoke_login_nonce(username: str, nonce: str | None):
+ user = (username or "").strip().lower()
+ if not user or not nonce:
+ return
+ pool = state.active_login_nonces.get(user)
+ if not pool:
+ return
+ pool.discard(nonce)
+ if not pool:
+ state.active_login_nonces.pop(user, None)
+
+
+def _cookie_debug_snapshot():
+ raw = request.headers.get("Cookie", "") or ""
+ return raw[:300]
+
+
+def _expire_session_cookie(response):
+ """尽可能清理不同 domain/path 组合下的 session cookie(线上反向代理场景兜底)。"""
+ cookie_name = current_app.config.get("SESSION_COOKIE_NAME", "session")
+ configured_domain = current_app.config.get("SESSION_COOKIE_DOMAIN")
+ host = (request.host or "").split(":")[0]
+ candidate_domains = {None, configured_domain, host}
+ if host.count(".") >= 2:
+ # 兼容 .example.com / example.com 两种历史写法
+ parent = ".".join(host.split(".")[1:])
+ candidate_domains.add(parent)
+ candidate_domains.add(f".{parent}")
+ for domain in candidate_domains:
+ try:
+ response.delete_cookie(cookie_name, path="/", domain=domain)
+ except Exception:
+ continue
+ response.headers["Cache-Control"] = "no-store"
+ return response
@auth_bp.route('/api/csrf-token', methods=['GET'])
@@ -46,6 +122,7 @@ def host_mode_enabled():
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'GET':
+ auth_debug_log(f"[auth_debug] GET /login session={_session_debug_snapshot()} cookie={_cookie_debug_snapshot()}")
if session.get('username'):
return redirect('/new')
if not state.container_manager.has_capacity():
@@ -87,9 +164,16 @@ def login():
except Exception as exc:
debug_log(f"加载个性化偏好失败: {exc}")
+ # 清理旧会话(尤其是从 host-login 切换到普通登录时遗留的 host_mode)
+ prev_username = session.get('username')
+ prev_nonce = session.get('login_nonce')
+ _revoke_login_nonce(prev_username, prev_nonce)
+ session.clear()
+
session['logged_in'] = True
session['username'] = record.username
session['role'] = record.role or 'user'
+ session['host_mode'] = False
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")
@@ -97,6 +181,7 @@ def login():
session['run_mode'] = preferred_run_mode
session['thinking_mode'] = preferred_run_mode != 'fast'
session.permanent = True
+ _issue_login_nonce(record.username)
clear_failures("login", identifier=client_ip)
try:
state.container_manager.ensure_container(record.username, str(workspace.project_path), preferred_mode="docker")
@@ -140,6 +225,7 @@ def host_login():
session['thinking_mode'] = default_thinking
session['run_mode'] = current_app.config.get('DEFAULT_RUN_MODE', "deep" if default_thinking else "fast")
session.permanent = True
+ _issue_login_nonce('host')
# 预先创建宿主机模式的终端/容器句柄(host 模式不会启动 Docker)
try:
@@ -155,7 +241,9 @@ def host_login():
@auth_bp.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'GET':
+ auth_debug_log(f"[auth_debug] GET /register session={_session_debug_snapshot()} cookie={_cookie_debug_snapshot()}")
if session.get('username'):
+ auth_debug_log("[auth_debug] GET /register redirected to /new because session.username exists")
return redirect('/new')
return current_app.send_static_file('register.html')
@@ -171,6 +259,7 @@ def register():
return jsonify({"success": False, "error": "注册请求过于频繁,请稍后再试。", "retry_after": retry_after}), 429
try:
state.user_manager.register_user(username, email, password, invite_code)
+ auth_debug_log(f"[auth_debug] POST /register success username={username}")
return jsonify({"success": True})
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
@@ -178,10 +267,16 @@ def register():
return jsonify({"success": False, "error": str(exc)}), 500
-@auth_bp.route('/logout', methods=['POST'])
+@auth_bp.route('/logout', methods=['GET', 'POST'])
def logout():
+ auth_debug_log(
+ f"[auth_debug] {request.method} /logout before_clear "
+ f"session={_session_debug_snapshot()} cookie={_cookie_debug_snapshot()}"
+ )
username = session.get('username')
+ login_nonce = session.get('login_nonce')
session.clear()
+ _revoke_login_nonce(username, login_nonce)
if username:
# 清理该用户相关的所有终端/容器(包含 API 多工作区)
term_keys = [k for k in list(state.user_terminals.keys()) if k == username or k.startswith(f"{username}::")]
@@ -194,7 +289,89 @@ def logout():
for token_value, meta in list(state.pending_socket_tokens.items()):
if meta.get("username") == username:
state.pending_socket_tokens.pop(token_value, None)
- return jsonify({"success": True})
+ auth_debug_log(f"[auth_debug] {request.method} /logout after_clear session={_session_debug_snapshot()}")
+ if request.method == 'GET':
+ resp = make_response(redirect('/login?logged_out=1'))
+ resp = _expire_session_cookie(resp)
+ auth_debug_log(f"[auth_debug] GET /logout set-cookie={resp.headers.get('Set-Cookie', '')[:300]}")
+ return resp
+ resp = make_response(jsonify({"success": True}))
+ resp = _expire_session_cookie(resp)
+ auth_debug_log(f"[auth_debug] POST /logout set-cookie={resp.headers.get('Set-Cookie', '')[:300]}")
+ return resp
+
+
+@auth_bp.route('/api/session-status', methods=['GET'])
+def session_status():
+ """前端调试用:查看当前会话是否已清理。"""
+ snapshot = _session_debug_snapshot()
+ auth_debug_log(f"[auth_debug] GET /api/session-status session={snapshot} cookie={_cookie_debug_snapshot()}")
+ return jsonify({"success": True, "session": snapshot})
+
+
+@auth_bp.route('/api/tutorial-status', methods=['GET'])
+@api_login_required
+def get_tutorial_status():
+ username = (get_current_username() or "").strip().lower()
+ if not username:
+ return jsonify({"success": False, "error": "未登录"}), 401
+
+ if bool(session.get("host_mode")) or username == "host":
+ return jsonify({
+ "success": True,
+ "data": {
+ "username": username,
+ "applicable": False,
+ "tutorial_completed": True,
+ "should_prompt": False,
+ }
+ })
+
+ record = state.user_manager.get_user(username)
+ if not record:
+ return jsonify({
+ "success": True,
+ "data": {
+ "username": username,
+ "applicable": False,
+ "tutorial_completed": True,
+ "should_prompt": False,
+ }
+ })
+
+ completed = bool(getattr(record, "tutorial_completed", False))
+ return jsonify({
+ "success": True,
+ "data": {
+ "username": record.username,
+ "applicable": True,
+ "tutorial_completed": completed,
+ "should_prompt": not completed,
+ }
+ })
+
+
+@auth_bp.route('/api/tutorial-status', methods=['POST'])
+@api_login_required
+def set_tutorial_status():
+ username = (get_current_username() or "").strip().lower()
+ if not username:
+ return jsonify({"success": False, "error": "未登录"}), 401
+ if bool(session.get("host_mode")) or username == "host":
+ return jsonify({"success": False, "error": "宿主机模式无需设置新手教程状态"}), 400
+
+ payload = request.get_json(silent=True) or {}
+ completed = payload.get("tutorial_completed", True)
+ record = state.user_manager.set_tutorial_completed(username, bool(completed))
+ if not record:
+ return jsonify({"success": False, "error": "用户不存在"}), 404
+ return jsonify({
+ "success": True,
+ "data": {
+ "username": record.username,
+ "tutorial_completed": bool(record.tutorial_completed),
+ }
+ })
@auth_bp.route('/')
diff --git a/server/auth_helpers.py b/server/auth_helpers.py
index 1e04c5a..ecd3e04 100644
--- a/server/auth_helpers.py
+++ b/server/auth_helpers.py
@@ -10,7 +10,14 @@ from . import state
def is_logged_in() -> bool:
- return session.get('username') is not None
+ username = (session.get('username') or '').strip().lower()
+ if not username:
+ return False
+ nonce = session.get('login_nonce')
+ if not nonce:
+ return False
+ pool = state.active_login_nonces.get(username) or set()
+ return nonce in pool
def login_required(view_func):
diff --git a/server/state.py b/server/state.py
index 0ac7877..bb0c9a9 100644
--- a/server/state.py
+++ b/server/state.py
@@ -36,6 +36,7 @@ RATE_LIMIT_BUCKETS: Dict[str, deque] = defaultdict(deque)
FAILURE_TRACKERS: Dict[str, Dict[str, float]] = {}
pending_socket_tokens: Dict[str, Dict[str, Any]] = {}
usage_trackers: Dict[str, UsageTracker] = {}
+active_login_nonces: Dict[str, set] = defaultdict(set)
MONITOR_SNAPSHOT_CACHE: Dict[str, Dict[str, Any]] = {}
MONITOR_SNAPSHOT_CACHE_LIMIT = 120
@@ -86,6 +87,7 @@ __all__ = [
"FAILURE_TRACKERS",
"pending_socket_tokens",
"usage_trackers",
+ "active_login_nonces",
"MONITOR_SNAPSHOT_CACHE",
"MONITOR_SNAPSHOT_CACHE_LIMIT",
"PROJECT_STORAGE_CACHE",
diff --git a/static/src/App.vue b/static/src/App.vue
index 4ade2c0..720f47a 100644
--- a/static/src/App.vue
+++ b/static/src/App.vue
@@ -352,6 +352,13 @@
欢迎
+是否需要通过新手教程来快速认识这个系统?
+