Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
239 changes: 235 additions & 4 deletions backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand All @@ -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(),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -2100,4 +2332,3 @@ def assets_upload():
print("=" * 50)

app.run(host="0.0.0.0", port=backend_port, debug=False)

28 changes: 26 additions & 2 deletions backend/store_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
Binary file added frontend/btn-auto-detect-sprite.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading