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:
JOJO 2026-01-22 09:21:53 +08:00
parent 72cdd69f9b
commit d6fb59e1d8
32 changed files with 13031 additions and 6055 deletions

View File

@ -77,6 +77,8 @@ npm install
python main.py
# Web 模式(默认 8091
python -m server.app # 推荐
# 兼容旧命令(启动时会提示已弃用)
python web_server.py
```

View File

@ -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
View 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
View 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
View 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
})

View 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
})

View 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"

File diff suppressed because it is too large Load Diff

View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

241
server/auth.py Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

286
server/context.py Normal file
View 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

File diff suppressed because it is too large Load Diff

7
server/extensions.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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",
]

View 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;

File diff suppressed because it is too large Load Diff