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>
This commit is contained in:
parent
72cdd69f9b
commit
d6fb59e1d8
@ -77,6 +77,8 @@ npm install
|
||||
python main.py
|
||||
|
||||
# Web 模式(默认 8091)
|
||||
python -m server.app # 推荐
|
||||
# 兼容旧命令(启动时会提示已弃用)
|
||||
python web_server.py
|
||||
```
|
||||
|
||||
|
||||
@ -151,7 +151,8 @@ class UserContainerManager:
|
||||
with self._lock:
|
||||
handle = self._containers.get(username)
|
||||
if not handle:
|
||||
return {"username": username, "mode": "host"}
|
||||
# 未找到句柄,视为未运行
|
||||
return {"username": username, "mode": "host", "running": False}
|
||||
|
||||
info = {
|
||||
"username": username,
|
||||
@ -161,6 +162,8 @@ class UserContainerManager:
|
||||
"container_name": handle.container_name,
|
||||
"created_at": handle.created_at,
|
||||
"last_active": handle.last_active,
|
||||
# host 模式下,句柄存在即可认为“运行中”,便于监控统计
|
||||
"running": handle.mode != "docker",
|
||||
}
|
||||
|
||||
if handle.mode == "docker" and include_stats:
|
||||
@ -171,6 +174,11 @@ class UserContainerManager:
|
||||
self._log_stats(username, stats)
|
||||
if state:
|
||||
info["state"] = state
|
||||
# 尽量从容器状态读取运行标记
|
||||
if "State" in state:
|
||||
info["running"] = bool(state.get("State", {}).get("Running", False))
|
||||
elif "running" in state:
|
||||
info["running"] = bool(state.get("running"))
|
||||
|
||||
return info
|
||||
|
||||
|
||||
4
server/__init__.py
Normal file
4
server/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
"""Web server package entry."""
|
||||
from .app import app, socketio, run_server, parse_arguments, initialize_system, resource_busy_page
|
||||
|
||||
__all__ = ["app", "socketio", "run_server", "parse_arguments", "initialize_system", "resource_busy_page"]
|
||||
302
server/_admin_segment.py
Normal file
302
server/_admin_segment.py
Normal file
@ -0,0 +1,302 @@
|
||||
@app.route('/admin/monitor')
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_monitor_page():
|
||||
"""管理员监控页面入口"""
|
||||
return send_from_directory(str(ADMIN_ASSET_DIR), 'index.html')
|
||||
|
||||
@app.route('/admin/policy')
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_policy_page():
|
||||
"""管理员策略配置页面"""
|
||||
return send_from_directory(Path(app.static_folder) / 'admin_policy', 'index.html')
|
||||
|
||||
@app.route('/admin/custom-tools')
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_custom_tools_page():
|
||||
"""自定义工具管理页面"""
|
||||
return send_from_directory(str(ADMIN_CUSTOM_TOOLS_DIR), 'index.html')
|
||||
|
||||
|
||||
@app.route('/api/admin/balance', methods=['GET'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_balance_api():
|
||||
"""查询第三方账户余额(Kimi/DeepSeek/Qwen)。"""
|
||||
data = balance_client.fetch_all_balances()
|
||||
return jsonify({"success": True, "data": data})
|
||||
|
||||
|
||||
@app.route('/admin/assets/<path:filename>')
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_asset_file(filename: str):
|
||||
return send_from_directory(str(ADMIN_ASSET_DIR), filename)
|
||||
|
||||
|
||||
@app.route('/user_upload/<path:filename>')
|
||||
@login_required
|
||||
def serve_user_upload(filename: str):
|
||||
"""
|
||||
直接向前端暴露当前登录用户的上传目录文件,用于 <show_image src="/user_upload/..."> 等场景。
|
||||
- 仅登录用户可访问
|
||||
- 路径穿越校验:目标必须位于用户自己的 uploads_dir 内
|
||||
"""
|
||||
user = get_current_user_record()
|
||||
if not user:
|
||||
return redirect('/login')
|
||||
|
||||
workspace = user_manager.ensure_user_workspace(user.username)
|
||||
uploads_dir = workspace.uploads_dir.resolve()
|
||||
|
||||
target = (uploads_dir / filename).resolve()
|
||||
try:
|
||||
target.relative_to(uploads_dir)
|
||||
except ValueError:
|
||||
abort(403)
|
||||
|
||||
if not target.exists() or not target.is_file():
|
||||
abort(404)
|
||||
|
||||
return send_from_directory(str(uploads_dir), str(target.relative_to(uploads_dir)))
|
||||
|
||||
|
||||
@app.route('/workspace/<path:filename>')
|
||||
@login_required
|
||||
def serve_workspace_file(filename: str):
|
||||
"""
|
||||
暴露当前登录用户项目目录下的文件(主要用于图片展示)。
|
||||
- 仅登录用户可访问自己的项目文件
|
||||
- 路径穿越校验:目标必须位于用户自己的 project_path 内
|
||||
- 非图片直接拒绝,避免误暴露其他文件
|
||||
"""
|
||||
user = get_current_user_record()
|
||||
if not user:
|
||||
return redirect('/login')
|
||||
|
||||
workspace = user_manager.ensure_user_workspace(user.username)
|
||||
project_root = workspace.project_path.resolve()
|
||||
|
||||
target = (project_root / filename).resolve()
|
||||
try:
|
||||
target.relative_to(project_root)
|
||||
except ValueError:
|
||||
abort(403)
|
||||
|
||||
if not target.exists() or not target.is_file():
|
||||
abort(404)
|
||||
|
||||
mime_type, _ = mimetypes.guess_type(str(target))
|
||||
if not mime_type or not mime_type.startswith("image/"):
|
||||
abort(415)
|
||||
|
||||
return send_from_directory(str(target.parent), target.name)
|
||||
|
||||
|
||||
@app.route('/static/<path:filename>')
|
||||
def static_files(filename):
|
||||
"""提供静态文件"""
|
||||
if filename.startswith('admin_dashboard'):
|
||||
abort(404)
|
||||
return send_from_directory('static', filename)
|
||||
|
||||
@app.route('/api/status')
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def get_status(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""获取系统状态(增强版:包含对话信息)"""
|
||||
status = terminal.get_status()
|
||||
|
||||
# 添加终端状态信息
|
||||
if terminal.terminal_manager:
|
||||
terminal_status = terminal.terminal_manager.list_terminals()
|
||||
status['terminals'] = terminal_status
|
||||
|
||||
# 【新增】添加当前对话的详细信息
|
||||
try:
|
||||
current_conv = terminal.context_manager.current_conversation_id
|
||||
status['conversation'] = status.get('conversation', {})
|
||||
status['conversation']['current_id'] = current_conv
|
||||
if current_conv and not current_conv.startswith('temp_'):
|
||||
current_conv_data = terminal.context_manager.conversation_manager.load_conversation(current_conv)
|
||||
if current_conv_data:
|
||||
status['conversation']['title'] = current_conv_data.get('title', '未知对话')
|
||||
status['conversation']['created_at'] = current_conv_data.get('created_at')
|
||||
status['conversation']['updated_at'] = current_conv_data.get('updated_at')
|
||||
except Exception as e:
|
||||
print(f"[Status] 获取当前对话信息失败: {e}")
|
||||
|
||||
status['project_path'] = str(workspace.project_path)
|
||||
try:
|
||||
status['container'] = container_manager.get_container_status(username)
|
||||
except Exception as exc:
|
||||
status['container'] = {"success": False, "error": str(exc)}
|
||||
status['version'] = AGENT_VERSION
|
||||
try:
|
||||
policy = resolve_admin_policy(user_manager.get_user(username))
|
||||
status['admin_policy'] = {
|
||||
"ui_blocks": policy.get("ui_blocks") or {},
|
||||
"disabled_models": policy.get("disabled_models") or [],
|
||||
"forced_category_states": policy.get("forced_category_states") or {},
|
||||
"version": policy.get("updated_at"),
|
||||
}
|
||||
except Exception as exc:
|
||||
debug_log(f"[status] 附加管理员策略失败: {exc}")
|
||||
return jsonify(status)
|
||||
|
||||
@app.route('/api/container-status')
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def get_container_status_api(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""轮询容器状态(供前端用量面板定时刷新)。"""
|
||||
try:
|
||||
status = container_manager.get_container_status(username)
|
||||
return jsonify({"success": True, "data": status})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
@app.route('/api/project-storage')
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def get_project_storage(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""获取项目目录占用情况,供前端轮询。"""
|
||||
now = time.time()
|
||||
cache_entry = PROJECT_STORAGE_CACHE.get(username)
|
||||
if cache_entry and (now - cache_entry.get("ts", 0)) < PROJECT_STORAGE_CACHE_TTL_SECONDS:
|
||||
return jsonify({"success": True, "data": cache_entry["data"]})
|
||||
try:
|
||||
file_manager = getattr(terminal, 'file_manager', None)
|
||||
if not file_manager:
|
||||
return jsonify({"success": False, "error": "文件管理器未初始化"}), 500
|
||||
used_bytes = file_manager._get_project_size()
|
||||
limit_bytes = PROJECT_MAX_STORAGE_MB * 1024 * 1024 if PROJECT_MAX_STORAGE_MB else None
|
||||
usage_percent = (used_bytes / limit_bytes * 100) if limit_bytes else None
|
||||
data = {
|
||||
"used_bytes": used_bytes,
|
||||
"limit_bytes": limit_bytes,
|
||||
"limit_label": f"{PROJECT_MAX_STORAGE_MB}MB" if PROJECT_MAX_STORAGE_MB else "未限制",
|
||||
"usage_percent": usage_percent
|
||||
}
|
||||
PROJECT_STORAGE_CACHE[username] = {"ts": now, "data": data}
|
||||
return jsonify({"success": True, "data": data})
|
||||
except Exception as exc:
|
||||
stale = PROJECT_STORAGE_CACHE.get(username)
|
||||
if stale:
|
||||
return jsonify({"success": True, "data": stale.get("data"), "stale": True}), 200
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
@app.route('/api/admin/dashboard')
|
||||
@api_login_required
|
||||
@admin_api_required
|
||||
def admin_dashboard_snapshot_api():
|
||||
try:
|
||||
snapshot = build_admin_dashboard_snapshot()
|
||||
return jsonify({"success": True, "data": snapshot})
|
||||
except Exception as exc:
|
||||
logging.exception("Failed to build admin dashboard")
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
@app.route('/api/admin/policy', methods=['GET', 'POST'])
|
||||
@api_login_required
|
||||
@admin_api_required
|
||||
def admin_policy_api():
|
||||
if request.method == 'GET':
|
||||
try:
|
||||
data = admin_policy_manager.load_policy()
|
||||
defaults = admin_policy_manager.describe_defaults()
|
||||
return jsonify({"success": True, "data": data, "defaults": defaults})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
# POST 更新
|
||||
payload = request.get_json() or {}
|
||||
target_type = payload.get("target_type")
|
||||
target_value = payload.get("target_value") or ""
|
||||
config = payload.get("config") or {}
|
||||
try:
|
||||
saved = admin_policy_manager.save_scope_policy(target_type, target_value, config)
|
||||
return jsonify({"success": True, "data": saved})
|
||||
except ValueError as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 400
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
@app.route('/api/admin/custom-tools', methods=['GET', 'POST', 'DELETE'])
|
||||
@api_login_required
|
||||
@admin_api_required
|
||||
def admin_custom_tools_api():
|
||||
"""自定义工具管理(仅全局管理员)。"""
|
||||
try:
|
||||
if request.method == 'GET':
|
||||
return jsonify({"success": True, "data": custom_tool_registry.list_tools()})
|
||||
if request.method == 'POST':
|
||||
payload = request.get_json() or {}
|
||||
saved = custom_tool_registry.upsert_tool(payload)
|
||||
return jsonify({"success": True, "data": saved})
|
||||
# DELETE
|
||||
tool_id = request.args.get("id") or (request.get_json() or {}).get("id")
|
||||
if not tool_id:
|
||||
return jsonify({"success": False, "error": "缺少 id"}), 400
|
||||
removed = custom_tool_registry.delete_tool(tool_id)
|
||||
if removed:
|
||||
return jsonify({"success": True, "data": {"deleted": tool_id}})
|
||||
return jsonify({"success": False, "error": "未找到该工具"}), 404
|
||||
except ValueError as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 400
|
||||
except Exception as exc:
|
||||
logging.exception("custom-tools API error")
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
@app.route('/api/admin/custom-tools/file', methods=['GET', 'POST'])
|
||||
@api_login_required
|
||||
@admin_api_required
|
||||
def admin_custom_tools_file_api():
|
||||
tool_id = request.args.get("id") or (request.get_json() or {}).get("id")
|
||||
name = request.args.get("name") or (request.get_json() or {}).get("name")
|
||||
if not tool_id or not name:
|
||||
return jsonify({"success": False, "error": "缺少 id 或 name"}), 400
|
||||
tool_dir = Path(custom_tool_registry.root) / tool_id
|
||||
if not tool_dir.exists():
|
||||
return jsonify({"success": False, "error": "工具不存在"}), 404
|
||||
target = tool_dir / name
|
||||
|
||||
if request.method == 'GET':
|
||||
if not target.exists():
|
||||
return jsonify({"success": False, "error": "文件不存在"}), 404
|
||||
try:
|
||||
return target.read_text(encoding="utf-8")
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
# POST 保存文件
|
||||
payload = request.get_json() or {}
|
||||
content = payload.get("content")
|
||||
try:
|
||||
target.write_text(content or "", encoding="utf-8")
|
||||
return jsonify({"success": True})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
@app.route('/api/admin/custom-tools/reload', methods=['POST'])
|
||||
@api_login_required
|
||||
@admin_api_required
|
||||
def admin_custom_tools_reload_api():
|
||||
try:
|
||||
custom_tool_registry.reload()
|
||||
return jsonify({"success": True})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
@app.route('/api/effective-policy', methods=['GET'])
|
||||
@api_login_required
|
||||
def effective_policy_api():
|
||||
record = get_current_user_record()
|
||||
policy = resolve_admin_policy(record)
|
||||
return jsonify({"success": True, "data": policy})
|
||||
|
||||
|
||||
556
server/_chat_block.py
Normal file
556
server/_chat_block.py
Normal file
@ -0,0 +1,556 @@
|
||||
@app.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)
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@app.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)
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@app.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)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": data,
|
||||
"tool_categories": terminal.get_tool_settings_snapshot(),
|
||||
"thinking_interval_default": THINKING_FAST_INTERVAL,
|
||||
"thinking_interval_range": {
|
||||
"min": THINKING_INTERVAL_MIN,
|
||||
"max": THINKING_INTERVAL_MAX
|
||||
}
|
||||
})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
@app.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)
|
||||
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}")
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": config,
|
||||
"tool_categories": terminal.get_tool_settings_snapshot(),
|
||||
"thinking_interval_default": THINKING_FAST_INTERVAL,
|
||||
"thinking_interval_range": {
|
||||
"min": THINKING_INTERVAL_MIN,
|
||||
"max": THINKING_INTERVAL_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
|
||||
|
||||
@app.route('/api/memory', methods=['GET'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def api_memory_entries(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""返回主/任务记忆条目列表,供虚拟显示器加载"""
|
||||
memory_type = request.args.get('type', 'main')
|
||||
if memory_type not in ('main', 'task'):
|
||||
return jsonify({"success": False, "error": "type 必须是 main 或 task"}), 400
|
||||
try:
|
||||
entries = terminal.memory_manager._read_entries(memory_type) # type: ignore
|
||||
return jsonify({"success": True, "type": memory_type, "entries": entries})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
@app.route('/api/gui/monitor_snapshot', methods=['GET'])
|
||||
@api_login_required
|
||||
def get_monitor_snapshot_api():
|
||||
execution_id = request.args.get('executionId') or request.args.get('execution_id') or request.args.get('id')
|
||||
if not execution_id:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '缺少 executionId 参数'
|
||||
}), 400
|
||||
stage = (request.args.get('stage') or 'before').lower()
|
||||
if stage not in {'before', 'after'}:
|
||||
stage = 'before'
|
||||
snapshot = get_cached_monitor_snapshot(execution_id, stage)
|
||||
if not snapshot:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '未找到对应快照'
|
||||
}), 404
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'snapshot': snapshot,
|
||||
'stage': stage
|
||||
})
|
||||
|
||||
@app.route('/api/focused')
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def get_focused_files(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""获取聚焦文件"""
|
||||
focused = {}
|
||||
for path, content in terminal.focused_files.items():
|
||||
focused[path] = {
|
||||
"content": content,
|
||||
"size": len(content),
|
||||
"lines": content.count('\n') + 1
|
||||
}
|
||||
return jsonify(focused)
|
||||
|
||||
@app.route('/api/todo-list')
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def get_todo_list(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""获取当前待办列表"""
|
||||
todo_snapshot = terminal.context_manager.get_todo_snapshot()
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": todo_snapshot
|
||||
})
|
||||
|
||||
@app.route('/api/upload', methods=['POST'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
@rate_limited("legacy_upload", 20, 300, scope="user")
|
||||
def upload_file(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""处理前端文件上传请求"""
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_upload"):
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "文件上传已被管理员禁用",
|
||||
"message": "被管理员禁用上传"
|
||||
}), 403
|
||||
if 'file' not in request.files:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "未找到文件",
|
||||
"message": "请求中缺少文件字段"
|
||||
}), 400
|
||||
|
||||
uploaded_file = request.files['file']
|
||||
original_name = (request.form.get('filename') or '').strip()
|
||||
|
||||
if not uploaded_file or not uploaded_file.filename or uploaded_file.filename.strip() == '':
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "文件名为空",
|
||||
"message": "请选择要上传的文件"
|
||||
}), 400
|
||||
|
||||
raw_name = original_name or uploaded_file.filename
|
||||
filename = sanitize_filename_preserve_unicode(raw_name)
|
||||
if not filename:
|
||||
filename = secure_filename(raw_name)
|
||||
if not filename:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "非法文件名",
|
||||
"message": "文件名包含不支持的字符"
|
||||
}), 400
|
||||
|
||||
file_manager = getattr(terminal, 'file_manager', None)
|
||||
if file_manager is None:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "文件管理器未初始化"
|
||||
}), 500
|
||||
|
||||
target_folder_relative = UPLOAD_FOLDER_NAME
|
||||
valid_folder, folder_error, folder_path = file_manager._validate_path(target_folder_relative)
|
||||
if not valid_folder:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": folder_error
|
||||
}), 400
|
||||
|
||||
try:
|
||||
folder_path.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as exc:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": f"创建上传目录失败: {exc}"
|
||||
}), 500
|
||||
|
||||
target_relative = str(Path(target_folder_relative) / filename)
|
||||
valid_file, file_error, target_full_path = file_manager._validate_path(target_relative)
|
||||
if not valid_file:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": file_error
|
||||
}), 400
|
||||
|
||||
final_path = target_full_path
|
||||
if final_path.exists():
|
||||
stem = final_path.stem
|
||||
suffix = final_path.suffix
|
||||
counter = 1
|
||||
|
||||
while final_path.exists():
|
||||
candidate_name = f"{stem}_{counter}{suffix}"
|
||||
target_relative = str(Path(target_folder_relative) / candidate_name)
|
||||
valid_file, file_error, candidate_path = file_manager._validate_path(target_relative)
|
||||
if not valid_file:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": file_error
|
||||
}), 400
|
||||
final_path = candidate_path
|
||||
counter += 1
|
||||
|
||||
try:
|
||||
relative_path = str(final_path.relative_to(workspace.project_path))
|
||||
except Exception as exc:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": f"路径解析失败: {exc}"
|
||||
}), 400
|
||||
|
||||
guard = get_upload_guard(workspace)
|
||||
try:
|
||||
result = guard.process_upload(
|
||||
uploaded_file,
|
||||
final_path,
|
||||
username=username,
|
||||
source="legacy_upload",
|
||||
original_name=raw_name,
|
||||
relative_path=relative_path,
|
||||
)
|
||||
except UploadSecurityError as exc:
|
||||
return build_upload_error_response(exc)
|
||||
except Exception as exc:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": f"保存文件失败: {exc}"
|
||||
}), 500
|
||||
|
||||
metadata = result.get("metadata", {})
|
||||
print(f"{OUTPUT_FORMATS['file']} 上传文件: {relative_path}")
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"path": relative_path,
|
||||
"filename": final_path.name,
|
||||
"folder": target_folder_relative,
|
||||
"scan": metadata.get("scan"),
|
||||
"sha256": metadata.get("sha256"),
|
||||
"size": metadata.get("size"),
|
||||
})
|
||||
|
||||
|
||||
@app.errorhandler(RequestEntityTooLarge)
|
||||
def handle_file_too_large(error):
|
||||
"""全局捕获上传超大小"""
|
||||
size_mb = MAX_UPLOAD_SIZE / (1024 * 1024)
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "文件过大",
|
||||
"message": f"单个文件大小不可超过 {size_mb:.1f} MB"
|
||||
}), 413
|
||||
|
||||
|
||||
@app.route('/api/download/file')
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def download_file_api(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""下载单个文件"""
|
||||
path = (request.args.get('path') or '').strip()
|
||||
if not path:
|
||||
return jsonify({"success": False, "error": "缺少路径参数"}), 400
|
||||
|
||||
valid, error, full_path = terminal.file_manager._validate_path(path)
|
||||
if not valid or full_path is None:
|
||||
return jsonify({"success": False, "error": error or "路径校验失败"}), 400
|
||||
if not full_path.exists() or not full_path.is_file():
|
||||
return jsonify({"success": False, "error": "文件不存在"}), 404
|
||||
|
||||
return send_file(
|
||||
full_path,
|
||||
as_attachment=True,
|
||||
download_name=full_path.name
|
||||
)
|
||||
|
||||
|
||||
@app.route('/api/download/folder')
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def download_folder_api(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""打包并下载文件夹"""
|
||||
path = (request.args.get('path') or '').strip()
|
||||
if not path:
|
||||
return jsonify({"success": False, "error": "缺少路径参数"}), 400
|
||||
|
||||
valid, error, full_path = terminal.file_manager._validate_path(path)
|
||||
if not valid or full_path is None:
|
||||
return jsonify({"success": False, "error": error or "路径校验失败"}), 400
|
||||
if not full_path.exists() or not full_path.is_dir():
|
||||
return jsonify({"success": False, "error": "文件夹不存在"}), 404
|
||||
|
||||
buffer = BytesIO()
|
||||
folder_name = Path(path).name or full_path.name or "archive"
|
||||
|
||||
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_buffer:
|
||||
# 确保目录本身被包含
|
||||
zip_buffer.write(full_path, arcname=folder_name + '/')
|
||||
|
||||
for item in full_path.rglob('*'):
|
||||
relative_name = Path(folder_name) / item.relative_to(full_path)
|
||||
if item.is_dir():
|
||||
zip_buffer.write(item, arcname=str(relative_name) + '/')
|
||||
else:
|
||||
zip_buffer.write(item, arcname=str(relative_name))
|
||||
|
||||
buffer.seek(0)
|
||||
return send_file(
|
||||
buffer,
|
||||
mimetype='application/zip',
|
||||
as_attachment=True,
|
||||
download_name=f"{folder_name}.zip"
|
||||
)
|
||||
|
||||
@app.route('/api/tool-settings', methods=['GET', 'POST'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def tool_settings(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""获取或更新工具启用状态"""
|
||||
if request.method == 'GET':
|
||||
snapshot = terminal.get_tool_settings_snapshot()
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"categories": snapshot
|
||||
})
|
||||
|
||||
data = request.get_json() or {}
|
||||
category = data.get('category')
|
||||
if category is None:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "缺少类别参数",
|
||||
"message": "请求体需要提供 category 字段"
|
||||
}), 400
|
||||
|
||||
if 'enabled' not in data:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "缺少启用状态",
|
||||
"message": "请求体需要提供 enabled 字段"
|
||||
}), 400
|
||||
|
||||
try:
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_tool_toggle"):
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "工具开关已被管理员禁用",
|
||||
"message": "被管理员强制禁用"
|
||||
}), 403
|
||||
enabled = bool(data['enabled'])
|
||||
forced = getattr(terminal, "admin_forced_category_states", {}) or {}
|
||||
if isinstance(forced.get(category), bool) and forced[category] != enabled:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "该工具类别已被管理员强制为启用/禁用,无法修改",
|
||||
"message": "被管理员强制启用/禁用"
|
||||
}), 403
|
||||
terminal.set_tool_category_enabled(category, enabled)
|
||||
snapshot = terminal.get_tool_settings_snapshot()
|
||||
socketio.emit('tool_settings_updated', {
|
||||
'categories': snapshot
|
||||
}, room=f"user_{username}")
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"categories": snapshot
|
||||
})
|
||||
except ValueError as exc:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(exc)
|
||||
}), 400
|
||||
|
||||
@app.route('/api/terminals')
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def get_terminals(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""获取终端会话列表"""
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_realtime_terminal"):
|
||||
return jsonify({"success": False, "error": "实时终端已被管理员禁用"}), 403
|
||||
if terminal.terminal_manager:
|
||||
result = terminal.terminal_manager.list_terminals()
|
||||
return jsonify(result)
|
||||
else:
|
||||
return jsonify({"sessions": [], "active": None, "total": 0})
|
||||
|
||||
|
||||
@app.route('/api/socket-token', methods=['GET'])
|
||||
@api_login_required
|
||||
def issue_socket_token():
|
||||
"""生成一次性 WebSocket token,供握手阶段使用。"""
|
||||
username = get_current_username()
|
||||
prune_socket_tokens()
|
||||
now = time.time()
|
||||
for token_value, meta in list(pending_socket_tokens.items()):
|
||||
if meta.get("username") == username:
|
||||
pending_socket_tokens.pop(token_value, None)
|
||||
token_value = secrets.token_urlsafe(32)
|
||||
pending_socket_tokens[token_value] = {
|
||||
"username": username,
|
||||
"expires_at": now + SOCKET_TOKEN_TTL_SECONDS,
|
||||
"fingerprint": (request.headers.get('User-Agent') or '')[:128],
|
||||
}
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"token": token_value,
|
||||
"expires_in": SOCKET_TOKEN_TTL_SECONDS
|
||||
})
|
||||
|
||||
|
||||
569
server/_chat_usage_segment.py
Normal file
569
server/_chat_usage_segment.py
Normal file
@ -0,0 +1,569 @@
|
||||
@app.route('/api/usage', methods=['GET'])
|
||||
@api_login_required
|
||||
def get_usage_stats():
|
||||
"""返回当前用户的模型/搜索调用统计。"""
|
||||
username = get_current_username()
|
||||
tracker = get_or_create_usage_tracker(username)
|
||||
if not tracker:
|
||||
return jsonify({"success": False, "error": "未找到用户"}), 404
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": tracker.get_stats()
|
||||
})
|
||||
|
||||
@app.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)
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@app.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)
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@app.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)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": data,
|
||||
"tool_categories": terminal.get_tool_settings_snapshot(),
|
||||
"thinking_interval_default": THINKING_FAST_INTERVAL,
|
||||
"thinking_interval_range": {
|
||||
"min": THINKING_INTERVAL_MIN,
|
||||
"max": THINKING_INTERVAL_MAX
|
||||
}
|
||||
})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
@app.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)
|
||||
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}")
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": config,
|
||||
"tool_categories": terminal.get_tool_settings_snapshot(),
|
||||
"thinking_interval_default": THINKING_FAST_INTERVAL,
|
||||
"thinking_interval_range": {
|
||||
"min": THINKING_INTERVAL_MIN,
|
||||
"max": THINKING_INTERVAL_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
|
||||
|
||||
@app.route('/api/memory', methods=['GET'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def api_memory_entries(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""返回主/任务记忆条目列表,供虚拟显示器加载"""
|
||||
memory_type = request.args.get('type', 'main')
|
||||
if memory_type not in ('main', 'task'):
|
||||
return jsonify({"success": False, "error": "type 必须是 main 或 task"}), 400
|
||||
try:
|
||||
entries = terminal.memory_manager._read_entries(memory_type) # type: ignore
|
||||
return jsonify({"success": True, "type": memory_type, "entries": entries})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
@app.route('/api/gui/monitor_snapshot', methods=['GET'])
|
||||
@api_login_required
|
||||
def get_monitor_snapshot_api():
|
||||
execution_id = request.args.get('executionId') or request.args.get('execution_id') or request.args.get('id')
|
||||
if not execution_id:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '缺少 executionId 参数'
|
||||
}), 400
|
||||
stage = (request.args.get('stage') or 'before').lower()
|
||||
if stage not in {'before', 'after'}:
|
||||
stage = 'before'
|
||||
snapshot = get_cached_monitor_snapshot(execution_id, stage)
|
||||
if not snapshot:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '未找到对应快照'
|
||||
}), 404
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'snapshot': snapshot,
|
||||
'stage': stage
|
||||
})
|
||||
|
||||
@app.route('/api/focused')
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def get_focused_files(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""获取聚焦文件"""
|
||||
focused = {}
|
||||
for path, content in terminal.focused_files.items():
|
||||
focused[path] = {
|
||||
"content": content,
|
||||
"size": len(content),
|
||||
"lines": content.count('\n') + 1
|
||||
}
|
||||
return jsonify(focused)
|
||||
|
||||
@app.route('/api/todo-list')
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def get_todo_list(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""获取当前待办列表"""
|
||||
todo_snapshot = terminal.context_manager.get_todo_snapshot()
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": todo_snapshot
|
||||
})
|
||||
|
||||
@app.route('/api/upload', methods=['POST'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
@rate_limited("legacy_upload", 20, 300, scope="user")
|
||||
def upload_file(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""处理前端文件上传请求"""
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_upload"):
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "文件上传已被管理员禁用",
|
||||
"message": "被管理员禁用上传"
|
||||
}), 403
|
||||
if 'file' not in request.files:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "未找到文件",
|
||||
"message": "请求中缺少文件字段"
|
||||
}), 400
|
||||
|
||||
uploaded_file = request.files['file']
|
||||
original_name = (request.form.get('filename') or '').strip()
|
||||
|
||||
if not uploaded_file or not uploaded_file.filename or uploaded_file.filename.strip() == '':
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "文件名为空",
|
||||
"message": "请选择要上传的文件"
|
||||
}), 400
|
||||
|
||||
raw_name = original_name or uploaded_file.filename
|
||||
filename = sanitize_filename_preserve_unicode(raw_name)
|
||||
if not filename:
|
||||
filename = secure_filename(raw_name)
|
||||
if not filename:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "非法文件名",
|
||||
"message": "文件名包含不支持的字符"
|
||||
}), 400
|
||||
|
||||
file_manager = getattr(terminal, 'file_manager', None)
|
||||
if file_manager is None:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "文件管理器未初始化"
|
||||
}), 500
|
||||
|
||||
target_folder_relative = UPLOAD_FOLDER_NAME
|
||||
valid_folder, folder_error, folder_path = file_manager._validate_path(target_folder_relative)
|
||||
if not valid_folder:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": folder_error
|
||||
}), 400
|
||||
|
||||
try:
|
||||
folder_path.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as exc:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": f"创建上传目录失败: {exc}"
|
||||
}), 500
|
||||
|
||||
target_relative = str(Path(target_folder_relative) / filename)
|
||||
valid_file, file_error, target_full_path = file_manager._validate_path(target_relative)
|
||||
if not valid_file:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": file_error
|
||||
}), 400
|
||||
|
||||
final_path = target_full_path
|
||||
if final_path.exists():
|
||||
stem = final_path.stem
|
||||
suffix = final_path.suffix
|
||||
counter = 1
|
||||
|
||||
while final_path.exists():
|
||||
candidate_name = f"{stem}_{counter}{suffix}"
|
||||
target_relative = str(Path(target_folder_relative) / candidate_name)
|
||||
valid_file, file_error, candidate_path = file_manager._validate_path(target_relative)
|
||||
if not valid_file:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": file_error
|
||||
}), 400
|
||||
final_path = candidate_path
|
||||
counter += 1
|
||||
|
||||
try:
|
||||
relative_path = str(final_path.relative_to(workspace.project_path))
|
||||
except Exception as exc:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": f"路径解析失败: {exc}"
|
||||
}), 400
|
||||
|
||||
guard = get_upload_guard(workspace)
|
||||
try:
|
||||
result = guard.process_upload(
|
||||
uploaded_file,
|
||||
final_path,
|
||||
username=username,
|
||||
source="legacy_upload",
|
||||
original_name=raw_name,
|
||||
relative_path=relative_path,
|
||||
)
|
||||
except UploadSecurityError as exc:
|
||||
return build_upload_error_response(exc)
|
||||
except Exception as exc:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": f"保存文件失败: {exc}"
|
||||
}), 500
|
||||
|
||||
metadata = result.get("metadata", {})
|
||||
print(f"{OUTPUT_FORMATS['file']} 上传文件: {relative_path}")
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"path": relative_path,
|
||||
"filename": final_path.name,
|
||||
"folder": target_folder_relative,
|
||||
"scan": metadata.get("scan"),
|
||||
"sha256": metadata.get("sha256"),
|
||||
"size": metadata.get("size"),
|
||||
})
|
||||
|
||||
|
||||
@app.errorhandler(RequestEntityTooLarge)
|
||||
def handle_file_too_large(error):
|
||||
"""全局捕获上传超大小"""
|
||||
size_mb = MAX_UPLOAD_SIZE / (1024 * 1024)
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "文件过大",
|
||||
"message": f"单个文件大小不可超过 {size_mb:.1f} MB"
|
||||
}), 413
|
||||
|
||||
|
||||
@app.route('/api/download/file')
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def download_file_api(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""下载单个文件"""
|
||||
path = (request.args.get('path') or '').strip()
|
||||
if not path:
|
||||
return jsonify({"success": False, "error": "缺少路径参数"}), 400
|
||||
|
||||
valid, error, full_path = terminal.file_manager._validate_path(path)
|
||||
if not valid or full_path is None:
|
||||
return jsonify({"success": False, "error": error or "路径校验失败"}), 400
|
||||
if not full_path.exists() or not full_path.is_file():
|
||||
return jsonify({"success": False, "error": "文件不存在"}), 404
|
||||
|
||||
return send_file(
|
||||
full_path,
|
||||
as_attachment=True,
|
||||
download_name=full_path.name
|
||||
)
|
||||
|
||||
|
||||
@app.route('/api/download/folder')
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def download_folder_api(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""打包并下载文件夹"""
|
||||
path = (request.args.get('path') or '').strip()
|
||||
if not path:
|
||||
return jsonify({"success": False, "error": "缺少路径参数"}), 400
|
||||
|
||||
valid, error, full_path = terminal.file_manager._validate_path(path)
|
||||
if not valid or full_path is None:
|
||||
return jsonify({"success": False, "error": error or "路径校验失败"}), 400
|
||||
if not full_path.exists() or not full_path.is_dir():
|
||||
return jsonify({"success": False, "error": "文件夹不存在"}), 404
|
||||
|
||||
buffer = BytesIO()
|
||||
folder_name = Path(path).name or full_path.name or "archive"
|
||||
|
||||
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_buffer:
|
||||
# 确保目录本身被包含
|
||||
zip_buffer.write(full_path, arcname=folder_name + '/')
|
||||
|
||||
for item in full_path.rglob('*'):
|
||||
relative_name = Path(folder_name) / item.relative_to(full_path)
|
||||
if item.is_dir():
|
||||
zip_buffer.write(item, arcname=str(relative_name) + '/')
|
||||
else:
|
||||
zip_buffer.write(item, arcname=str(relative_name))
|
||||
|
||||
buffer.seek(0)
|
||||
return send_file(
|
||||
buffer,
|
||||
mimetype='application/zip',
|
||||
as_attachment=True,
|
||||
download_name=f"{folder_name}.zip"
|
||||
)
|
||||
|
||||
@app.route('/api/tool-settings', methods=['GET', 'POST'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def tool_settings(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""获取或更新工具启用状态"""
|
||||
if request.method == 'GET':
|
||||
snapshot = terminal.get_tool_settings_snapshot()
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"categories": snapshot
|
||||
})
|
||||
|
||||
data = request.get_json() or {}
|
||||
category = data.get('category')
|
||||
if category is None:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "缺少类别参数",
|
||||
"message": "请求体需要提供 category 字段"
|
||||
}), 400
|
||||
|
||||
if 'enabled' not in data:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "缺少启用状态",
|
||||
"message": "请求体需要提供 enabled 字段"
|
||||
}), 400
|
||||
|
||||
try:
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_tool_toggle"):
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "工具开关已被管理员禁用",
|
||||
"message": "被管理员强制禁用"
|
||||
}), 403
|
||||
enabled = bool(data['enabled'])
|
||||
forced = getattr(terminal, "admin_forced_category_states", {}) or {}
|
||||
if isinstance(forced.get(category), bool) and forced[category] != enabled:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "该工具类别已被管理员强制为启用/禁用,无法修改",
|
||||
"message": "被管理员强制启用/禁用"
|
||||
}), 403
|
||||
terminal.set_tool_category_enabled(category, enabled)
|
||||
snapshot = terminal.get_tool_settings_snapshot()
|
||||
socketio.emit('tool_settings_updated', {
|
||||
'categories': snapshot
|
||||
}, room=f"user_{username}")
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"categories": snapshot
|
||||
})
|
||||
except ValueError as exc:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(exc)
|
||||
}), 400
|
||||
|
||||
@app.route('/api/terminals')
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def get_terminals(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""获取终端会话列表"""
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_realtime_terminal"):
|
||||
return jsonify({"success": False, "error": "实时终端已被管理员禁用"}), 403
|
||||
if terminal.terminal_manager:
|
||||
result = terminal.terminal_manager.list_terminals()
|
||||
return jsonify(result)
|
||||
else:
|
||||
return jsonify({"sessions": [], "active": None, "total": 0})
|
||||
|
||||
|
||||
@app.route('/api/socket-token', methods=['GET'])
|
||||
@api_login_required
|
||||
def issue_socket_token():
|
||||
"""生成一次性 WebSocket token,供握手阶段使用。"""
|
||||
username = get_current_username()
|
||||
prune_socket_tokens()
|
||||
now = time.time()
|
||||
for token_value, meta in list(pending_socket_tokens.items()):
|
||||
if meta.get("username") == username:
|
||||
pending_socket_tokens.pop(token_value, None)
|
||||
token_value = secrets.token_urlsafe(32)
|
||||
pending_socket_tokens[token_value] = {
|
||||
"username": username,
|
||||
"expires_at": now + SOCKET_TOKEN_TTL_SECONDS,
|
||||
"fingerprint": (request.headers.get('User-Agent') or '')[:128],
|
||||
}
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"token": token_value,
|
||||
"expires_in": SOCKET_TOKEN_TTL_SECONDS
|
||||
})
|
||||
|
||||
|
||||
20
server/_constants_snippet.txt
Normal file
20
server/_constants_snippet.txt
Normal file
@ -0,0 +1,20 @@
|
||||
DEFAULT_PORT = 8091
|
||||
THINKING_FAILURE_KEYWORDS = ["⚠️", "🛑", "失败", "错误", "异常", "终止", "error", "failed", "未完成", "超时", "强制"]
|
||||
CSRF_HEADER_NAME = "X-CSRF-Token"
|
||||
CSRF_SESSION_KEY = "_csrf_token"
|
||||
CSRF_SAFE_METHODS = {"GET", "HEAD", "OPTIONS", "TRACE"}
|
||||
CSRF_PROTECTED_PATHS = {"/login", "/register", "/logout"}
|
||||
CSRF_PROTECTED_PREFIXES = ("/api/",)
|
||||
CSRF_EXEMPT_PATHS = {"/api/csrf-token"}
|
||||
FAILED_LOGIN_LIMIT = 5
|
||||
FAILED_LOGIN_LOCK_SECONDS = 300
|
||||
SOCKET_TOKEN_TTL_SECONDS = 45
|
||||
PROJECT_STORAGE_CACHE: Dict[str, Dict[str, Any]] = {}
|
||||
PROJECT_STORAGE_CACHE_TTL_SECONDS = float(os.environ.get("PROJECT_STORAGE_CACHE_TTL", "30"))
|
||||
USER_IDLE_TIMEOUT_SECONDS = int(os.environ.get("USER_IDLE_TIMEOUT_SECONDS", "900"))
|
||||
LAST_ACTIVE_FILE = Path(LOGS_DIR).expanduser().resolve() / "last_active.json"
|
||||
_last_active_lock = threading.Lock()
|
||||
_last_active_cache: Dict[str, float] = {}
|
||||
_idle_reaper_started = False
|
||||
TITLE_PROMPT_PATH = PROJECT_ROOT / "prompts" / "title_generation_prompt.txt"
|
||||
|
||||
3116
server/_conversation_segment.py
Normal file
3116
server/_conversation_segment.py
Normal file
File diff suppressed because it is too large
Load Diff
26
server/_globals_snippet.txt
Normal file
26
server/_globals_snippet.txt
Normal file
@ -0,0 +1,26 @@
|
||||
user_manager = UserManager()
|
||||
custom_tool_registry = CustomToolRegistry()
|
||||
container_manager = UserContainerManager()
|
||||
user_terminals: Dict[str, WebTerminal] = {}
|
||||
terminal_rooms: Dict[str, set] = {}
|
||||
connection_users: Dict[str, str] = {}
|
||||
stop_flags: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
MONITOR_FILE_TOOLS = {'append_to_file', 'modify_file', 'write_file_diff'}
|
||||
MONITOR_MEMORY_TOOLS = {'update_memory'}
|
||||
MONITOR_SNAPSHOT_CHAR_LIMIT = 60000
|
||||
MONITOR_MEMORY_ENTRY_LIMIT = 256
|
||||
RATE_LIMIT_BUCKETS: Dict[str, deque] = defaultdict(deque)
|
||||
FAILURE_TRACKERS: Dict[str, Dict[str, float]] = {}
|
||||
pending_socket_tokens: Dict[str, Dict[str, Any]] = {}
|
||||
usage_trackers: Dict[str, UsageTracker] = {}
|
||||
|
||||
MONITOR_SNAPSHOT_CACHE: Dict[str, Dict[str, Any]] = {}
|
||||
MONITOR_SNAPSHOT_CACHE_LIMIT = 120
|
||||
|
||||
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
|
||||
|
||||
297
server/_socket_segment.py
Normal file
297
server/_socket_segment.py
Normal file
@ -0,0 +1,297 @@
|
||||
@socketio.on('connect')
|
||||
def handle_connect(auth):
|
||||
"""客户端连接"""
|
||||
print(f"[WebSocket] 客户端连接: {request.sid}")
|
||||
username = get_current_username()
|
||||
token_value = (auth or {}).get('socket_token') if isinstance(auth, dict) else None
|
||||
if not username or not consume_socket_token(token_value, username):
|
||||
emit('error', {'message': '未登录或连接凭证无效'})
|
||||
disconnect()
|
||||
return
|
||||
|
||||
emit('connected', {'status': 'Connected to server'})
|
||||
connection_users[request.sid] = username
|
||||
|
||||
# 清理可能存在的停止标志和状态
|
||||
stop_flags.pop(request.sid, None)
|
||||
|
||||
join_room(f"user_{username}")
|
||||
join_room(f"user_{username}_terminal")
|
||||
if request.sid not in terminal_rooms:
|
||||
terminal_rooms[request.sid] = set()
|
||||
terminal_rooms[request.sid].update({f"user_{username}", f"user_{username}_terminal"})
|
||||
|
||||
terminal, workspace = get_user_resources(username)
|
||||
if terminal:
|
||||
reset_system_state(terminal)
|
||||
emit('system_ready', {
|
||||
'project_path': str(workspace.project_path),
|
||||
'thinking_mode': bool(getattr(terminal, "thinking_mode", False)),
|
||||
'version': AGENT_VERSION
|
||||
}, room=request.sid)
|
||||
|
||||
if terminal.terminal_manager:
|
||||
terminals = terminal.terminal_manager.get_terminal_list()
|
||||
emit('terminal_list_update', {
|
||||
'terminals': terminals,
|
||||
'active': terminal.terminal_manager.active_terminal
|
||||
}, room=request.sid)
|
||||
|
||||
if terminal.terminal_manager.active_terminal:
|
||||
for name, term in terminal.terminal_manager.terminals.items():
|
||||
emit('terminal_started', {
|
||||
'session': name,
|
||||
'working_dir': str(term.working_dir),
|
||||
'shell': term.shell_command,
|
||||
'time': term.start_time.isoformat() if term.start_time else None
|
||||
}, room=request.sid)
|
||||
|
||||
@socketio.on('disconnect')
|
||||
def handle_disconnect():
|
||||
"""客户端断开"""
|
||||
print(f"[WebSocket] 客户端断开: {request.sid}")
|
||||
username = connection_users.pop(request.sid, None)
|
||||
task_info = stop_flags.get(request.sid)
|
||||
if isinstance(task_info, dict):
|
||||
task_info['stop'] = True
|
||||
pending_task = task_info.get('task')
|
||||
if pending_task and not pending_task.done():
|
||||
debug_log(f"disconnect: cancel task for {request.sid}")
|
||||
pending_task.cancel()
|
||||
terminal = task_info.get('terminal')
|
||||
if terminal:
|
||||
reset_system_state(terminal)
|
||||
|
||||
# 清理停止标志
|
||||
stop_flags.pop(request.sid, None)
|
||||
|
||||
# 从所有房间移除
|
||||
for room in list(terminal_rooms.get(request.sid, [])):
|
||||
leave_room(room)
|
||||
if request.sid in terminal_rooms:
|
||||
del terminal_rooms[request.sid]
|
||||
|
||||
if username:
|
||||
leave_room(f"user_{username}")
|
||||
leave_room(f"user_{username}_terminal")
|
||||
|
||||
@socketio.on('stop_task')
|
||||
def handle_stop_task():
|
||||
"""处理停止任务请求"""
|
||||
print(f"[停止] 收到停止请求: {request.sid}")
|
||||
|
||||
task_info = stop_flags.get(request.sid)
|
||||
if not isinstance(task_info, dict):
|
||||
task_info = {'stop': False, 'task': None, 'terminal': None}
|
||||
stop_flags[request.sid] = task_info
|
||||
|
||||
if task_info.get('task') and not task_info['task'].done():
|
||||
debug_log(f"正在取消任务: {request.sid}")
|
||||
task_info['task'].cancel()
|
||||
|
||||
task_info['stop'] = True
|
||||
if task_info.get('terminal'):
|
||||
reset_system_state(task_info['terminal'])
|
||||
|
||||
emit('stop_requested', {
|
||||
'message': '停止请求已接收,正在取消任务...'
|
||||
})
|
||||
|
||||
@socketio.on('terminal_subscribe')
|
||||
def handle_terminal_subscribe(data):
|
||||
"""订阅终端事件"""
|
||||
session_name = data.get('session')
|
||||
subscribe_all = data.get('all', False)
|
||||
|
||||
username, terminal, _ = get_terminal_for_sid(request.sid)
|
||||
if not username or not terminal or not terminal.terminal_manager:
|
||||
emit('error', {'message': 'Terminal system not initialized'})
|
||||
return
|
||||
policy = resolve_admin_policy(user_manager.get_user(username))
|
||||
if policy.get("ui_blocks", {}).get("block_realtime_terminal"):
|
||||
emit('error', {'message': '实时终端已被管理员禁用'})
|
||||
return
|
||||
|
||||
if request.sid not in terminal_rooms:
|
||||
terminal_rooms[request.sid] = set()
|
||||
|
||||
if subscribe_all:
|
||||
# 订阅所有终端事件
|
||||
room_name = f"user_{username}_terminal"
|
||||
join_room(room_name)
|
||||
terminal_rooms[request.sid].add(room_name)
|
||||
print(f"[Terminal] {request.sid} 订阅所有终端事件")
|
||||
|
||||
# 发送当前终端状态
|
||||
emit('terminal_subscribed', {
|
||||
'type': 'all',
|
||||
'terminals': terminal.terminal_manager.get_terminal_list()
|
||||
})
|
||||
elif session_name:
|
||||
# 订阅特定终端会话
|
||||
room_name = f'user_{username}_terminal_{session_name}'
|
||||
join_room(room_name)
|
||||
terminal_rooms[request.sid].add(room_name)
|
||||
print(f"[Terminal] {request.sid} 订阅终端: {session_name}")
|
||||
|
||||
# 发送该终端的当前输出
|
||||
output_result = terminal.terminal_manager.get_terminal_output(session_name, 100)
|
||||
if output_result['success']:
|
||||
emit('terminal_history', {
|
||||
'session': session_name,
|
||||
'output': output_result['output']
|
||||
})
|
||||
|
||||
@socketio.on('terminal_unsubscribe')
|
||||
def handle_terminal_unsubscribe(data):
|
||||
"""取消订阅终端事件"""
|
||||
session_name = data.get('session')
|
||||
username = connection_users.get(request.sid)
|
||||
|
||||
if session_name:
|
||||
room_name = f'user_{username}_terminal_{session_name}' if username else f'terminal_{session_name}'
|
||||
leave_room(room_name)
|
||||
if request.sid in terminal_rooms:
|
||||
terminal_rooms[request.sid].discard(room_name)
|
||||
print(f"[Terminal] {request.sid} 取消订阅终端: {session_name}")
|
||||
|
||||
@socketio.on('get_terminal_output')
|
||||
def handle_get_terminal_output(data):
|
||||
"""获取终端输出历史"""
|
||||
session_name = data.get('session')
|
||||
lines = data.get('lines', 50)
|
||||
|
||||
username, terminal, _ = get_terminal_for_sid(request.sid)
|
||||
if not terminal or not terminal.terminal_manager:
|
||||
emit('error', {'message': 'Terminal system not initialized'})
|
||||
return
|
||||
policy = resolve_admin_policy(user_manager.get_user(username))
|
||||
if policy.get("ui_blocks", {}).get("block_realtime_terminal"):
|
||||
emit('error', {'message': '实时终端已被管理员禁用'})
|
||||
return
|
||||
|
||||
result = terminal.terminal_manager.get_terminal_output(session_name, lines)
|
||||
|
||||
if result['success']:
|
||||
emit('terminal_output_history', {
|
||||
'session': session_name,
|
||||
'output': result['output'],
|
||||
'is_interactive': result.get('is_interactive', False),
|
||||
'last_command': result.get('last_command', '')
|
||||
})
|
||||
else:
|
||||
emit('error', {'message': result['error']})
|
||||
|
||||
@socketio.on('send_message')
|
||||
def handle_message(data):
|
||||
"""处理用户消息"""
|
||||
username, terminal, workspace = get_terminal_for_sid(request.sid)
|
||||
if not terminal:
|
||||
emit('error', {'message': 'System not initialized'})
|
||||
return
|
||||
|
||||
message = (data.get('message') or '').strip()
|
||||
images = data.get('images') or []
|
||||
if not message and not images:
|
||||
emit('error', {'message': '消息不能为空'})
|
||||
return
|
||||
if images and getattr(terminal, "model_key", None) != "qwen3-vl-plus":
|
||||
emit('error', {'message': '当前模型不支持图片,请切换到 Qwen-VL'})
|
||||
return
|
||||
|
||||
print(f"[WebSocket] 收到消息: {message}")
|
||||
debug_log(f"\n{'='*80}\n新任务开始: {message}\n{'='*80}")
|
||||
record_user_activity(username)
|
||||
|
||||
requested_conversation_id = data.get('conversation_id')
|
||||
try:
|
||||
conversation_id, created_new = ensure_conversation_loaded(terminal, requested_conversation_id)
|
||||
except RuntimeError as exc:
|
||||
emit('error', {'message': str(exc)})
|
||||
return
|
||||
try:
|
||||
conv_data = terminal.context_manager.conversation_manager.load_conversation(conversation_id) or {}
|
||||
except Exception:
|
||||
conv_data = {}
|
||||
title = conv_data.get('title', '新对话')
|
||||
|
||||
socketio.emit('conversation_resolved', {
|
||||
'conversation_id': conversation_id,
|
||||
'title': title,
|
||||
'created': created_new
|
||||
}, room=request.sid)
|
||||
|
||||
if created_new:
|
||||
socketio.emit('conversation_list_update', {
|
||||
'action': 'created',
|
||||
'conversation_id': conversation_id
|
||||
}, room=f"user_{username}")
|
||||
socketio.emit('conversation_changed', {
|
||||
'conversation_id': conversation_id,
|
||||
'title': title
|
||||
}, room=request.sid)
|
||||
|
||||
client_sid = request.sid
|
||||
|
||||
def send_to_client(event_type, data):
|
||||
"""发送消息到客户端"""
|
||||
socketio.emit(event_type, data, room=client_sid)
|
||||
|
||||
# 模型活动事件:用于刷新“在线”心跳(回复/工具调用都算活动)
|
||||
activity_events = {
|
||||
'ai_message_start', 'thinking_start', 'thinking_chunk', 'thinking_end',
|
||||
'text_start', 'text_chunk', 'text_end',
|
||||
'tool_hint', 'tool_preparing', 'tool_start', 'update_action',
|
||||
'append_payload', 'modify_payload', 'system_message',
|
||||
'task_complete'
|
||||
}
|
||||
last_model_activity = 0.0
|
||||
|
||||
def send_with_activity(event_type, data):
|
||||
"""模型产生输出或调用工具时刷新活跃时间,防止长回复被误判下线。"""
|
||||
nonlocal last_model_activity
|
||||
if event_type in activity_events:
|
||||
now = time.time()
|
||||
# 轻量节流:1 秒内多次事件只记一次
|
||||
if now - last_model_activity >= 1.0:
|
||||
record_user_activity(username)
|
||||
last_model_activity = now
|
||||
send_to_client(event_type, data)
|
||||
|
||||
# 传递客户端ID
|
||||
images = data.get('images') or []
|
||||
socketio.start_background_task(process_message_task, terminal, message, images, send_with_activity, client_sid, workspace, username)
|
||||
|
||||
|
||||
@socketio.on('client_chunk_log')
|
||||
def handle_client_chunk_log(data):
|
||||
"""前端chunk日志上报"""
|
||||
conversation_id = data.get('conversation_id')
|
||||
chunk_index = int(data.get('index') or data.get('chunk_index') or 0)
|
||||
elapsed = float(data.get('elapsed') or 0.0)
|
||||
length = int(data.get('length') or len(data.get('content') or ""))
|
||||
client_ts = float(data.get('ts') or 0.0)
|
||||
log_frontend_chunk(conversation_id, chunk_index, elapsed, length, client_ts)
|
||||
|
||||
|
||||
@socketio.on('client_stream_debug_log')
|
||||
def handle_client_stream_debug_log(data):
|
||||
"""前端流式调试日志"""
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
entry = dict(data)
|
||||
entry.setdefault('server_ts', time.time())
|
||||
log_streaming_debug_entry(entry)
|
||||
|
||||
# 在 web_server.py 中添加以下对话管理API接口
|
||||
# 添加在现有路由之后,@socketio 事件处理之前
|
||||
|
||||
# ==========================================
|
||||
# 对话管理API接口
|
||||
# ==========================================
|
||||
|
||||
|
||||
# conversation routes moved to server/conversation.py
|
||||
@app.route('/resource_busy')
|
||||
def resource_busy_page():
|
||||
return app.send_static_file('resource_busy.html'), 503
|
||||
13
server/_usage_block.py
Normal file
13
server/_usage_block.py
Normal file
@ -0,0 +1,13 @@
|
||||
@app.route('/api/usage', methods=['GET'])
|
||||
@api_login_required
|
||||
def get_usage_stats():
|
||||
"""返回当前用户的模型/搜索调用统计。"""
|
||||
username = get_current_username()
|
||||
tracker = get_or_create_usage_tracker(username)
|
||||
if not tracker:
|
||||
return jsonify({"success": False, "error": "未找到用户"}), 404
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": tracker.get_stats()
|
||||
})
|
||||
|
||||
284
server/admin.py
Normal file
284
server/admin.py
Normal file
@ -0,0 +1,284 @@
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
import time
|
||||
import mimetypes
|
||||
import logging
|
||||
import json
|
||||
from flask import Blueprint, jsonify, send_from_directory, request, current_app, redirect, abort
|
||||
|
||||
from .auth_helpers import login_required, admin_required, admin_api_required, api_login_required, get_current_user_record, resolve_admin_policy
|
||||
from .security import rate_limited
|
||||
from .utils_common import debug_log
|
||||
from . import state # 使用动态 state,确保与入口实例保持一致
|
||||
from .state import custom_tool_registry, user_manager, container_manager, PROJECT_MAX_STORAGE_MB
|
||||
from .conversation import build_admin_dashboard_snapshot as _build_dashboard_rich
|
||||
from modules import admin_policy_manager, balance_client
|
||||
from collections import Counter
|
||||
|
||||
admin_bp = Blueprint('admin', __name__)
|
||||
|
||||
@admin_bp.route('/admin/monitor')
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_monitor_page():
|
||||
"""管理员监控页面入口"""
|
||||
return send_from_directory(str(Path(current_app.static_folder)/"admin_dashboard"), 'index.html')
|
||||
|
||||
@admin_bp.route('/admin/policy')
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_policy_page():
|
||||
"""管理员策略配置页面"""
|
||||
return send_from_directory(Path(current_app.static_folder) / 'admin_policy', 'index.html')
|
||||
|
||||
@admin_bp.route('/admin/custom-tools')
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_custom_tools_page():
|
||||
"""自定义工具管理页面"""
|
||||
return send_from_directory(str(Path(current_app.static_folder)/"custom_tools"), 'index.html')
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/balance', methods=['GET'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_balance_api():
|
||||
"""查询第三方账户余额(Kimi/DeepSeek/Qwen)。"""
|
||||
data = balance_client.fetch_all_balances()
|
||||
return jsonify({"success": True, "data": data})
|
||||
|
||||
|
||||
@admin_bp.route('/admin/assets/<path:filename>')
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_asset_file(filename: str):
|
||||
return send_from_directory(str(Path(current_app.static_folder)/"admin_dashboard"), filename)
|
||||
|
||||
|
||||
@admin_bp.route('/user_upload/<path:filename>')
|
||||
@login_required
|
||||
def serve_user_upload(filename: str):
|
||||
"""
|
||||
直接向前端暴露当前登录用户的上传目录文件,用于 <show_image src="/user_upload/..."> 等场景。
|
||||
- 仅登录用户可访问
|
||||
- 路径穿越校验:目标必须位于用户自己的 uploads_dir 内
|
||||
"""
|
||||
user = get_current_user_record()
|
||||
if not user:
|
||||
return redirect('/login')
|
||||
|
||||
workspace = user_manager.ensure_user_workspace(user.username)
|
||||
uploads_dir = workspace.uploads_dir.resolve()
|
||||
|
||||
target = (uploads_dir / filename).resolve()
|
||||
try:
|
||||
target.relative_to(uploads_dir)
|
||||
except ValueError:
|
||||
abort(403)
|
||||
|
||||
if not target.exists() or not target.is_file():
|
||||
abort(404)
|
||||
|
||||
return send_from_directory(str(uploads_dir), str(target.relative_to(uploads_dir)))
|
||||
|
||||
|
||||
@admin_bp.route('/workspace/<path:filename>')
|
||||
@login_required
|
||||
def serve_workspace_file(filename: str):
|
||||
"""
|
||||
暴露当前登录用户项目目录下的文件(主要用于图片展示)。
|
||||
- 仅登录用户可访问自己的项目文件
|
||||
- 路径穿越校验:目标必须位于用户自己的 project_path 内
|
||||
- 非图片直接拒绝,避免误暴露其他文件
|
||||
"""
|
||||
user = get_current_user_record()
|
||||
if not user:
|
||||
return redirect('/login')
|
||||
|
||||
workspace = user_manager.ensure_user_workspace(user.username)
|
||||
project_root = workspace.project_path.resolve()
|
||||
|
||||
target = (project_root / filename).resolve()
|
||||
try:
|
||||
target.relative_to(project_root)
|
||||
except ValueError:
|
||||
abort(403)
|
||||
|
||||
if not target.exists() or not target.is_file():
|
||||
abort(404)
|
||||
|
||||
mime_type, _ = mimetypes.guess_type(str(target))
|
||||
if not mime_type or not mime_type.startswith("image/"):
|
||||
abort(415)
|
||||
|
||||
return send_from_directory(str(target.parent), target.name)
|
||||
|
||||
|
||||
@admin_bp.route('/static/<path:filename>')
|
||||
def static_files(filename):
|
||||
"""提供静态文件"""
|
||||
if filename.startswith('admin_dashboard'):
|
||||
abort(404)
|
||||
return send_from_directory('static', filename)
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/dashboard')
|
||||
@api_login_required
|
||||
@admin_api_required
|
||||
def admin_dashboard_snapshot_api():
|
||||
try:
|
||||
# 若当前管理员没有容器句柄,主动确保容器存在,避免面板始终显示“宿主机模式”
|
||||
try:
|
||||
record = get_current_user_record()
|
||||
uname = record.username if record else None
|
||||
if uname:
|
||||
handles = state.container_manager.list_containers()
|
||||
if uname not in handles:
|
||||
state.container_manager.ensure_container(uname, str(state.user_manager.ensure_user_workspace(uname).project_path))
|
||||
except Exception as ensure_exc:
|
||||
logging.getLogger(__name__).warning("ensure_container for admin failed: %s", ensure_exc)
|
||||
|
||||
snapshot = build_admin_dashboard_snapshot()
|
||||
# 双通道输出:标准日志 + 调试文件
|
||||
try:
|
||||
logging.getLogger(__name__).info(
|
||||
"[admin_dashboard] snapshot=%s",
|
||||
json.dumps(snapshot, ensure_ascii=False)[:2000],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
debug_log(f"[admin_dashboard] {json.dumps(snapshot, ensure_ascii=False)[:2000]}")
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify({"success": True, "data": snapshot})
|
||||
except Exception as exc:
|
||||
logging.exception("Failed to build admin dashboard")
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
def build_admin_dashboard_snapshot():
|
||||
"""
|
||||
复用对话模块的完整统计逻辑,返回带用量/Token/存储/上传等数据的仪表盘快照。
|
||||
"""
|
||||
try:
|
||||
return _build_dashboard_rich()
|
||||
except Exception as exc:
|
||||
# 兜底:若高级统计失败,仍返回最小信息,避免前端 500
|
||||
logging.getLogger(__name__).warning("rich dashboard snapshot failed: %s", exc)
|
||||
handle_map = state.container_manager.list_containers()
|
||||
return {
|
||||
"generated_at": time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
||||
"overview": {
|
||||
"totals": {
|
||||
"users": len(state.user_manager.list_users() or {}),
|
||||
"active_users": len(handle_map),
|
||||
"containers_active": len(handle_map),
|
||||
"containers_max": getattr(state.container_manager, "max_containers", len(handle_map)),
|
||||
"available_container_slots": None,
|
||||
}
|
||||
},
|
||||
"containers": [
|
||||
{"username": u, "status": h} for u, h in handle_map.items()
|
||||
],
|
||||
}
|
||||
|
||||
@admin_bp.route('/api/admin/policy', methods=['GET', 'POST'])
|
||||
@api_login_required
|
||||
@admin_api_required
|
||||
def admin_policy_api():
|
||||
if request.method == 'GET':
|
||||
try:
|
||||
data = admin_policy_manager.load_policy()
|
||||
defaults = admin_policy_manager.describe_defaults()
|
||||
return jsonify({"success": True, "data": data, "defaults": defaults})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
# POST 更新
|
||||
payload = request.get_json() or {}
|
||||
target_type = payload.get("target_type")
|
||||
target_value = payload.get("target_value") or ""
|
||||
config = payload.get("config") or {}
|
||||
try:
|
||||
saved = admin_policy_manager.save_scope_policy(target_type, target_value, config)
|
||||
return jsonify({"success": True, "data": saved})
|
||||
except ValueError as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 400
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/custom-tools', methods=['GET', 'POST', 'DELETE'])
|
||||
@api_login_required
|
||||
@admin_api_required
|
||||
def admin_custom_tools_api():
|
||||
"""自定义工具管理(仅全局管理员)。"""
|
||||
try:
|
||||
if request.method == 'GET':
|
||||
return jsonify({"success": True, "data": custom_tool_registry.list_tools()})
|
||||
if request.method == 'POST':
|
||||
payload = request.get_json() or {}
|
||||
saved = custom_tool_registry.upsert_tool(payload)
|
||||
return jsonify({"success": True, "data": saved})
|
||||
# DELETE
|
||||
tool_id = request.args.get("id") or (request.get_json() or {}).get("id")
|
||||
if not tool_id:
|
||||
return jsonify({"success": False, "error": "缺少 id"}), 400
|
||||
removed = custom_tool_registry.delete_tool(tool_id)
|
||||
if removed:
|
||||
return jsonify({"success": True, "data": {"deleted": tool_id}})
|
||||
return jsonify({"success": False, "error": "未找到该工具"}), 404
|
||||
except ValueError as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 400
|
||||
except Exception as exc:
|
||||
logging.exception("custom-tools API error")
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/custom-tools/file', methods=['GET', 'POST'])
|
||||
@api_login_required
|
||||
@admin_api_required
|
||||
def admin_custom_tools_file_api():
|
||||
tool_id = request.args.get("id") or (request.get_json() or {}).get("id")
|
||||
name = request.args.get("name") or (request.get_json() or {}).get("name")
|
||||
if not tool_id or not name:
|
||||
return jsonify({"success": False, "error": "缺少 id 或 name"}), 400
|
||||
tool_dir = Path(custom_tool_registry.root) / tool_id
|
||||
if not tool_dir.exists():
|
||||
return jsonify({"success": False, "error": "工具不存在"}), 404
|
||||
target = tool_dir / name
|
||||
|
||||
if request.method == 'GET':
|
||||
if not target.exists():
|
||||
return jsonify({"success": False, "error": "文件不存在"}), 404
|
||||
try:
|
||||
return target.read_text(encoding="utf-8")
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
# POST 保存文件
|
||||
payload = request.get_json() or {}
|
||||
content = payload.get("content")
|
||||
try:
|
||||
target.write_text(content or "", encoding="utf-8")
|
||||
return jsonify({"success": True})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/custom-tools/reload', methods=['POST'])
|
||||
@api_login_required
|
||||
@admin_api_required
|
||||
def admin_custom_tools_reload_api():
|
||||
try:
|
||||
custom_tool_registry.reload()
|
||||
return jsonify({"success": True})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
@admin_bp.route('/api/effective-policy', methods=['GET'])
|
||||
@api_login_required
|
||||
def effective_policy_api():
|
||||
record = get_current_user_record()
|
||||
policy = resolve_admin_policy(record)
|
||||
return jsonify({"success": True, "data": policy})
|
||||
48
server/app.py
Normal file
48
server/app.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""统一入口:复用 app_legacy,便于逐步替换/收敛。"""
|
||||
import argparse
|
||||
|
||||
from .app_legacy import (
|
||||
app,
|
||||
socketio,
|
||||
run_server as _run_server,
|
||||
parse_arguments as _parse_arguments,
|
||||
initialize_system,
|
||||
resource_busy_page,
|
||||
DEFAULT_PORT,
|
||||
)
|
||||
|
||||
|
||||
def parse_arguments():
|
||||
"""向后兼容的参数解析,默认端口、路径等与 app_legacy 一致。"""
|
||||
return _parse_arguments()
|
||||
|
||||
|
||||
def run_server(path: str, thinking_mode: bool = False, port: int = DEFAULT_PORT, debug: bool = False):
|
||||
"""统一 run_server 入口,便于 future 替换实现。"""
|
||||
return _run_server(path=path, thinking_mode=thinking_mode, port=port, debug=debug)
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_arguments()
|
||||
run_server(
|
||||
path=args.path,
|
||||
thinking_mode=args.thinking_mode,
|
||||
port=args.port,
|
||||
debug=args.debug,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"app",
|
||||
"socketio",
|
||||
"run_server",
|
||||
"parse_arguments",
|
||||
"initialize_system",
|
||||
"resource_busy_page",
|
||||
"main",
|
||||
"DEFAULT_PORT",
|
||||
]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1348
server/app_legacy.py
Normal file
1348
server/app_legacy.py
Normal file
File diff suppressed because it is too large
Load Diff
241
server/auth.py
Normal file
241
server/auth.py
Normal file
@ -0,0 +1,241 @@
|
||||
from __future__ import annotations
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from flask import Blueprint, request, jsonify, session, redirect, send_from_directory, abort, current_app
|
||||
|
||||
from modules.personalization_manager import load_personalization_config
|
||||
from modules.user_manager import UserWorkspace
|
||||
|
||||
from .auth_helpers import login_required, api_login_required, get_current_user_record, get_current_username
|
||||
from .security import (
|
||||
get_csrf_token,
|
||||
check_rate_limit,
|
||||
register_failure,
|
||||
is_action_blocked,
|
||||
clear_failures,
|
||||
)
|
||||
from .context import with_terminal, get_gui_manager
|
||||
from . import state
|
||||
from .utils_common import debug_log
|
||||
|
||||
auth_bp = Blueprint("auth", __name__)
|
||||
|
||||
|
||||
@auth_bp.route('/api/csrf-token', methods=['GET'])
|
||||
def issue_csrf_token():
|
||||
token = get_csrf_token()
|
||||
response = jsonify({"success": True, "token": token})
|
||||
response.headers['Cache-Control'] = 'no-store'
|
||||
return response
|
||||
|
||||
|
||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if request.method == 'GET':
|
||||
if session.get('username'):
|
||||
return redirect('/new')
|
||||
if not state.container_manager.has_capacity():
|
||||
return current_app.send_static_file('resource_busy.html'), 503
|
||||
return current_app.send_static_file('login.html')
|
||||
|
||||
data = request.get_json() or {}
|
||||
email = (data.get('email') or '').strip()
|
||||
password = data.get('password') or ''
|
||||
client_ip = request.headers.get('X-Forwarded-For', '').split(',')[0].strip() or request.remote_addr or 'unknown'
|
||||
|
||||
limited, retry_after = check_rate_limit("login", 10, 60, client_ip)
|
||||
if limited:
|
||||
return jsonify({"success": False, "error": "登录请求过于频繁,请稍后再试。", "retry_after": retry_after}), 429
|
||||
|
||||
blocked, block_for = is_action_blocked("login", identifier=client_ip)
|
||||
if blocked:
|
||||
return jsonify({"success": False, "error": f"尝试次数过多,请 {block_for} 秒后重试。", "retry_after": block_for}), 429
|
||||
|
||||
record = state.user_manager.authenticate(email, password)
|
||||
if not record:
|
||||
wait_seconds = register_failure("login", state.FAILED_LOGIN_LIMIT, state.FAILED_LOGIN_LOCK_SECONDS, identifier=client_ip)
|
||||
error_payload = {"success": False, "error": "账号或密码错误"}
|
||||
status_code = 401
|
||||
if wait_seconds:
|
||||
error_payload.update({"error": f"尝试次数过多,请 {wait_seconds} 秒后重试。", "retry_after": wait_seconds})
|
||||
status_code = 429
|
||||
return jsonify(error_payload), status_code
|
||||
|
||||
workspace = state.user_manager.ensure_user_workspace(record.username)
|
||||
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):
|
||||
normalized_mode = candidate_mode.lower()
|
||||
if normalized_mode in {"fast", "thinking", "deep"}:
|
||||
preferred_run_mode = normalized_mode
|
||||
except Exception as exc:
|
||||
debug_log(f"加载个性化偏好失败: {exc}")
|
||||
|
||||
session['logged_in'] = True
|
||||
session['username'] = record.username
|
||||
session['role'] = record.role or 'user'
|
||||
default_thinking = current_app.config.get('DEFAULT_THINKING_MODE', False)
|
||||
session['thinking_mode'] = default_thinking
|
||||
session['run_mode'] = current_app.config.get('DEFAULT_RUN_MODE', "deep" if default_thinking else "fast")
|
||||
if preferred_run_mode:
|
||||
session['run_mode'] = preferred_run_mode
|
||||
session['thinking_mode'] = preferred_run_mode != 'fast'
|
||||
session.permanent = True
|
||||
clear_failures("login", identifier=client_ip)
|
||||
try:
|
||||
state.container_manager.ensure_container(record.username, str(workspace.project_path))
|
||||
except RuntimeError as exc:
|
||||
session.clear()
|
||||
return jsonify({"success": False, "error": str(exc), "code": "resource_busy"}), 503
|
||||
from .usage import record_user_activity
|
||||
record_user_activity(record.username)
|
||||
get_csrf_token(force_new=True)
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@auth_bp.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
if request.method == 'GET':
|
||||
if session.get('username'):
|
||||
return redirect('/new')
|
||||
return current_app.send_static_file('register.html')
|
||||
|
||||
data = request.get_json() or {}
|
||||
username = (data.get('username') or '').strip()
|
||||
email = (data.get('email') or '').strip()
|
||||
password = data.get('password') or ''
|
||||
invite_code = (data.get('invite_code') or '').strip()
|
||||
|
||||
from .security import get_client_ip
|
||||
limited, retry_after = check_rate_limit("register", 5, 300, get_client_ip())
|
||||
if limited:
|
||||
return jsonify({"success": False, "error": "注册请求过于频繁,请稍后再试。", "retry_after": retry_after}), 429
|
||||
try:
|
||||
state.user_manager.register_user(username, email, password, invite_code)
|
||||
return jsonify({"success": True})
|
||||
except ValueError as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 400
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/logout', methods=['POST'])
|
||||
def logout():
|
||||
username = session.get('username')
|
||||
session.clear()
|
||||
if username and username in state.user_terminals:
|
||||
state.user_terminals.pop(username, None)
|
||||
if username:
|
||||
state.container_manager.release_container(username, reason="logout")
|
||||
for token_value, meta in list(state.pending_socket_tokens.items()):
|
||||
if meta.get("username") == username:
|
||||
state.pending_socket_tokens.pop(token_value, None)
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@auth_bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
return redirect('/new')
|
||||
|
||||
|
||||
@auth_bp.route('/new')
|
||||
@login_required
|
||||
def new_page():
|
||||
return current_app.send_static_file('index.html')
|
||||
|
||||
|
||||
@auth_bp.route('/<conv:conversation_id>')
|
||||
@login_required
|
||||
def conversation_page(conversation_id):
|
||||
return current_app.send_static_file('index.html')
|
||||
|
||||
|
||||
@auth_bp.route('/terminal')
|
||||
@login_required
|
||||
def terminal_page():
|
||||
from .auth_helpers import resolve_admin_policy
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_realtime_terminal"):
|
||||
return "实时终端已被管理员禁用", 403
|
||||
return current_app.send_static_file('terminal.html')
|
||||
|
||||
|
||||
@auth_bp.route('/file-manager')
|
||||
@login_required
|
||||
def gui_file_manager_page():
|
||||
from .auth_helpers import resolve_admin_policy
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_file_manager"):
|
||||
return "文件管理器已被管理员禁用", 403
|
||||
return send_from_directory(Path(current_app.static_folder) / 'file_manager', 'index.html')
|
||||
|
||||
|
||||
@auth_bp.route('/file-manager/editor')
|
||||
@login_required
|
||||
def gui_file_editor_page():
|
||||
return send_from_directory(Path(current_app.static_folder) / 'file_manager', 'editor.html')
|
||||
|
||||
|
||||
@auth_bp.route('/file-preview/<path:relative_path>')
|
||||
@login_required
|
||||
@with_terminal
|
||||
def gui_file_preview(relative_path: str, terminal, workspace: UserWorkspace, username: str):
|
||||
manager = get_gui_manager(workspace)
|
||||
try:
|
||||
target = manager.prepare_download(relative_path)
|
||||
if not target.is_file():
|
||||
return "预览仅支持文件", 400
|
||||
return send_from_directory(directory=target.parent, path=target.name, mimetype='text/html')
|
||||
except Exception as exc:
|
||||
return f"无法预览文件: {exc}", 400
|
||||
|
||||
|
||||
@auth_bp.route('/user_upload/<path:filename>')
|
||||
@login_required
|
||||
def serve_user_upload(filename: str):
|
||||
user = get_current_user_record()
|
||||
if not user:
|
||||
return redirect('/login')
|
||||
workspace = state.user_manager.ensure_user_workspace(user.username)
|
||||
uploads_dir = workspace.uploads_dir.resolve()
|
||||
target = (uploads_dir / filename).resolve()
|
||||
try:
|
||||
target.relative_to(uploads_dir)
|
||||
except ValueError:
|
||||
abort(403)
|
||||
if not target.exists() or not target.is_file():
|
||||
abort(404)
|
||||
return send_from_directory(str(uploads_dir), str(target.relative_to(uploads_dir)))
|
||||
|
||||
|
||||
@auth_bp.route('/workspace/<path:filename>')
|
||||
@login_required
|
||||
def serve_workspace_file(filename: str):
|
||||
user = get_current_user_record()
|
||||
if not user:
|
||||
return redirect('/login')
|
||||
workspace = state.user_manager.ensure_user_workspace(user.username)
|
||||
project_root = workspace.project_path.resolve()
|
||||
target = (project_root / filename).resolve()
|
||||
try:
|
||||
target.relative_to(project_root)
|
||||
except ValueError:
|
||||
abort(403)
|
||||
if not target.exists() or not target.is_file():
|
||||
abort(404)
|
||||
mime_type, _ = mimetypes.guess_type(str(target))
|
||||
if not mime_type or not mime_type.startswith("image/"):
|
||||
abort(415)
|
||||
return send_from_directory(str(target.parent), target.name)
|
||||
|
||||
|
||||
@auth_bp.route('/static/<path:filename>')
|
||||
def static_files(filename):
|
||||
if filename.startswith('admin_dashboard'):
|
||||
abort(404)
|
||||
return send_from_directory('static', filename)
|
||||
|
||||
__all__ = ["auth_bp"]
|
||||
103
server/auth_helpers.py
Normal file
103
server/auth_helpers.py
Normal file
@ -0,0 +1,103 @@
|
||||
"""认证与角色相关基础函数,供各模块复用。"""
|
||||
from __future__ import annotations
|
||||
from functools import wraps
|
||||
from typing import Optional, Any, Dict
|
||||
from flask import session, redirect, jsonify
|
||||
|
||||
from modules import admin_policy_manager
|
||||
from .utils_common import debug_log
|
||||
from . import state
|
||||
|
||||
|
||||
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 state.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
|
||||
|
||||
__all__ = [
|
||||
"is_logged_in",
|
||||
"login_required",
|
||||
"api_login_required",
|
||||
"get_current_username",
|
||||
"get_current_user_record",
|
||||
"get_current_user_role",
|
||||
"is_admin_user",
|
||||
"resolve_admin_policy",
|
||||
"admin_required",
|
||||
"admin_api_required",
|
||||
]
|
||||
592
server/chat.py
Normal file
592
server/chat.py
Normal file
@ -0,0 +1,592 @@
|
||||
from __future__ import annotations
|
||||
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,
|
||||
save_personalization_config,
|
||||
THINKING_INTERVAL_MIN,
|
||||
THINKING_INTERVAL_MAX,
|
||||
)
|
||||
from modules.upload_security import UploadSecurityError
|
||||
from modules.user_manager import UserWorkspace
|
||||
from core.web_terminal import WebTerminal
|
||||
|
||||
from .auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record, get_current_username
|
||||
from .context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response, ensure_conversation_loaded, get_or_create_usage_tracker
|
||||
from .security import rate_limited, prune_socket_tokens
|
||||
from .utils_common import debug_log
|
||||
from .state import PROJECT_MAX_STORAGE_MB, THINKING_FAILURE_KEYWORDS, pending_socket_tokens, SOCKET_TOKEN_TTL_SECONDS
|
||||
from .extensions import socketio
|
||||
from .monitor import get_cached_monitor_snapshot
|
||||
from .files import sanitize_filename_preserve_unicode
|
||||
|
||||
UPLOAD_FOLDER_NAME = "user_upload"
|
||||
|
||||
chat_bp = Blueprint('chat', __name__)
|
||||
|
||||
@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)
|
||||
)
|
||||
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)
|
||||
)
|
||||
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)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": data,
|
||||
"tool_categories": terminal.get_tool_settings_snapshot(),
|
||||
"thinking_interval_default": THINKING_FAST_INTERVAL,
|
||||
"thinking_interval_range": {
|
||||
"min": THINKING_INTERVAL_MIN,
|
||||
"max": THINKING_INTERVAL_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)
|
||||
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}")
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": config,
|
||||
"tool_categories": terminal.get_tool_settings_snapshot(),
|
||||
"thinking_interval_default": THINKING_FAST_INTERVAL,
|
||||
"thinking_interval_range": {
|
||||
"min": THINKING_INTERVAL_MIN,
|
||||
"max": THINKING_INTERVAL_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
|
||||
|
||||
@chat_bp.route('/api/memory', methods=['GET'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def api_memory_entries(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""返回主/任务记忆条目列表,供虚拟显示器加载"""
|
||||
memory_type = request.args.get('type', 'main')
|
||||
if memory_type not in ('main', 'task'):
|
||||
return jsonify({"success": False, "error": "type 必须是 main 或 task"}), 400
|
||||
try:
|
||||
entries = terminal.memory_manager._read_entries(memory_type) # type: ignore
|
||||
return jsonify({"success": True, "type": memory_type, "entries": entries})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
@chat_bp.route('/api/gui/monitor_snapshot', methods=['GET'])
|
||||
@api_login_required
|
||||
def get_monitor_snapshot_api():
|
||||
execution_id = request.args.get('executionId') or request.args.get('execution_id') or request.args.get('id')
|
||||
if not execution_id:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '缺少 executionId 参数'
|
||||
}), 400
|
||||
stage = (request.args.get('stage') or 'before').lower()
|
||||
if stage not in {'before', 'after'}:
|
||||
stage = 'before'
|
||||
snapshot = get_cached_monitor_snapshot(execution_id, stage)
|
||||
if not snapshot:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '未找到对应快照'
|
||||
}), 404
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'snapshot': snapshot,
|
||||
'stage': stage
|
||||
})
|
||||
|
||||
@chat_bp.route('/api/focused')
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def get_focused_files(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""获取聚焦文件"""
|
||||
focused = {}
|
||||
for path, content in terminal.focused_files.items():
|
||||
focused[path] = {
|
||||
"content": content,
|
||||
"size": len(content),
|
||||
"lines": content.count('\n') + 1
|
||||
}
|
||||
return jsonify(focused)
|
||||
|
||||
@chat_bp.route('/api/todo-list')
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def get_todo_list(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""获取当前待办列表"""
|
||||
todo_snapshot = terminal.context_manager.get_todo_snapshot()
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": todo_snapshot
|
||||
})
|
||||
|
||||
@chat_bp.route('/api/upload', methods=['POST'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
@rate_limited("legacy_upload", 20, 300, scope="user")
|
||||
def upload_file(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""处理前端文件上传请求"""
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_upload"):
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "文件上传已被管理员禁用",
|
||||
"message": "被管理员禁用上传"
|
||||
}), 403
|
||||
if 'file' not in request.files:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "未找到文件",
|
||||
"message": "请求中缺少文件字段"
|
||||
}), 400
|
||||
|
||||
uploaded_file = request.files['file']
|
||||
original_name = (request.form.get('filename') or '').strip()
|
||||
|
||||
if not uploaded_file or not uploaded_file.filename or uploaded_file.filename.strip() == '':
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "文件名为空",
|
||||
"message": "请选择要上传的文件"
|
||||
}), 400
|
||||
|
||||
raw_name = original_name or uploaded_file.filename
|
||||
filename = sanitize_filename_preserve_unicode(raw_name)
|
||||
if not filename:
|
||||
filename = secure_filename(raw_name)
|
||||
if not filename:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "非法文件名",
|
||||
"message": "文件名包含不支持的字符"
|
||||
}), 400
|
||||
|
||||
file_manager = getattr(terminal, 'file_manager', None)
|
||||
if file_manager is None:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "文件管理器未初始化"
|
||||
}), 500
|
||||
|
||||
target_folder_relative = UPLOAD_FOLDER_NAME
|
||||
valid_folder, folder_error, folder_path = file_manager._validate_path(target_folder_relative)
|
||||
if not valid_folder:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": folder_error
|
||||
}), 400
|
||||
|
||||
try:
|
||||
folder_path.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as exc:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": f"创建上传目录失败: {exc}"
|
||||
}), 500
|
||||
|
||||
target_relative = str(Path(target_folder_relative) / filename)
|
||||
valid_file, file_error, target_full_path = file_manager._validate_path(target_relative)
|
||||
if not valid_file:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": file_error
|
||||
}), 400
|
||||
|
||||
final_path = target_full_path
|
||||
if final_path.exists():
|
||||
stem = final_path.stem
|
||||
suffix = final_path.suffix
|
||||
counter = 1
|
||||
|
||||
while final_path.exists():
|
||||
candidate_name = f"{stem}_{counter}{suffix}"
|
||||
target_relative = str(Path(target_folder_relative) / candidate_name)
|
||||
valid_file, file_error, candidate_path = file_manager._validate_path(target_relative)
|
||||
if not valid_file:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": file_error
|
||||
}), 400
|
||||
final_path = candidate_path
|
||||
counter += 1
|
||||
|
||||
try:
|
||||
relative_path = str(final_path.relative_to(workspace.project_path))
|
||||
except Exception as exc:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": f"路径解析失败: {exc}"
|
||||
}), 400
|
||||
|
||||
guard = get_upload_guard(workspace)
|
||||
try:
|
||||
result = guard.process_upload(
|
||||
uploaded_file,
|
||||
final_path,
|
||||
username=username,
|
||||
source="legacy_upload",
|
||||
original_name=raw_name,
|
||||
relative_path=relative_path,
|
||||
)
|
||||
except UploadSecurityError as exc:
|
||||
return build_upload_error_response(exc)
|
||||
except Exception as exc:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": f"保存文件失败: {exc}"
|
||||
}), 500
|
||||
|
||||
metadata = result.get("metadata", {})
|
||||
print(f"{OUTPUT_FORMATS['file']} 上传文件: {relative_path}")
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"path": relative_path,
|
||||
"filename": final_path.name,
|
||||
"folder": target_folder_relative,
|
||||
"scan": metadata.get("scan"),
|
||||
"sha256": metadata.get("sha256"),
|
||||
"size": metadata.get("size"),
|
||||
})
|
||||
|
||||
|
||||
@chat_bp.errorhandler(RequestEntityTooLarge)
|
||||
def handle_file_too_large(error):
|
||||
"""全局捕获上传超大小"""
|
||||
size_mb = MAX_UPLOAD_SIZE / (1024 * 1024)
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "文件过大",
|
||||
"message": f"单个文件大小不可超过 {size_mb:.1f} MB"
|
||||
}), 413
|
||||
|
||||
|
||||
@chat_bp.route('/api/download/file')
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def download_file_api(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""下载单个文件"""
|
||||
path = (request.args.get('path') or '').strip()
|
||||
if not path:
|
||||
return jsonify({"success": False, "error": "缺少路径参数"}), 400
|
||||
|
||||
valid, error, full_path = terminal.file_manager._validate_path(path)
|
||||
if not valid or full_path is None:
|
||||
return jsonify({"success": False, "error": error or "路径校验失败"}), 400
|
||||
if not full_path.exists() or not full_path.is_file():
|
||||
return jsonify({"success": False, "error": "文件不存在"}), 404
|
||||
|
||||
return send_file(
|
||||
full_path,
|
||||
as_attachment=True,
|
||||
download_name=full_path.name
|
||||
)
|
||||
|
||||
|
||||
@chat_bp.route('/api/download/folder')
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def download_folder_api(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""打包并下载文件夹"""
|
||||
path = (request.args.get('path') or '').strip()
|
||||
if not path:
|
||||
return jsonify({"success": False, "error": "缺少路径参数"}), 400
|
||||
|
||||
valid, error, full_path = terminal.file_manager._validate_path(path)
|
||||
if not valid or full_path is None:
|
||||
return jsonify({"success": False, "error": error or "路径校验失败"}), 400
|
||||
if not full_path.exists() or not full_path.is_dir():
|
||||
return jsonify({"success": False, "error": "文件夹不存在"}), 404
|
||||
|
||||
buffer = BytesIO()
|
||||
folder_name = Path(path).name or full_path.name or "archive"
|
||||
|
||||
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_buffer:
|
||||
# 确保目录本身被包含
|
||||
zip_buffer.write(full_path, arcname=folder_name + '/')
|
||||
|
||||
for item in full_path.rglob('*'):
|
||||
relative_name = Path(folder_name) / item.relative_to(full_path)
|
||||
if item.is_dir():
|
||||
zip_buffer.write(item, arcname=str(relative_name) + '/')
|
||||
else:
|
||||
zip_buffer.write(item, arcname=str(relative_name))
|
||||
|
||||
buffer.seek(0)
|
||||
return send_file(
|
||||
buffer,
|
||||
mimetype='application/zip',
|
||||
as_attachment=True,
|
||||
download_name=f"{folder_name}.zip"
|
||||
)
|
||||
|
||||
@chat_bp.route('/api/tool-settings', methods=['GET', 'POST'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def tool_settings(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""获取或更新工具启用状态"""
|
||||
if request.method == 'GET':
|
||||
snapshot = terminal.get_tool_settings_snapshot()
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"categories": snapshot
|
||||
})
|
||||
|
||||
data = request.get_json() or {}
|
||||
category = data.get('category')
|
||||
if category is None:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "缺少类别参数",
|
||||
"message": "请求体需要提供 category 字段"
|
||||
}), 400
|
||||
|
||||
if 'enabled' not in data:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "缺少启用状态",
|
||||
"message": "请求体需要提供 enabled 字段"
|
||||
}), 400
|
||||
|
||||
try:
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_tool_toggle"):
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "工具开关已被管理员禁用",
|
||||
"message": "被管理员强制禁用"
|
||||
}), 403
|
||||
enabled = bool(data['enabled'])
|
||||
forced = getattr(terminal, "admin_forced_category_states", {}) or {}
|
||||
if isinstance(forced.get(category), bool) and forced[category] != enabled:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "该工具类别已被管理员强制为启用/禁用,无法修改",
|
||||
"message": "被管理员强制启用/禁用"
|
||||
}), 403
|
||||
terminal.set_tool_category_enabled(category, enabled)
|
||||
snapshot = terminal.get_tool_settings_snapshot()
|
||||
socketio.emit('tool_settings_updated', {
|
||||
'categories': snapshot
|
||||
}, room=f"user_{username}")
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"categories": snapshot
|
||||
})
|
||||
except ValueError as exc:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(exc)
|
||||
}), 400
|
||||
|
||||
@chat_bp.route('/api/terminals')
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def get_terminals(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""获取终端会话列表"""
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_realtime_terminal"):
|
||||
return jsonify({"success": False, "error": "实时终端已被管理员禁用"}), 403
|
||||
if terminal.terminal_manager:
|
||||
result = terminal.terminal_manager.list_terminals()
|
||||
return jsonify(result)
|
||||
else:
|
||||
return jsonify({"sessions": [], "active": None, "total": 0})
|
||||
|
||||
|
||||
@chat_bp.route('/api/socket-token', methods=['GET'])
|
||||
@api_login_required
|
||||
def issue_socket_token():
|
||||
"""生成一次性 WebSocket token,供握手阶段使用。"""
|
||||
username = get_current_username()
|
||||
prune_socket_tokens()
|
||||
now = time.time()
|
||||
for token_value, meta in list(pending_socket_tokens.items()):
|
||||
if meta.get("username") == username:
|
||||
pending_socket_tokens.pop(token_value, None)
|
||||
token_value = secrets.token_urlsafe(32)
|
||||
pending_socket_tokens[token_value] = {
|
||||
"username": username,
|
||||
"expires_at": now + SOCKET_TOKEN_TTL_SECONDS,
|
||||
"fingerprint": (request.headers.get('User-Agent') or '')[:128],
|
||||
}
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"token": token_value,
|
||||
"expires_in": SOCKET_TOKEN_TTL_SECONDS
|
||||
})
|
||||
2336
server/chat_flow.py
Normal file
2336
server/chat_flow.py
Normal file
File diff suppressed because it is too large
Load Diff
286
server/context.py
Normal file
286
server/context.py
Normal file
@ -0,0 +1,286 @@
|
||||
"""用户终端与工作区相关的共享辅助函数。"""
|
||||
from __future__ import annotations
|
||||
from functools import wraps
|
||||
from typing import Optional, Tuple, Dict, Any
|
||||
from flask import session, jsonify
|
||||
|
||||
from core.web_terminal import WebTerminal
|
||||
from modules.gui_file_manager import GuiFileManager
|
||||
from modules.upload_security import UploadQuarantineManager, UploadSecurityError
|
||||
from modules.personalization_manager import load_personalization_config
|
||||
from modules.usage_tracker import UsageTracker
|
||||
|
||||
from . import state
|
||||
from .utils_common import debug_log
|
||||
from .auth_helpers import get_current_username, get_current_user_record, get_current_user_role # will create helper module
|
||||
|
||||
|
||||
def make_terminal_callback(username: str):
|
||||
"""生成面向指定用户的广播函数"""
|
||||
from .extensions import socketio
|
||||
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['modules.user_manager.UserWorkspace']]:
|
||||
from modules.user_manager import UserWorkspace
|
||||
username = (username or get_current_username())
|
||||
if not username:
|
||||
return None, None
|
||||
record = get_current_user_record()
|
||||
workspace = state.user_manager.ensure_user_workspace(username)
|
||||
container_handle = state.container_manager.ensure_container(username, str(workspace.project_path))
|
||||
usage_tracker = get_or_create_usage_tracker(username, workspace)
|
||||
terminal = state.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
|
||||
state.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
|
||||
from modules import admin_policy_manager
|
||||
policy = admin_policy_manager.get_effective_policy(
|
||||
record.username if record else None,
|
||||
get_current_user_role(record),
|
||||
getattr(record, "invite_code", None),
|
||||
)
|
||||
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['modules.user_manager.UserWorkspace'] = None) -> Optional[UsageTracker]:
|
||||
if not username:
|
||||
return None
|
||||
tracker = state.usage_trackers.get(username)
|
||||
if tracker:
|
||||
return tracker
|
||||
from modules.user_manager import UserWorkspace
|
||||
if workspace is None:
|
||||
workspace = state.user_manager.ensure_user_workspace(username)
|
||||
record = state.user_manager.get_user(username)
|
||||
role = getattr(record, "role", "user") if record else "user"
|
||||
tracker = UsageTracker(str(workspace.data_dir), role=role or "user")
|
||||
state.usage_trackers[username] = tracker
|
||||
return tracker
|
||||
|
||||
|
||||
def emit_user_quota_update(username: Optional[str]):
|
||||
from .extensions import socketio
|
||||
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):
|
||||
username = state.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):
|
||||
return GuiFileManager(str(workspace.project_path))
|
||||
|
||||
|
||||
def get_upload_guard(workspace):
|
||||
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]):
|
||||
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:
|
||||
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))
|
||||
if hasattr(terminal, 'current_session_id'):
|
||||
terminal.current_session_id += 1
|
||||
debug_log(f"重置会话ID为: {terminal.current_session_id}")
|
||||
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()}")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"get_user_resources",
|
||||
"with_terminal",
|
||||
"get_terminal_for_sid",
|
||||
"get_gui_manager",
|
||||
"get_upload_guard",
|
||||
"build_upload_error_response",
|
||||
"ensure_conversation_loaded",
|
||||
"reset_system_state",
|
||||
"get_or_create_usage_tracker",
|
||||
"emit_user_quota_update",
|
||||
"attach_user_broadcast",
|
||||
]
|
||||
1175
server/conversation.py
Normal file
1175
server/conversation.py
Normal file
File diff suppressed because it is too large
Load Diff
7
server/extensions.py
Normal file
7
server/extensions.py
Normal file
@ -0,0 +1,7 @@
|
||||
"""Flask/SocketIO 扩展实例。"""
|
||||
from flask_socketio import SocketIO
|
||||
|
||||
# 统一的 SocketIO 实例,使用线程模式以兼容现有逻辑
|
||||
socketio = SocketIO(cors_allowed_origins="*", async_mode='threading', logger=False, engineio_logger=False)
|
||||
|
||||
__all__ = ["socketio"]
|
||||
314
server/files.py
Normal file
314
server/files.py
Normal file
@ -0,0 +1,314 @@
|
||||
"""文件与GUI文件管理相关路由。"""
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
|
||||
from flask import Blueprint, jsonify, request, send_file
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from modules.upload_security import UploadSecurityError
|
||||
from .auth_helpers import api_login_required, resolve_admin_policy, get_current_user_record
|
||||
from .security import rate_limited
|
||||
from .context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response
|
||||
from .utils_common import debug_log
|
||||
|
||||
files_bp = Blueprint("files", __name__)
|
||||
|
||||
|
||||
def sanitize_filename_preserve_unicode(filename: str) -> str:
|
||||
"""在保留中文等字符的同时,移除危险字符和路径成分"""
|
||||
import re
|
||||
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 ""
|
||||
return cleaned[:255]
|
||||
|
||||
@files_bp.route('/api/files')
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def get_files(terminal, workspace, username):
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("collapse_workspace") or policy.get("ui_blocks", {}).get("block_file_manager"):
|
||||
return jsonify({"success": False, "error": "文件浏览已被管理员禁用"}), 403
|
||||
structure = terminal.context_manager.get_project_structure()
|
||||
return jsonify(structure)
|
||||
|
||||
|
||||
def _format_entry(entry) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": entry.name,
|
||||
"path": entry.path,
|
||||
"type": entry.type,
|
||||
"size": entry.size,
|
||||
"modified_at": entry.modified_at,
|
||||
"extension": entry.extension,
|
||||
"is_editable": entry.is_editable,
|
||||
}
|
||||
|
||||
|
||||
@files_bp.route('/api/gui/files/entries', methods=['GET'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def gui_list_entries(terminal, workspace, username):
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_file_manager"):
|
||||
return jsonify({"success": False, "error": "文件管理已被管理员禁用"}), 403
|
||||
relative_path = request.args.get('path') or ""
|
||||
manager = get_gui_manager(workspace)
|
||||
try:
|
||||
resolved_path, entries = manager.list_directory(relative_path)
|
||||
breadcrumb = manager.breadcrumb(resolved_path)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": {
|
||||
"path": resolved_path,
|
||||
"breadcrumb": breadcrumb,
|
||||
"items": [_format_entry(entry) for entry in entries]
|
||||
}
|
||||
})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
@files_bp.route('/api/gui/files/create', methods=['POST'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
@rate_limited("gui_file_create", 30, 60, scope="user")
|
||||
def gui_create_entry(terminal, workspace, username):
|
||||
payload = request.get_json() or {}
|
||||
parent = payload.get('path') or ""
|
||||
name = payload.get('name') or ""
|
||||
entry_type = payload.get('type') or "file"
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_file_manager"):
|
||||
return jsonify({"success": False, "error": "文件管理已被管理员禁用"}), 403
|
||||
manager = get_gui_manager(workspace)
|
||||
try:
|
||||
new_path = manager.create_entry(parent, name, entry_type)
|
||||
return jsonify({"success": True, "path": new_path})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
@files_bp.route('/api/gui/files/delete', methods=['POST'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
@rate_limited("gui_file_delete", 30, 60, scope="user")
|
||||
def gui_delete_entries(terminal, workspace, username):
|
||||
payload = request.get_json() or {}
|
||||
paths = payload.get('paths') or []
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_file_manager"):
|
||||
return jsonify({"success": False, "error": "文件管理已被管理员禁用"}), 403
|
||||
manager = get_gui_manager(workspace)
|
||||
try:
|
||||
result = manager.delete_entries(paths)
|
||||
return jsonify({"success": True, "result": result})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
@files_bp.route('/api/gui/files/rename', methods=['POST'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
@rate_limited("gui_file_rename", 30, 60, scope="user")
|
||||
def gui_rename_entry(terminal, workspace, username):
|
||||
payload = request.get_json() or {}
|
||||
path = payload.get('path')
|
||||
new_name = payload.get('new_name')
|
||||
if not path or not new_name:
|
||||
return jsonify({"success": False, "error": "缺少 path 或 new_name"}), 400
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_file_manager"):
|
||||
return jsonify({"success": False, "error": "文件管理已被管理员禁用"}), 403
|
||||
manager = get_gui_manager(workspace)
|
||||
try:
|
||||
new_path = manager.rename_entry(path, new_name)
|
||||
return jsonify({"success": True, "path": new_path})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
@files_bp.route('/api/gui/files/copy', methods=['POST'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
@rate_limited("gui_file_copy", 40, 120, scope="user")
|
||||
def gui_copy_entries(terminal, workspace, username):
|
||||
payload = request.get_json() or {}
|
||||
paths = payload.get('paths') or []
|
||||
target_dir = payload.get('target_dir') or ""
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_file_manager"):
|
||||
return jsonify({"success": False, "error": "文件管理已被管理员禁用"}), 403
|
||||
manager = get_gui_manager(workspace)
|
||||
try:
|
||||
result = manager.copy_entries(paths, target_dir)
|
||||
return jsonify({"success": True, "result": result})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
@files_bp.route('/api/gui/files/move', methods=['POST'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
@rate_limited("gui_file_move", 40, 120, scope="user")
|
||||
def gui_move_entries(terminal, workspace, username):
|
||||
payload = request.get_json() or {}
|
||||
paths = payload.get('paths') or []
|
||||
target_dir = payload.get('target_dir') or ""
|
||||
manager = get_gui_manager(workspace)
|
||||
try:
|
||||
result = manager.move_entries(paths, target_dir)
|
||||
return jsonify({"success": True, "result": result})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
@files_bp.route('/api/gui/files/upload', methods=['POST'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
@rate_limited("gui_file_upload", 10, 300, scope="user")
|
||||
def gui_upload_entry(terminal, workspace, username):
|
||||
policy = resolve_admin_policy(get_current_user_record())
|
||||
if policy.get("ui_blocks", {}).get("block_upload"):
|
||||
return jsonify({"success": False, "error": "文件上传已被管理员禁用"}), 403
|
||||
if 'file' not in request.files:
|
||||
return jsonify({"success": False, "error": "未找到文件"}), 400
|
||||
file_obj = request.files['file']
|
||||
if not file_obj or not file_obj.filename:
|
||||
return jsonify({"success": False, "error": "文件名为空"}), 400
|
||||
current_dir = request.form.get('path') or ""
|
||||
raw_name = request.form.get('filename') or file_obj.filename
|
||||
filename = sanitize_filename_preserve_unicode(raw_name) or secure_filename(raw_name)
|
||||
if not filename:
|
||||
return jsonify({"success": False, "error": "非法文件名"}), 400
|
||||
manager = get_gui_manager(workspace)
|
||||
try:
|
||||
target_path = manager.prepare_upload(current_dir, filename)
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 400
|
||||
try:
|
||||
relative_path = manager._to_relative(target_path)
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 400
|
||||
guard = get_upload_guard(workspace)
|
||||
try:
|
||||
result = guard.process_upload(
|
||||
file_obj,
|
||||
target_path,
|
||||
username=username,
|
||||
source="web_gui",
|
||||
original_name=raw_name,
|
||||
relative_path=relative_path,
|
||||
)
|
||||
except UploadSecurityError as exc:
|
||||
return build_upload_error_response(exc)
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": f"保存文件失败: {exc}"}), 500
|
||||
|
||||
metadata = result.get("metadata", {})
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"path": relative_path,
|
||||
"filename": target_path.name,
|
||||
"scan": metadata.get("scan"),
|
||||
"sha256": metadata.get("sha256"),
|
||||
"size": metadata.get("size"),
|
||||
})
|
||||
|
||||
|
||||
@files_bp.route('/api/gui/files/download', methods=['GET'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def gui_download_entry(terminal, workspace, username):
|
||||
path = request.args.get('path')
|
||||
if not path:
|
||||
return jsonify({"success": False, "error": "缺少 path"}), 400
|
||||
manager = get_gui_manager(workspace)
|
||||
try:
|
||||
target = manager.prepare_download(path)
|
||||
if target.is_dir():
|
||||
memory_file = BytesIO()
|
||||
with zipfile.ZipFile(memory_file, mode='w', compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for root, dirs, files in os.walk(target):
|
||||
for file in files:
|
||||
full_path = Path(root) / file
|
||||
arcname = manager._to_relative(full_path)
|
||||
zf.write(full_path, arcname=arcname)
|
||||
memory_file.seek(0)
|
||||
download_name = f"{target.name}.zip"
|
||||
return send_file(memory_file, as_attachment=True, download_name=download_name, mimetype='application/zip')
|
||||
return send_file(target, as_attachment=True, download_name=target.name)
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
@files_bp.route('/api/gui/files/download/batch', methods=['POST'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def gui_download_batch(terminal, workspace, username):
|
||||
payload = request.get_json() or {}
|
||||
paths = payload.get('paths') or []
|
||||
if not paths:
|
||||
return jsonify({"success": False, "error": "缺少待下载的路径"}), 400
|
||||
manager = get_gui_manager(workspace)
|
||||
try:
|
||||
memory_file = BytesIO()
|
||||
with zipfile.ZipFile(memory_file, mode='w', compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for rel in paths:
|
||||
target = manager.prepare_download(rel)
|
||||
arc_base = rel.strip('/') or target.name
|
||||
if target.is_dir():
|
||||
for root, _, files in os.walk(target):
|
||||
for file in files:
|
||||
full_path = Path(root) / file
|
||||
relative_sub = full_path.relative_to(target)
|
||||
arcname = Path(arc_base) / relative_sub
|
||||
zf.write(full_path, arcname=str(arcname))
|
||||
else:
|
||||
zf.write(target, arcname=arc_base)
|
||||
memory_file.seek(0)
|
||||
download_name = f"selected_{len(paths)}.zip"
|
||||
return send_file(memory_file, as_attachment=True, download_name=download_name, mimetype='application/zip')
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
@files_bp.route('/api/gui/files/text', methods=['GET', 'POST'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def gui_text_entry(terminal, workspace, username):
|
||||
manager = get_gui_manager(workspace)
|
||||
if request.method == 'GET':
|
||||
path = request.args.get('path')
|
||||
if not path:
|
||||
return jsonify({"success": False, "error": "缺少 path"}), 400
|
||||
try:
|
||||
content, modified = manager.read_text(path)
|
||||
return jsonify({"success": True, "path": path, "content": content, "modified_at": modified})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 400
|
||||
|
||||
payload = request.get_json() or {}
|
||||
path = payload.get('path')
|
||||
content = payload.get('content')
|
||||
if path is None or content is None:
|
||||
return jsonify({"success": False, "error": "缺少 path 或 content"}), 400
|
||||
try:
|
||||
result = manager.write_text(path, content)
|
||||
return jsonify({"success": True, "data": result})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
__all__ = ["files_bp"]
|
||||
50
server/monitor.py
Normal file
50
server/monitor.py
Normal file
@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
import time
|
||||
from typing import Optional, Dict, Any
|
||||
from .utils_common import debug_log
|
||||
from .state import MONITOR_SNAPSHOT_CACHE, MONITOR_SNAPSHOT_CACHE_LIMIT
|
||||
|
||||
__all__ = ["cache_monitor_snapshot", "get_cached_monitor_snapshot"]
|
||||
|
||||
|
||||
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
|
||||
254
server/security.py
Normal file
254
server/security.py
Normal file
@ -0,0 +1,254 @@
|
||||
"""安全相关工具:限流、CSRF、Socket Token、工具结果压缩等。"""
|
||||
from __future__ import annotations
|
||||
import hmac
|
||||
import secrets
|
||||
import time
|
||||
from typing import Dict, Any, Optional, Tuple
|
||||
from flask import request, session, jsonify
|
||||
from functools import wraps
|
||||
|
||||
from . import state
|
||||
|
||||
# 便捷别名
|
||||
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
|
||||
from .auth import get_current_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 = state.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 = state.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 = state.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}"
|
||||
state.FAILURE_TRACKERS.pop(key, None)
|
||||
|
||||
|
||||
def get_csrf_token(force_new: bool = False) -> str:
|
||||
token = session.get(state.CSRF_SESSION_KEY)
|
||||
if force_new or not token:
|
||||
token = secrets.token_urlsafe(32)
|
||||
session[state.CSRF_SESSION_KEY] = token
|
||||
return token
|
||||
|
||||
|
||||
def requires_csrf_protection(path: str) -> bool:
|
||||
if path in state.CSRF_EXEMPT_PATHS:
|
||||
return False
|
||||
if path in state.CSRF_PROTECTED_PATHS:
|
||||
return True
|
||||
return any(path.startswith(prefix) for prefix in state.CSRF_PROTECTED_PREFIXES)
|
||||
|
||||
|
||||
def validate_csrf_request() -> bool:
|
||||
expected = session.get(state.CSRF_SESSION_KEY)
|
||||
provided = request.headers.get(state.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(state.pending_socket_tokens.items()):
|
||||
if meta.get("expires_at", 0) <= current:
|
||||
state.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 = state.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
|
||||
|
||||
def attach_security_hooks(app):
|
||||
"""注册 CSRF 校验与通用安全响应头。"""
|
||||
@app.before_request
|
||||
def _enforce_csrf_token():
|
||||
method = (request.method or "GET").upper()
|
||||
if method in state.CSRF_SAFE_METHODS:
|
||||
return
|
||||
if not requires_csrf_protection(request.path):
|
||||
return
|
||||
if validate_csrf_request():
|
||||
return
|
||||
return jsonify({"success": False, "error": "CSRF validation failed"}), 403
|
||||
|
||||
@app.after_request
|
||||
def _apply_security_headers(response):
|
||||
response.headers.setdefault("X-Frame-Options", "SAMEORIGIN")
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
if response.mimetype == "application/json":
|
||||
response.headers.setdefault("Cache-Control", "no-store")
|
||||
if app.config.get("SESSION_COOKIE_SECURE"):
|
||||
response.headers.setdefault("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||
return response
|
||||
|
||||
__all__ = [
|
||||
"get_client_ip",
|
||||
"resolve_identifier",
|
||||
"check_rate_limit",
|
||||
"rate_limited",
|
||||
"register_failure",
|
||||
"is_action_blocked",
|
||||
"clear_failures",
|
||||
"get_csrf_token",
|
||||
"requires_csrf_protection",
|
||||
"validate_csrf_request",
|
||||
"prune_socket_tokens",
|
||||
"consume_socket_token",
|
||||
"format_tool_result_notice",
|
||||
"compact_web_search_result",
|
||||
"attach_security_hooks",
|
||||
]
|
||||
__all__ = [
|
||||
"get_client_ip",
|
||||
"resolve_identifier",
|
||||
"check_rate_limit",
|
||||
"rate_limited",
|
||||
"register_failure",
|
||||
"is_action_blocked",
|
||||
"clear_failures",
|
||||
"get_csrf_token",
|
||||
"requires_csrf_protection",
|
||||
"validate_csrf_request",
|
||||
"prune_socket_tokens",
|
||||
"consume_socket_token",
|
||||
"format_tool_result_notice",
|
||||
"compact_web_search_result",
|
||||
]
|
||||
315
server/socket_handlers.py
Normal file
315
server/socket_handlers.py
Normal file
@ -0,0 +1,315 @@
|
||||
from __future__ import annotations
|
||||
import asyncio, time, json, re
|
||||
from typing import Dict, Any
|
||||
from flask import request
|
||||
from flask_socketio import emit, join_room, leave_room, disconnect
|
||||
|
||||
from .extensions import socketio
|
||||
from .auth_helpers import get_current_username, resolve_admin_policy
|
||||
from .context import (
|
||||
get_terminal_for_sid,
|
||||
ensure_conversation_loaded,
|
||||
reset_system_state,
|
||||
get_user_resources,
|
||||
)
|
||||
from .utils_common import debug_log, log_frontend_chunk, log_streaming_debug_entry
|
||||
from .state import connection_users, stop_flags, terminal_rooms, pending_socket_tokens, user_manager
|
||||
from .usage import record_user_activity
|
||||
from .chat_flow import start_chat_task
|
||||
from .security import consume_socket_token, prune_socket_tokens
|
||||
from config import OUTPUT_FORMATS, AGENT_VERSION
|
||||
|
||||
@socketio.on('connect')
|
||||
def handle_connect(auth):
|
||||
"""客户端连接"""
|
||||
print(f"[WebSocket] 客户端连接: {request.sid}")
|
||||
username = get_current_username()
|
||||
token_value = (auth or {}).get('socket_token') if isinstance(auth, dict) else None
|
||||
if not username or not consume_socket_token(token_value, username):
|
||||
emit('error', {'message': '未登录或连接凭证无效'})
|
||||
disconnect()
|
||||
return
|
||||
|
||||
emit('connected', {'status': 'Connected to server'})
|
||||
connection_users[request.sid] = username
|
||||
|
||||
# 清理可能存在的停止标志和状态
|
||||
stop_flags.pop(request.sid, None)
|
||||
|
||||
join_room(f"user_{username}")
|
||||
join_room(f"user_{username}_terminal")
|
||||
if request.sid not in terminal_rooms:
|
||||
terminal_rooms[request.sid] = set()
|
||||
terminal_rooms[request.sid].update({f"user_{username}", f"user_{username}_terminal"})
|
||||
|
||||
terminal, workspace = get_user_resources(username)
|
||||
if terminal:
|
||||
reset_system_state(terminal)
|
||||
emit('system_ready', {
|
||||
'project_path': str(workspace.project_path),
|
||||
'thinking_mode': bool(getattr(terminal, "thinking_mode", False)),
|
||||
'version': AGENT_VERSION
|
||||
}, room=request.sid)
|
||||
|
||||
if terminal.terminal_manager:
|
||||
terminals = terminal.terminal_manager.get_terminal_list()
|
||||
emit('terminal_list_update', {
|
||||
'terminals': terminals,
|
||||
'active': terminal.terminal_manager.active_terminal
|
||||
}, room=request.sid)
|
||||
|
||||
if terminal.terminal_manager.active_terminal:
|
||||
for name, term in terminal.terminal_manager.terminals.items():
|
||||
emit('terminal_started', {
|
||||
'session': name,
|
||||
'working_dir': str(term.working_dir),
|
||||
'shell': term.shell_command,
|
||||
'time': term.start_time.isoformat() if term.start_time else None
|
||||
}, room=request.sid)
|
||||
|
||||
@socketio.on('disconnect')
|
||||
def handle_disconnect():
|
||||
"""客户端断开"""
|
||||
print(f"[WebSocket] 客户端断开: {request.sid}")
|
||||
username = connection_users.pop(request.sid, None)
|
||||
task_info = stop_flags.get(request.sid)
|
||||
if isinstance(task_info, dict):
|
||||
task_info['stop'] = True
|
||||
pending_task = task_info.get('task')
|
||||
if pending_task and not pending_task.done():
|
||||
debug_log(f"disconnect: cancel task for {request.sid}")
|
||||
pending_task.cancel()
|
||||
terminal = task_info.get('terminal')
|
||||
if terminal:
|
||||
reset_system_state(terminal)
|
||||
|
||||
# 清理停止标志
|
||||
stop_flags.pop(request.sid, None)
|
||||
|
||||
# 从所有房间移除
|
||||
for room in list(terminal_rooms.get(request.sid, [])):
|
||||
leave_room(room)
|
||||
if request.sid in terminal_rooms:
|
||||
del terminal_rooms[request.sid]
|
||||
|
||||
if username:
|
||||
leave_room(f"user_{username}")
|
||||
leave_room(f"user_{username}_terminal")
|
||||
|
||||
@socketio.on('stop_task')
|
||||
def handle_stop_task():
|
||||
"""处理停止任务请求"""
|
||||
print(f"[停止] 收到停止请求: {request.sid}")
|
||||
|
||||
task_info = stop_flags.get(request.sid)
|
||||
if not isinstance(task_info, dict):
|
||||
task_info = {'stop': False, 'task': None, 'terminal': None}
|
||||
stop_flags[request.sid] = task_info
|
||||
|
||||
if task_info.get('task') and not task_info['task'].done():
|
||||
debug_log(f"正在取消任务: {request.sid}")
|
||||
task_info['task'].cancel()
|
||||
|
||||
task_info['stop'] = True
|
||||
if task_info.get('terminal'):
|
||||
reset_system_state(task_info['terminal'])
|
||||
|
||||
emit('stop_requested', {
|
||||
'message': '停止请求已接收,正在取消任务...'
|
||||
})
|
||||
|
||||
@socketio.on('terminal_subscribe')
|
||||
def handle_terminal_subscribe(data):
|
||||
"""订阅终端事件"""
|
||||
session_name = data.get('session')
|
||||
subscribe_all = data.get('all', False)
|
||||
|
||||
username, terminal, _ = get_terminal_for_sid(request.sid)
|
||||
if not username or not terminal or not terminal.terminal_manager:
|
||||
emit('error', {'message': 'Terminal system not initialized'})
|
||||
return
|
||||
policy = resolve_admin_policy(user_manager.get_user(username))
|
||||
if policy.get("ui_blocks", {}).get("block_realtime_terminal"):
|
||||
emit('error', {'message': '实时终端已被管理员禁用'})
|
||||
return
|
||||
|
||||
if request.sid not in terminal_rooms:
|
||||
terminal_rooms[request.sid] = set()
|
||||
|
||||
if subscribe_all:
|
||||
# 订阅所有终端事件
|
||||
room_name = f"user_{username}_terminal"
|
||||
join_room(room_name)
|
||||
terminal_rooms[request.sid].add(room_name)
|
||||
print(f"[Terminal] {request.sid} 订阅所有终端事件")
|
||||
|
||||
# 发送当前终端状态
|
||||
emit('terminal_subscribed', {
|
||||
'type': 'all',
|
||||
'terminals': terminal.terminal_manager.get_terminal_list()
|
||||
})
|
||||
elif session_name:
|
||||
# 订阅特定终端会话
|
||||
room_name = f'user_{username}_terminal_{session_name}'
|
||||
join_room(room_name)
|
||||
terminal_rooms[request.sid].add(room_name)
|
||||
print(f"[Terminal] {request.sid} 订阅终端: {session_name}")
|
||||
|
||||
# 发送该终端的当前输出
|
||||
output_result = terminal.terminal_manager.get_terminal_output(session_name, 100)
|
||||
if output_result['success']:
|
||||
emit('terminal_history', {
|
||||
'session': session_name,
|
||||
'output': output_result['output']
|
||||
})
|
||||
|
||||
@socketio.on('terminal_unsubscribe')
|
||||
def handle_terminal_unsubscribe(data):
|
||||
"""取消订阅终端事件"""
|
||||
session_name = data.get('session')
|
||||
username = connection_users.get(request.sid)
|
||||
|
||||
if session_name:
|
||||
room_name = f'user_{username}_terminal_{session_name}' if username else f'terminal_{session_name}'
|
||||
leave_room(room_name)
|
||||
if request.sid in terminal_rooms:
|
||||
terminal_rooms[request.sid].discard(room_name)
|
||||
print(f"[Terminal] {request.sid} 取消订阅终端: {session_name}")
|
||||
|
||||
@socketio.on('get_terminal_output')
|
||||
def handle_get_terminal_output(data):
|
||||
"""获取终端输出历史"""
|
||||
session_name = data.get('session')
|
||||
lines = data.get('lines', 50)
|
||||
|
||||
username, terminal, _ = get_terminal_for_sid(request.sid)
|
||||
if not terminal or not terminal.terminal_manager:
|
||||
emit('error', {'message': 'Terminal system not initialized'})
|
||||
return
|
||||
policy = resolve_admin_policy(user_manager.get_user(username))
|
||||
if policy.get("ui_blocks", {}).get("block_realtime_terminal"):
|
||||
emit('error', {'message': '实时终端已被管理员禁用'})
|
||||
return
|
||||
|
||||
result = terminal.terminal_manager.get_terminal_output(session_name, lines)
|
||||
|
||||
if result['success']:
|
||||
emit('terminal_output_history', {
|
||||
'session': session_name,
|
||||
'output': result['output'],
|
||||
'is_interactive': result.get('is_interactive', False),
|
||||
'last_command': result.get('last_command', '')
|
||||
})
|
||||
else:
|
||||
emit('error', {'message': result['error']})
|
||||
|
||||
@socketio.on('send_message')
|
||||
def handle_message(data):
|
||||
"""处理用户消息"""
|
||||
username, terminal, workspace = get_terminal_for_sid(request.sid)
|
||||
if not terminal:
|
||||
emit('error', {'message': 'System not initialized'})
|
||||
return
|
||||
|
||||
message = (data.get('message') or '').strip()
|
||||
images = data.get('images') or []
|
||||
if not message and not images:
|
||||
emit('error', {'message': '消息不能为空'})
|
||||
return
|
||||
if images and getattr(terminal, "model_key", None) != "qwen3-vl-plus":
|
||||
emit('error', {'message': '当前模型不支持图片,请切换到 Qwen-VL'})
|
||||
return
|
||||
|
||||
print(f"[WebSocket] 收到消息: {message}")
|
||||
debug_log(f"\n{'='*80}\n新任务开始: {message}\n{'='*80}")
|
||||
record_user_activity(username)
|
||||
|
||||
requested_conversation_id = data.get('conversation_id')
|
||||
try:
|
||||
conversation_id, created_new = ensure_conversation_loaded(terminal, requested_conversation_id)
|
||||
except RuntimeError as exc:
|
||||
emit('error', {'message': str(exc)})
|
||||
return
|
||||
try:
|
||||
conv_data = terminal.context_manager.conversation_manager.load_conversation(conversation_id) or {}
|
||||
except Exception:
|
||||
conv_data = {}
|
||||
title = conv_data.get('title', '新对话')
|
||||
|
||||
socketio.emit('conversation_resolved', {
|
||||
'conversation_id': conversation_id,
|
||||
'title': title,
|
||||
'created': created_new
|
||||
}, room=request.sid)
|
||||
|
||||
if created_new:
|
||||
socketio.emit('conversation_list_update', {
|
||||
'action': 'created',
|
||||
'conversation_id': conversation_id
|
||||
}, room=f"user_{username}")
|
||||
socketio.emit('conversation_changed', {
|
||||
'conversation_id': conversation_id,
|
||||
'title': title
|
||||
}, room=request.sid)
|
||||
|
||||
client_sid = request.sid
|
||||
|
||||
def send_to_client(event_type, data):
|
||||
"""发送消息到客户端"""
|
||||
socketio.emit(event_type, data, room=client_sid)
|
||||
|
||||
# 模型活动事件:用于刷新“在线”心跳(回复/工具调用都算活动)
|
||||
activity_events = {
|
||||
'ai_message_start', 'thinking_start', 'thinking_chunk', 'thinking_end',
|
||||
'text_start', 'text_chunk', 'text_end',
|
||||
'tool_hint', 'tool_preparing', 'tool_start', 'update_action',
|
||||
'append_payload', 'modify_payload', 'system_message',
|
||||
'task_complete'
|
||||
}
|
||||
last_model_activity = 0.0
|
||||
|
||||
def send_with_activity(event_type, data):
|
||||
"""模型产生输出或调用工具时刷新活跃时间,防止长回复被误判下线。"""
|
||||
nonlocal last_model_activity
|
||||
if event_type in activity_events:
|
||||
now = time.time()
|
||||
# 轻量节流:1 秒内多次事件只记一次
|
||||
if now - last_model_activity >= 1.0:
|
||||
record_user_activity(username)
|
||||
last_model_activity = now
|
||||
send_to_client(event_type, data)
|
||||
|
||||
# 传递客户端ID
|
||||
images = data.get('images') or []
|
||||
start_chat_task(terminal, message, images, send_with_activity, client_sid, workspace, username)
|
||||
|
||||
|
||||
@socketio.on('client_chunk_log')
|
||||
def handle_client_chunk_log(data):
|
||||
"""前端chunk日志上报"""
|
||||
conversation_id = data.get('conversation_id')
|
||||
chunk_index = int(data.get('index') or data.get('chunk_index') or 0)
|
||||
elapsed = float(data.get('elapsed') or 0.0)
|
||||
length = int(data.get('length') or len(data.get('content') or ""))
|
||||
client_ts = float(data.get('ts') or 0.0)
|
||||
log_frontend_chunk(conversation_id, chunk_index, elapsed, length, client_ts)
|
||||
|
||||
|
||||
@socketio.on('client_stream_debug_log')
|
||||
def handle_client_stream_debug_log(data):
|
||||
"""前端流式调试日志"""
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
entry = dict(data)
|
||||
entry.setdefault('server_ts', time.time())
|
||||
log_streaming_debug_entry(entry)
|
||||
|
||||
# 在 web_server.py 中添加以下对话管理API接口
|
||||
# 添加在现有路由之后,@socketio 事件处理之前
|
||||
|
||||
# ==========================================
|
||||
# 对话管理API接口
|
||||
# ==========================================
|
||||
|
||||
|
||||
# conversation routes moved to server/conversation.py
|
||||
142
server/state.py
Normal file
142
server/state.py
Normal file
@ -0,0 +1,142 @@
|
||||
"""共享状态与常量,供各子模块使用。"""
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import threading
|
||||
from collections import defaultdict, deque
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from config import LOGS_DIR, PROJECT_MAX_STORAGE_BYTES, PROJECT_MAX_STORAGE_MB
|
||||
from core.web_terminal import WebTerminal
|
||||
from modules.custom_tool_registry import CustomToolRegistry
|
||||
from modules.usage_tracker import UsageTracker
|
||||
from modules.user_container_manager import UserContainerManager
|
||||
from modules.user_manager import UserManager
|
||||
|
||||
# 全局实例
|
||||
user_manager = UserManager()
|
||||
custom_tool_registry = CustomToolRegistry()
|
||||
container_manager = UserContainerManager()
|
||||
user_terminals: Dict[str, WebTerminal] = {}
|
||||
terminal_rooms: Dict[str, set] = {}
|
||||
connection_users: Dict[str, str] = {}
|
||||
RECENT_UPLOAD_EVENT_LIMIT = 150
|
||||
RECENT_UPLOAD_FEED_LIMIT = 60
|
||||
stop_flags: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# 监控/限流/用量
|
||||
MONITOR_FILE_TOOLS = {'append_to_file', 'modify_file', 'write_file_diff'}
|
||||
MONITOR_MEMORY_TOOLS = {'update_memory'}
|
||||
MONITOR_SNAPSHOT_CHAR_LIMIT = 60000
|
||||
MONITOR_MEMORY_ENTRY_LIMIT = 256
|
||||
RATE_LIMIT_BUCKETS: Dict[str, deque] = defaultdict(deque)
|
||||
FAILURE_TRACKERS: Dict[str, Dict[str, float]] = {}
|
||||
pending_socket_tokens: Dict[str, Dict[str, Any]] = {}
|
||||
usage_trackers: Dict[str, UsageTracker] = {}
|
||||
|
||||
MONITOR_SNAPSHOT_CACHE: Dict[str, Dict[str, Any]] = {}
|
||||
MONITOR_SNAPSHOT_CACHE_LIMIT = 120
|
||||
RECENT_UPLOAD_EVENT_LIMIT = 150
|
||||
RECENT_UPLOAD_FEED_LIMIT = 60
|
||||
|
||||
# 路径与缓存设置(依赖项目配置)
|
||||
PROJECT_STORAGE_CACHE: Dict[str, Dict[str, Any]] = {}
|
||||
PROJECT_STORAGE_CACHE_TTL_SECONDS = float(os.environ.get("PROJECT_STORAGE_CACHE_TTL", "30"))
|
||||
|
||||
# 其他配置
|
||||
DEFAULT_PORT = 8091
|
||||
THINKING_FAILURE_KEYWORDS = ["⚠️", "🛑", "失败", "错误", "异常", "终止", "error", "failed", "未完成", "超时", "强制"]
|
||||
CSRF_HEADER_NAME = "X-CSRF-Token"
|
||||
CSRF_SESSION_KEY = "_csrf_token"
|
||||
CSRF_SAFE_METHODS = {"GET", "HEAD", "OPTIONS", "TRACE"}
|
||||
CSRF_PROTECTED_PATHS = {"/login", "/register", "/logout"}
|
||||
CSRF_PROTECTED_PREFIXES = ("/api/",)
|
||||
CSRF_EXEMPT_PATHS = {"/api/csrf-token"}
|
||||
FAILED_LOGIN_LIMIT = 5
|
||||
FAILED_LOGIN_LOCK_SECONDS = 300
|
||||
SOCKET_TOKEN_TTL_SECONDS = 45
|
||||
USER_IDLE_TIMEOUT_SECONDS = int(os.environ.get("USER_IDLE_TIMEOUT_SECONDS", "900"))
|
||||
LAST_ACTIVE_FILE = Path(LOGS_DIR).expanduser().resolve() / "last_active.json"
|
||||
_last_active_lock = threading.Lock()
|
||||
_last_active_cache: Dict[str, float] = {}
|
||||
_idle_reaper_started = False
|
||||
TITLE_PROMPT_PATH = Path(__file__).resolve().parent.parent / "prompts" / "title_generation_prompt.txt"
|
||||
|
||||
# 项目存储限制常量也会被使用
|
||||
PROJECT_MAX_STORAGE_BYTES = PROJECT_MAX_STORAGE_BYTES
|
||||
PROJECT_MAX_STORAGE_MB = PROJECT_MAX_STORAGE_MB
|
||||
|
||||
__all__ = [
|
||||
"user_manager",
|
||||
"custom_tool_registry",
|
||||
"container_manager",
|
||||
"user_terminals",
|
||||
"terminal_rooms",
|
||||
"connection_users",
|
||||
"stop_flags",
|
||||
"MONITOR_FILE_TOOLS",
|
||||
"MONITOR_MEMORY_TOOLS",
|
||||
"MONITOR_SNAPSHOT_CHAR_LIMIT",
|
||||
"MONITOR_MEMORY_ENTRY_LIMIT",
|
||||
"RATE_LIMIT_BUCKETS",
|
||||
"FAILURE_TRACKERS",
|
||||
"pending_socket_tokens",
|
||||
"usage_trackers",
|
||||
"MONITOR_SNAPSHOT_CACHE",
|
||||
"MONITOR_SNAPSHOT_CACHE_LIMIT",
|
||||
"PROJECT_STORAGE_CACHE",
|
||||
"PROJECT_STORAGE_CACHE_TTL_SECONDS",
|
||||
"DEFAULT_PORT",
|
||||
"THINKING_FAILURE_KEYWORDS",
|
||||
"CSRF_HEADER_NAME",
|
||||
"CSRF_SESSION_KEY",
|
||||
"CSRF_SAFE_METHODS",
|
||||
"CSRF_PROTECTED_PATHS",
|
||||
"CSRF_PROTECTED_PREFIXES",
|
||||
"CSRF_EXEMPT_PATHS",
|
||||
"FAILED_LOGIN_LIMIT",
|
||||
"FAILED_LOGIN_LOCK_SECONDS",
|
||||
"SOCKET_TOKEN_TTL_SECONDS",
|
||||
"USER_IDLE_TIMEOUT_SECONDS",
|
||||
"LAST_ACTIVE_FILE",
|
||||
"_last_active_lock",
|
||||
"_last_active_cache",
|
||||
"_idle_reaper_started",
|
||||
"TITLE_PROMPT_PATH",
|
||||
"PROJECT_MAX_STORAGE_BYTES",
|
||||
"PROJECT_MAX_STORAGE_MB",
|
||||
"RECENT_UPLOAD_EVENT_LIMIT",
|
||||
"RECENT_UPLOAD_FEED_LIMIT",
|
||||
"get_last_active_ts",
|
||||
]
|
||||
|
||||
|
||||
def get_last_active_ts(username: str, fallback: Optional[float] = None) -> Optional[float]:
|
||||
"""
|
||||
返回最近活跃时间,优先使用缓存;当容器句柄中的时间更新、更晚时,自动刷新缓存。
|
||||
这样避免“缓存过旧导致刚触碰的容器被立即回收”的问题。
|
||||
"""
|
||||
fallback_val: Optional[float]
|
||||
try:
|
||||
fallback_val = float(fallback) if fallback is not None else None
|
||||
except (TypeError, ValueError):
|
||||
fallback_val = None
|
||||
|
||||
with _last_active_lock:
|
||||
cached = _last_active_cache.get(username)
|
||||
try:
|
||||
cached_val = float(cached) if cached is not None else None
|
||||
except (TypeError, ValueError):
|
||||
cached_val = None
|
||||
|
||||
# 若没有缓存,或句柄时间更新、更晚,则刷新缓存
|
||||
if cached_val is None:
|
||||
if fallback_val is not None:
|
||||
_last_active_cache[username] = fallback_val
|
||||
return fallback_val
|
||||
|
||||
if fallback_val is not None and fallback_val > cached_val:
|
||||
_last_active_cache[username] = fallback_val
|
||||
return fallback_val
|
||||
|
||||
return cached_val
|
||||
96
server/status.py
Normal file
96
server/status.py
Normal file
@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
import time
|
||||
from flask import Blueprint, jsonify
|
||||
|
||||
from .auth_helpers import api_login_required, resolve_admin_policy
|
||||
from .context import with_terminal
|
||||
from .state import (
|
||||
PROJECT_STORAGE_CACHE,
|
||||
PROJECT_STORAGE_CACHE_TTL_SECONDS,
|
||||
PROJECT_MAX_STORAGE_MB,
|
||||
container_manager,
|
||||
user_manager,
|
||||
)
|
||||
from config import AGENT_VERSION
|
||||
|
||||
status_bp = Blueprint('status', __name__)
|
||||
|
||||
|
||||
@status_bp.route('/api/status')
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def get_status(terminal, workspace, username):
|
||||
"""获取系统状态(包含对话、容器、版本等信息)"""
|
||||
status = terminal.get_status()
|
||||
if terminal.terminal_manager:
|
||||
status['terminals'] = terminal.terminal_manager.list_terminals()
|
||||
try:
|
||||
current_conv = terminal.context_manager.current_conversation_id
|
||||
status.setdefault('conversation', {})['current_id'] = current_conv
|
||||
if current_conv and not current_conv.startswith('temp_'):
|
||||
current_conv_data = terminal.context_manager.conversation_manager.load_conversation(current_conv)
|
||||
if current_conv_data:
|
||||
status['conversation']['title'] = current_conv_data.get('title', '未知对话')
|
||||
status['conversation']['created_at'] = current_conv_data.get('created_at')
|
||||
status['conversation']['updated_at'] = current_conv_data.get('updated_at')
|
||||
except Exception as exc:
|
||||
print(f"[Status] 获取当前对话信息失败: {exc}")
|
||||
status['project_path'] = str(workspace.project_path)
|
||||
try:
|
||||
status['container'] = container_manager.get_container_status(username)
|
||||
except Exception as exc:
|
||||
status['container'] = {"success": False, "error": str(exc)}
|
||||
status['version'] = AGENT_VERSION
|
||||
try:
|
||||
policy = resolve_admin_policy(user_manager.get_user(username))
|
||||
status['admin_policy'] = {
|
||||
"ui_blocks": policy.get("ui_blocks") or {},
|
||||
"disabled_models": policy.get("disabled_models") or [],
|
||||
"forced_category_states": policy.get("forced_category_states") or {},
|
||||
"version": policy.get("updated_at"),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify(status)
|
||||
|
||||
|
||||
@status_bp.route('/api/container-status')
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def get_container_status_api(terminal, workspace, username):
|
||||
try:
|
||||
status = container_manager.get_container_status(username)
|
||||
return jsonify({"success": True, "data": status})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
@status_bp.route('/api/project-storage')
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def get_project_storage(terminal, workspace, username):
|
||||
now = time.time()
|
||||
cache_entry = PROJECT_STORAGE_CACHE.get(username)
|
||||
if cache_entry and (now - cache_entry.get("ts", 0)) < PROJECT_STORAGE_CACHE_TTL_SECONDS:
|
||||
return jsonify({"success": True, "data": cache_entry["data"]})
|
||||
try:
|
||||
file_manager = getattr(terminal, 'file_manager', None)
|
||||
if not file_manager:
|
||||
return jsonify({"success": False, "error": "文件管理器未初始化"}), 500
|
||||
used_bytes = file_manager._get_project_size()
|
||||
limit_bytes = PROJECT_MAX_STORAGE_MB * 1024 * 1024 if PROJECT_MAX_STORAGE_MB else None
|
||||
usage_percent = (used_bytes / limit_bytes * 100) if limit_bytes else None
|
||||
data = {
|
||||
"used_bytes": used_bytes,
|
||||
"limit_bytes": limit_bytes,
|
||||
"limit_label": f"{PROJECT_MAX_STORAGE_MB}MB" if PROJECT_MAX_STORAGE_MB else "未限制",
|
||||
"usage_percent": usage_percent
|
||||
}
|
||||
PROJECT_STORAGE_CACHE[username] = {"ts": now, "data": data}
|
||||
return jsonify({"success": True, "data": data})
|
||||
except Exception as exc:
|
||||
stale = PROJECT_STORAGE_CACHE.get(username)
|
||||
if stale:
|
||||
return jsonify({"success": True, "data": stale.get("data"), "stale": True}), 200
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
244
server/tasks.py
Normal file
244
server/tasks.py
Normal file
@ -0,0 +1,244 @@
|
||||
"""简单任务 API:将聊天任务与 WebSocket 解耦,支持后台运行与轮询。"""
|
||||
from __future__ import annotations
|
||||
import time
|
||||
import threading
|
||||
import uuid
|
||||
from collections import deque
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
from .auth_helpers import api_login_required, get_current_username
|
||||
from .context import get_user_resources, ensure_conversation_loaded
|
||||
from .chat_flow import run_chat_task_sync
|
||||
from .state import stop_flags
|
||||
from .utils_common import debug_log
|
||||
|
||||
|
||||
class TaskRecord:
|
||||
__slots__ = (
|
||||
"task_id",
|
||||
"username",
|
||||
"status",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"message",
|
||||
"conversation_id",
|
||||
"events",
|
||||
"thread",
|
||||
"error",
|
||||
)
|
||||
|
||||
def __init__(self, task_id: str, username: str, message: str, conversation_id: Optional[str]):
|
||||
self.task_id = task_id
|
||||
self.username = username
|
||||
self.status = "pending"
|
||||
self.created_at = time.time()
|
||||
self.updated_at = self.created_at
|
||||
self.message = message
|
||||
self.conversation_id = conversation_id
|
||||
self.events: deque[Dict[str, Any]] = deque(maxlen=1000)
|
||||
self.thread: Optional[threading.Thread] = None
|
||||
self.error: Optional[str] = None
|
||||
|
||||
|
||||
class TaskManager:
|
||||
"""线程内存版任务管理器,后续可替换为 Redis/DB。"""
|
||||
|
||||
def __init__(self):
|
||||
self._tasks: Dict[str, TaskRecord] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# ---- public APIs ----
|
||||
def create_chat_task(self, username: str, message: str, images: List[Any], conversation_id: Optional[str]) -> TaskRecord:
|
||||
task_id = str(uuid.uuid4())
|
||||
record = TaskRecord(task_id, username, message, conversation_id)
|
||||
with self._lock:
|
||||
self._tasks[task_id] = record
|
||||
thread = threading.Thread(target=self._run_chat_task, args=(record, images), daemon=True)
|
||||
record.thread = thread
|
||||
record.status = "running"
|
||||
record.updated_at = time.time()
|
||||
thread.start()
|
||||
return record
|
||||
|
||||
def get_task(self, username: str, task_id: str) -> Optional[TaskRecord]:
|
||||
with self._lock:
|
||||
rec = self._tasks.get(task_id)
|
||||
if not rec or rec.username != username:
|
||||
return None
|
||||
return rec
|
||||
|
||||
def list_tasks(self, username: str) -> List[TaskRecord]:
|
||||
with self._lock:
|
||||
return [rec for rec in self._tasks.values() if rec.username == username]
|
||||
|
||||
def cancel_task(self, username: str, task_id: str) -> bool:
|
||||
rec = self.get_task(username, task_id)
|
||||
if not rec:
|
||||
return False
|
||||
# 标记停止标志;chat_flow 会检测 stop_flags
|
||||
entry = stop_flags.get(task_id)
|
||||
if not isinstance(entry, dict):
|
||||
entry = {'stop': False, 'task': None, 'terminal': None}
|
||||
stop_flags[task_id] = entry
|
||||
entry['stop'] = True
|
||||
try:
|
||||
if entry.get('task') and hasattr(entry['task'], "cancel"):
|
||||
entry['task'].cancel()
|
||||
except Exception:
|
||||
pass
|
||||
with self._lock:
|
||||
rec.status = "cancel_requested"
|
||||
rec.updated_at = time.time()
|
||||
return True
|
||||
|
||||
# ---- internal helpers ----
|
||||
def _append_event(self, rec: TaskRecord, event_type: str, data: Dict[str, Any]):
|
||||
with self._lock:
|
||||
idx = rec.events[-1]["idx"] + 1 if rec.events else 0
|
||||
rec.events.append({
|
||||
"idx": idx,
|
||||
"type": event_type,
|
||||
"data": data,
|
||||
"ts": time.time(),
|
||||
})
|
||||
rec.updated_at = time.time()
|
||||
|
||||
def _run_chat_task(self, rec: TaskRecord, images: List[Any]):
|
||||
username = rec.username
|
||||
try:
|
||||
terminal, workspace = get_user_resources(username)
|
||||
if not terminal or not workspace:
|
||||
raise RuntimeError("系统未初始化")
|
||||
|
||||
# 确保会话加载
|
||||
conversation_id = rec.conversation_id
|
||||
try:
|
||||
conversation_id, _ = ensure_conversation_loaded(terminal, conversation_id)
|
||||
rec.conversation_id = conversation_id
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"对话加载失败: {exc}") from exc
|
||||
|
||||
def sender(event_type, data):
|
||||
# 记录事件
|
||||
self._append_event(rec, event_type, data)
|
||||
# 在线用户仍然收到实时推送(房间 user_{username})
|
||||
try:
|
||||
from .extensions import socketio
|
||||
socketio.emit(event_type, data, room=f"user_{username}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 将 task_id 作为 client_sid,供 stop_flags 检测
|
||||
run_chat_task_sync(
|
||||
terminal=terminal,
|
||||
message=rec.message,
|
||||
images=images,
|
||||
sender=sender,
|
||||
client_sid=rec.task_id,
|
||||
workspace=workspace,
|
||||
username=username,
|
||||
)
|
||||
|
||||
# 结束状态
|
||||
with self._lock:
|
||||
rec.status = "canceled" if rec.task_id in stop_flags and stop_flags[rec.task_id].get('stop') else "succeeded"
|
||||
rec.updated_at = time.time()
|
||||
except Exception as exc:
|
||||
debug_log(f"[Task] 后台任务失败: {exc}")
|
||||
self._append_event(rec, "error", {"message": str(exc)})
|
||||
with self._lock:
|
||||
rec.status = "failed"
|
||||
rec.error = str(exc)
|
||||
rec.updated_at = time.time()
|
||||
finally:
|
||||
# 清理 stop_flags
|
||||
stop_flags.pop(rec.task_id, None)
|
||||
|
||||
|
||||
task_manager = TaskManager()
|
||||
tasks_bp = Blueprint("tasks", __name__)
|
||||
|
||||
|
||||
@tasks_bp.route("/api/tasks", methods=["GET"])
|
||||
@api_login_required
|
||||
def list_tasks_api():
|
||||
username = get_current_username()
|
||||
recs = task_manager.list_tasks(username)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": [
|
||||
{
|
||||
"task_id": r.task_id,
|
||||
"status": r.status,
|
||||
"created_at": r.created_at,
|
||||
"updated_at": r.updated_at,
|
||||
"message": r.message,
|
||||
"conversation_id": r.conversation_id,
|
||||
"error": r.error,
|
||||
} for r in sorted(recs, key=lambda x: x.created_at, reverse=True)
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@tasks_bp.route("/api/tasks", methods=["POST"])
|
||||
@api_login_required
|
||||
def create_task_api():
|
||||
username = get_current_username()
|
||||
payload = request.get_json() or {}
|
||||
message = (payload.get("message") or "").strip()
|
||||
images = payload.get("images") or []
|
||||
conversation_id = payload.get("conversation_id")
|
||||
if not message and not images:
|
||||
return jsonify({"success": False, "error": "消息不能为空"}), 400
|
||||
rec = task_manager.create_chat_task(username, message, images, conversation_id)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": {
|
||||
"task_id": rec.task_id,
|
||||
"status": rec.status,
|
||||
"created_at": rec.created_at,
|
||||
"conversation_id": rec.conversation_id,
|
||||
}
|
||||
}), 202
|
||||
|
||||
|
||||
@tasks_bp.route("/api/tasks/<task_id>", methods=["GET"])
|
||||
@api_login_required
|
||||
def get_task_api(task_id: str):
|
||||
username = get_current_username()
|
||||
rec = task_manager.get_task(username, task_id)
|
||||
if not rec:
|
||||
return jsonify({"success": False, "error": "任务不存在"}), 404
|
||||
try:
|
||||
offset = int(request.args.get("from", 0))
|
||||
except Exception:
|
||||
offset = 0
|
||||
events = [e for e in rec.events if e["idx"] >= offset]
|
||||
next_offset = events[-1]["idx"] + 1 if events else offset
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": {
|
||||
"task_id": rec.task_id,
|
||||
"status": rec.status,
|
||||
"created_at": rec.created_at,
|
||||
"updated_at": rec.updated_at,
|
||||
"message": rec.message,
|
||||
"conversation_id": rec.conversation_id,
|
||||
"error": rec.error,
|
||||
"events": events,
|
||||
"next_offset": next_offset,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@tasks_bp.route("/api/tasks/<task_id>/cancel", methods=["POST"])
|
||||
@api_login_required
|
||||
def cancel_task_api(task_id: str):
|
||||
username = get_current_username()
|
||||
ok = task_manager.cancel_task(username, task_id)
|
||||
if not ok:
|
||||
return jsonify({"success": False, "error": "任务不存在"}), 404
|
||||
return jsonify({"success": True})
|
||||
|
||||
55
server/usage.py
Normal file
55
server/usage.py
Normal file
@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import time
|
||||
from flask import Blueprint, jsonify
|
||||
from typing import Optional
|
||||
|
||||
from .auth_helpers import api_login_required, get_current_username
|
||||
from .context import get_or_create_usage_tracker
|
||||
from .state import (
|
||||
container_manager,
|
||||
LAST_ACTIVE_FILE,
|
||||
_last_active_lock,
|
||||
_last_active_cache,
|
||||
)
|
||||
|
||||
usage_bp = Blueprint('usage', __name__)
|
||||
|
||||
|
||||
@usage_bp.route('/api/usage', methods=['GET'])
|
||||
@api_login_required
|
||||
def get_usage_stats():
|
||||
"""返回当前用户的模型/搜索调用统计。"""
|
||||
username = get_current_username()
|
||||
tracker = get_or_create_usage_tracker(username)
|
||||
if not tracker:
|
||||
return jsonify({"success": False, "error": "未找到用户"}), 404
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": tracker.get_stats()
|
||||
})
|
||||
|
||||
|
||||
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:
|
||||
# 失败不阻塞主流程
|
||||
pass
|
||||
|
||||
|
||||
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()
|
||||
|
||||
195
server/utils_common.py
Normal file
195
server/utils_common.py
Normal file
@ -0,0 +1,195 @@
|
||||
"""通用工具:文本处理、日志写入等(由原 web_server.py 拆分)。"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from config import LOGS_DIR
|
||||
|
||||
# 文件名安全处理
|
||||
|
||||
def _sanitize_filename_component(text: str) -> str:
|
||||
safe = (text or "untitled").strip()
|
||||
safe = __import__('re').sub(r'[\\/:*?"<>|]+', '_', safe)
|
||||
return safe or "untitled"
|
||||
|
||||
|
||||
def build_review_lines(messages, limit=None):
|
||||
"""
|
||||
将对话消息序列拍平成简化文本。
|
||||
保留 user / assistant / system 以及 assistant 内的 tool 调用与 tool 消息。
|
||||
limit 为正整数时,最多返回该数量的行(用于预览)。
|
||||
"""
|
||||
lines: List[str] = []
|
||||
|
||||
def append_line(text: str):
|
||||
lines.append(text.rstrip())
|
||||
|
||||
def extract_text(content):
|
||||
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 = 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]
|
||||
|
||||
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)
|
||||
if isinstance(limit, int) and limit > 0 and len(lines) >= limit:
|
||||
return lines[:limit]
|
||||
|
||||
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
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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)
|
||||
|
||||
__all__ = [
|
||||
"_sanitize_filename_component",
|
||||
"build_review_lines",
|
||||
"brief_log",
|
||||
"debug_log",
|
||||
"log_backend_chunk",
|
||||
"log_frontend_chunk",
|
||||
"log_streaming_debug_entry",
|
||||
"DEBUG_LOG_FILE",
|
||||
"CHUNK_BACKEND_LOG_FILE",
|
||||
"CHUNK_FRONTEND_LOG_FILE",
|
||||
"STREAMING_DEBUG_LOG_FILE",
|
||||
]
|
||||
@ -347,7 +347,7 @@ const fetchDashboard = async (background = false) => {
|
||||
loading.value = true;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch('/api/admin/dashboard');
|
||||
const resp = await fetch('/api/admin/dashboard', { credentials: 'same-origin' });
|
||||
if (!resp.ok) {
|
||||
throw new Error(`请求失败:${resp.status}`);
|
||||
}
|
||||
@ -355,6 +355,10 @@ const fetchDashboard = async (background = false) => {
|
||||
if (!payload.success) {
|
||||
throw new Error(payload.error || '未知错误');
|
||||
}
|
||||
// 额外调试:若后台返回 debug 字段,打印到控制台便于问题定位(仅开发模式会看到)
|
||||
if (import.meta.env.DEV && payload.data?.debug) {
|
||||
console.info('[admin dashboard debug]', payload.data.debug);
|
||||
}
|
||||
snapshot.value = payload.data;
|
||||
errorMessage.value = null;
|
||||
bannerError.value = null;
|
||||
|
||||
6080
web_server.py
6080
web_server.py
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user