Add hub AI config tab with sync to instances and deploy secret bootstrap.
Wire bootstrap_deploy_secrets into setup_env.sh (one-time HUB_BRIDGE_TOKEN, FLASK_SECRET_KEY, HUB_SESSION_SECRET). Remove AI section from instance env UI; hub saves OPENAI settings to all four .env files. SSO unchanged. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,7 +11,7 @@ HUB_PORT=5100
|
||||
|
||||
# 与三实例 .env 中 HUB_BRIDGE_TOKEN 相同的长随机串
|
||||
# 中控 → 各 Flask:请求头 X-Hub-Token
|
||||
# 中控 → 各子代理:请求头 X-Control-Token(可与子代理 CONTROL_TOKEN 同值,hub 会用 HUB_BRIDGE_TOKEN 转发)
|
||||
# 中控 → 各子代理:请求头 X-Control-Token(与 HUB_BRIDGE_TOKEN 同值;agent 优先读 HUB_BRIDGE_TOKEN)
|
||||
# 中控「打开实例」SSO 链接也复用此令牌签名(默认 2 小时内有效、单次使用)
|
||||
# HUB_BRIDGE_TOKEN=your-long-random-token
|
||||
# HUB_SSO_TTL_SEC=7200
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
EXCHANGE binance(默认)| okx | gate
|
||||
PORT 默认 15200(与 crypto_monitor_* 的 Flask APP_PORT 错开;中控默认聚合 15200–15202)
|
||||
HOST 默认 127.0.0.1
|
||||
CONTROL_TOKEN 可选;请求头 X-Control-Token
|
||||
HUB_BRIDGE_TOKEN 与中控一致;请求头 X-Control-Token(优先于已废弃的 CONTROL_TOKEN)
|
||||
|
||||
Binance:BINANCE_API_KEY / BINANCE_API_SECRET;余额为 **U 本位永续合约账户** USDT(与 `crypto_monitor_binance` 的合约口径一致,非现货钱包);BINANCE_POSITION_MODE;BINANCE_MARGIN_MODE
|
||||
OKX:OKX_API_KEY / OKX_API_SECRET / OKX_API_PASSPHRASE;OKX_TD_MODE;OKX_POS_MODE
|
||||
@@ -54,7 +54,7 @@ from exchange_orders import (
|
||||
|
||||
HOST = os.getenv("HOST", "127.0.0.1")
|
||||
PORT = int(os.getenv("PORT", "15200"))
|
||||
CONTROL_TOKEN = (os.getenv("CONTROL_TOKEN") or "").strip()
|
||||
CONTROL_TOKEN = (os.getenv("HUB_BRIDGE_TOKEN") or os.getenv("CONTROL_TOKEN") or "").strip()
|
||||
|
||||
_raw_ex = (os.getenv("EXCHANGE") or "binance").strip().lower()
|
||||
if _raw_ex in ("binance", "bnb", "ba"):
|
||||
|
||||
@@ -1692,6 +1692,45 @@ def api_admin_restart():
|
||||
return result
|
||||
|
||||
|
||||
class HubAiEnvBody(BaseModel):
|
||||
values: dict[str, str] = Field(default_factory=dict)
|
||||
restart: bool = True
|
||||
|
||||
|
||||
@app.get("/api/settings/ai-env")
|
||||
def api_get_ai_env(request: Request):
|
||||
_require_hub_logged_in(request)
|
||||
from hub_env_lib import get_hub_ai_env_payload
|
||||
|
||||
return {"ok": True, **get_hub_ai_env_payload()}
|
||||
|
||||
|
||||
@app.post("/api/settings/ai-env")
|
||||
def api_save_ai_env(request: Request, body: HubAiEnvBody):
|
||||
_require_hub_logged_in(request)
|
||||
from hub_env_lib import get_hub_ai_env_payload, restart_all_pm2, save_hub_ai_env
|
||||
|
||||
result = save_hub_ai_env(body.values or {})
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(status_code=400, detail="; ".join(result.get("errors") or ["保存失败"]))
|
||||
restart_result = None
|
||||
if body.restart and result.get("restart_required"):
|
||||
restart_result = restart_all_pm2()
|
||||
if not restart_result.get("ok"):
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="保存成功但 PM2 重启失败,请手动 restart",
|
||||
)
|
||||
payload = get_hub_ai_env_payload()
|
||||
return {
|
||||
"ok": True,
|
||||
"changed": result.get("changed") or {},
|
||||
"restart_required": bool(result.get("restart_required")),
|
||||
"restart": restart_result,
|
||||
"sync_status": payload.get("sync_status"),
|
||||
}
|
||||
|
||||
|
||||
async def _fetch_agent_status(client: httpx.AsyncClient, ex: dict) -> dict:
|
||||
url = f"{ex['agent_url'].rstrip('/')}/status"
|
||||
try:
|
||||
|
||||
@@ -8,6 +8,11 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from lib.env.env_file_lib import apply_env_updates, load_env_file_into_environ
|
||||
from lib.env.shared_env_lib import (
|
||||
apply_ai_env_to_all,
|
||||
build_ai_env_payload,
|
||||
restart_hub_and_instances_pm2,
|
||||
)
|
||||
|
||||
HUB_DIR = Path(__file__).resolve().parent
|
||||
|
||||
@@ -27,6 +32,18 @@ def update_hub_credentials(*, new_password: str, new_username: str | None = None
|
||||
return changed
|
||||
|
||||
|
||||
def get_hub_ai_env_payload() -> dict[str, Any]:
|
||||
return build_ai_env_payload(hub_env_path())
|
||||
|
||||
|
||||
def save_hub_ai_env(updates: dict[str, str]) -> dict[str, Any]:
|
||||
return apply_ai_env_to_all(updates)
|
||||
|
||||
|
||||
def restart_all_pm2() -> dict[str, Any]:
|
||||
return restart_hub_and_instances_pm2()
|
||||
|
||||
|
||||
def restart_hub_pm2() -> dict[str, Any]:
|
||||
app_name = (os.getenv("PM2_APP_NAME") or "").strip() or "manual-trading-hub"
|
||||
if not sys.platform.startswith("linux"):
|
||||
|
||||
@@ -3042,7 +3042,8 @@ button.btn-sm {
|
||||
#hub-sec-2:checked ~ .hub-config-tabs label[for="hub-sec-2"],
|
||||
#hub-sec-3:checked ~ .hub-config-tabs label[for="hub-sec-3"],
|
||||
#hub-sec-4:checked ~ .hub-config-tabs label[for="hub-sec-4"],
|
||||
#hub-sec-5:checked ~ .hub-config-tabs label[for="hub-sec-5"] {
|
||||
#hub-sec-5:checked ~ .hub-config-tabs label[for="hub-sec-5"],
|
||||
#hub-sec-6:checked ~ .hub-config-tabs label[for="hub-sec-6"] {
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--accent);
|
||||
font-weight: 600;
|
||||
@@ -3053,7 +3054,8 @@ button.btn-sm {
|
||||
#hub-sec-2:checked ~ .hub-config-panels .hub-panel--2,
|
||||
#hub-sec-3:checked ~ .hub-config-panels .hub-panel--3,
|
||||
#hub-sec-4:checked ~ .hub-config-panels .hub-panel--4,
|
||||
#hub-sec-5:checked ~ .hub-config-panels .hub-panel--5 {
|
||||
#hub-sec-5:checked ~ .hub-config-panels .hub-panel--5,
|
||||
#hub-sec-6:checked ~ .hub-config-panels .hub-panel--6 {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -3099,6 +3101,10 @@ button.btn-sm {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.hub-ai-env-grid .field-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.settings-status-line {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
|
||||
@@ -4347,6 +4347,111 @@
|
||||
});
|
||||
}
|
||||
|
||||
function renderHubAiSyncStatus(sync) {
|
||||
const el = document.getElementById("hub-ai-sync-status");
|
||||
if (!el) return;
|
||||
if (!sync) {
|
||||
el.textContent = "";
|
||||
return;
|
||||
}
|
||||
if (sync.all_synced) {
|
||||
el.textContent = "三所实例 AI 配置已与中控同步";
|
||||
el.classList.remove("is-err");
|
||||
return;
|
||||
}
|
||||
const parts = [];
|
||||
const inst = sync.instances || {};
|
||||
Object.keys(inst).forEach((ex) => {
|
||||
const row = inst[ex];
|
||||
if (row && !row.ok) parts.push(`${ex} 不一致`);
|
||||
});
|
||||
el.textContent = parts.length ? `未完全同步:${parts.join("、")}` : "同步状态未知";
|
||||
el.classList.add("is-err");
|
||||
}
|
||||
|
||||
function renderHubAiEnvFields(fields) {
|
||||
const box = document.getElementById("hub-ai-env-fields");
|
||||
if (!box) return;
|
||||
box.innerHTML = (fields || [])
|
||||
.map((f) => {
|
||||
const wide = f.key === "OPENAI_API_BASE" || f.key === "OLLAMA_API" ? " field-wide" : "";
|
||||
const inputType = f.sensitive ? "password" : f.type === "int" ? "number" : "text";
|
||||
const placeholder = f.sensitive && f.has_value ? f.masked || "****" : f.note || "";
|
||||
const value = f.sensitive ? "" : esc(f.current || f.default || "");
|
||||
const note = f.note ? `<span class="field-note">${esc(f.note)}</span>` : "";
|
||||
return `<div class="field${wide}">
|
||||
<label for="hub-ai-${esc(f.key)}">${esc(f.label || f.key)}${note}</label>
|
||||
<input id="hub-ai-${esc(f.key)}" class="hub-ai-field" data-ai-key="${esc(f.key)}" type="${inputType}" value="${value}" placeholder="${esc(placeholder)}" autocomplete="off" />
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function loadHubAiEnvSettings() {
|
||||
const box = document.getElementById("hub-ai-env-fields");
|
||||
if (!box) return;
|
||||
try {
|
||||
const r = await apiFetch("/api/settings/ai-env");
|
||||
const j = await r.json();
|
||||
if (!r.ok) throw new Error(j.detail || "加载失败");
|
||||
renderHubAiEnvFields(j.fields || []);
|
||||
renderHubAiSyncStatus(j.sync_status);
|
||||
} catch (e) {
|
||||
box.innerHTML = `<span class="err">${esc(String(e.message || e))}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function collectHubAiEnvValues() {
|
||||
const values = {};
|
||||
document.querySelectorAll(".hub-ai-field[data-ai-key]").forEach((el) => {
|
||||
values[el.dataset.aiKey] = el.value;
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
async function saveHubAiEnv() {
|
||||
const status = document.getElementById("hub-ai-env-save-status");
|
||||
const setStatus = (msg, err) => {
|
||||
if (!status) return;
|
||||
status.textContent = msg || "";
|
||||
status.classList.toggle("is-err", !!err);
|
||||
};
|
||||
setStatus("保存并同步中…");
|
||||
try {
|
||||
const r = await apiFetch("/api/settings/ai-env", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ values: collectHubAiEnvValues(), restart: true }),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!r.ok) throw new Error(j.detail || j.msg || "保存失败");
|
||||
if (!j.changed || !Object.keys(j.changed).length) {
|
||||
setStatus("未修改(与当前配置相同)");
|
||||
showToast("未修改");
|
||||
return;
|
||||
}
|
||||
setStatus("已同步三所,服务重启中…");
|
||||
await waitHubHealth();
|
||||
setStatus("AI 配置已保存并同步至三所实例");
|
||||
showToast("AI 配置已保存并同步");
|
||||
await loadHubAiEnvSettings();
|
||||
} catch (e) {
|
||||
setStatus(String(e.message || e), true);
|
||||
showToast(String(e.message || e), true);
|
||||
}
|
||||
}
|
||||
|
||||
function initHubAiEnvSettings() {
|
||||
const btn = document.getElementById("hub-ai-env-save-btn");
|
||||
if (btn && btn.dataset.bound !== "1") {
|
||||
btn.dataset.bound = "1";
|
||||
btn.addEventListener("click", () => {
|
||||
void saveHubAiEnv();
|
||||
});
|
||||
}
|
||||
void loadHubAiEnvSettings();
|
||||
}
|
||||
|
||||
function macroDatetimeLocalToApi(v) {
|
||||
if (!v) return "";
|
||||
return String(v).trim().replace("T", " ").slice(0, 16);
|
||||
@@ -4491,6 +4596,7 @@
|
||||
loadSettingsMetaLine();
|
||||
initMacroCalendarSettings();
|
||||
initHubPasswordSettings();
|
||||
initHubAiEnvSettings();
|
||||
loadMacroCalendarUI();
|
||||
loadSettings().then((data) => {
|
||||
syncDisplayPrefsUI(data);
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
|
||||
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
|
||||
<link rel="stylesheet" href="/assets/app.css?v=20260708-hub-settings-tabs" />
|
||||
<link rel="stylesheet" href="/assets/app.css?v=20260708-hub-ai-env" />
|
||||
<link rel="stylesheet" href="/assets/trade_stats_calendar.css?v=3" />
|
||||
<link rel="stylesheet" href="/assets/account_risk_badge.css?v=4" />
|
||||
<script src="/assets/account_risk_badge.js?v=4"></script>
|
||||
@@ -904,6 +904,7 @@
|
||||
<input type="radio" name="hub-settings-section" id="hub-sec-3" class="hub-tab-radio">
|
||||
<input type="radio" name="hub-settings-section" id="hub-sec-4" class="hub-tab-radio">
|
||||
<input type="radio" name="hub-settings-section" id="hub-sec-5" class="hub-tab-radio">
|
||||
<input type="radio" name="hub-settings-section" id="hub-sec-6" class="hub-tab-radio">
|
||||
<div class="hub-config-tabs" role="tablist" aria-label="系统设置分类">
|
||||
<label for="hub-sec-0" class="hub-tab-btn" role="tab">账户密码</label>
|
||||
<label for="hub-sec-1" class="hub-tab-btn" role="tab">显示与导航</label>
|
||||
@@ -911,6 +912,7 @@
|
||||
<label for="hub-sec-3" class="hub-tab-btn" role="tab">交易监管</label>
|
||||
<label for="hub-sec-4" class="hub-tab-btn" role="tab">备份恢复</label>
|
||||
<label for="hub-sec-5" class="hub-tab-btn" role="tab">交易所账户</label>
|
||||
<label for="hub-sec-6" class="hub-tab-btn" role="tab">AI 配置</label>
|
||||
</div>
|
||||
<div class="hub-config-panels">
|
||||
<section class="hub-panel hub-panel--0 hub-settings-tab-panel" role="tabpanel">
|
||||
@@ -1110,6 +1112,22 @@
|
||||
</div>
|
||||
<div id="settings-list" class="settings-grid-wrap"></div>
|
||||
</section>
|
||||
|
||||
<section class="hub-panel hub-panel--6 hub-settings-tab-panel settings-ai-env-panel" data-settings-section="ai-env" role="tabpanel">
|
||||
<div class="hub-settings-tab-head">
|
||||
<h3 class="hub-settings-tab-title">AI 复盘(统一配置)</h3>
|
||||
<button type="button" class="primary" id="hub-ai-env-save-btn">保存并同步</button>
|
||||
</div>
|
||||
<p class="settings-display-hint">
|
||||
保存后写入 <code>manual_trading_hub/.env</code>,并<strong>强制同步</strong>至三所实例 <code>.env</code>(OKX / Binance / Gate)。
|
||||
实例 env 配置页已不再提供 AI 项,请仅在此修改。密钥留空表示不修改原值。
|
||||
</p>
|
||||
<p id="hub-ai-sync-status" class="settings-meta-line"></p>
|
||||
<div id="hub-ai-env-fields" class="settings-grid hub-ai-env-grid"></div>
|
||||
<div class="hub-settings-tab-actions">
|
||||
<span id="hub-ai-env-save-status" class="settings-status-line"></span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user