agent-Specialization/server/chat/approval.py
JOJO 66fa90d6cc feat(server): headless 启动入口(Gateway 专用路由面)+ approval 拆独立蓝图
- 新增 server/headless_app.py:仅挂 gateway/tasks/status/approval/usage 五件蓝图,
  无 web 站点路由面(/login、会话管理、静态页全砍),根路径返回 HTML 告知页;
  供 CLI 探测无服务时 spawn(同 RuntimeService 单例/数据目录/端口 8091,
  与 full 形态互斥不并存)
- server/chat/approval.py 拆出 approval_bp(tool/plan/question 6 条路由 URL 不变),
  解除对 chat_bp 的 import 循环,full/headless 双形态共用
- cli/src/gateway.ts:spawn 切 server.headless_app;python 解释器改 pickPython
  依赖探测(import yaml/flask 逐个试 .venv → homebrew 3.12/3.11 → python3),
  修本机 .venv 缺 pyyaml 导致的自启动即崩
- 修复 socket 移除残留雷:make_terminal_callback 定义随 broadcast.py 被删,
  workflow_runtime_api 模块级 import 即炸;resources.py 补兼容空操作(恒返 None)
- AGENTS.md §2 补 headless 启动命令、§12.5 补兼容空操作与 headless 形态说明

验证:tsc  py_compile  冒烟 6/6  headless 装配 47 路由 
8093 真实冒烟(health/Bearer/404 面/告知页)

Co-authored-by: Astrion powered by Kimi-K3 <astrion-agent@users.noreply.github.com>
2026-09-11 19:00:22 +08:00

181 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
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
# 审批路由独立蓝图:审批(工具/计划/提问)属 Runtime 人机交互契约,
# 不属 web chat 域headless 形态只挂本蓝图而不挂 chat_bp。
# 注意URL 路径与原 chat_bp 时期完全一致,前端与客户端零改动。
approval_bp = Blueprint("approval", __name__)
from werkzeug.utils import secure_filename
from werkzeug.exceptions import RequestEntityTooLarge
import secrets
from config import MAX_UPLOAD_SIZE, OUTPUT_FORMATS
from modules.personalization_manager import (
load_personalization_config,
resolve_context_compression_settings,
save_personalization_config,
RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN,
RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX,
)
from modules.skills_manager import (
get_skills_catalog,
infer_private_skills_dir,
merge_enabled_skills,
sync_workspace_skills,
)
from modules.upload_security import UploadSecurityError
from modules.host_sandbox_policy import load_policy, save_policy
from modules.user_manager import UserWorkspace
from core.web_terminal import WebTerminal
from config.model_profiles import get_model_context_window
from server.auth_helpers import resolve_admin_policy, get_current_user_record, get_current_username
from server.gateway_auth import api_login_or_host_token_required
from server.context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response, ensure_conversation_loaded, get_or_create_usage_tracker
from server.security import rate_limited
from server.utils_common import debug_log
from server.state import PROJECT_MAX_STORAGE_MB
from server.runtime import runtime_service
from server.monitor import get_cached_monitor_snapshot
from modules.i18n import tr
UPLOAD_FOLDER_NAME = ".astrion/user_upload"
@approval_bp.route('/api/user-questions/pending', methods=['GET'])
@api_login_or_host_token_required
@with_terminal
def list_pending_user_questions(terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""获取当前用户待回答的问题列表。"""
requested_conv_id = (request.args.get("conversation_id") or "").strip() or None
if requested_conv_id is None:
requested_conv_id = getattr(terminal.context_manager, "current_conversation_id", None)
items = runtime_service.list_pending_approvals(username, requested_conv_id, kind="question")["question"]
return jsonify({
"success": True,
"items": items,
"conversation_id": requested_conv_id,
})
@approval_bp.route('/api/user-questions/<question_id>/answer', methods=['POST'])
@api_login_or_host_token_required
@with_terminal
@rate_limited("user_question_answer", 120, 60, scope="user")
def answer_user_question(terminal: WebTerminal, workspace: UserWorkspace, username: str, question_id: str):
"""提交 ask_user 工具问题的回答。"""
data = request.get_json() or {}
try:
item = runtime_service.resolve_approval(
"question",
username=username,
item_id=question_id,
selected_option_id=data.get("selected_option_id"),
text=data.get("text"),
dismissed=bool(data.get("dismissed")),
)
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
except KeyError:
return jsonify({"success": False, "error": tr("chat_approval.question_not_found")}), 404
except PermissionError as exc:
return jsonify({"success": False, "error": str(exc)}), 403
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 500
return jsonify({
"success": True,
"item": item,
})
@approval_bp.route('/api/plan-approvals/pending', methods=['GET'])
@api_login_or_host_token_required
@with_terminal
def list_pending_plan_approvals(terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""获取当前用户待批准的计划列表work_mode=plan 的 submit_plan 工具)。"""
requested_conv_id = (request.args.get("conversation_id") or "").strip() or None
if requested_conv_id is None:
requested_conv_id = getattr(terminal.context_manager, "current_conversation_id", None)
items = runtime_service.list_pending_approvals(username, requested_conv_id, kind="plan")["plan"]
return jsonify({
"success": True,
"items": items,
"conversation_id": requested_conv_id,
})
@approval_bp.route('/api/plan-approvals/<approval_id>/answer', methods=['POST'])
@api_login_or_host_token_required
@with_terminal
@rate_limited("plan_approval_answer", 120, 60, scope="user")
def answer_plan_approval(terminal: WebTerminal, workspace: UserWorkspace, username: str, approval_id: str):
"""提交计划批准/拒绝决策。approved=true 时工具循环侧会自动切换到 execute 模式。"""
data = request.get_json() or {}
try:
item = runtime_service.resolve_approval(
"plan",
username=username,
item_id=approval_id,
approved=bool(data.get("approved")),
comment=data.get("comment"),
)
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
except KeyError:
return jsonify({"success": False, "error": tr("chat_approval.plan_approval_not_found")}), 404
except PermissionError as exc:
return jsonify({"success": False, "error": str(exc)}), 403
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 500
return jsonify({
"success": True,
"item": item,
})
@approval_bp.route('/api/tool-approvals/pending', methods=['GET'])
@api_login_or_host_token_required
@with_terminal
def list_pending_tool_approvals(terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""获取当前用户待审批工具列表。"""
requested_conv_id = (request.args.get("conversation_id") or "").strip() or None
if requested_conv_id is None:
requested_conv_id = getattr(terminal.context_manager, "current_conversation_id", None)
items = runtime_service.list_pending_approvals(username, requested_conv_id, kind="tool")["tool"]
return jsonify({
"success": True,
"items": items,
"conversation_id": requested_conv_id,
})
@approval_bp.route('/api/tool-approvals/<approval_id>/decision', methods=['POST'])
@api_login_or_host_token_required
@with_terminal
@rate_limited("tool_approval_decision", 60, 60, scope="user")
def decide_tool_approval(terminal: WebTerminal, workspace: UserWorkspace, username: str, approval_id: str):
"""提交工具审批决策。"""
data = request.get_json() or {}
decision = str(data.get("decision") or "").strip().lower()
try:
item = runtime_service.resolve_approval(
"tool", username=username, item_id=approval_id, decision=decision
)
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
except KeyError:
return jsonify({"success": False, "error": tr("chat_approval.approval_not_found")}), 404
except PermissionError as exc:
return jsonify({"success": False, "error": str(exc)}), 403
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 500
return jsonify({
"success": True,
"item": item,
})