from __future__ import annotations from server.chat import chat_bp import json, time from datetime import datetime from typing import Dict, Any, Optional from pathlib import Path from io import BytesIO import zipfile import os from flask import Blueprint, jsonify, request, session, send_file from werkzeug.utils import secure_filename from werkzeug.exceptions import RequestEntityTooLarge import secrets from config import THINKING_FAST_INTERVAL, MAX_UPLOAD_SIZE, OUTPUT_FORMATS from modules.personalization_manager import ( load_personalization_config, resolve_context_compression_settings, save_personalization_config, THINKING_INTERVAL_MIN, THINKING_INTERVAL_MAX, RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN, RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX, ) from modules.skills_manager import ( get_skills_catalog, infer_private_skills_dir, merge_enabled_skills, sync_workspace_skills, ) from modules.upload_security import UploadSecurityError from modules.host_sandbox_policy import load_policy, save_policy from modules.user_manager import UserWorkspace from core.web_terminal import WebTerminal from config.model_profiles import get_model_context_window from server.auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record, get_current_username from server.context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response, ensure_conversation_loaded, get_or_create_usage_tracker from server.security import rate_limited, prune_socket_tokens from server.utils_common import debug_log from server.state import PROJECT_MAX_STORAGE_MB, THINKING_FAILURE_KEYWORDS, pending_socket_tokens, SOCKET_TOKEN_TTL_SECONDS from server.state import tool_approval_manager, user_question_manager from server.extensions import socketio from server.monitor import get_cached_monitor_snapshot from server.files import sanitize_filename_preserve_unicode UPLOAD_FOLDER_NAME = ".astrion/user_upload" def _dispatch_runtime_mode_notice(terminal: WebTerminal, username: str, text: str, source: str = "notify") -> None: notice = str(text or "").strip() if not notice: return # 仅在“有运行中的任务”时注入 [系统通知|notify],空闲期切换不写入 user 消息。 try: from server.tasks import task_manager current_conv = getattr(getattr(terminal, "context_manager", None), "current_conversation_id", None) running_tasks = [ r for r in task_manager.list_tasks(username) if r.status in {"pending", "running", "cancel_requested"} ] if current_conv: running_tasks.sort(key=lambda r: 0 if r.conversation_id == current_conv else 1) if not running_tasks: debug_log(f"[RuntimeMode] idle switch ignored notify injection: {notice}") return target = running_tasks[0] result = task_manager.enqueue_runtime_guidance( username=username, task_id=target.task_id, message=notice, source=source, ) if not result.get("success"): debug_log(f"[RuntimeMode] enqueue runtime guidance failed: {result}") except Exception as exc: debug_log(f"[RuntimeMode] dispatch notice failed: {exc}") @chat_bp.route('/api/thinking-mode', methods=['POST']) @api_login_required @with_terminal @rate_limited("thinking_mode_toggle", 15, 60, scope="user") def update_thinking_mode(terminal: WebTerminal, workspace: UserWorkspace, username: str): """切换思考模式""" try: data = request.get_json() or {} requested_mode = data.get('mode') if requested_mode in {"fast", "thinking", "deep"}: target_mode = requested_mode elif 'thinking_mode' in data: target_mode = "thinking" if bool(data.get('thinking_mode')) else "fast" else: target_mode = terminal.run_mode terminal.set_run_mode(target_mode) if terminal.thinking_mode: terminal.api_client.start_new_task(force_deep=terminal.deep_thinking_mode) else: terminal.api_client.start_new_task() session['thinking_mode'] = terminal.thinking_mode session['run_mode'] = terminal.run_mode # 更新当前对话的元数据 ctx = terminal.context_manager if ctx.current_conversation_id: try: ctx.conversation_manager.save_conversation( conversation_id=ctx.current_conversation_id, messages=ctx.conversation_history, project_path=str(ctx.project_path), todo_list=ctx.todo_list, thinking_mode=terminal.thinking_mode, run_mode=terminal.run_mode, model_key=getattr(terminal, "model_key", None), has_images=getattr(ctx, "has_images", False), has_videos=getattr(ctx, "has_videos", False) ) except Exception as exc: print(f"[API] 保存思考模式到对话失败: {exc}") status = terminal.get_status() socketio.emit('status_update', status, room=f"user_{username}") return jsonify({ "success": True, "data": { "thinking_mode": terminal.thinking_mode, "mode": terminal.run_mode } }) except Exception as exc: print(f"[API] 切换思考模式失败: {exc}") code = 400 if isinstance(exc, ValueError) else 500 return jsonify({ "success": False, "error": str(exc), "message": "切换思考模式时发生异常" }), code @chat_bp.route('/api/model', methods=['POST']) @api_login_required @with_terminal @rate_limited("model_switch", 10, 60, scope="user") def update_model(terminal: WebTerminal, workspace: UserWorkspace, username: str): """切换基础模型(快速/思考模型组合)。""" try: data = request.get_json() or {} model_key = data.get("model_key") if not model_key: return jsonify({"success": False, "error": "缺少 model_key"}), 400 # 管理员禁用模型校验 policy = resolve_admin_policy(get_current_user_record()) disabled_models = set(policy.get("disabled_models") or []) if model_key in disabled_models: return jsonify({ "success": False, "error": "该模型已被管理员禁用", "message": "被管理员强制禁用" }), 403 terminal.set_model(model_key) # fast-only 时 run_mode 可能被强制为 fast session["model_key"] = terminal.model_key session["run_mode"] = terminal.run_mode session["thinking_mode"] = terminal.thinking_mode # 更新当前对话元数据 ctx = terminal.context_manager if ctx.current_conversation_id: try: ctx.conversation_manager.save_conversation( conversation_id=ctx.current_conversation_id, messages=ctx.conversation_history, project_path=str(ctx.project_path), todo_list=ctx.todo_list, thinking_mode=terminal.thinking_mode, run_mode=terminal.run_mode, model_key=terminal.model_key, has_images=getattr(ctx, "has_images", False), has_videos=getattr(ctx, "has_videos", False) ) except Exception as exc: print(f"[API] 保存模型到对话失败: {exc}") status = terminal.get_status() socketio.emit('status_update', status, room=f"user_{username}") return jsonify({ "success": True, "data": { "model_key": terminal.model_key, "run_mode": terminal.run_mode, "thinking_mode": terminal.thinking_mode } }) except Exception as exc: print(f"[API] 切换模型失败: {exc}") code = 400 if isinstance(exc, ValueError) else 500 return jsonify({"success": False, "error": str(exc), "message": str(exc)}), code @chat_bp.route('/api/personalization', methods=['GET']) @api_login_required @with_terminal def get_personalization_settings(terminal: WebTerminal, workspace: UserWorkspace, username: str): """获取个性化配置""" try: policy = resolve_admin_policy(get_current_user_record()) if policy.get("ui_blocks", {}).get("block_personal_space"): return jsonify({"success": False, "error": "个人空间已被管理员禁用"}), 403 data = load_personalization_config(workspace.data_dir) private_skills_dir = infer_private_skills_dir(workspace.data_dir) skills_catalog = get_skills_catalog(private_dir=private_skills_dir) enabled_skills = merge_enabled_skills( data.get("enabled_skills"), skills_catalog, data.get("skills_catalog_snapshot"), ) data_out = dict(data) data_out["enabled_skills"] = enabled_skills compression_settings = resolve_context_compression_settings(data_out) try: model_window = get_model_context_window(getattr(terminal, "model_key", None)) except Exception: model_window = None return jsonify({ "success": True, "data": data_out, "tool_categories": terminal.get_tool_settings_snapshot(), "skills_catalog": skills_catalog, "context_compression_settings": { **compression_settings, "context_window_tokens": min( int(compression_settings.get("deep_trigger_tokens") or 0), int(model_window or compression_settings.get("deep_trigger_tokens") or 0), ), }, "thinking_interval_default": THINKING_FAST_INTERVAL, "thinking_interval_range": { "min": THINKING_INTERVAL_MIN, "max": THINKING_INTERVAL_MAX }, "recent_conversations_prompt_limit_range": { "min": RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN, "max": RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX, } }) except Exception as exc: return jsonify({"success": False, "error": str(exc)}), 500 @chat_bp.route('/api/personalization', methods=['POST']) @api_login_required @with_terminal @rate_limited("personalization_update", 20, 300, scope="user") def update_personalization_settings(terminal: WebTerminal, workspace: UserWorkspace, username: str): """更新个性化配置""" payload = request.get_json() or {} try: policy = resolve_admin_policy(get_current_user_record()) if policy.get("ui_blocks", {}).get("block_personal_space"): return jsonify({"success": False, "error": "个人空间已被管理员禁用"}), 403 config = save_personalization_config(workspace.data_dir, payload) private_skills_dir = infer_private_skills_dir(workspace.data_dir) skills_catalog = get_skills_catalog(private_dir=private_skills_dir) enabled_skills = merge_enabled_skills( config.get("enabled_skills"), skills_catalog, config.get("skills_catalog_snapshot"), ) stored_skills = None if len(enabled_skills) == len(skills_catalog) else enabled_skills catalog_snapshot = [item.get("id") for item in skills_catalog if item.get("id")] if config.get("enabled_skills") != stored_skills or config.get("skills_catalog_snapshot") != catalog_snapshot: config = dict(config) config["enabled_skills"] = stored_skills config["skills_catalog_snapshot"] = catalog_snapshot config = save_personalization_config(workspace.data_dir, config) try: sync_workspace_skills(workspace.project_path, enabled_skills, private_dir=private_skills_dir) except Exception as sync_exc: debug_log(f"[Skills] 同步失败: {sync_exc}") try: terminal.apply_personalization_preferences(config) session['run_mode'] = terminal.run_mode session['thinking_mode'] = terminal.thinking_mode ctx = getattr(terminal, 'context_manager', None) if ctx and getattr(ctx, 'current_conversation_id', None): try: ctx.conversation_manager.save_conversation( conversation_id=ctx.current_conversation_id, messages=ctx.conversation_history, project_path=str(ctx.project_path), todo_list=ctx.todo_list, thinking_mode=terminal.thinking_mode, run_mode=terminal.run_mode ) except Exception as meta_exc: debug_log(f"应用个性化偏好失败: 同步对话元数据异常 {meta_exc}") try: status = terminal.get_status() socketio.emit('status_update', status, room=f"user_{username}") except Exception as status_exc: debug_log(f"广播个性化状态失败: {status_exc}") except Exception as exc: debug_log(f"应用个性化偏好失败: {exc}") config_out = dict(config) config_out["enabled_skills"] = enabled_skills compression_settings = resolve_context_compression_settings(config_out) try: model_window = get_model_context_window(getattr(terminal, "model_key", None)) except Exception: model_window = None return jsonify({ "success": True, "data": config_out, "tool_categories": terminal.get_tool_settings_snapshot(), "skills_catalog": skills_catalog, "context_compression_settings": { **compression_settings, "context_window_tokens": min( int(compression_settings.get("deep_trigger_tokens") or 0), int(model_window or compression_settings.get("deep_trigger_tokens") or 0), ), }, "thinking_interval_default": THINKING_FAST_INTERVAL, "thinking_interval_range": { "min": THINKING_INTERVAL_MIN, "max": THINKING_INTERVAL_MAX }, "recent_conversations_prompt_limit_range": { "min": RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN, "max": RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX, } }) except ValueError as exc: return jsonify({"success": False, "error": str(exc)}), 400 except Exception as exc: return jsonify({"success": False, "error": str(exc)}), 500