- 彻底移除「每 N 次请求强制思考」的间隔调度机制(思考/非思考交替导致缓存命中率极低) - 运行模式收敛为 fast/thinking:原 deep 改名 thinking,历史数据读取时映射 - 新增推理强度五档滑块(low/medium/high/xhigh/max + 默认不传参),置于模型/模式弹窗 - 模型配置新增 reasoning_effort 开关;档位会话级持久化到对话 meta,随对话保存/恢复 - 个人空间新增「默认推理强度」;新用户默认运行模式改为 thinking - 滑块交互:JS 插值驱动(点击/拖动/吸附全程平滑),首帧档位语义定位无闪烁 - 保存链路:前端防抖 600ms + conversation_id 兜底,修复 /new 发消息丢档位、切对话恢复错误
410 lines
19 KiB
Python
410 lines
19 KiB
Python
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 MAX_UPLOAD_SIZE, OUTPUT_FORMATS
|
||
from modules.personalization_manager import (
|
||
load_personalization_config,
|
||
resolve_context_compression_settings,
|
||
save_personalization_config,
|
||
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, 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):
|
||
"""切换运行模式(fast / thinking;旧值 deep 自动映射为 thinking)"""
|
||
try:
|
||
data = request.get_json() or {}
|
||
requested_mode = data.get('mode')
|
||
if isinstance(requested_mode, str) and requested_mode.lower() == 'deep':
|
||
requested_mode = 'thinking'
|
||
if requested_mode in {"fast", "thinking"}:
|
||
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)
|
||
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/reasoning-effort', methods=['POST'])
|
||
@api_login_required
|
||
@with_terminal
|
||
@rate_limited("reasoning_effort_toggle", 20, 60, scope="user")
|
||
def update_reasoning_effort(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||
"""设置会话级推理强度;effort 为 null/空 表示默认(请求中不传该参数)。"""
|
||
try:
|
||
data = request.get_json() or {}
|
||
effort = data.get('effort')
|
||
if isinstance(effort, str) and not effort.strip():
|
||
effort = None
|
||
# 前端防抖序列开始时记录的所属对话:指定后把值写入该对话 meta;
|
||
# terminal 当前状态仅当保存目标就是当前对话时才更新,避免串写
|
||
ctx = terminal.context_manager
|
||
target_conversation_id = str(data.get('conversation_id') or '').strip() or None
|
||
is_current_target = not target_conversation_id or target_conversation_id == ctx.current_conversation_id
|
||
if is_current_target:
|
||
terminal.set_reasoning_effort(effort)
|
||
save_conversation_id = target_conversation_id or ctx.current_conversation_id
|
||
# 更新对话的元数据
|
||
if save_conversation_id:
|
||
try:
|
||
if is_current_target:
|
||
ctx.conversation_manager.save_conversation(
|
||
conversation_id=save_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,
|
||
reasoning_effort=effort,
|
||
model_key=getattr(terminal, "model_key", None),
|
||
has_images=getattr(ctx, "has_images", False),
|
||
has_videos=getattr(ctx, "has_videos", False)
|
||
)
|
||
else:
|
||
# 所属对话已不是当前对话:只更新推理强度 meta。
|
||
# 重新读出目标对话消息回传(merge 语义下保持不变),
|
||
# 避免把当前对话的消息串写过去
|
||
existing = ctx.conversation_manager.load_conversation(save_conversation_id) or {}
|
||
ctx.conversation_manager.save_conversation(
|
||
conversation_id=save_conversation_id,
|
||
messages=existing.get("messages") or [],
|
||
reasoning_effort=effort,
|
||
)
|
||
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": {
|
||
"reasoning_effort": terminal.reasoning_effort
|
||
}
|
||
})
|
||
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,
|
||
reasoning_effort=terminal.reasoning_effort,
|
||
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),
|
||
),
|
||
},
|
||
"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 的 模型/模式/推理强度 以对话 meta 为权威,
|
||
# 保存个性化默认值不得回写当前对话;工作区级(/new)则立即生效
|
||
terminal.apply_personalization_preferences(
|
||
config,
|
||
apply_default_modes=not bool(getattr(terminal, '_bound_conversation_id', None)),
|
||
)
|
||
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),
|
||
),
|
||
},
|
||
"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
|