agent-Specialization/server/chat/settings.py
JOJO 1289c31dfe fix(model): 修复重启后进入对话模型回变为默认模型
对话级隔离后存在四处模型覆盖/错存路径:
1. 对话级 terminal 绑定加载用 restore_model=False,重启后新建的
   对话级 terminal 丢失对话保存的模型 -> 绑定加载恢复模型
2. /api/model 把工作区级 terminal 的陈旧 current_conversation_id
   当目标保存,模型写到错误对话或丢失(且用陈旧 history 全量保存
   有覆盖新消息风险)-> 仅当请求显式携带 conversation_id 且与
   terminal 当前对话一致时才持久化
3. 前端切换模型不携带 conversation_id -> handleModelSelect 带上
4. _apply_workspace_personalization_preferences 把 session 全局模型
   /个性化默认模型覆盖到对话级 terminal -> 对话级 terminal 跳过,
   其模型由绑定加载权威确定
2026-07-20 02:20:06 +08:00

352 lines
16 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.

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
# 更新对话元数据:仅当请求显式携带 conversation_id 时持久化。
# 对话级隔离后,未带 conversation_id 时拿到的是工作区级 terminal
# 其 current_conversation_id 可能是陈旧对话(安全导航/切换后),
# 盲目保存会把模型写到错误对话,或用陈旧 history 覆盖新消息。
# /new 页面(无 conversation_id不持久化新对话在首个任务时
# 由前端随任务传入的 model_key 落盘。
requested_cid = str(data.get("conversation_id") or "").strip()
if requested_cid and not requested_cid.startswith("conv_"):
requested_cid = f"conv_{requested_cid}"
ctx = terminal.context_manager
current_cid = getattr(ctx, "current_conversation_id", None)
if requested_cid and current_cid:
if current_cid == requested_cid:
try:
ctx.conversation_manager.save_conversation(
conversation_id=current_cid,
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}")
else:
print(
f"[API] 跳过模型保存:请求对话 {requested_cid} "
f"与 terminal 当前对话 {current_cid} 不一致"
)
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