agent-Specialization/server/app_legacy.py
JOJO d6fb59e1d8 refactor: split web_server into modular architecture
- Refactor 6000+ line web_server.py into server/ module
- Create separate modules: auth, chat, conversation, files, admin, etc.
- Keep web_server.py as backward-compatible entry point
- Add container running status field in user_container_manager
- Improve admin dashboard API with credentials and debug support

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-22 09:21:53 +08:00

1349 lines
50 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.

# web_server.py - Web服务器修复版 - 确保text_end事件正确发送 + 停止功能)
import asyncio
import json
import os
import sys
import re
import threading
from typing import Dict, List, Optional, Callable, Any, Tuple
from flask import Flask, request, jsonify, send_from_directory, session, redirect, send_file, abort
from flask_socketio import SocketIO, emit, join_room, leave_room, disconnect
from flask_cors import CORS
from werkzeug.exceptions import RequestEntityTooLarge
from pathlib import Path
from io import BytesIO
import zipfile
import argparse
from functools import wraps
from datetime import timedelta
import time
from datetime import datetime
from collections import defaultdict, deque, Counter
from config.model_profiles import get_model_profile
from modules import admin_policy_manager, balance_client
from modules.custom_tool_registry import CustomToolRegistry
import server.state as state # 共享单例
from server.auth import auth_bp
from server.files import files_bp
from server.admin import admin_bp
from server.conversation import conversation_bp
from server.chat import chat_bp
from server.usage import usage_bp
from server.status import status_bp
from server.tasks import tasks_bp
from server.socket_handlers import socketio
from server.security import attach_security_hooks
from werkzeug.utils import secure_filename
from werkzeug.routing import BaseConverter
import secrets
import logging
import hmac
import mimetypes
# ==========================================
# 回顾文件生成辅助
# ==========================================
def _sanitize_filename_component(text: str) -> str:
safe = (text or "untitled").strip()
safe = re.sub(r'[\\/:*?"<>|]+', '_', safe)
return safe or "untitled"
def build_review_lines(messages, limit=None):
"""
将对话消息序列拍平成简化文本。
保留 user / assistant / system 以及 assistant 内的 tool 调用与 tool 消息。
limit 为正整数时,最多返回该数量的行(用于预览)。
"""
lines = []
def append_line(text: str):
lines.append(text.rstrip())
def extract_text(content):
# content 可能是字符串、列表OpenAI 新结构)或字典
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
parts.append(item.get("text") or "")
elif isinstance(item, str):
parts.append(item)
return "".join(parts)
if isinstance(content, dict):
return content.get("text") or ""
return ""
def append_tool_call(name, args):
try:
args_text = json.dumps(args, ensure_ascii=False)
except Exception:
args_text = str(args)
append_line(f"tool_call{name} {args_text}")
for msg in messages or []:
role = msg.get("role")
base_content_raw = msg.get("content") if isinstance(msg.get("content"), (str, list, dict)) else msg.get("text") or ""
base_content = extract_text(base_content_raw)
if role in ("user", "assistant", "system"):
append_line(f"{role}{base_content}")
if role == "tool":
append_line(f"tool{extract_text(base_content_raw)}")
if role == "assistant":
# actions 格式
actions = msg.get("actions") or []
for action in actions:
if action.get("type") != "tool":
continue
tool = action.get("tool") or {}
name = tool.get("name") or "tool"
args = tool.get("arguments")
if args is None:
args = tool.get("argumentSnapshot")
try:
args_text = json.dumps(args, ensure_ascii=False)
except Exception:
args_text = str(args)
append_line(f"tool_call{name} {args_text}")
tool_content = tool.get("content")
if tool_content is None:
if isinstance(tool.get("result"), str):
tool_content = tool.get("result")
elif tool.get("result") is not None:
try:
tool_content = json.dumps(tool.get("result"), ensure_ascii=False)
except Exception:
tool_content = str(tool.get("result"))
elif tool.get("message"):
tool_content = tool.get("message")
else:
tool_content = ""
append_line(f"tool{tool_content}")
if isinstance(limit, int) and limit > 0 and len(lines) >= limit:
return lines[:limit]
# OpenAI 风格 tool_calls
tool_calls = msg.get("tool_calls") or []
for tc in tool_calls:
fn = tc.get("function") or {}
name = fn.get("name") or "tool"
args_raw = fn.get("arguments")
try:
args_obj = json.loads(args_raw) if isinstance(args_raw, str) else args_raw
except Exception:
args_obj = args_raw
append_tool_call(name, args_obj)
# tool 结果在单独的 tool 消息
if isinstance(limit, int) and limit > 0 and len(lines) >= limit:
return lines[:limit]
# content 内嵌 tool_call部分供应商
if isinstance(base_content_raw, list):
for item in base_content_raw:
if isinstance(item, dict) and item.get("type") == "tool_call":
fn = item.get("function") or {}
name = fn.get("name") or "tool"
args_raw = fn.get("arguments")
try:
args_obj = json.loads(args_raw) if isinstance(args_raw, str) else args_raw
except Exception:
args_obj = args_raw
append_tool_call(name, args_obj)
if isinstance(limit, int) and limit > 0 and len(lines) >= limit:
return lines[:limit]
if isinstance(limit, int) and limit > 0 and len(lines) >= limit:
return lines[:limit]
return lines if limit is None else lines[:limit]
# 控制台输出策略:默认静默,只保留简要事件
_ORIGINAL_PRINT = print
ENABLE_VERBOSE_CONSOLE = True
def brief_log(message: str):
"""始终输出的简要日志(模型输出/工具调用等关键事件)"""
try:
_ORIGINAL_PRINT(message)
except Exception:
pass
if not ENABLE_VERBOSE_CONSOLE:
import builtins
def _silent_print(*args, **kwargs):
return
builtins.print = _silent_print
# 抑制 Flask/Werkzeug 访问日志,只保留 brief_log 输出
logging.getLogger('werkzeug').setLevel(logging.ERROR)
logging.getLogger('werkzeug').disabled = True
for noisy_logger in ('engineio.server', 'socketio.server'):
logging.getLogger(noisy_logger).setLevel(logging.ERROR)
logging.getLogger(noisy_logger).disabled = True
# 静音子智能体模块错误日志(交由 brief_log 或前端提示处理)
sub_agent_logger = logging.getLogger('modules.sub_agent_manager')
sub_agent_logger.setLevel(logging.CRITICAL)
sub_agent_logger.disabled = True
sub_agent_logger.propagate = False
for h in list(sub_agent_logger.handlers):
sub_agent_logger.removeHandler(h)
# 添加项目根目录到Python路径
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from core.web_terminal import WebTerminal
from config import (
OUTPUT_FORMATS,
AUTO_FIX_TOOL_CALL,
AUTO_FIX_MAX_ATTEMPTS,
MAX_ITERATIONS_PER_TASK,
MAX_CONSECUTIVE_SAME_TOOL,
MAX_TOTAL_TOOL_CALLS,
TOOL_CALL_COOLDOWN,
MAX_UPLOAD_SIZE,
DEFAULT_CONVERSATIONS_LIMIT,
MAX_CONVERSATIONS_LIMIT,
CONVERSATIONS_DIR,
DEFAULT_RESPONSE_MAX_TOKENS,
DEFAULT_PROJECT_PATH,
LOGS_DIR,
AGENT_VERSION,
THINKING_FAST_INTERVAL,
MAX_ACTIVE_USER_CONTAINERS,
PROJECT_MAX_STORAGE_MB,
PROJECT_MAX_STORAGE_BYTES,
UPLOAD_SCAN_LOG_SUBDIR,
)
from modules.user_manager import UserManager, UserWorkspace
from modules.gui_file_manager import GuiFileManager
from modules.upload_security import UploadQuarantineManager, UploadSecurityError
from modules.personalization_manager import (
load_personalization_config,
save_personalization_config,
THINKING_INTERVAL_MIN,
THINKING_INTERVAL_MAX,
)
from modules.user_container_manager import UserContainerManager
from modules.usage_tracker import UsageTracker, QUOTA_DEFAULTS
from utils.tool_result_formatter import format_tool_result_for_context
from utils.conversation_manager import ConversationManager
from utils.api_client import DeepSeekClient
from .files import files_bp
app = Flask(__name__, static_folder=str(PROJECT_ROOT / 'static'))
app.config['MAX_CONTENT_LENGTH'] = MAX_UPLOAD_SIZE
_secret_key = os.environ.get("WEB_SECRET_KEY") or os.environ.get("SECRET_KEY")
if not _secret_key:
_secret_key = secrets.token_hex(32)
print("[security] WEB_SECRET_KEY 未设置,已生成临时密钥(重启后失效)。")
app.config['SECRET_KEY'] = _secret_key
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=12)
_cookie_secure_env = (os.environ.get("WEB_COOKIE_SECURE") or "").strip().lower()
app.config['SESSION_COOKIE_SAMESITE'] = os.environ.get("WEB_COOKIE_SAMESITE", "Strict")
app.config['SESSION_COOKIE_SECURE'] = _cookie_secure_env in {"1", "true", "yes"}
app.config['SESSION_COOKIE_HTTPONLY'] = True
CORS(app)
socketio.init_app(app, cors_allowed_origins='*', async_mode='threading', logger=False, engineio_logger=False)
class EndpointFilter(logging.Filter):
"""过滤掉噪声请求日志。"""
BLOCK_PATTERNS = (
"GET /api/project-storage",
"GET /api/container-status",
)
def filter(self, record: logging.LogRecord) -> bool:
message = record.getMessage()
return not any(pattern in message for pattern in self.BLOCK_PATTERNS)
logging.getLogger('werkzeug').addFilter(EndpointFilter())
class ConversationIdConverter(BaseConverter):
regex = r'(?:conv_)?\d{8}_\d{6}_\d{3}'
app.url_map.converters['conv'] = ConversationIdConverter
# 注册各功能模块的蓝图(在自定义 converter 之后)
app.register_blueprint(auth_bp)
app.register_blueprint(files_bp)
app.register_blueprint(admin_bp)
app.register_blueprint(conversation_bp)
app.register_blueprint(chat_bp)
app.register_blueprint(usage_bp)
app.register_blueprint(status_bp)
app.register_blueprint(tasks_bp)
# 安全钩子CSRF 校验 + 响应头)
attach_security_hooks(app)
# 统一复用 state 中的单例,避免拆分后出现状态分叉
user_manager = state.user_manager
custom_tool_registry = state.custom_tool_registry
container_manager = state.container_manager
user_terminals = state.user_terminals
terminal_rooms = state.terminal_rooms
connection_users = state.connection_users
stop_flags = state.stop_flags
MONITOR_FILE_TOOLS = state.MONITOR_FILE_TOOLS
MONITOR_MEMORY_TOOLS = state.MONITOR_MEMORY_TOOLS
MONITOR_SNAPSHOT_CHAR_LIMIT = state.MONITOR_SNAPSHOT_CHAR_LIMIT
MONITOR_MEMORY_ENTRY_LIMIT = state.MONITOR_MEMORY_ENTRY_LIMIT
RATE_LIMIT_BUCKETS = state.RATE_LIMIT_BUCKETS
FAILURE_TRACKERS = state.FAILURE_TRACKERS
pending_socket_tokens = state.pending_socket_tokens
usage_trackers = state.usage_trackers
MONITOR_SNAPSHOT_CACHE = state.MONITOR_SNAPSHOT_CACHE
MONITOR_SNAPSHOT_CACHE_LIMIT = state.MONITOR_SNAPSHOT_CACHE_LIMIT
ADMIN_ASSET_DIR = (Path(app.static_folder) / 'admin_dashboard').resolve()
ADMIN_CUSTOM_TOOLS_DIR = (Path(app.static_folder) / 'custom_tools').resolve()
ADMIN_CUSTOM_TOOLS_DIR = (Path(app.static_folder) / 'custom_tools').resolve()
RECENT_UPLOAD_EVENT_LIMIT = 150
RECENT_UPLOAD_FEED_LIMIT = 60
DEFAULT_PORT = 8091
THINKING_FAILURE_KEYWORDS = ["⚠️", "🛑", "失败", "错误", "异常", "终止", "error", "failed", "未完成", "超时", "强制"]
CSRF_HEADER_NAME = state.CSRF_HEADER_NAME
CSRF_SESSION_KEY = state.CSRF_SESSION_KEY
CSRF_SAFE_METHODS = state.CSRF_SAFE_METHODS
CSRF_PROTECTED_PATHS = state.CSRF_PROTECTED_PATHS
CSRF_PROTECTED_PREFIXES = state.CSRF_PROTECTED_PREFIXES
CSRF_EXEMPT_PATHS = state.CSRF_EXEMPT_PATHS
FAILED_LOGIN_LIMIT = state.FAILED_LOGIN_LIMIT
FAILED_LOGIN_LOCK_SECONDS = state.FAILED_LOGIN_LOCK_SECONDS
SOCKET_TOKEN_TTL_SECONDS = state.SOCKET_TOKEN_TTL_SECONDS
PROJECT_STORAGE_CACHE = state.PROJECT_STORAGE_CACHE
PROJECT_STORAGE_CACHE_TTL_SECONDS = state.PROJECT_STORAGE_CACHE_TTL_SECONDS
USER_IDLE_TIMEOUT_SECONDS = state.USER_IDLE_TIMEOUT_SECONDS
LAST_ACTIVE_FILE = state.LAST_ACTIVE_FILE
_last_active_lock = state._last_active_lock
_last_active_cache = state._last_active_cache
_idle_reaper_started = False
TITLE_PROMPT_PATH = state.TITLE_PROMPT_PATH
def sanitize_filename_preserve_unicode(filename: str) -> str:
"""在保留中文等字符的同时,移除危险字符和路径成分"""
if not filename:
return ""
cleaned = filename.strip().replace("\x00", "")
if not cleaned:
return ""
# 去除路径成分
cleaned = cleaned.replace("\\", "/").split("/")[-1]
# 替换不安全符号
cleaned = re.sub(r'[<>:"\\|?*\n\r\t]', "_", cleaned)
# 去掉前后的点避免隐藏文件/穿越
cleaned = cleaned.strip(". ")
if not cleaned:
return ""
# Windows/Unix 通用文件名长度安全上限
return cleaned[:255]
def _load_last_active_cache():
"""从持久化文件加载最近活跃时间,失败时保持空缓存。"""
try:
LAST_ACTIVE_FILE.parent.mkdir(parents=True, exist_ok=True)
if not LAST_ACTIVE_FILE.exists():
return
data = json.loads(LAST_ACTIVE_FILE.read_text(encoding="utf-8"))
if isinstance(data, dict):
for user, ts in data.items():
try:
_last_active_cache[user] = float(ts)
except (TypeError, ValueError):
continue
except Exception:
# 读取失败时忽略,避免影响启动
pass
def _persist_last_active_cache():
"""原子写入最近活跃时间缓存。"""
try:
tmp = LAST_ACTIVE_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(_last_active_cache, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(LAST_ACTIVE_FILE)
except Exception:
# 写入失败不影响主流程,记录即可
debug_log("[IdleReaper] 写入 last_active 文件失败")
def record_user_activity(username: Optional[str], ts: Optional[float] = None):
"""记录用户最近活跃时间,刷新容器 handle 并持久化。"""
if not username:
return
now = ts or time.time()
with _last_active_lock:
_last_active_cache[username] = now
_persist_last_active_cache()
handle = container_manager.get_handle(username)
if handle:
handle.touch()
def get_last_active_ts(username: str, fallback: Optional[float] = None) -> Optional[float]:
"""兼容旧调用,实际委托给 state 版本以保证缓存能被句柄时间更新。"""
return state.get_last_active_ts(username, fallback)
def idle_reaper_loop():
"""后台轮询:长时间无消息则回收用户容器。"""
while True:
try:
now = time.time()
handle_map = container_manager.list_containers()
for username, handle in list(handle_map.items()):
last_ts = get_last_active_ts(username, handle.get("last_active"))
if not last_ts:
continue
if now - last_ts >= USER_IDLE_TIMEOUT_SECONDS:
debug_log(f"[IdleReaper] 回收容器: {username} (idle {int(now - last_ts)}s)")
container_manager.release_container(username, reason="idle_timeout")
time.sleep(60)
except Exception as exc:
debug_log(f"[IdleReaper] 后台循环异常: {exc}")
time.sleep(60)
def start_background_jobs():
"""启动一次性的后台任务(容器空闲回收)。"""
global _idle_reaper_started
if _idle_reaper_started:
return
_idle_reaper_started = True
_load_last_active_cache()
socketio.start_background_task(idle_reaper_loop)
async def _generate_title_async(user_message: str) -> Optional[str]:
"""使用快速模型生成对话标题。"""
if not user_message:
return None
client = DeepSeekClient(thinking_mode=False, web_mode=True)
try:
prompt_text = TITLE_PROMPT_PATH.read_text(encoding="utf-8")
except Exception:
prompt_text = "生成一个简洁的、3-5个词的标题并包含单个emoji使用用户的语言直接输出标题。"
messages = [
{"role": "system", "content": prompt_text},
{"role": "user", "content": user_message}
]
try:
async for resp in client.chat(messages, tools=[], stream=False):
try:
content = resp.get("choices", [{}])[0].get("message", {}).get("content")
if content:
return " ".join(str(content).strip().split())
except Exception:
continue
except Exception as exc:
debug_log(f"[TitleGen] 生成标题异常: {exc}")
return None
def generate_conversation_title_background(web_terminal: WebTerminal, conversation_id: str, user_message: str, username: str):
"""在后台生成对话标题并更新索引、推送给前端。"""
if not conversation_id or not user_message:
return
async def _runner():
title = await _generate_title_async(user_message)
if not title:
return
# 限长,避免标题过长
safe_title = title[:80]
ok = False
try:
ok = web_terminal.context_manager.conversation_manager.update_conversation_title(conversation_id, safe_title)
except Exception as exc:
debug_log(f"[TitleGen] 保存标题失败: {exc}")
if not ok:
return
try:
socketio.emit('conversation_changed', {
'conversation_id': conversation_id,
'title': safe_title
}, room=f"user_{username}")
socketio.emit('conversation_list_update', {
'action': 'updated',
'conversation_id': conversation_id
}, room=f"user_{username}")
except Exception as exc:
debug_log(f"[TitleGen] 推送标题更新失败: {exc}")
try:
asyncio.run(_runner())
except Exception as exc:
debug_log(f"[TitleGen] 任务执行失败: {exc}")
def cache_monitor_snapshot(execution_id: Optional[str], stage: str, snapshot: Optional[Dict[str, Any]]):
"""缓存工具执行前/后的文件快照。"""
if not execution_id or not snapshot or not snapshot.get('content'):
return
normalized_stage = 'after' if stage == 'after' else 'before'
entry = MONITOR_SNAPSHOT_CACHE.get(execution_id) or {
'before': None,
'after': None,
'path': snapshot.get('path'),
'timestamp': 0.0
}
entry[normalized_stage] = {
'path': snapshot.get('path'),
'content': snapshot.get('content'),
'lines': snapshot.get('lines') if snapshot.get('lines') is not None else None
}
entry['path'] = snapshot.get('path') or entry.get('path')
entry['timestamp'] = time.time()
MONITOR_SNAPSHOT_CACHE[execution_id] = entry
if len(MONITOR_SNAPSHOT_CACHE) > MONITOR_SNAPSHOT_CACHE_LIMIT:
try:
oldest_key = min(
MONITOR_SNAPSHOT_CACHE.keys(),
key=lambda key: MONITOR_SNAPSHOT_CACHE[key].get('timestamp', 0.0)
)
MONITOR_SNAPSHOT_CACHE.pop(oldest_key, None)
except ValueError:
pass
def get_cached_monitor_snapshot(execution_id: Optional[str], stage: str) -> Optional[Dict[str, Any]]:
if not execution_id:
return None
entry = MONITOR_SNAPSHOT_CACHE.get(execution_id)
if not entry:
return None
normalized_stage = 'after' if stage == 'after' else 'before'
snapshot = entry.get(normalized_stage)
if snapshot and snapshot.get('content'):
return snapshot
return None
def get_client_ip() -> str:
"""获取客户端IP支持 X-Forwarded-For."""
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
return request.remote_addr or "unknown"
def resolve_identifier(scope: str = "ip", identifier: Optional[str] = None, kwargs: Optional[Dict[str, Any]] = None) -> str:
if identifier:
return identifier
if scope == "user":
if kwargs:
username = kwargs.get('username')
if username:
return username
username = get_current_username()
if username:
return username
return get_client_ip()
def check_rate_limit(action: str, limit: int, window_seconds: int, identifier: Optional[str]) -> Tuple[bool, int]:
"""针对指定动作进行简单的滑动窗口限频。"""
bucket_key = f"{action}:{identifier or 'anonymous'}"
bucket = RATE_LIMIT_BUCKETS[bucket_key]
now = time.time()
while bucket and now - bucket[0] > window_seconds:
bucket.popleft()
if len(bucket) >= limit:
retry_after = window_seconds - int(now - bucket[0])
return True, max(retry_after, 1)
bucket.append(now)
return False, 0
def rate_limited(action: str, limit: int, window_seconds: int, scope: str = "ip", error_message: Optional[str] = None):
"""装饰器:为路由增加速率限制。"""
def decorator(func):
@wraps(func)
def wrapped(*args, **kwargs):
identifier = resolve_identifier(scope, kwargs=kwargs)
limited, retry_after = check_rate_limit(action, limit, window_seconds, identifier)
if limited:
message = error_message or "请求过于频繁,请稍后再试。"
return jsonify({
"success": False,
"error": message,
"retry_after": retry_after
}), 429
return func(*args, **kwargs)
return wrapped
return decorator
def register_failure(action: str, limit: int, lock_seconds: int, scope: str = "ip", identifier: Optional[str] = None, kwargs: Optional[Dict[str, Any]] = None) -> int:
"""记录失败次数,超过阈值后触发锁定。"""
ident = resolve_identifier(scope, identifier, kwargs)
key = f"{action}:{ident}"
now = time.time()
entry = FAILURE_TRACKERS.setdefault(key, {"count": 0, "blocked_until": 0})
blocked_until = entry.get("blocked_until", 0)
if blocked_until and blocked_until > now:
return int(blocked_until - now)
entry["count"] = entry.get("count", 0) + 1
if entry["count"] >= limit:
entry["count"] = 0
entry["blocked_until"] = now + lock_seconds
return lock_seconds
return 0
def is_action_blocked(action: str, scope: str = "ip", identifier: Optional[str] = None, kwargs: Optional[Dict[str, Any]] = None) -> Tuple[bool, int]:
ident = resolve_identifier(scope, identifier, kwargs)
key = f"{action}:{ident}"
entry = FAILURE_TRACKERS.get(key)
if not entry:
return False, 0
now = time.time()
blocked_until = entry.get("blocked_until", 0)
if blocked_until and blocked_until > now:
return True, int(blocked_until - now)
return False, 0
def clear_failures(action: str, scope: str = "ip", identifier: Optional[str] = None, kwargs: Optional[Dict[str, Any]] = None):
ident = resolve_identifier(scope, identifier, kwargs)
key = f"{action}:{ident}"
FAILURE_TRACKERS.pop(key, None)
def get_csrf_token(force_new: bool = False) -> str:
token = session.get(CSRF_SESSION_KEY)
if force_new or not token:
token = secrets.token_urlsafe(32)
session[CSRF_SESSION_KEY] = token
return token
def requires_csrf_protection(path: str) -> bool:
if path in CSRF_EXEMPT_PATHS:
return False
if path in CSRF_PROTECTED_PATHS:
return True
return any(path.startswith(prefix) for prefix in CSRF_PROTECTED_PREFIXES)
def validate_csrf_request() -> bool:
expected = session.get(CSRF_SESSION_KEY)
provided = request.headers.get(CSRF_HEADER_NAME) or request.form.get("csrf_token")
if not expected or not provided:
return False
try:
return hmac.compare_digest(str(provided), str(expected))
except Exception:
return False
def prune_socket_tokens(now: Optional[float] = None):
current = now or time.time()
for token, meta in list(pending_socket_tokens.items()):
if meta.get("expires_at", 0) <= current:
pending_socket_tokens.pop(token, None)
def consume_socket_token(token_value: Optional[str], username: Optional[str]) -> bool:
if not token_value or not username:
return False
prune_socket_tokens()
token_meta = pending_socket_tokens.pop(token_value, None)
if not token_meta:
return False
if token_meta.get("username") != username:
return False
if token_meta.get("expires_at", 0) <= time.time():
return False
fingerprint = token_meta.get("fingerprint") or ""
request_fp = (request.headers.get("User-Agent") or "")[:128]
if fingerprint and request_fp and not hmac.compare_digest(fingerprint, request_fp):
return False
return True
def format_tool_result_notice(tool_name: str, tool_call_id: Optional[str], content: str) -> str:
"""将工具执行结果转为系统消息文本,方便在对话中回传。"""
header = f"[工具结果] {tool_name}"
if tool_call_id:
header += f" (tool_call_id={tool_call_id})"
body = (content or "").strip()
if not body:
body = "(无附加输出)"
return f"{header}\n{body}"
def compact_web_search_result(result_data: Dict[str, Any]) -> Dict[str, Any]:
"""提取 web_search 结果中前端展示所需的关键字段,避免持久化时丢失列表。"""
if not isinstance(result_data, dict):
return {"success": False, "error": "invalid search result"}
compact: Dict[str, Any] = {
"success": bool(result_data.get("success")),
"summary": result_data.get("summary"),
"query": result_data.get("query"),
"filters": result_data.get("filters") or {},
"total_results": result_data.get("total_results", 0)
}
# 仅保留前端需要渲染的字段,避免巨大正文导致历史加载时缺失
items: List[Dict[str, Any]] = []
for item in result_data.get("results") or []:
if not isinstance(item, dict):
continue
items.append({
"index": item.get("index"),
"title": item.get("title") or item.get("name"),
"url": item.get("url")
})
compact["results"] = items
if not compact.get("success") and result_data.get("error"):
compact["error"] = result_data.get("error")
return compact
# 创建调试日志文件
DEBUG_LOG_FILE = Path(LOGS_DIR).expanduser().resolve() / "debug_stream.log"
CHUNK_BACKEND_LOG_FILE = Path(LOGS_DIR).expanduser().resolve() / "chunk_backend.log"
CHUNK_FRONTEND_LOG_FILE = Path(LOGS_DIR).expanduser().resolve() / "chunk_frontend.log"
STREAMING_DEBUG_LOG_FILE = Path(LOGS_DIR).expanduser().resolve() / "streaming_debug.log"
UPLOAD_FOLDER_NAME = "user_upload"
def is_logged_in() -> bool:
return session.get('username') is not None
def login_required(view_func):
@wraps(view_func)
def wrapped(*args, **kwargs):
if not is_logged_in():
return redirect('/login')
return view_func(*args, **kwargs)
return wrapped
def api_login_required(view_func):
@wraps(view_func)
def wrapped(*args, **kwargs):
if not is_logged_in():
return jsonify({"error": "Unauthorized"}), 401
return view_func(*args, **kwargs)
return wrapped
def get_current_username() -> Optional[str]:
return session.get('username')
def get_current_user_record():
username = get_current_username()
if not username:
return None
return user_manager.get_user(username)
def get_current_user_role(record=None) -> str:
role = session.get('role')
if role:
return role
if record is None:
record = get_current_user_record()
return (record.role if record and record.role else 'user')
def is_admin_user(record=None) -> bool:
role = get_current_user_role(record)
return isinstance(role, str) and role.lower() == 'admin'
def resolve_admin_policy(record=None) -> Dict[str, Any]:
"""获取当前用户生效的管理员策略。"""
if record is None:
record = get_current_user_record()
username = record.username if record else None
role = get_current_user_role(record)
invite_code = getattr(record, "invite_code", None)
try:
return admin_policy_manager.get_effective_policy(username, role, invite_code)
except Exception as exc:
debug_log(f"[admin_policy] 加载失败: {exc}")
return admin_policy_manager.get_effective_policy(username, role, invite_code)
def admin_required(view_func):
@wraps(view_func)
def wrapped(*args, **kwargs):
record = get_current_user_record()
if not record or not is_admin_user(record):
return redirect('/new')
return view_func(*args, **kwargs)
return wrapped
def admin_api_required(view_func):
@wraps(view_func)
def wrapped(*args, **kwargs):
record = get_current_user_record()
if not record or not is_admin_user(record):
return jsonify({"success": False, "error": "需要管理员权限"}), 403
return view_func(*args, **kwargs)
return wrapped
def make_terminal_callback(username: str):
"""生成面向指定用户的广播函数"""
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 get_user_resources(username: Optional[str] = None) -> Tuple[Optional[WebTerminal], Optional[UserWorkspace]]:
username = (username or get_current_username())
if not username:
return None, None
record = get_current_user_record()
workspace = user_manager.ensure_user_workspace(username)
container_handle = container_manager.ensure_container(username, str(workspace.project_path))
usage_tracker = get_or_create_usage_tracker(username, workspace)
terminal = user_terminals.get(username)
if not terminal:
run_mode = session.get('run_mode')
thinking_mode_flag = session.get('thinking_mode')
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
user_terminals[username] = terminal
terminal.username = username
terminal.user_role = get_current_user_role(record)
terminal.quota_update_callback = lambda metric=None: emit_user_quota_update(username)
session['run_mode'] = terminal.run_mode
session['thinking_mode'] = terminal.thinking_mode
else:
terminal.update_container_session(container_handle)
attach_user_broadcast(terminal, username)
terminal.username = username
terminal.user_role = get_current_user_role(record)
terminal.quota_update_callback = lambda metric=None: emit_user_quota_update(username)
# 应用管理员策略(工具分类、强制开关、模型禁用)
try:
from core.tool_config import ToolCategory
policy = resolve_admin_policy(user_manager.get_user(username))
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 ["kimi", "deepseek", "qwen3-vl-plus", "qwen3-max"]:
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}")
return terminal, workspace
def get_or_create_usage_tracker(username: Optional[str], workspace: Optional[UserWorkspace] = None) -> Optional[UsageTracker]:
if not username:
return None
tracker = usage_trackers.get(username)
if tracker:
return tracker
if workspace is None:
workspace = user_manager.ensure_user_workspace(username)
record = user_manager.get_user(username)
role = getattr(record, "role", "user") if record else "user"
tracker = UsageTracker(str(workspace.data_dir), role=role or "user")
usage_trackers[username] = tracker
return tracker
def emit_user_quota_update(username: Optional[str]):
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 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) -> Tuple[Optional[str], Optional[WebTerminal], Optional[UserWorkspace]]:
username = 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: UserWorkspace) -> GuiFileManager:
"""构建 GUI 文件管理器"""
return GuiFileManager(str(workspace.project_path))
def get_upload_guard(workspace: UserWorkspace) -> UploadQuarantineManager:
"""构建上传隔离管理器"""
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]) -> Tuple[str, bool]:
"""确保终端加载指定对话,若无则创建新的"""
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"]
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", "对话加载失败"))
# 切换到对话记录的运行模式
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()
session['run_mode'] = terminal.run_mode
session['thinking_mode'] = terminal.thinking_mode
except Exception:
pass
return conversation_id, created_new
def reset_system_state(terminal: Optional[WebTerminal]):
"""完整重置系统状态,确保停止后能正常开始新任务"""
if not terminal:
return
try:
# 1. 重置API客户端状态
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))
# 2. 重置主终端会话状态
if hasattr(terminal, 'current_session_id'):
terminal.current_session_id += 1 # 开始新会话
debug_log(f"重置会话ID为: {terminal.current_session_id}")
# 3. 清理读取文件跟踪器
debug_log("清理文件读取跟踪器")
# 4. 重置Web特有的状态属性
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()}")
def _write_log(file_path: Path, message: str) -> None:
file_path.parent.mkdir(parents=True, exist_ok=True)
with file_path.open('a', encoding='utf-8') as f:
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]
f.write(f"[{timestamp}] {message}\n")
def debug_log(message):
"""写入调试日志"""
_write_log(DEBUG_LOG_FILE, message)
def log_backend_chunk(conversation_id: str, iteration: int, chunk_index: int, elapsed: float, char_len: int, content_preview: str):
preview = content_preview.replace('\n', '\\n')
_write_log(
CHUNK_BACKEND_LOG_FILE,
f"conv={conversation_id or 'unknown'} iter={iteration} chunk={chunk_index} elapsed={elapsed:.3f}s len={char_len} preview={preview}"
)
def log_frontend_chunk(conversation_id: str, chunk_index: int, elapsed: float, char_len: int, client_ts: float):
_write_log(
CHUNK_FRONTEND_LOG_FILE,
f"conv={conversation_id or 'unknown'} chunk={chunk_index} elapsed={elapsed:.3f}s len={char_len} client_ts={client_ts}"
)
def log_streaming_debug_entry(data: Dict[str, Any]):
try:
serialized = json.dumps(data, ensure_ascii=False)
except Exception:
serialized = str(data)
_write_log(STREAMING_DEBUG_LOG_FILE, serialized)
def get_thinking_state(terminal: WebTerminal) -> Dict[str, Any]:
"""获取(或初始化)思考调度状态。"""
state = getattr(terminal, "_thinking_state", None)
if not state:
state = {"fast_streak": 0, "force_next": False, "suppress_next": False}
terminal._thinking_state = state
return state
def mark_force_thinking(terminal: WebTerminal, reason: str = ""):
"""标记下一次API调用必须使用思考模型。"""
if getattr(terminal, "deep_thinking_mode", False):
return
if not getattr(terminal, "thinking_mode", False):
return
state = get_thinking_state(terminal)
state["force_next"] = True
if reason:
debug_log(f"[Thinking] 下次强制思考,原因: {reason}")
def mark_suppress_thinking(terminal: WebTerminal):
"""标记下一次API调用必须跳过思考模型例如写入窗口"""
if getattr(terminal, "deep_thinking_mode", False):
return
if not getattr(terminal, "thinking_mode", False):
return
state = get_thinking_state(terminal)
state["suppress_next"] = True
def apply_thinking_schedule(terminal: WebTerminal):
"""根据当前状态配置API客户端的思考/快速模式。"""
client = terminal.api_client
if getattr(terminal, "deep_thinking_mode", False):
client.force_thinking_next_call = False
client.skip_thinking_next_call = False
return
if not getattr(terminal, "thinking_mode", False):
client.force_thinking_next_call = False
client.skip_thinking_next_call = False
return
state = get_thinking_state(terminal)
awaiting_writes = getattr(terminal, "pending_append_request", None) or getattr(terminal, "pending_modify_request", None)
if awaiting_writes:
client.skip_thinking_next_call = True
state["suppress_next"] = False
debug_log("[Thinking] 检测到写入窗口请求,跳过思考。")
return
if state.get("suppress_next"):
client.skip_thinking_next_call = True
state["suppress_next"] = False
debug_log("[Thinking] 由于写入窗口,下一次跳过思考。")
return
if state.get("force_next"):
client.force_thinking_next_call = True
state["force_next"] = False
state["fast_streak"] = 0
debug_log("[Thinking] 响应失败,下一次强制思考。")
return
custom_interval = getattr(terminal, "thinking_fast_interval", THINKING_FAST_INTERVAL)
interval = max(0, custom_interval or 0)
if interval > 0:
allowed_fast = max(0, interval - 1)
if state.get("fast_streak", 0) >= allowed_fast:
client.force_thinking_next_call = True
state["fast_streak"] = 0
if allowed_fast == 0:
debug_log("[Thinking] 频率=1持续思考。")
else:
debug_log(f"[Thinking] 快速模式已连续 {allowed_fast} 次,下一次强制思考。")
return
client.force_thinking_next_call = False
client.skip_thinking_next_call = False
def update_thinking_after_call(terminal: WebTerminal):
"""一次API调用完成后更新快速计数。"""
if getattr(terminal, "deep_thinking_mode", False):
state = get_thinking_state(terminal)
state["fast_streak"] = 0
return
if not getattr(terminal, "thinking_mode", False):
return
state = get_thinking_state(terminal)
if terminal.api_client.last_call_used_thinking:
state["fast_streak"] = 0
else:
state["fast_streak"] = state.get("fast_streak", 0) + 1
debug_log(f"[Thinking] 快速模式计数: {state['fast_streak']}")
def maybe_mark_failure_from_message(terminal: WebTerminal, content: Optional[str]):
"""根据system消息内容判断是否需要强制思考。"""
if not content:
return
normalized = content.lower()
if any(keyword.lower() in normalized for keyword in THINKING_FAILURE_KEYWORDS):
mark_force_thinking(terminal, reason="system_message")
def detect_tool_failure(result_data: Any) -> bool:
"""识别工具返回结果是否代表失败。"""
if not isinstance(result_data, dict):
return False
if result_data.get("success") is False:
return True
status = str(result_data.get("status", "")).lower()
if status in {"failed", "error"}:
return True
error_msg = result_data.get("error")
if isinstance(error_msg, str) and error_msg.strip():
return True
return False
# 终端广播回调函数
def terminal_broadcast(event_type, data):
"""广播终端事件到所有订阅者"""
try:
# 对于全局事件,发送给所有连接的客户端
if event_type in ('token_update', 'todo_updated'):
socketio.emit(event_type, data) # 全局广播,不限制房间
debug_log(f"全局广播{event_type}: {data}")
else:
# 其他终端事件发送到终端订阅者房间
socketio.emit(event_type, data, room='terminal_subscribers')
# 如果是特定会话的事件,也发送到该会话的专属房间
if 'session' in data:
session_room = f"terminal_{data['session']}"
socketio.emit(event_type, data, room=session_room)
debug_log(f"终端广播: {event_type} - {data}")
except Exception as e:
debug_log(f"终端广播错误: {e}")
# Routes removed; now provided by Blueprints in server/auth.py and server/files.py
# admin routes moved to server/admin.py
# chat/usage routes moved to server/chat.py and server/usage.py
# socket handlers moved to server/socket_handlers.py
def initialize_system(path: str, thinking_mode: bool = False):
"""初始化系统(多用户版本仅负责写日志和配置)"""
DEBUG_LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
with DEBUG_LOG_FILE.open('w', encoding='utf-8') as f:
f.write(f"调试日志开始 - {datetime.now()}\n")
f.write(f"项目路径: {path}\n")
f.write(f"思考模式: {'思考模式' if thinking_mode else '快速模式'}\n")
f.write(f"自动修复: {'开启' if AUTO_FIX_TOOL_CALL else '关闭'}\n")
f.write(f"最大迭代: {MAX_ITERATIONS_PER_TASK}\n")
f.write(f"最大工具调用: {MAX_TOTAL_TOOL_CALLS}\n")
f.write("="*80 + "\n")
print(f"[Init] 初始化Web系统...")
print(f"[Init] 项目路径: {path}")
print(f"[Init] 运行模式: {'思考模式(首次思考,后续快速)' if thinking_mode else '快速模式(无思考)'}")
print(f"[Init] 自动修复: {'开启' if AUTO_FIX_TOOL_CALL else '关闭'}")
print(f"[Init] 调试日志: {DEBUG_LOG_FILE}")
app.config['DEFAULT_THINKING_MODE'] = thinking_mode
app.config['DEFAULT_RUN_MODE'] = "thinking" if thinking_mode else "fast"
print(f"{OUTPUT_FORMATS['success']} Web系统初始化完成多用户模式")
def run_server(path: str, thinking_mode: bool = False, port: int = DEFAULT_PORT, debug: bool = False):
"""运行Web服务器"""
initialize_system(path, thinking_mode)
start_background_jobs()
socketio.run(
app,
host='0.0.0.0',
port=port,
debug=debug,
use_reloader=debug,
allow_unsafe_werkzeug=True
)
def parse_arguments():
parser = argparse.ArgumentParser(description="AI Agent Web Server")
parser.add_argument(
"--path",
default=str(Path(DEFAULT_PROJECT_PATH).resolve()),
help="项目工作目录(默认使用 config.DEFAULT_PROJECT_PATH"
)
parser.add_argument(
"--port",
type=int,
default=DEFAULT_PORT,
help=f"监听端口(默认 {DEFAULT_PORT}"
)
parser.add_argument(
"--debug",
action="store_true",
help="开发模式,启用 Flask/Socket.IO 热重载"
)
parser.add_argument(
"--thinking-mode",
action="store_true",
help="启用思考模式(首次请求使用 reasoning"
)
return parser.parse_args()
@app.route('/resource_busy')
def resource_busy_page():
return app.send_static_file('resource_busy.html'), 503
if __name__ == "__main__":
args = parse_arguments()
run_server(
path=args.path,
thinking_mode=args.thinking_mode,
port=args.port,
debug=args.debug
)