- 新增 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>
181 lines
7.4 KiB
Python
181 lines
7.4 KiB
Python
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,
|
||
})
|