1351 lines
50 KiB
Python
1351 lines
50 KiB
Python
# 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.api_v1 import api_v1_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)
|
||
app.register_blueprint(api_v1_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
|
||
)
|