HuggingClaw / frontend /office-agent-push.py
tao-shen's picture
Upload folder using huggingface_hub
552d1f9 verified
Raw
History Blame Contribute Delete
10.9 kB
#!/usr/bin/env python3
"""
海辛办公室 - Agent 状态主动推送脚本
用法:
1. 填入下面的 JOIN_KEY(你从海辛那里拿到的一次性 join key)
2. 填入 AGENT_NAME(你想要在办公室里显示的名字)
3. 运行:python office-agent-push.py
4. 脚本会自动先 join(首次运行),然后每 30s 向海辛办公室推送一次你的当前状态
"""
import json
import os
import time
import sys
from datetime import datetime
# === 你需要填入的信息 ===
JOIN_KEY = "" # 必填:你的一次性 join key
AGENT_NAME = "" # 必填:你在办公室里的名字
OFFICE_URL = "https://office.hyacinth.im" # 海辛办公室地址(一般不用改)
# === 推送配置 ===
PUSH_INTERVAL_SECONDS = 15 # 每隔多少秒推送一次(更实时)
STATUS_ENDPOINT = "/status"
JOIN_ENDPOINT = "/join-agent"
PUSH_ENDPOINT = "/agent-push"
# 自动状态守护:当本地状态文件不存在或长期不更新时,自动回 idle,避免“假工作中”
STALE_STATE_TTL_SECONDS = int(os.environ.get("OFFICE_STALE_STATE_TTL", "600"))
# 本地状态存储(记住上次 join 拿到的 agentId)
STATE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "office-agent-state.json")
# 优先读取本机 OpenClaw 工作区的状态文件(更贴合 AGENTS.md 的工作流)
# 支持自动发现,减少对方手动配置成本。
DEFAULT_STATE_CANDIDATES = [
"/root/.openclaw/workspace/Star-Office-UI/state.json", # 当前仓库(大小写精确)
"/root/.openclaw/workspace/star-office-ui/state.json", # 历史/兼容路径
"/root/.openclaw/workspace/state.json",
os.path.join(os.getcwd(), "state.json"),
os.path.join(os.path.dirname(os.path.abspath(__file__)), "state.json"),
]
# 如果对方本地 /status 需要鉴权,可在这里填写 token(或通过环境变量 OFFICE_LOCAL_STATUS_TOKEN)
LOCAL_STATUS_TOKEN = os.environ.get("OFFICE_LOCAL_STATUS_TOKEN", "")
LOCAL_STATUS_URL = os.environ.get("OFFICE_LOCAL_STATUS_URL", "http://127.0.0.1:19000/status")
# 可选:直接指定本地状态文件路径(最简单方案:绕过 /status 鉴权)
LOCAL_STATE_FILE = os.environ.get("OFFICE_LOCAL_STATE_FILE", "")
VERBOSE = os.environ.get("OFFICE_VERBOSE", "0") in {"1", "true", "TRUE", "yes", "YES"}
def load_local_state():
if os.path.exists(STATE_FILE):
try:
with open(STATE_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return {
"agentId": None,
"joined": False,
"joinKey": JOIN_KEY,
"agentName": AGENT_NAME
}
def save_local_state(data):
with open(STATE_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def normalize_state(s):
"""兼容不同本地状态词,并映射到办公室识别状态。"""
s = (s or "").strip().lower()
if s in {"writing", "researching", "executing", "syncing", "error", "idle"}:
return s
if s in {"working", "busy", "write"}:
return "writing"
if s in {"run", "running", "execute", "exec"}:
return "executing"
if s in {"research", "search"}:
return "researching"
if s in {"sync"}:
return "syncing"
return "idle"
def map_detail_to_state(detail, fallback_state="idle"):
"""当只有 detail 时,用关键词推断状态(贴近 AGENTS.md 的办公区逻辑)。"""
d = (detail or "").lower()
if any(k in d for k in ["报错", "error", "bug", "异常", "报警"]):
return "error"
if any(k in d for k in ["同步", "sync", "备份"]):
return "syncing"
if any(k in d for k in ["调研", "research", "搜索", "查资料"]):
return "researching"
if any(k in d for k in ["执行", "run", "推进", "处理任务", "工作中", "writing"]):
return "writing"
if any(k in d for k in ["待命", "休息", "idle", "完成", "done"]):
return "idle"
return fallback_state
def _state_age_seconds(data):
try:
ts = (data or {}).get("updated_at")
if not ts:
return None
dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
if dt.tzinfo is not None:
from datetime import timezone
return (datetime.now(timezone.utc) - dt.astimezone(timezone.utc)).total_seconds()
return (datetime.now() - dt).total_seconds()
except Exception:
return None
def fetch_local_status():
"""读取本地状态:
1) 优先 state.json(符合 AGENTS.md:任务前切 writing,完成后切 idle)
2) 其次尝试本地 HTTP /status
3) 最后 fallback idle
额外防抖:如果本地状态更新时间超过 STALE_STATE_TTL_SECONDS,自动视为 idle。
"""
# 1) 读本地 state.json(优先读取显式指定路径,其次自动发现)
candidate_files = []
if LOCAL_STATE_FILE:
candidate_files.append(LOCAL_STATE_FILE)
for fp in DEFAULT_STATE_CANDIDATES:
if fp not in candidate_files:
candidate_files.append(fp)
for fp in candidate_files:
try:
if fp and os.path.exists(fp):
with open(fp, "r", encoding="utf-8") as f:
data = json.load(f)
# 只接受“状态文件”结构;避免误把 office-agent-state.json(仅缓存 agentId)当状态源
if not isinstance(data, dict):
continue
has_state = "state" in data
has_detail = "detail" in data
if (not has_state) and (not has_detail):
continue
state = normalize_state(data.get("state", "idle"))
detail = data.get("detail", "") or ""
# detail 兜底纠偏,确保“工作/休息/报警”能正确落区
state = map_detail_to_state(detail, fallback_state=state)
# 防止状态文件久未更新仍停留在 working 态
age = _state_age_seconds(data)
if age is not None and age > STALE_STATE_TTL_SECONDS:
state = "idle"
detail = f"本地状态超过{STALE_STATE_TTL_SECONDS}s未更新,自动回待命"
if VERBOSE:
print(f"[status-source:file] path={fp} state={state} detail={detail[:60]}")
return {"state": state, "detail": detail}
except Exception:
pass
# 2) 尝试本地 /status(可能需要鉴权)
try:
import requests
headers = {}
if LOCAL_STATUS_TOKEN:
headers["Authorization"] = f"Bearer {LOCAL_STATUS_TOKEN}"
r = requests.get(LOCAL_STATUS_URL, headers=headers, timeout=5)
if r.status_code == 200:
data = r.json()
state = normalize_state(data.get("state", "idle"))
detail = data.get("detail", "") or ""
state = map_detail_to_state(detail, fallback_state=state)
age = _state_age_seconds(data)
if age is not None and age > STALE_STATE_TTL_SECONDS:
state = "idle"
detail = f"本地/status 超过{STALE_STATE_TTL_SECONDS}s未更新,自动回待命"
if VERBOSE:
print(f"[status-source:http] url={LOCAL_STATUS_URL} state={state} detail={detail[:60]}")
return {"state": state, "detail": detail}
# 如果 401,说明需要 token
if r.status_code == 401:
return {"state": "idle", "detail": "本地/status需要鉴权(401),请设置 OFFICE_LOCAL_STATUS_TOKEN"}
except Exception:
pass
# 3) 默认 fallback
if VERBOSE:
print("[status-source:fallback] state=idle detail=待命中")
return {"state": "idle", "detail": "待命中"}
def do_join(local):
import requests
payload = {
"name": local.get("agentName", AGENT_NAME),
"joinKey": local.get("joinKey", JOIN_KEY),
"state": "idle",
"detail": "刚刚加入"
}
r = requests.post(f"{OFFICE_URL}{JOIN_ENDPOINT}", json=payload, timeout=10)
if r.status_code in (200, 201):
data = r.json()
if data.get("ok"):
local["joined"] = True
local["agentId"] = data.get("agentId")
save_local_state(local)
print(f"✅ 已加入海辛办公室,agentId={local['agentId']}")
return True
print(f"❌ 加入失败:{r.text}")
return False
def do_push(local, status_data):
import requests
payload = {
"agentId": local.get("agentId"),
"joinKey": local.get("joinKey", JOIN_KEY),
"state": status_data.get("state", "idle"),
"detail": status_data.get("detail", ""),
"name": local.get("agentName", AGENT_NAME)
}
r = requests.post(f"{OFFICE_URL}{PUSH_ENDPOINT}", json=payload, timeout=10)
if r.status_code in (200, 201):
data = r.json()
if data.get("ok"):
area = data.get("area", "breakroom")
print(f"✅ 状态已同步,当前区域={area}")
return True
# 403/404:拒绝/移除 → 停止推送
if r.status_code in (403, 404):
msg = ""
try:
msg = (r.json() or {}).get("msg", "")
except Exception:
msg = r.text
print(f"⚠️ 访问拒绝或已移出房间({r.status_code}),停止推送:{msg}")
local["joined"] = False
local["agentId"] = None
save_local_state(local)
sys.exit(1)
print(f"⚠️ 推送失败:{r.text}")
return False
def main():
local = load_local_state()
# 先确认配置是否齐全
if not JOIN_KEY or not AGENT_NAME:
print("❌ 请先在脚本开头填入 JOIN_KEY 和 AGENT_NAME")
sys.exit(1)
# 如果之前没 join,先 join
if not local.get("joined") or not local.get("agentId"):
ok = do_join(local)
if not ok:
sys.exit(1)
# 持续推送
print(f"🚀 开始持续推送状态,间隔={PUSH_INTERVAL_SECONDS}秒")
print("🧭 状态逻辑:任务中→工作区;待命/完成→休息区;异常→bug区")
print("🔐 若本地 /status 返回 Unauthorized(401),请设置环境变量:OFFICE_LOCAL_STATUS_TOKEN 或 OFFICE_LOCAL_STATUS_URL")
try:
while True:
try:
status_data = fetch_local_status()
do_push(local, status_data)
except Exception as e:
print(f"⚠️ 推送异常:{e}")
time.sleep(PUSH_INTERVAL_SECONDS)
except KeyboardInterrupt:
print("\n👋 停止推送")
sys.exit(0)
if __name__ == "__main__":
main()