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>
This commit is contained in:
parent
e5808ea5be
commit
66fa90d6cc
@ -110,6 +110,10 @@
|
|||||||
- 可选参数:`python -m server.app --path ./project --port 8091 --debug --thinking-mode`
|
- 可选参数:`python -m server.app --path ./project --port 8091 --debug --thinking-mode`
|
||||||
- 兼容启动方式:`python web_server.py`
|
- 兼容启动方式:`python web_server.py`
|
||||||
- 统一入口:`python main.py`(当前实现会默认进入 Web 启动流程)
|
- 统一入口:`python main.py`(当前实现会默认进入 Web 启动流程)
|
||||||
|
- **Headless 启动(CLI 自启动专用)**:`python -m server.headless_app --path <cwd> --port 8091 --thinking-mode`
|
||||||
|
- 只挂运行时蓝图(gateway / tasks / status / approval / usage 五件套),不含 web 站点路由面(无 /login、无静态页、无会话管理);根路径 `/` 返回简单 HTML 告知页
|
||||||
|
- 与 full 形态同进程模型(RuntimeService 单例/同一数据目录/同一端口 8091),只是启动时挂的路由面不同;**绝不能与 full 形态双进程并存**(MultiAgentState/门闸/审批 Map 是进程内单例)
|
||||||
|
- CLI(`cli/src/gateway.ts`)探测无服务时 spawn 的就是这个入口;python 解释器经依赖探测(import yaml/flask)从 .venv → homebrew 3.12/3.11 → python3 中选第一个可用的
|
||||||
|
|
||||||
### Frontend(根目录)
|
### Frontend(根目录)
|
||||||
- 安装依赖:`npm install`
|
- 安装依赖:`npm install`
|
||||||
@ -613,7 +617,9 @@ Web 端实时通道曾长期双轨(REST 任务轮询为主 + Socket.IO 辅助
|
|||||||
**硬性约束**:
|
**硬性约束**:
|
||||||
1. 新增任何「实时通知」一律写任务事件流(`_append_event`)或扩 `/api/status` 载荷,**禁止**重新引入 socket 推送。
|
1. 新增任何「实时通知」一律写任务事件流(`_append_event`)或扩 `/api/status` 载荷,**禁止**重新引入 socket 推送。
|
||||||
2. `server/extensions.py` 只剩 `run_background`(daemon 线程);不要再往里加推送类函数。
|
2. `server/extensions.py` 只剩 `run_background`(daemon 线程);不要再往里加推送类函数。
|
||||||
3. `WebTerminal.broadcast()` / `terminal_manager.broadcast` 调用点保留但回调恒为 None(判空安全),新代码不要依赖它们产生用户可见效果。
|
3. `WebTerminal.broadcast()` / `terminal_manager.broadcast` 调用点保留但回调恒为 None(判空安全),新代码不要依赖它们产生用户可见效果。`make_terminal_callback(username)`(`server/context/resources.py`)同理:Socket.IO 移除后是恒返 None 的兼容空操作,保留仅为维持历史 import 路径(`workflow_runtime_api.py`、`resources.py` 自身构造 WebTerminal)不炸。
|
||||||
|
|
||||||
|
**Headless 形态(2026-09-11 新增)**:`server/headless_app.py` 提供无 web 站点的启动入口(CLI 自启动专用,详见 §2);审批三类端点(tool/plan/question)已从 chat_bp 拆为独立蓝图 `server/chat/approval.py::approval_bp`(6 条 URL 一字不变),full 与 headless 双形态共用。
|
||||||
|
|
||||||
## 13) 工具动态加载(tool_loading,2026-09 新增)
|
## 13) 工具动态加载(tool_loading,2026-09 新增)
|
||||||
|
|
||||||
|
|||||||
@ -8,11 +8,36 @@ import { readFile, mkdir } from 'node:fs/promises';
|
|||||||
import { existsSync, openSync, mkdirSync } from 'node:fs';
|
import { existsSync, openSync, mkdirSync } from 'node:fs';
|
||||||
import { basename, resolve } from 'node:path';
|
import { basename, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn, execFileSync } from 'node:child_process';
|
||||||
|
|
||||||
const DEFAULT_PORT = 8091;
|
const DEFAULT_PORT = 8091;
|
||||||
const TOKEN_PATH = `${homedir()}/.astrion/astrion/host/data/host_api_token`;
|
const TOKEN_PATH = `${homedir()}/.astrion/astrion/host/data/host_api_token`;
|
||||||
|
|
||||||
|
/** 探测能跑后端的 python 解释器:依次试候选,能 import 服务端关键依赖(yaml/flask)才用;
|
||||||
|
* 全部失败时退回第一个存在的候选(让启动日志留下真实报错,而不是 CLI 侧静默)。 */
|
||||||
|
function pickPython(repoRoot: string): string {
|
||||||
|
const candidates = [
|
||||||
|
resolve(repoRoot, '.venv/bin/python'),
|
||||||
|
'/opt/homebrew/bin/python3.12',
|
||||||
|
'/opt/homebrew/bin/python3.11',
|
||||||
|
'/usr/local/bin/python3.12',
|
||||||
|
'/usr/local/bin/python3.11',
|
||||||
|
'python3',
|
||||||
|
];
|
||||||
|
let firstExisting = '';
|
||||||
|
for (const bin of candidates) {
|
||||||
|
if (bin.startsWith('/') && !existsSync(bin)) continue;
|
||||||
|
if (!firstExisting) firstExisting = bin;
|
||||||
|
try {
|
||||||
|
execFileSync(bin, ['-c', 'import yaml, flask'], { stdio: 'ignore' });
|
||||||
|
return bin;
|
||||||
|
} catch {
|
||||||
|
// 缺依赖,试下一个
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstExisting || 'python3';
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkspaceItem {
|
export interface WorkspaceItem {
|
||||||
workspace_id: string;
|
workspace_id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@ -104,22 +129,23 @@ export class GatewayClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** CLI 自启动后端(detached + unref,对齐旧 Ink 版参数;显式 --port 避免默认端口歧义)
|
/** CLI 自启动后端(detached + unref;走 headless 入口,只挂 Gateway/任务/审批等运行时蓝图,
|
||||||
* python 优先用项目 .venv(系统 python3 缺依赖会启动即崩);输出落日志文件便于诊断启动失败。 */
|
* 不拉起 web 站点路由面;显式 --port 避免默认端口歧义)
|
||||||
|
* python 解释器做依赖探测(import yaml/flask 通过才用)——本机 .venv 缺 yaml 会启动即崩;
|
||||||
|
* 输出落日志文件便于诊断启动失败。 */
|
||||||
private spawnServer(cwd: string): void {
|
private spawnServer(cwd: string): void {
|
||||||
// cli/src → 项目根;fileURLToPath 正确解码中文路径(new URL().pathname 会留下
|
// cli/src → 项目根;fileURLToPath 正确解码中文路径(new URL().pathname 会留下
|
||||||
// 百分号编码,导致 existsSync 永远 false、spawn cwd 无效——中文路径必用此函数)
|
// 百分号编码,导致 existsSync 永远 false、spawn cwd 无效——中文路径必用此函数)
|
||||||
const repoRoot = resolve(fileURLToPath(new URL('../..', import.meta.url)));
|
const repoRoot = resolve(fileURLToPath(new URL('../..', import.meta.url)));
|
||||||
const port = Number(process.env.ASTRION_API_PORT || process.env.WEB_SERVER_PORT || DEFAULT_PORT);
|
const port = Number(process.env.ASTRION_API_PORT || process.env.WEB_SERVER_PORT || DEFAULT_PORT);
|
||||||
const venvPython = resolve(repoRoot, '.venv/bin/python');
|
const pythonBin = pickPython(repoRoot);
|
||||||
const pythonBin = existsSync(venvPython) ? venvPython : 'python3';
|
|
||||||
const logDir = `${homedir()}/.astrion/astrion/host/logs`;
|
const logDir = `${homedir()}/.astrion/astrion/host/logs`;
|
||||||
try {
|
try {
|
||||||
mkdirSync(logDir, { recursive: true });
|
mkdirSync(logDir, { recursive: true });
|
||||||
const logFd = openSync(resolve(logDir, 'cli_spawned_server.log'), 'a');
|
const logFd = openSync(resolve(logDir, 'cli_spawned_server.log'), 'a');
|
||||||
const child = spawn(
|
const child = spawn(
|
||||||
pythonBin,
|
pythonBin,
|
||||||
['-m', 'server.app', '--path', cwd, '--port', String(port), '--thinking-mode'],
|
['-m', 'server.headless_app', '--path', cwd, '--port', String(port), '--thinking-mode'],
|
||||||
{
|
{
|
||||||
cwd: repoRoot,
|
cwd: repoRoot,
|
||||||
env: {
|
env: {
|
||||||
|
|||||||
@ -31,6 +31,7 @@ from server.files import files_bp
|
|||||||
from server.admin import admin_bp
|
from server.admin import admin_bp
|
||||||
from server.conversation import conversation_bp
|
from server.conversation import conversation_bp
|
||||||
from server.chat import chat_bp
|
from server.chat import chat_bp
|
||||||
|
from server.chat.approval import approval_bp
|
||||||
from server.usage import usage_bp
|
from server.usage import usage_bp
|
||||||
from server.status import status_bp
|
from server.status import status_bp
|
||||||
from server.tasks.web import get_tasks_blueprint
|
from server.tasks.web import get_tasks_blueprint
|
||||||
@ -294,6 +295,7 @@ app.register_blueprint(files_bp)
|
|||||||
app.register_blueprint(admin_bp)
|
app.register_blueprint(admin_bp)
|
||||||
app.register_blueprint(conversation_bp)
|
app.register_blueprint(conversation_bp)
|
||||||
app.register_blueprint(chat_bp)
|
app.register_blueprint(chat_bp)
|
||||||
|
app.register_blueprint(approval_bp)
|
||||||
app.register_blueprint(usage_bp)
|
app.register_blueprint(usage_bp)
|
||||||
app.register_blueprint(status_bp)
|
app.register_blueprint(status_bp)
|
||||||
app.register_blueprint(get_tasks_blueprint())
|
app.register_blueprint(get_tasks_blueprint())
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from server.chat import chat_bp
|
|
||||||
import json, time
|
import json, time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
@ -9,6 +8,11 @@ import zipfile
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, session, send_file
|
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.utils import secure_filename
|
||||||
from werkzeug.exceptions import RequestEntityTooLarge
|
from werkzeug.exceptions import RequestEntityTooLarge
|
||||||
import secrets
|
import secrets
|
||||||
@ -45,7 +49,7 @@ from server.monitor import get_cached_monitor_snapshot
|
|||||||
from modules.i18n import tr
|
from modules.i18n import tr
|
||||||
|
|
||||||
UPLOAD_FOLDER_NAME = ".astrion/user_upload"
|
UPLOAD_FOLDER_NAME = ".astrion/user_upload"
|
||||||
@chat_bp.route('/api/user-questions/pending', methods=['GET'])
|
@approval_bp.route('/api/user-questions/pending', methods=['GET'])
|
||||||
@api_login_or_host_token_required
|
@api_login_or_host_token_required
|
||||||
@with_terminal
|
@with_terminal
|
||||||
def list_pending_user_questions(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
def list_pending_user_questions(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||||
@ -60,7 +64,7 @@ def list_pending_user_questions(terminal: WebTerminal, workspace: UserWorkspace,
|
|||||||
"conversation_id": requested_conv_id,
|
"conversation_id": requested_conv_id,
|
||||||
})
|
})
|
||||||
|
|
||||||
@chat_bp.route('/api/user-questions/<question_id>/answer', methods=['POST'])
|
@approval_bp.route('/api/user-questions/<question_id>/answer', methods=['POST'])
|
||||||
@api_login_or_host_token_required
|
@api_login_or_host_token_required
|
||||||
@with_terminal
|
@with_terminal
|
||||||
@rate_limited("user_question_answer", 120, 60, scope="user")
|
@rate_limited("user_question_answer", 120, 60, scope="user")
|
||||||
@ -90,7 +94,7 @@ def answer_user_question(terminal: WebTerminal, workspace: UserWorkspace, userna
|
|||||||
"item": item,
|
"item": item,
|
||||||
})
|
})
|
||||||
|
|
||||||
@chat_bp.route('/api/plan-approvals/pending', methods=['GET'])
|
@approval_bp.route('/api/plan-approvals/pending', methods=['GET'])
|
||||||
@api_login_or_host_token_required
|
@api_login_or_host_token_required
|
||||||
@with_terminal
|
@with_terminal
|
||||||
def list_pending_plan_approvals(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
def list_pending_plan_approvals(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||||
@ -105,7 +109,7 @@ def list_pending_plan_approvals(terminal: WebTerminal, workspace: UserWorkspace,
|
|||||||
"conversation_id": requested_conv_id,
|
"conversation_id": requested_conv_id,
|
||||||
})
|
})
|
||||||
|
|
||||||
@chat_bp.route('/api/plan-approvals/<approval_id>/answer', methods=['POST'])
|
@approval_bp.route('/api/plan-approvals/<approval_id>/answer', methods=['POST'])
|
||||||
@api_login_or_host_token_required
|
@api_login_or_host_token_required
|
||||||
@with_terminal
|
@with_terminal
|
||||||
@rate_limited("plan_approval_answer", 120, 60, scope="user")
|
@rate_limited("plan_approval_answer", 120, 60, scope="user")
|
||||||
@ -134,7 +138,7 @@ def answer_plan_approval(terminal: WebTerminal, workspace: UserWorkspace, userna
|
|||||||
"item": item,
|
"item": item,
|
||||||
})
|
})
|
||||||
|
|
||||||
@chat_bp.route('/api/tool-approvals/pending', methods=['GET'])
|
@approval_bp.route('/api/tool-approvals/pending', methods=['GET'])
|
||||||
@api_login_or_host_token_required
|
@api_login_or_host_token_required
|
||||||
@with_terminal
|
@with_terminal
|
||||||
def list_pending_tool_approvals(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
def list_pending_tool_approvals(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||||
@ -149,7 +153,7 @@ def list_pending_tool_approvals(terminal: WebTerminal, workspace: UserWorkspace,
|
|||||||
"conversation_id": requested_conv_id,
|
"conversation_id": requested_conv_id,
|
||||||
})
|
})
|
||||||
|
|
||||||
@chat_bp.route('/api/tool-approvals/<approval_id>/decision', methods=['POST'])
|
@approval_bp.route('/api/tool-approvals/<approval_id>/decision', methods=['POST'])
|
||||||
@api_login_or_host_token_required
|
@api_login_or_host_token_required
|
||||||
@with_terminal
|
@with_terminal
|
||||||
@rate_limited("tool_approval_decision", 60, 60, scope="user")
|
@rate_limited("tool_approval_decision", 60, 60, scope="user")
|
||||||
|
|||||||
@ -25,6 +25,7 @@ _SYMBOL_MODULE = {
|
|||||||
"apply_conversation_overrides": "server.context.conversation",
|
"apply_conversation_overrides": "server.context.conversation",
|
||||||
# resources
|
# resources
|
||||||
"get_user_resources": "server.context.resources",
|
"get_user_resources": "server.context.resources",
|
||||||
|
"make_terminal_callback": "server.context.resources",
|
||||||
"_make_terminal_key": "server.context.resources",
|
"_make_terminal_key": "server.context.resources",
|
||||||
"_touch_terminal_activity": "server.context.resources",
|
"_touch_terminal_activity": "server.context.resources",
|
||||||
"_set_terminal_workspace_label": "server.context.resources",
|
"_set_terminal_workspace_label": "server.context.resources",
|
||||||
@ -42,6 +43,7 @@ __all__ = [
|
|||||||
"NoWorkspaceError",
|
"NoWorkspaceError",
|
||||||
"RuntimeIdentity",
|
"RuntimeIdentity",
|
||||||
"get_user_resources",
|
"get_user_resources",
|
||||||
|
"make_terminal_callback",
|
||||||
"with_terminal",
|
"with_terminal",
|
||||||
"get_terminal_for_sid",
|
"get_terminal_for_sid",
|
||||||
"get_gui_manager",
|
"get_gui_manager",
|
||||||
|
|||||||
@ -59,6 +59,14 @@ from server.context.personalization import _apply_workspace_personalization_pref
|
|||||||
from server.context.usage import get_or_create_usage_tracker
|
from server.context.usage import get_or_create_usage_tracker
|
||||||
|
|
||||||
|
|
||||||
|
def make_terminal_callback(username: str):
|
||||||
|
"""Socket.IO 移除后的兼容空操作:实时推送已全量走任务事件流/REST 轮询,
|
||||||
|
回调恒为 None(WebTerminal 与 emit_workflow_progress 均判空安全)。
|
||||||
|
保留本符号仅为维持历史 import 路径不炸,新代码不应依赖它产生用户可见效果。
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _make_terminal_key(
|
def _make_terminal_key(
|
||||||
username: str,
|
username: str,
|
||||||
|
|||||||
199
server/headless_app.py
Normal file
199
server/headless_app.py
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
"""Astrion headless 后端入口(Gateway + Runtime,无 Web 站点职责)。
|
||||||
|
|
||||||
|
与 `server.app`(full 形态)是**同一后端进程的两种启动方式**:同一 RuntimeService
|
||||||
|
单例、同一数据目录、同一端口(默认 8091)。区别仅在启动时挂载的路由面——
|
||||||
|
headless 只挂 CLI / 桌面 / API 客户端依赖的 API 蓝图:
|
||||||
|
|
||||||
|
gateway_bp : /api/runtime/sessions(CLI 主通道:创建会话 / 拉历史)
|
||||||
|
tasks : run.* 任务域(发消息 / 事件轮询 / 取消)
|
||||||
|
status_bp : /api/health 探活、/api/status、host_workspace 工作区管理
|
||||||
|
approval_bp : 工具 / 计划 / 提问三组审批(Runtime 人机交互契约)
|
||||||
|
usage_bp : /api/usage 配额查询
|
||||||
|
|
||||||
|
被砍的「web 站点职责」(静态页面、web 登录、旧版通道等)在本形态下访问返回 404;
|
||||||
|
GET / 返回形态告知页(避免浏览器打开时误判为服务故障)。
|
||||||
|
|
||||||
|
注意:本入口通过 import server.app_legacy 复用 initialize_system / start_background_jobs,
|
||||||
|
因此 import 链与 full 形态一致(app_legacy 模块级创建的 app 对象不会被 serve)。
|
||||||
|
headless 精简的是**路由面**(攻击面收敛 + CLI 依赖契约显式化),不是 import 链。
|
||||||
|
|
||||||
|
启动:python -m server.headless_app [--path PATH] [--port 8091] [--thinking-mode] [--debug]
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
DEFAULT_PROJECT_PATH,
|
||||||
|
MAX_UPLOAD_SIZE,
|
||||||
|
OUTPUT_FORMATS,
|
||||||
|
WEB_SERVER_HOST,
|
||||||
|
WEB_SERVER_PORT,
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_PORT = WEB_SERVER_PORT
|
||||||
|
|
||||||
|
# 复用 full 形态的初始化与后台任务(模块级 app 对象仅被创建,不会被 serve)
|
||||||
|
from server.app_legacy import initialize_system, start_background_jobs # noqa: F401
|
||||||
|
from server.gateway_api import gateway_bp
|
||||||
|
from server.tasks.web import get_tasks_blueprint
|
||||||
|
from server.status import status_bp
|
||||||
|
from server.chat.approval import approval_bp
|
||||||
|
from server.usage import usage_bp
|
||||||
|
from server.security import attach_security_hooks
|
||||||
|
|
||||||
|
HEADLESS_BLUEPRINTS = (
|
||||||
|
("gateway", gateway_bp),
|
||||||
|
("tasks", get_tasks_blueprint()),
|
||||||
|
("status", status_bp),
|
||||||
|
("approval", approval_bp),
|
||||||
|
("usage", usage_bp),
|
||||||
|
)
|
||||||
|
|
||||||
|
_HEADLESS_LANDING_HTML = """<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Astrion · API 后端实例</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #faf9f5;
|
||||||
|
color: #1f1f1f;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||||
|
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||||
|
}
|
||||||
|
.card { max-width: 560px; padding: 40px 44px; }
|
||||||
|
.brand { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
|
||||||
|
.brand-name { font-size: 22px; font-weight: 650; letter-spacing: 0.2px; }
|
||||||
|
.badge {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(204, 120, 92, 0.14);
|
||||||
|
color: #b05c3f;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
h1 { font-size: 17px; font-weight: 600; margin-bottom: 10px; }
|
||||||
|
p { font-size: 14px; line-height: 1.75; color: #55534e; margin-bottom: 10px; }
|
||||||
|
code {
|
||||||
|
font-family: "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
background: #efe9de;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.cmd {
|
||||||
|
display: block;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #f5f0e8;
|
||||||
|
border: 1px solid #e8e0d2;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #3d3929;
|
||||||
|
}
|
||||||
|
.muted { font-size: 12px; color: #8a8578; margin-top: 18px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="brand-name">Astrion</span>
|
||||||
|
<span class="badge">HEADLESS</span>
|
||||||
|
</div>
|
||||||
|
<h1>这是 Astrion 的 API 后端实例,运行正常。</h1>
|
||||||
|
<p>
|
||||||
|
本实例以 <strong>headless 形态</strong>启动:只提供 Gateway / Runtime API
|
||||||
|
(供 CLI、桌面端、Android 等客户端使用),不包含 Web 界面。
|
||||||
|
</p>
|
||||||
|
<p>如需 Web 界面,请改用 full 形态启动:</p>
|
||||||
|
<span class="cmd">python -m server.app</span>
|
||||||
|
<p class="muted">Astrion headless backend · same runtime, API-only surface</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def create_headless_app() -> Flask:
|
||||||
|
"""创建 headless 形态的 Flask app(最小 API 路由面)。"""
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config['MAX_CONTENT_LENGTH'] = MAX_UPLOAD_SIZE
|
||||||
|
# Flask session 仍被 Bearer 通道的身份注入使用(请求内 session 对象),
|
||||||
|
# 必须配置 SECRET_KEY;解析规则与 full 形态一致。
|
||||||
|
_secret_key = os.environ.get("WEB_SECRET_KEY") or os.environ.get("SECRET_KEY")
|
||||||
|
if not _secret_key:
|
||||||
|
_secret_key = secrets.token_hex(32)
|
||||||
|
print(f"{OUTPUT_FORMATS['warning']} WEB_SECRET_KEY 未设置,已生成临时密钥(重启后所有会话将失效)。")
|
||||||
|
app.config['SECRET_KEY'] = _secret_key
|
||||||
|
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=12)
|
||||||
|
app.config['SESSION_COOKIE_SAMESITE'] = os.environ.get("WEB_COOKIE_SAMESITE", "Strict")
|
||||||
|
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
||||||
|
_cookie_secure_env = (os.environ.get("WEB_COOKIE_SECURE") or "").strip().lower()
|
||||||
|
app.config['SESSION_COOKIE_SECURE'] = _cookie_secure_env in {"1", "true", "yes"}
|
||||||
|
|
||||||
|
attach_security_hooks(app)
|
||||||
|
|
||||||
|
for _name, bp in HEADLESS_BLUEPRINTS:
|
||||||
|
app.register_blueprint(bp)
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def headless_landing():
|
||||||
|
return _HEADLESS_LANDING_HTML
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def run_headless(path: str, thinking_mode: bool = False, port: int = DEFAULT_PORT, debug: bool = False):
|
||||||
|
"""初始化并以 headless 形态运行(与 full 形态共享初始化与后台任务)。"""
|
||||||
|
app = create_headless_app()
|
||||||
|
# 与 full 形态一致:同机多实例时 Cookie 按端口隔离,避免互相覆盖
|
||||||
|
app.config['SESSION_COOKIE_NAME'] = os.environ.get(
|
||||||
|
"WEB_SESSION_COOKIE_NAME", f"agents_session_{port}"
|
||||||
|
)
|
||||||
|
initialize_system(path, thinking_mode)
|
||||||
|
start_background_jobs()
|
||||||
|
app.run(
|
||||||
|
host=WEB_SERVER_HOST,
|
||||||
|
port=port,
|
||||||
|
debug=debug,
|
||||||
|
use_reloader=debug,
|
||||||
|
threaded=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_arguments():
|
||||||
|
parser = argparse.ArgumentParser(description="Astrion headless backend (Gateway + Runtime, API-only)")
|
||||||
|
parser.add_argument(
|
||||||
|
"--path",
|
||||||
|
default=str(Path(DEFAULT_PROJECT_PATH).resolve()),
|
||||||
|
help="默认工作区路径(仅作兜底,工作区可在客户端中管理)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="监听端口")
|
||||||
|
parser.add_argument("--thinking-mode", action="store_true", help="启用思考模式")
|
||||||
|
parser.add_argument("--debug", action="store_true", help="调试模式")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_arguments()
|
||||||
|
print(f"{OUTPUT_FORMATS['info']} 以 headless 形态启动(仅 API 路由面,无 Web 界面)...")
|
||||||
|
run_headless(path=args.path, thinking_mode=args.thinking_mode, port=args.port, debug=args.debug)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Reference in New Issue
Block a user