diff --git a/backend/app.py b/backend/app.py index f64c6757..9bf5a290 100644 --- a/backend/app.py +++ b/backend/app.py @@ -67,6 +67,19 @@ _last_home_rotate_at = 0 ASSET_DEFAULTS_FILE = os.path.join(ROOT_DIR, "asset-defaults.json") RUNTIME_CONFIG_FILE = os.path.join(ROOT_DIR, "runtime-config.json") +AUTO_DETECT_CONSENT_VERSION = 1 +AUTO_DETECT_MAX_AGENTS = int(os.getenv("STAR_OFFICE_AUTO_DETECT_MAX_AGENTS", "8")) +AUTO_DETECT_SOURCE = "local-auto-detect" +STAR_OFFICE_LOG_DIR = os.getenv("STAR_OFFICE_LOG_DIR", "/tmp/star-office-ui") +AUTO_DETECT_DAEMON_PID_FILE = os.path.join(STAR_OFFICE_LOG_DIR, "auto-detect.pid") +AUTO_DETECT_DAEMON_LOG_FILE = os.path.join(STAR_OFFICE_LOG_DIR, "auto-detect.log") +AUTO_DETECT_DAEMON_SCRIPT = os.path.join(ROOT_DIR, "scripts", "auto_detect", "daemon.py") +AUTO_DETECT_JOIN_KEY = os.getenv("STAR_OFFICE_JOIN_KEY", "ocj_example_team_01") +AUTO_DETECT_PYTHON = ( + os.path.join(ROOT_DIR, ".venv", "bin", "python") + if os.path.exists(os.path.join(ROOT_DIR, ".venv", "bin", "python")) + else sys.executable +) # Canonical agent states: single source of truth for validation and mapping VALID_AGENT_STATES = frozenset({"idle", "writing", "researching", "executing", "syncing", "error"}) @@ -354,6 +367,125 @@ def save_runtime_config(data): _store_save_runtime_config(RUNTIME_CONFIG_FILE, data) +def is_auto_detect_enabled() -> bool: + cfg = load_runtime_config() + return bool(cfg.get("auto_detect_enabled")) + + +def _normalize_agent_source(value: str) -> str: + raw = (value or "").strip().lower() + if raw in {AUTO_DETECT_SOURCE, "auto-detect", "local_auto_detect"}: + return AUTO_DETECT_SOURCE + if raw: + return raw + return "" + + +def is_auto_detect_agent(agent: dict) -> bool: + if not isinstance(agent, dict) or agent.get("isMain"): + return False + source = (agent.get("source") or "").strip().lower() + return source == AUTO_DETECT_SOURCE + + +def strip_auto_detect_agents(agents: list[dict]) -> tuple[list[dict], list[dict]]: + kept = [] + removed = [] + for agent in agents: + if is_auto_detect_agent(agent): + removed.append(agent) + continue + kept.append(agent) + return kept, removed + + +def _pid_is_running(pid: int | None) -> bool: + if not pid: + return False + try: + os.kill(int(pid), 0) + return True + except Exception: + return False + + +def _read_pid_file(path: str) -> int | None: + try: + with open(path, "r", encoding="utf-8") as f: + return int((f.read() or "").strip()) + except Exception: + return None + + +def _get_backend_port() -> int: + try: + port = int(os.environ.get("STAR_BACKEND_PORT", "19000")) + except Exception: + port = 19000 + return port if port > 0 else 19000 + + +def ensure_auto_detect_daemon_running() -> tuple[bool, str]: + """Start the local auto-detect daemon if it is not already running.""" + if not os.path.exists(AUTO_DETECT_DAEMON_SCRIPT): + return False, "auto-detect daemon script not found" + + os.makedirs(STAR_OFFICE_LOG_DIR, exist_ok=True) + + existing_pid = _read_pid_file(AUTO_DETECT_DAEMON_PID_FILE) + if _pid_is_running(existing_pid): + return True, "already-running" + + if existing_pid: + try: + os.remove(AUTO_DETECT_DAEMON_PID_FILE) + except Exception: + pass + + env = os.environ.copy() + env["STAR_OFFICE_URL"] = f"http://127.0.0.1:{_get_backend_port()}" + env["STAR_OFFICE_JOIN_KEY"] = AUTO_DETECT_JOIN_KEY + + try: + with open(AUTO_DETECT_DAEMON_LOG_FILE, "a", encoding="utf-8") as log_fp: + proc = subprocess.Popen( + [AUTO_DETECT_PYTHON, AUTO_DETECT_DAEMON_SCRIPT], + cwd=ROOT_DIR, + env=env, + stdin=subprocess.DEVNULL, + stdout=log_fp, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + with open(AUTO_DETECT_DAEMON_PID_FILE, "w", encoding="utf-8") as f: + f.write(str(proc.pid)) + return True, f"started:{proc.pid}" + except Exception as e: + return False, str(e) + + +def get_auto_detect_daemon_state(enabled: bool) -> tuple[bool, bool, str]: + """Return (daemon_alive, daemon_running, daemon_status). + + daemon_running = process alive (factual, never lies). + daemon_status = functional state: + - disabled: not enabled, daemon not running + - waiting-consent: not enabled, but daemon alive in background + - running: enabled and daemon alive (actively syncing) + - stopped: enabled but daemon not running + """ + daemon_alive = _pid_is_running(_read_pid_file(AUTO_DETECT_DAEMON_PID_FILE)) + if enabled and daemon_alive: + status = "running" + elif enabled and not daemon_alive: + status = "stopped" + elif not enabled and daemon_alive: + status = "waiting-consent" + else: + status = "disabled" + return daemon_alive, daemon_alive, status + + def _ensure_home_favorites_index(): os.makedirs(HOME_FAVORITES_DIR, exist_ok=True) if not os.path.exists(HOME_FAVORITES_INDEX_FILE): @@ -834,11 +966,20 @@ def state_to_area(state): except Exception: pass +# Best-effort daemon recovery on backend start without relying on config GET side effects. +try: + if is_auto_detect_enabled(): + ensure_auto_detect_daemon_running() +except Exception: + pass + @app.route("/agents", methods=["GET"]) def get_agents(): """Get full agents list (for multi-agent UI), with auto-cleanup on access""" agents = load_agents_state() + if not is_auto_detect_enabled(): + agents, _removed = strip_auto_detect_agents(agents) now = datetime.now() cleaned_agents = [] @@ -962,10 +1103,20 @@ def join_agent(): state = data.get("state", "idle") detail = data.get("detail", "") join_key = data.get("joinKey", "").strip() + agent_source = _normalize_agent_source(data.get("source") or "") # Normalize state early for compatibility state = normalize_agent_state(state) + if agent_source == AUTO_DETECT_SOURCE and not is_auto_detect_enabled(): + return jsonify({"ok": False, "msg": "本机状态自动检测默认关闭,需用户先在界面中明确启用"}), 403 + + if agent_source == AUTO_DETECT_SOURCE: + current_agents = load_agents_state() + auto_count = sum(1 for a in current_agents if is_auto_detect_agent(a) and a.get("name") != name) + if auto_count >= AUTO_DETECT_MAX_AGENTS: + return jsonify({"ok": False, "msg": f"本机自动检测 agent 已达上限 ({AUTO_DETECT_MAX_AGENTS})"}), 429 + if not join_key: return jsonify({"ok": False, "msg": "请提供接入密钥"}), 400 @@ -1047,7 +1198,7 @@ def _age_seconds(dt_str): existing["detail"] = detail existing["updated_at"] = datetime.now().isoformat() existing["area"] = state_to_area(state) - existing["source"] = "remote-openclaw" + existing["source"] = agent_source or existing.get("source") or "remote-openclaw" existing["joinKey"] = join_key existing["authStatus"] = "approved" existing["authApprovedAt"] = datetime.now().isoformat() @@ -1070,7 +1221,7 @@ def _age_seconds(dt_str): "detail": detail, "updated_at": datetime.now().isoformat(), "area": state_to_area(state), - "source": "remote-openclaw", + "source": agent_source or "remote-openclaw", "joinKey": join_key, "authStatus": "approved", "authApprovedAt": datetime.now().isoformat(), @@ -1175,6 +1326,7 @@ def agent_push(): state = (data.get("state") or "").strip() detail = (data.get("detail") or "").strip() name = (data.get("name") or "").strip() + agent_source = _normalize_agent_source(data.get("source") or "") if not agent_id or not join_key or not state: return jsonify({"ok": False, "msg": "缺少 agentId/joinKey/state"}), 400 @@ -1202,6 +1354,9 @@ def agent_push(): if not target: return jsonify({"ok": False, "msg": "agent 未注册,请先 join"}), 404 + if (agent_source == AUTO_DETECT_SOURCE or is_auto_detect_agent(target)) and not is_auto_detect_enabled(): + return jsonify({"ok": False, "msg": "本机状态自动检测默认关闭,需用户先在界面中明确启用"}), 403 + # Auth check: only approved agents can push. # Note: "offline" is a presence state (stale), not a revoked authorization. # Allow offline agents to resume pushing and auto-promote them back to approved. @@ -1222,7 +1377,7 @@ def agent_push(): target["name"] = name target["updated_at"] = datetime.now().isoformat() target["area"] = state_to_area(state) - target["source"] = "remote-openclaw" + target["source"] = agent_source or target.get("source") or "remote-openclaw" target["lastPushAt"] = datetime.now().isoformat() save_agents_state(agents) @@ -1833,6 +1988,83 @@ def gemini_config_get(): return jsonify({"ok": False, "msg": str(e)}), 500 +@app.route("/config/auto-detect", methods=["GET"]) +def auto_detect_config_get(): + try: + cfg = load_runtime_config() + enabled = bool(cfg.get("auto_detect_enabled")) + _daemon_alive, daemon_running, daemon_status = get_auto_detect_daemon_state(enabled) + agents = load_agents_state() + auto_count = sum(1 for a in agents if is_auto_detect_agent(a)) + return jsonify({ + "ok": True, + "enabled": enabled, + "granted_at": cfg.get("auto_detect_consent_granted_at"), + "consent_version": int(cfg.get("auto_detect_consent_version") or AUTO_DETECT_CONSENT_VERSION), + "daemon_running": daemon_running, + "daemon_status": daemon_status, + "agent_count": auto_count, + "agent_max": AUTO_DETECT_MAX_AGENTS, + }) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/config/auto-detect", methods=["POST"]) +def auto_detect_config_set(): + guard = _require_asset_editor_auth() + if guard: + return guard + try: + data = request.get_json(silent=True) or {} + raw_enabled = data.get("enabled") + if raw_enabled is None: + return jsonify({"ok": False, "msg": "缺少 enabled"}), 400 + + if isinstance(raw_enabled, bool): + enabled = raw_enabled + elif isinstance(raw_enabled, (int, float)): + enabled = raw_enabled != 0 + else: + enabled = str(raw_enabled).strip().lower() in {"1", "true", "yes", "on"} + + if enabled and not bool(data.get("confirm")): + return jsonify({"ok": False, "msg": "启用前需要用户明确确认授权"}), 400 + + payload = { + "auto_detect_enabled": enabled, + "auto_detect_consent_version": AUTO_DETECT_CONSENT_VERSION, + "auto_detect_consent_granted_at": datetime.now().isoformat() if enabled else None, + } + save_runtime_config(payload) + if enabled: + ensure_auto_detect_daemon_running() + cleared_agents = 0 + if not enabled: + agents = load_agents_state() + cleaned_agents, removed_agents = strip_auto_detect_agents(agents) + if len(cleaned_agents) != len(agents): + save_agents_state(cleaned_agents) + cleared_agents = len(removed_agents) + agents = load_agents_state() + auto_count = sum(1 for a in agents if is_auto_detect_agent(a)) + _daemon_alive, daemon_running, daemon_status = get_auto_detect_daemon_state(enabled) + return jsonify({ + "ok": True, + "enabled": enabled, + "granted_at": payload["auto_detect_consent_granted_at"], + "consent_version": AUTO_DETECT_CONSENT_VERSION, + "daemon_running": daemon_running, + "daemon_status": daemon_status, + "agent_count": auto_count, + "agent_max": AUTO_DETECT_MAX_AGENTS, + "cleared_agents": cleared_agents, + "msg": "Auto-detect 已启用" if enabled else "Auto-detect 已关闭", + }) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + @app.route("/config/gemini", methods=["POST"]) def gemini_config_set(): guard = _require_asset_editor_auth() @@ -2100,4 +2332,3 @@ def assets_upload(): print("=" * 50) app.run(host="0.0.0.0", port=backend_port, debug=False) - diff --git a/backend/store_utils.py b/backend/store_utils.py index 2a0e3405..9c9e9fd9 100644 --- a/backend/store_utils.py +++ b/backend/store_utils.py @@ -10,6 +10,16 @@ import os +def _normalize_bool(value, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return value != 0 + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return default + + def _load_json(path: str): """Load JSON from a file; caller handles missing file or parse errors.""" with open(path, "r", encoding="utf-8") as f: @@ -86,17 +96,31 @@ def _normalize_user_model(model_name: str) -> str: def load_runtime_config(path: str) -> dict: - """Load runtime config (gemini_api_key, gemini_model) from env and optional JSON file.""" + """Load runtime config from env and optional JSON file.""" base = { "gemini_api_key": os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY") or "", "gemini_model": _normalize_user_model(os.getenv("GEMINI_MODEL") or "nanobanana-pro"), + "auto_detect_enabled": _normalize_bool(os.getenv("STAR_OFFICE_AUTO_DETECT_ENABLED"), False), + "auto_detect_consent_granted_at": None, + "auto_detect_consent_version": 1, } if os.path.exists(path): try: data = _load_json(path) if isinstance(data, dict): - base.update({k: data.get(k, base.get(k)) for k in ["gemini_api_key", "gemini_model"]}) + base.update({ + k: data.get(k, base.get(k)) + for k in [ + "gemini_api_key", + "gemini_model", + "auto_detect_enabled", + "auto_detect_consent_granted_at", + "auto_detect_consent_version", + ] + }) base["gemini_model"] = _normalize_user_model(base.get("gemini_model") or "nanobanana-pro") + base["auto_detect_enabled"] = _normalize_bool(base.get("auto_detect_enabled"), False) + base["auto_detect_consent_version"] = int(base.get("auto_detect_consent_version") or 1) except Exception: pass return base diff --git a/frontend/btn-auto-detect-sprite.png b/frontend/btn-auto-detect-sprite.png new file mode 100644 index 00000000..889865b6 Binary files /dev/null and b/frontend/btn-auto-detect-sprite.png differ diff --git a/frontend/electron-standalone.html b/frontend/electron-standalone.html index a07cd341..3ca6583b 100644 --- a/frontend/electron-standalone.html +++ b/frontend/electron-standalone.html @@ -471,6 +471,67 @@ width: 0%; transition: width 0.3s ease; } + #auto-detect-disable-popup-title:empty { + display: none; + } + + #auto-detect-disable-popup { + position: fixed; + z-index: 100020; + width: 240px; + border: 4px solid #0e1119; + background: #141722; + font-family: 'ArkPixel', monospace; + padding: 14px; + color: #e5e7eb; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + display: grid; + gap: 10px; + image-rendering: pixelated; + } + #auto-detect-disable-popup[hidden] { + display: none !important; + } + #auto-detect-disable-popup-title { + color: #ffd700; + font-family: 'ArkPixel', monospace; + font-size: 13px; + font-weight: bold; + letter-spacing: 2px; + } + #auto-detect-disable-popup-body { + color: #9ca3af; + font-family: 'ArkPixel', monospace; + font-size: 11px; + line-height: 1.5; + } + #auto-detect-disable-popup-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; + } + #btn-disable-confirm { + min-height: 30px; + background: #5a1818; + border: 2px solid #e94560; + color: #fecaca; + font-family: 'ArkPixel', monospace; + font-size: 11px; + } + #btn-disable-confirm:hover { + background: #6a2828; + } + #btn-disable-cancel { + min-height: 30px; + background: #3a3f4f; + border: 2px solid #555; + color: #9ca3af; + font-family: 'ArkPixel', monospace; + font-size: 11px; + } + #btn-disable-cancel:hover { + color: #e5e7eb; + } #status-text { position: absolute; bottom: 12px; @@ -588,15 +649,81 @@ left bottom, left bottom, right bottom, right bottom; } + #control-bar-header { + position: relative; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 6px 10px 10px; + } #control-bar-title { color: #ffd700; font-size: 16px; font-weight: bold; text-align: center; letter-spacing: 1px; - padding: 6px 0 10px; + padding: 0; border-bottom: 0; } + /* 本机检测入口:像素精灵按钮,钢蓝色调示意"可选";亮=on / 暗=off */ + #control-bar #btn-auto-detect-entry { + position: absolute; + right: 8px; + top: 6px; + width: 76px; + height: 42px; + min-height: auto !important; + background-image: url('/static/btn-auto-detect-sprite.png') !important; + background-color: transparent !important; + background-repeat: no-repeat !important; + background-size: 300% 100% !important; + background-position: 0 0 !important; + border: none !important; + border-radius: 0 !important; + box-shadow: none !important; + appearance: none; + -webkit-appearance: none; + image-rendering: pixelated; + color: #c8d8e8; + font-family: 'ArkPixel', monospace; + font-size: 10px; + font-weight: 400; + line-height: 1; + letter-spacing: 0.3px; + text-shadow: 0 1px 0 rgba(30, 50, 80, 0.5); + padding: 0 6px 7px !important; + cursor: pointer; + transition: padding-top .08s ease, padding-bottom .08s ease, filter .12s ease; + } + /* off = 暗淡但可读 */ + #control-bar #btn-auto-detect-entry.is-off { + filter: brightness(0.75) saturate(0.5); + color: #3a4a5a; + text-shadow: 0 1px 0 rgba(150, 170, 200, 0.3); + } + #control-bar #btn-auto-detect-entry:hover { + background-color: transparent !important; + border: none !important; + filter: brightness(1.1); + } + #control-bar #btn-auto-detect-entry:active { + background-position: 50% 0 !important; + padding-top: 4px !important; + padding-bottom: 0 !important; + filter: brightness(0.92); + } + /* on = 亮绿 */ + #control-bar #btn-auto-detect-entry.is-enabled { + filter: brightness(1.15) saturate(1.2); + color: #b8f060; + text-shadow: 0 1px 0 rgba(40, 80, 10, 0.6); + } + #control-bar #btn-auto-detect-entry.is-syncing { + filter: brightness(1.0); + color: #f0d860; + text-shadow: 0 1px 0 rgba(80, 60, 10, 0.5); + } #control-buttons { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -606,6 +733,7 @@ padding-left: 10px; padding-right: 10px; box-sizing: border-box; + flex: 0 0 auto; } #btn-open-drawer { grid-column: 1 / -1; @@ -1029,10 +1157,11 @@ appearance: none; -webkit-appearance: none; image-rendering: pixelated; - color: #5e6366 !important; - font-weight: 400 !important; - font-size: 15px !important; - text-shadow: none !important; + color: #4a2a08 !important; + font-weight: 700 !important; + font-size: 16px !important; + letter-spacing: .4px; + text-shadow: 0 1px 0 rgba(255, 244, 214, 0.5) !important; padding: 0 10px 10px !important; line-height: 1 !important; transition: padding-top .08s ease, padding-bottom .08s ease, filter .12s ease; @@ -1426,7 +1555,7 @@ border-color: #faf4cf; background: rgba(237, 214, 144, 0.96); } - .panel-toggle-title { + body.desktop-shell .panel-toggle-title { cursor: pointer; user-select: none; } @@ -1508,6 +1637,16 @@ #memo-title { font-size: 14px; } + #control-bar-header { + gap: 6px; + padding-bottom: 8px; + } + #btn-auto-detect-entry { + right: 8px; + top: 3px; + font-size: 9px; + padding: 3px 7px; + } #control-buttons button, #control-bar button, @@ -1537,7 +1676,7 @@ gap: 8px; } - #status-text { + #status-text { bottom: 8px; left: 8px; max-width: 64vw; @@ -1606,7 +1745,15 @@
- + +