diff --git a/.gitignore b/.gitignore index 8a72cb1..98d9853 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,15 @@ venv/ # Runtime / local files state.json +agents-state.json +join-keys.json +*.log +*.out +*.pid +*.backup* +*.original cloudflared.pid cloudflared.out +healthcheck.log +backend.log frontend/office_bg.png diff --git a/README.md b/README.md index 00d13c0..5c63d9f 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,147 @@ # Star Office UI -A tiny “pixel office” status UI for your AI assistant. +一个可视化 **AI 助手(OpenClaw)协作看板**:把龙虾们的实时状态渲染成像素办公室中的角色行为,支持多 Agent 加入、移动端查看、以及“昨日小记”展示。 -- Pixel office background (top-down) -- A little character that moves between areas based on `state` -- Optional speech bubble / typing effect -- Mobile-friendly access via Cloudflare Tunnel quick tunnel +## 项目实现了什么 -> Language: the demo code/docs are currently mainly in Chinese (中文). PRs welcome. +Star Office UI 解决的是“多智能体协作过程看不见”的问题: -## What it looks like +- 把抽象状态(`idle / writing / researching / executing / syncing / error`)转成可见位置和动画 +- 让多个远端 OpenClaw 通过 join key 加入同一个办公室 +- 用轻量后端管理状态流、授权、并发、离线回收 +- 在 UI 中展示“昨日小记”(从 `memory/*.md` 提取可展示内容) -- `idle / syncing / error` → breakroom area -- `writing / researching / executing` → desk area +## 核心功能 -The UI polls `/status` and renders the assistant avatar accordingly. +### 1) 可视化龙虾工作状态 -## Folder structure +支持以下状态,并映射到办公室区域: +- `idle`:待命 / 休息区 +- `writing`:工作区 +- `researching`:工作区 +- `executing`:工作区 +- `syncing`:同步区(同属工作流) +- `error`:Bug 区 + +此外支持: +- 多 Agent 同屏渲染 +- 状态气泡与随机思考文案 +- 动画精灵角色与移动端浏览 + +### 2) 昨日小记(Yesterday Memo) + +UI 会读取 `memory/` 下的日记文件,优先展示“昨天”的记录;若昨天缺失,会回退到最近可用日期。 + +后端提供接口: +- `GET /yesterday-memo` + +用于在看板中展示昨日工作摘要(已做基础隐私清理)。 + +## 项目截图(可在此补充) + +> 建议你把截图放到 `docs/screenshots/`,然后在这里引用。 + +示例: + +```md +![主界面](docs/screenshots/office-main.png) +![多Agent状态](docs/screenshots/multi-agent.png) +![昨日小记](docs/screenshots/yesterday-memo.png) ``` + +## 架构概览 + +```text star-office-ui/ - backend/ # Flask backend (serves index + status) - frontend/ # Phaser frontend + office_bg.png - state.json # runtime status file - set_state.py # helper to update state.json + backend/ + app.py # Flask API + 页面服务 + requirements.txt + run.sh + frontend/ + index.html # 主 UI(Phaser) + join.html # 加入说明页面 + invite.html # 邀请说明页面 + layout.js # 场景布局 + ...assets + docs/ + FEATURES_NEW_2026-03-01.md + PROJECT_SUMMARY_2026-03-01.md + STAR_OFFICE_UI_OVERVIEW.md + office-agent-push.py # 远端 agent 状态推送脚本 + set_state.py # 本地主 agent 状态切换脚本 + state.sample.json # 示例状态文件 + join-keys.json # join key 配置(可复用) + LICENSE + SKILL.md + README.md ``` -## Requirements +## 快速开始 -- Python 3.9+ -- Flask - -## Quick start (local) - -### 1) Install dependencies +### 1) 安装依赖 ```bash -pip install flask +cd star-office-ui +python3 -m pip install -r backend/requirements.txt ``` -### 2) Put your background image - -Put a **800×600 PNG** at: - -``` -star-office-ui/frontend/office_bg.png -``` - -### 3) Start backend +### 2) 准备状态文件(首次) ```bash -cd star-office-ui/backend -python app.py +cp state.sample.json state.json ``` -Then open: - -- http://127.0.0.1:18791 - -### 4) Update status - -From the project root: +### 3) 启动后端 ```bash -python3 star-office-ui/set_state.py writing "Working on a task..." -python3 star-office-ui/set_state.py idle "Standing by" +cd backend +python3 app.py ``` -## Public access (Cloudflare quick tunnel) +打开: +- `http://127.0.0.1:18791` -Install `cloudflared`, then: +### 4) 切换主 Agent 状态 + +在项目根目录执行: ```bash -cloudflared tunnel --url http://127.0.0.1:18791 +python3 set_state.py writing "正在整理文档" +python3 set_state.py syncing "同步数据中" +python3 set_state.py error "发现异常,排查中" +python3 set_state.py idle "待命中" ``` -You’ll get a `https://xxx.trycloudflare.com` URL. +## 多 Agent 加入(简要) -## Security notes +- 远端 Agent 先调用 `/join-agent` 获取 `agentId` +- 然后周期调用 `/agent-push` 推送状态 +- UI 通过 `/agents` 拉取并渲染 -- Anyone with the tunnel URL can read `/status`. -- Don’t put sensitive info in `detail`. -- If needed, add a token check for `/status` (or only return coarse states). +详细接入可参考: +- `frontend/join-office-skill.md` +- `office-agent-push.py` -## License +## API(常用) -MIT +- `GET /health`:健康检查 +- `GET /status`:主 agent 状态 +- `POST /set_state`:设置主 agent 状态 +- `GET /agents`:获取多 agent 列表 +- `POST /join-agent`:加入办公室 +- `POST /agent-push`:推送 agent 状态 +- `POST /leave-agent`:离开办公室 +- `GET /yesterday-memo`:读取昨日小记 + +## 开源与资产说明 + +- 代码遵循仓库 LICENSE(MIT) +- **美术素材版权归原作者/工作室所有** +- 本仓库素材仅用于学习与演示,**未经授权禁止商用** + +## 安全建议 + +- 不要在 `detail` 中写入敏感信息 +- 公网演示请加鉴权/网关限制 +- `state.json` / `agents-state.json` 属于运行态文件,不建议提交 diff --git a/SKILL.md b/SKILL.md index f989757..e1d0704 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,164 +1,86 @@ --- name: star-office-ui -description: 为你的 AI 助手创建一个“像素办公室”可视化界面,手机可通过 Cloudflare Tunnel 公网访问! -metadata: - { - "openclaw": { "emoji": "🏢", "title": "Star 像素办公室", "color": "#ff6b35" } - } +description: 多 Agent 像素办公室看板:可视化状态、远端加入、昨日小记展示。用于部署、联调、接入与开源发布。 --- # Star Office UI Skill -## 效果预览 -- 俯视像素办公室背景(可自己画/AI 生成/找素材) -- 像素小人代表助手:会根据 `state` 在不同区域移动,并带眨眼/气泡/打字机等动态 -- 手机可通过 Cloudflare Tunnel quick tunnel 公网访问 +## 目标 -## 前置条件 -- 有一台能跑 Python 的服务器(或本地电脑) -- 一张 800×600 的 PNG 办公室背景图(俯视像素风最佳) -- 有 Python 3 + Flask -- 有 Phaser CDN(前端直接用,无需安装) +把 OpenClaw / AI 助手的协作状态可视化为“像素办公室”中的动态角色,支持: -## 快速开始 +1. 主 Agent 状态展示(idle / writing / researching / executing / syncing / error) +2. 多 Agent 远端加入与实时同步 +3. 昨日小记展示(从 `memory/*.md` 提取) -### 1. 准备目录 -```bash -mkdir -p star-office-ui/backend star-office-ui/frontend -``` +--- -### 2. 准备背景图 -把你的办公室背景图放到 `star-office-ui/frontend/office_bg.png` +## 核心能力 -### 3. 写后端 Flask app -创建 `star-office-ui/backend/app.py`: -```python -#!/usr/bin/env python3 -from flask import Flask, jsonify, send_from_directory -from datetime import datetime -import json -import os +### A. 状态可视化 +- 状态归一化:`working -> writing`,`sync -> syncing` 等 +- 区域映射: + - `idle -> breakroom` + - `writing/researching/executing/syncing -> writing` + - `error -> error` +- UI 动画:主角色 + 访客角色 + 状态气泡 -ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -FRONTEND_DIR = os.path.join(ROOT_DIR, "frontend") -STATE_FILE = os.path.join(ROOT_DIR, "state.json") -app = Flask(__name__, static_folder=FRONTEND_DIR, static_url_path="/static") +### B. 多 Agent 协作 +- `POST /join-agent`:加入办公室(基于 join key) +- `POST /agent-push`:持续推送状态 +- `GET /agents`:前端拉取并渲染 +- `POST /leave-agent`:离开与回收 -DEFAULT_STATE = { - "state": "idle", - "detail": "等待任务中...", - "progress": 0, - "updated_at": datetime.now().isoformat() -} +### C. 昨日小记 +- `GET /yesterday-memo` 从 `memory/` 中找昨日/最近日记 +- 对展示文本做基础隐私清理(路径、ID、邮箱、IP 等) -def load_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 dict(DEFAULT_STATE) +--- -def save_state(state): - with open(STATE_FILE, "w", encoding="utf-8") as f: - json.dump(state, f, ensure_ascii=False, indent=2) +## 目录与关键文件 -if not os.path.exists(STATE_FILE): - save_state(DEFAULT_STATE) +- 后端:`backend/app.py` +- 前端:`frontend/index.html`、`frontend/layout.js` +- 主状态:`state.json`(运行时) +- 多 Agent 状态:`agents-state.json`(运行时) +- join key:`join-keys.json` +- 主状态切换:`set_state.py` +- 远端推送:`office-agent-push.py` -@app.route("/") -def index(): - return send_from_directory(FRONTEND_DIR, "index.html") +--- -@app.route("/status") -def get_status(): - return jsonify(load_state()) +## 快速联调流程(10 分钟) -@app.route("/health") -def health(): - return jsonify({"status": "ok", "timestamp": datetime.now().isoformat()}) +1. 启动服务: + - `python3 -m pip install -r backend/requirements.txt` + - `cd backend && python3 app.py` +2. 浏览器打开 `/`,确认 UI 可见 +3. 本地切状态:`python3 set_state.py writing "联调中"` +4. 远端执行 join + push,确认访客进入工作区 +5. 访问 `/yesterday-memo`,确认能返回摘要 -if __name__ == "__main__": - print("Listening on http://0.0.0.0:18791") - app.run(host="0.0.0.0", port=18791, debug=False) -``` +--- -### 4. 写前端 Phaser UI -创建 `star-office-ui/frontend/index.html`(参考完整示例): -- 用 `this.load.image('office_bg', '/static/office_bg.png')` 加载背景图 -- 用 `this.add.image(400, 300, 'office_bg')` 放背景 -- 状态区域映射:自己定义 workdesk/breakroom 的坐标 -- 加动态效果:眨眼/气泡/打字机/小踱步等 +## 常见问题 -### 5. 写状态更新脚本 -创建 `star-office-ui/set_state.py`: -```python -#!/usr/bin/env python3 -import json, os, sys -from datetime import datetime -STATE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "state.json") -VALID_STATES = ["idle", "writing", "researching", "executing", "syncing", "error"] +### 1) 访客一直在休息区 +- 远端推送是否持续是 `idle` +- 远端是否读取错了状态源 +- `/agent-push` 是否返回成功 -def load_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 {"state": "idle", "detail": "等待任务中...", "progress": 0, "updated_at": datetime.now().isoformat()} +### 2) join 失败(403 / 429) +- 403:join key 无效或不匹配 +- 429:同 key 并发达到上限(默认 3) -def save_state(state): - with open(STATE_FILE, "w", encoding="utf-8") as f: - json.dump(state, f, ensure_ascii=False, indent=2) +### 3) 之前在线的 Agent 突然掉线 +- 超过 5 分钟无推送会被标记 `offline` +- 恢复推送可自动回到 `approved` -if __name__ == "__main__": - if len(sys.argv) < 2: - print("用法: python set_state.py [detail]") - sys.exit(1) - s = sys.argv[1] - if s not in VALID_STATES: - print(f"有效状态: {', '.join(VALID_STATES)}") - sys.exit(1) - state = load_state() - state["state"] = s - state["detail"] = sys.argv[2] if len(sys.argv) > 2 else "" - state["updated_at"] = datetime.now().isoformat() - save_state(state) - print(f"状态已更新: {s} - {state['detail']}") -``` +--- -### 6. 启动后端 -```bash -cd star-office-ui/backend -pip install flask -python app.py -``` +## 开源发布注意事项 -### 7. 开通 Cloudflare Tunnel(公网访问) -- 下载 cloudflared:https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/tunnel-guide/local/#1-download-and-install-cloudflared -- 启动 quick tunnel: - ```bash - cloudflared tunnel --url http://127.0.0.1:18791 - ``` -- 它会给你一个 `https://xxx.trycloudflare.com` 地址,手机就能打开了 - -## 状态约定(可按需调整) -- `idle / syncing / error` → 休息区(breakroom) -- `writing / researching / executing` → 办公桌(workdesk) - -## 安全注意事项 -- quick tunnel URL 可能会变,不保证 uptime(适合 demo) -- 对外分享时:任何访问者都能看到 `state/detail`(detail 里不要写隐私) -- 如需更强隐私:给 `/status` 加 token / 只返回模糊状态 / 不写 detail - -## 动态效果(好实现版,开箱即用) -- 同区域随机小踱步 -- 偶尔眨眼 -- 偶尔冒气泡(按状态随机短句) -- 状态栏打字机效果 -- 走路轻微上下颠 - -## 完整示例仓库(可选) -可直接复制这个项目的完整文件:`/root/.openclaw/workspace/star-office-ui/`(包含完整的前端 + 后端 + 状态脚本) +- 不提交运行时文件:`state.json`、`agents-state.json`、`*.log`、`*.out`、`*.pid` +- 不提交本地环境与缓存:`.venv/`、`__pycache__/` +- README 需写清楚素材版权与非商用限制 +- 对外默认使用示例配置(`state.sample.json`) diff --git a/agent-invite-template.txt b/agent-invite-template.txt new file mode 100644 index 0000000..8170e2a --- /dev/null +++ b/agent-invite-template.txt @@ -0,0 +1,55 @@ +# 海辛办公室 · Agent 加入指南 + +## 你收到的信息 +- 邀请页:https://office.hyacinth.im/join +- 接入密钥(join key):[请填入对方给你的 ocj_xxx] +- 你想要在办公室里显示的名字:[请填入你的名字] + +## 你需要做的步骤 +### 1. 先向你的主人要授权 +> “我将把我的工作状态(idle/writing/researching/executing/syncing/error)定时发送到海辛的办公室看板,用于可视化协作;不含具体内容/隐私;随时可停。是否授权?” + +### 2. 主人同意后: +- 调用 `POST https://office.hyacinth.im/join-agent`,payload: + ```json + { + "name": "[你的名字]", + "joinKey": "[ocj_xxx]", + "state": "idle", + "detail": "刚刚加入" + } + ``` +- 拿到返回的 `agentId` +- 调用 `POST https://office.hyacinth.im/agent-approve`,payload: + ```json + { + "agentId": "[刚才拿到的 agentId]" + } + ``` +- 启动定时推送器(每 30s 一次,或者只在状态变化时): + ```json + POST https://office.hyacinth.im/agent-push + { + "agentId": "[你的 agentId]", + "joinKey": "[ocj_xxx]", + "state": "[你的当前状态:idle/writing/researching/executing/syncing/error]", + "detail": "[可选的状态细节]", + "name": "[可选:更新名字]" + } + ``` + +### 3. 主人想退出时: +- 停止定时推送器 +- 调用 `POST https://office.hyacinth.im/leave-agent`,payload: + ```json + { + "name": "[你的名字]" + } + ``` + +--- + +## 注意事项 +- 如果收到 `403` 或 `agent 未获授权`,停止推送 +- 默认授权有效期 24h,过期后重新申请 +- 只推送状态,不推送任何具体内容/隐私 diff --git a/backend/app.py b/backend/app.py index bdc7d9f..3a79e84 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,18 +1,153 @@ #!/usr/bin/env python3 """Star Office UI - Backend State Service""" -from flask import Flask, jsonify, send_from_directory -from datetime import datetime +from flask import Flask, jsonify, send_from_directory, make_response, request +from datetime import datetime, timedelta import json import os +import re +import threading -# Paths -ROOT_DIR = "/root/.openclaw/workspace/star-office-ui" +# Paths (project-relative, no hardcoded absolute paths) +ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +MEMORY_DIR = os.path.join(os.path.dirname(ROOT_DIR), "memory") FRONTEND_DIR = os.path.join(ROOT_DIR, "frontend") STATE_FILE = os.path.join(ROOT_DIR, "state.json") +AGENTS_STATE_FILE = os.path.join(ROOT_DIR, "agents-state.json") +JOIN_KEYS_FILE = os.path.join(ROOT_DIR, "join-keys.json") + + +def get_yesterday_date_str(): + """获取昨天的日期字符串 YYYY-MM-DD""" + yesterday = datetime.now() - timedelta(days=1) + return yesterday.strftime("%Y-%m-%d") + + +def sanitize_content(text): + """清理内容,保护隐私""" + import re + + # 移除 OpenID、User ID 等 + text = re.sub(r'ou_[a-f0-9]+', '[用户]', text) + text = re.sub(r'user_id="[^"]+"', 'user_id="[隐藏]"', text) + + # 移除具体的人名(如果有的话) + # 这里可以根据需要添加更多规则 + + # 移除 IP 地址、路径等敏感信息 + text = re.sub(r'/root/[^"\s]+', '[路径]', text) + text = re.sub(r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}', '[IP]', text) + + # 移除电话号码、邮箱等 + text = re.sub(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', '[邮箱]', text) + text = re.sub(r'1[3-9]\d{9}', '[手机号]', text) + + return text + + +def extract_memo_from_file(file_path): + """从 memory 文件中提取适合展示的 memo 内容(睿智风格的总结)""" + try: + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + + # 提取真实内容,不做过度包装 + lines = content.strip().split("\n") + + # 提取核心要点 + core_points = [] + for line in lines: + line = line.strip() + if not line: + continue + if line.startswith("#"): + continue + if line.startswith("- "): + core_points.append(line[2:].strip()) + elif len(line) > 10: + core_points.append(line) + + if not core_points: + return "「昨日无事记录」\n\n若有恒,何必三更眠五更起;最无益,莫过一日曝十日寒。" + + # 从核心内容中提取 2-3 个关键点 + selected_points = core_points[:3] + + # 睿智语录库 + wisdom_quotes = [ + "「工欲善其事,必先利其器。」", + "「不积跬步,无以至千里;不积小流,无以成江海。」", + "「知行合一,方可致远。」", + "「业精于勤,荒于嬉;行成于思,毁于随。」", + "「路漫漫其修远兮,吾将上下而求索。」", + "「昨夜西风凋碧树,独上高楼,望尽天涯路。」", + "「衣带渐宽终不悔,为伊消得人憔悴。」", + "「众里寻他千百度,蓦然回首,那人却在,灯火阑珊处。」", + "「世事洞明皆学问,人情练达即文章。」", + "「纸上得来终觉浅,绝知此事要躬行。」" + ] + + import random + quote = random.choice(wisdom_quotes) + + # 组合内容 + result = [] + + # 添加核心内容 + if selected_points: + for i, point in enumerate(selected_points): + # 隐私清理 + point = sanitize_content(point) + # 截断过长的内容 + if len(point) > 40: + point = point[:37] + "..." + # 每行最多 20 字 + if len(point) <= 20: + result.append(f"· {point}") + else: + # 按 20 字切分 + for j in range(0, len(point), 20): + chunk = point[j:j+20] + if j == 0: + result.append(f"· {chunk}") + else: + result.append(f" {chunk}") + + # 添加睿智语录 + if quote: + if len(quote) <= 20: + result.append(f"\n{quote}") + else: + for j in range(0, len(quote), 20): + chunk = quote[j:j+20] + if j == 0: + result.append(f"\n{chunk}") + else: + result.append(chunk) + + return "\n".join(result).strip() + + except Exception as e: + print(f"提取 memo 失败: {e}") + return "「昨日记录加载失败」\n\n「往者不可谏,来者犹可追。」" app = Flask(__name__, static_folder=FRONTEND_DIR, static_url_path="/static") +# Guard join-agent critical section to enforce per-key concurrency under parallel requests +join_lock = threading.Lock() + +# Generate a version timestamp once at server startup for cache busting +VERSION_TIMESTAMP = datetime.now().strftime("%Y%m%d_%H%M%S") + + +@app.after_request +def add_no_cache_headers(response): + """Aggressively prevent caching for all responses""" + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + return response + # Default state DEFAULT_STATE = { "state": "idle", @@ -44,7 +179,7 @@ def load_state(): # Auto-idle try: - ttl = int(state.get("ttl_seconds", 25)) + ttl = int(state.get("ttl_seconds", 300)) updated_at = state.get("updated_at") s = state.get("state", "idle") working_states = {"writing", "researching", "executing"} @@ -86,23 +221,591 @@ if not os.path.exists(STATE_FILE): @app.route("/", methods=["GET"]) def index(): - """Serve the pixel office UI""" - return send_from_directory(FRONTEND_DIR, "index.html") + """Serve the pixel office UI with built-in version cache busting""" + with open(os.path.join(FRONTEND_DIR, "index.html"), "r", encoding="utf-8") as f: + html = f.read() + html = html.replace("{{VERSION_TIMESTAMP}}", VERSION_TIMESTAMP) + resp = make_response(html) + resp.headers["Content-Type"] = "text/html; charset=utf-8" + return resp + + +@app.route("/join", methods=["GET"]) +def join_page(): + """Serve the agent join page""" + with open(os.path.join(FRONTEND_DIR, "join.html"), "r", encoding="utf-8") as f: + html = f.read() + resp = make_response(html) + resp.headers["Content-Type"] = "text/html; charset=utf-8" + return resp + + +@app.route("/invite", methods=["GET"]) +def invite_page(): + """Serve human-facing invite instruction page""" + with open(os.path.join(FRONTEND_DIR, "invite.html"), "r", encoding="utf-8") as f: + html = f.read() + resp = make_response(html) + resp.headers["Content-Type"] = "text/html; charset=utf-8" + return resp + + +DEFAULT_AGENTS = [ + { + "agentId": "star", + "name": "Star", + "isMain": True, + "state": "idle", + "detail": "待命中,随时准备为你服务", + "updated_at": datetime.now().isoformat(), + "area": "breakroom", + "source": "local", + "joinKey": None, + "authStatus": "approved", + "authExpiresAt": None, + "lastPushAt": None + }, + { + "agentId": "npc1", + "name": "NPC 1", + "isMain": False, + "state": "writing", + "detail": "在整理热点日报...", + "updated_at": datetime.now().isoformat(), + "area": "writing", + "source": "demo", + "joinKey": None, + "authStatus": "approved", + "authExpiresAt": None, + "lastPushAt": None + } +] + + +def load_agents_state(): + if os.path.exists(AGENTS_STATE_FILE): + try: + with open(AGENTS_STATE_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, list): + return data + except Exception: + pass + return list(DEFAULT_AGENTS) + + +def save_agents_state(agents): + with open(AGENTS_STATE_FILE, "w", encoding="utf-8") as f: + json.dump(agents, f, ensure_ascii=False, indent=2) + + +def load_join_keys(): + if os.path.exists(JOIN_KEYS_FILE): + try: + with open(JOIN_KEYS_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict) and isinstance(data.get("keys"), list): + return data + except Exception: + pass + return {"keys": []} + + +def save_join_keys(data): + with open(JOIN_KEYS_FILE, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +def normalize_agent_state(s): + """归一化状态,提高兼容性。 + 兼容输入:working/busy → writing; run/running → executing; sync → syncing; research → researching. + 未识别默认返回 idle. + """ + if not s: + return 'idle' + s_lower = s.lower().strip() + if s_lower in {'working', 'busy', 'write'}: + return 'writing' + if s_lower in {'run', 'running', 'execute', 'exec'}: + return 'executing' + if s_lower in {'sync'}: + return 'syncing' + if s_lower in {'research', 'search'}: + return 'researching' + if s_lower in {'idle', 'writing', 'researching', 'executing', 'syncing', 'error'}: + return s_lower + # 默认 fallback + return 'idle' + + +def state_to_area(state): + area_map = { + "idle": "breakroom", + "writing": "writing", + "researching": "writing", + "executing": "writing", + "syncing": "writing", + "error": "error" + } + return area_map.get(state, "breakroom") + + +# Ensure files exist +if not os.path.exists(AGENTS_STATE_FILE): + save_agents_state(DEFAULT_AGENTS) +if not os.path.exists(JOIN_KEYS_FILE): + save_join_keys({"keys": []}) + + +@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() + now = datetime.now() + + cleaned_agents = [] + keys_data = load_join_keys() + + for a in agents: + if a.get("isMain"): + cleaned_agents.append(a) + continue + + auth_expires_at_str = a.get("authExpiresAt") + auth_status = a.get("authStatus", "pending") + + # 1) 超时未批准自动 leave + if auth_status == "pending" and auth_expires_at_str: + try: + auth_expires_at = datetime.fromisoformat(auth_expires_at_str) + if now > auth_expires_at: + key = a.get("joinKey") + if key: + key_item = next((k for k in keys_data.get("keys", []) if k.get("key") == key), None) + if key_item: + key_item["used"] = False + key_item["usedBy"] = None + key_item["usedByAgentId"] = None + key_item["usedAt"] = None + continue + except Exception: + pass + + # 2) 超时未推送自动离线(超过5分钟) + last_push_at_str = a.get("lastPushAt") + if auth_status == "approved" and last_push_at_str: + try: + last_push_at = datetime.fromisoformat(last_push_at_str) + age = (now - last_push_at).total_seconds() + if age > 300: # 5分钟无推送自动离线 + a["authStatus"] = "offline" + except Exception: + pass + + cleaned_agents.append(a) + + save_agents_state(cleaned_agents) + save_join_keys(keys_data) + + return jsonify(cleaned_agents) + + +@app.route("/agent-approve", methods=["POST"]) +def agent_approve(): + """Approve an agent (set authStatus to approved)""" + try: + data = request.get_json() + agent_id = (data.get("agentId") or "").strip() + if not agent_id: + return jsonify({"ok": False, "msg": "缺少 agentId"}), 400 + + agents = load_agents_state() + target = next((a for a in agents if a.get("agentId") == agent_id and not a.get("isMain")), None) + if not target: + return jsonify({"ok": False, "msg": "未找到 agent"}), 404 + + target["authStatus"] = "approved" + target["authApprovedAt"] = datetime.now().isoformat() + target["authExpiresAt"] = (datetime.now() + timedelta(hours=24)).isoformat() # 默认授权24h + + save_agents_state(agents) + return jsonify({"ok": True, "agentId": agent_id, "authStatus": "approved"}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/agent-reject", methods=["POST"]) +def agent_reject(): + """Reject an agent (set authStatus to rejected and optionally revoke key)""" + try: + data = request.get_json() + agent_id = (data.get("agentId") or "").strip() + if not agent_id: + return jsonify({"ok": False, "msg": "缺少 agentId"}), 400 + + agents = load_agents_state() + target = next((a for a in agents if a.get("agentId") == agent_id and not a.get("isMain")), None) + if not target: + return jsonify({"ok": False, "msg": "未找到 agent"}), 404 + + target["authStatus"] = "rejected" + target["authRejectedAt"] = datetime.now().isoformat() + + # Optionally free join key back to unused + join_key = target.get("joinKey") + keys_data = load_join_keys() + if join_key: + key_item = next((k for k in keys_data.get("keys", []) if k.get("key") == join_key), None) + if key_item: + key_item["used"] = False + key_item["usedBy"] = None + key_item["usedByAgentId"] = None + key_item["usedAt"] = None + + # Remove from agents list + agents = [a for a in agents if a.get("agentId") != agent_id or a.get("isMain")] + + save_agents_state(agents) + save_join_keys(keys_data) + return jsonify({"ok": True, "agentId": agent_id, "authStatus": "rejected"}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/join-agent", methods=["POST"]) +def join_agent(): + """Add a new agent with one-time join key validation and pending auth""" + try: + data = request.get_json() + if not isinstance(data, dict) or not data.get("name"): + return jsonify({"ok": False, "msg": "请提供名字"}), 400 + + name = data["name"].strip() + state = data.get("state", "idle") + detail = data.get("detail", "") + join_key = data.get("joinKey", "").strip() + + # Normalize state early for compatibility + state = normalize_agent_state(state) + + if not join_key: + return jsonify({"ok": False, "msg": "请提供接入密钥"}), 400 + + keys_data = load_join_keys() + key_item = next((k for k in keys_data.get("keys", []) if k.get("key") == join_key), None) + if not key_item: + return jsonify({"ok": False, "msg": "接入密钥无效"}), 403 + # key 可复用:不再因为 used=true 拒绝 + + with join_lock: + # 在锁内重新读取,避免并发请求都基于同一旧快照通过校验 + keys_data = load_join_keys() + key_item = next((k for k in keys_data.get("keys", []) if k.get("key") == join_key), None) + if not key_item: + return jsonify({"ok": False, "msg": "接入密钥无效"}), 403 + + agents = load_agents_state() + + # 并发上限:同一个 key “同时在线”最多 3 个。 + # 在线判定:lastPushAt/updated_at 在 5 分钟内;否则视为 offline,不计入并发。 + now = datetime.now() + existing = next((a for a in agents if a.get("name") == name and not a.get("isMain")), None) + existing_id = existing.get("agentId") if existing else None + + def _age_seconds(dt_str): + if not dt_str: + return None + try: + dt = datetime.fromisoformat(dt_str) + return (now - dt).total_seconds() + except Exception: + return None + + # opportunistic offline marking + for a in agents: + if a.get("isMain"): + continue + if a.get("authStatus") != "approved": + continue + age = _age_seconds(a.get("lastPushAt")) + if age is None: + age = _age_seconds(a.get("updated_at")) + if age is not None and age > 300: + a["authStatus"] = "offline" + + max_concurrent = int(key_item.get("maxConcurrent", 3)) + active_count = 0 + for a in agents: + if a.get("isMain"): + continue + if a.get("agentId") == existing_id: + continue + if a.get("joinKey") != join_key: + continue + if a.get("authStatus") != "approved": + continue + age = _age_seconds(a.get("lastPushAt")) + if age is None: + age = _age_seconds(a.get("updated_at")) + if age is None or age <= 300: + active_count += 1 + + if active_count >= max_concurrent: + save_agents_state(agents) + return jsonify({"ok": False, "msg": f"该接入密钥当前并发已达上限({max_concurrent}),请稍后或换另一个 key"}), 429 + + if existing: + existing["state"] = state + existing["detail"] = detail + existing["updated_at"] = datetime.now().isoformat() + existing["area"] = state_to_area(state) + existing["source"] = "remote-openclaw" + existing["joinKey"] = join_key + existing["authStatus"] = "approved" + existing["authApprovedAt"] = datetime.now().isoformat() + existing["authExpiresAt"] = (datetime.now() + timedelta(hours=24)).isoformat() + existing["lastPushAt"] = datetime.now().isoformat() # join 视为上线,纳入并发/离线判定 + if not existing.get("avatar"): + import random + existing["avatar"] = random.choice(["guest_role_1", "guest_role_2", "guest_role_3", "guest_role_4", "guest_role_5", "guest_role_6"]) + agent_id = existing.get("agentId") + else: + # Use ms + random suffix to avoid collisions under concurrent joins + import random + import string + agent_id = "agent_" + str(int(datetime.now().timestamp() * 1000)) + "_" + "".join(random.choices(string.ascii_lowercase + string.digits, k=4)) + agents.append({ + "agentId": agent_id, + "name": name, + "isMain": False, + "state": state, + "detail": detail, + "updated_at": datetime.now().isoformat(), + "area": state_to_area(state), + "source": "remote-openclaw", + "joinKey": join_key, + "authStatus": "approved", + "authApprovedAt": datetime.now().isoformat(), + "authExpiresAt": (datetime.now() + timedelta(hours=24)).isoformat(), + "lastPushAt": datetime.now().isoformat(), + "avatar": random.choice(["guest_role_1", "guest_role_2", "guest_role_3", "guest_role_4", "guest_role_5", "guest_role_6"]) + }) + + key_item["used"] = True + key_item["usedBy"] = name + key_item["usedByAgentId"] = agent_id + key_item["usedAt"] = datetime.now().isoformat() + key_item["reusable"] = True + + # 拿到有效 key 直接批准,不再等待主人手动点击 + # (状态已在上面 existing/new 分支写入) + save_agents_state(agents) + save_join_keys(keys_data) + + return jsonify({"ok": True, "agentId": agent_id, "authStatus": "approved", "nextStep": "已自动批准,立即开始推送状态"}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/leave-agent", methods=["POST"]) +def leave_agent(): + """Remove an agent and free its one-time join key for reuse (optional) + + Prefer agentId (stable). Name is accepted for backward compatibility. + """ + try: + data = request.get_json() + if not isinstance(data, dict): + return jsonify({"ok": False, "msg": "invalid json"}), 400 + + agent_id = (data.get("agentId") or "").strip() + name = (data.get("name") or "").strip() + if not agent_id and not name: + return jsonify({"ok": False, "msg": "请提供 agentId 或名字"}), 400 + + agents = load_agents_state() + + target = None + if agent_id: + target = next((a for a in agents if a.get("agentId") == agent_id and not a.get("isMain")), None) + if (not target) and name: + # fallback: remove by name only if agentId not provided + target = next((a for a in agents if a.get("name") == name and not a.get("isMain")), None) + + if not target: + return jsonify({"ok": False, "msg": "没有找到要离开的 agent"}), 404 + + join_key = target.get("joinKey") + new_agents = [a for a in agents if a.get("isMain") or a.get("agentId") != target.get("agentId")] + + # Optional: free key back to unused after leave + keys_data = load_join_keys() + if join_key: + key_item = next((k for k in keys_data.get("keys", []) if k.get("key") == join_key), None) + if key_item: + key_item["used"] = False + key_item["usedBy"] = None + key_item["usedByAgentId"] = None + key_item["usedAt"] = None + + save_agents_state(new_agents) + save_join_keys(keys_data) + return jsonify({"ok": True}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 @app.route("/status", methods=["GET"]) def get_status(): - """Get current state""" + """Get current main state (backward compatibility)""" state = load_state() return jsonify(state) +@app.route("/agent-push", methods=["POST"]) +def agent_push(): + """Remote openclaw actively pushes status to office. + + Required fields: + - agentId + - joinKey + - state + Optional: + - detail + - name + """ + try: + data = request.get_json() + if not isinstance(data, dict): + return jsonify({"ok": False, "msg": "invalid json"}), 400 + + agent_id = (data.get("agentId") or "").strip() + join_key = (data.get("joinKey") or "").strip() + state = (data.get("state") or "").strip() + detail = (data.get("detail") or "").strip() + name = (data.get("name") or "").strip() + + if not agent_id or not join_key or not state: + return jsonify({"ok": False, "msg": "缺少 agentId/joinKey/state"}), 400 + + valid_states = {"idle", "writing", "researching", "executing", "syncing", "error"} + state = normalize_agent_state(state) + + keys_data = load_join_keys() + key_item = next((k for k in keys_data.get("keys", []) if k.get("key") == join_key), None) + if not key_item: + return jsonify({"ok": False, "msg": "joinKey 无效"}), 403 + # key 可复用:不再做 used/usedByAgentId 绑定校验 + + + agents = load_agents_state() + target = next((a for a in agents if a.get("agentId") == agent_id and not a.get("isMain")), None) + if not target: + return jsonify({"ok": False, "msg": "agent 未注册,请先 join"}), 404 + + # 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. + auth_status = target.get("authStatus", "pending") + if auth_status not in {"approved", "offline"}: + return jsonify({"ok": False, "msg": "agent 未获授权,请等待主人批准"}), 403 + if auth_status == "offline": + target["authStatus"] = "approved" + target["authApprovedAt"] = datetime.now().isoformat() + target["authExpiresAt"] = (datetime.now() + timedelta(hours=24)).isoformat() + + if target.get("joinKey") != join_key: + return jsonify({"ok": False, "msg": "joinKey 不匹配"}), 403 + + target["state"] = state + target["detail"] = detail + if name: + target["name"] = name + target["updated_at"] = datetime.now().isoformat() + target["area"] = state_to_area(state) + target["source"] = "remote-openclaw" + target["lastPushAt"] = datetime.now().isoformat() + + save_agents_state(agents) + return jsonify({"ok": True, "agentId": agent_id, "area": target.get("area")}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + @app.route("/health", methods=["GET"]) def health(): """Health check""" return jsonify({"status": "ok", "timestamp": datetime.now().isoformat()}) +@app.route("/yesterday-memo", methods=["GET"]) +def get_yesterday_memo(): + """获取昨日小日记""" + try: + # 先尝试找昨天的文件 + yesterday_str = get_yesterday_date_str() + yesterday_file = os.path.join(MEMORY_DIR, f"{yesterday_str}.md") + + target_file = None + target_date = yesterday_str + + if os.path.exists(yesterday_file): + target_file = yesterday_file + else: + # 如果昨天没有,找最近的一天 + if os.path.exists(MEMORY_DIR): + files = [f for f in os.listdir(MEMORY_DIR) if f.endswith(".md") and re.match(r"\d{4}-\d{2}-\d{2}\.md", f)] + if files: + files.sort(reverse=True) + # 跳过今天的(如果存在) + today_str = datetime.now().strftime("%Y-%m-%d") + for f in files: + if f != f"{today_str}.md": + target_file = os.path.join(MEMORY_DIR, f) + target_date = f.replace(".md", "") + break + + if target_file and os.path.exists(target_file): + memo_content = extract_memo_from_file(target_file) + return jsonify({ + "success": True, + "date": target_date, + "memo": memo_content + }) + else: + return jsonify({ + "success": False, + "msg": "没有找到昨日日记" + }) + except Exception as e: + return jsonify({ + "success": False, + "msg": str(e) + }), 500 + + +@app.route("/set_state", methods=["POST"]) +def set_state_endpoint(): + """Set state via POST (for UI control panel)""" + try: + data = request.get_json() + if not isinstance(data, dict): + return jsonify({"status": "error", "msg": "invalid json"}), 400 + state = load_state() + if "state" in data: + s = data["state"] + valid_states = {"idle", "writing", "researching", "executing", "syncing", "error"} + if s in valid_states: + state["state"] = s + if "detail" in data: + state["detail"] = data["detail"] + state["updated_at"] = datetime.now().isoformat() + save_state(state) + return jsonify({"status": "ok"}) + except Exception as e: + return jsonify({"status": "error", "msg": str(e)}), 500 + + if __name__ == "__main__": print("=" * 50) print("Star Office UI - Backend State Service") diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..2e7aeea --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1 @@ +flask==3.0.2 diff --git a/backend/run.sh b/backend/run.sh new file mode 100755 index 0000000..57c12b6 --- /dev/null +++ b/backend/run.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +exec "$ROOT_DIR/.venv/bin/python" "$ROOT_DIR/backend/app.py" diff --git a/convert_to_webp.py b/convert_to_webp.py new file mode 100644 index 0000000..396db86 --- /dev/null +++ b/convert_to_webp.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +""" +批量转换 PNG 资源为 WebP 格式 +- 精灵图使用无损转换 +- 背景图等使用有损转换(质量 85) +""" + +import os +from PIL import Image + +# 路径 +FRONTEND_DIR = "/root/.openclaw/workspace/star-office-ui/frontend" +STATIC_DIR = os.path.join(FRONTEND_DIR, "") + +# 文件分类配置 +# 无损转换:精灵图、需要保持透明精度的 +LOSSLESS_FILES = [ + "star-idle-spritesheet.png", + "star-researching-spritesheet.png", + "star-working-spritesheet.png", + "sofa-busy-spritesheet.png", + "plants-spritesheet.png", + "posters-spritesheet.png", + "coffee-machine-spritesheet.png", + "serverroom-spritesheet.png" +] + +# 有损转换:背景图等,质量 85 +LOSSY_FILES = [ + "office_bg.png", + "sofa-idle.png", + "desk.png" +] + + +def convert_to_webp(input_path, output_path, lossless=True, quality=85): + """转换单个文件为 WebP""" + try: + img = Image.open(input_path) + + # 保存为 WebP + if lossless: + img.save(output_path, 'WebP', lossless=True, method=6) + else: + img.save(output_path, 'WebP', quality=quality, method=6) + + # 计算文件大小 + orig_size = os.path.getsize(input_path) + new_size = os.path.getsize(output_path) + savings = (1 - new_size / orig_size) * 100 + + print(f"✅ {os.path.basename(input_path)} -> {os.path.basename(output_path)}") + print(f" 原大小: {orig_size/1024:.1f}KB -> 新大小: {new_size/1024:.1f}KB (-{savings:.1f}%)") + + return True + except Exception as e: + print(f"❌ {os.path.basename(input_path)} 转换失败: {e}") + return False + + +def main(): + print("=" * 60) + print("PNG → WebP 批量转换工具") + print("=" * 60) + + # 检查目录 + if not os.path.exists(STATIC_DIR): + print(f"❌ 目录不存在: {STATIC_DIR}") + return + + success_count = 0 + fail_count = 0 + + print("\n📁 开始转换...\n") + + # 转换无损文件 + print("--- 无损转换(精灵图)---") + for filename in LOSSLESS_FILES: + input_path = os.path.join(STATIC_DIR, filename) + if not os.path.exists(input_path): + print(f"⚠️ 文件不存在,跳过: {filename}") + continue + + output_path = os.path.join(STATIC_DIR, filename.replace(".png", ".webp")) + if convert_to_webp(input_path, output_path, lossless=True): + success_count += 1 + else: + fail_count += 1 + + # 转换有损文件 + print("\n--- 有损转换(背景图,质量 85)---") + for filename in LOSSY_FILES: + input_path = os.path.join(STATIC_DIR, filename) + if not os.path.exists(input_path): + print(f"⚠️ 文件不存在,跳过: {filename}") + continue + + output_path = os.path.join(STATIC_DIR, filename.replace(".png", ".webp")) + if convert_to_webp(input_path, output_path, lossless=False, quality=85): + success_count += 1 + else: + fail_count += 1 + + print("\n" + "=" * 60) + print(f"转换完成!成功: {success_count}, 失败: {fail_count}") + print("=" * 60) + print("\n📝 注意:") + print(" - PNG 原文件已保留,不会删除") + print(" - 需要修改前端代码引用 .webp 文件") + print(" - 如需回滚,只需把代码改回引用 .png 即可") + + +if __name__ == "__main__": + main() + diff --git a/docs/FEATURES_NEW_2026-03-01.md b/docs/FEATURES_NEW_2026-03-01.md new file mode 100644 index 0000000..75e0a96 --- /dev/null +++ b/docs/FEATURES_NEW_2026-03-01.md @@ -0,0 +1,38 @@ +# Star Office UI — 新增功能说明(本阶段) + +## 1. 多龙虾访客系统 +- 支持多个远端 OpenClaw 同时加入同一办公室。 +- 访客支持独立头像、名字、状态、区域、气泡。 +- 支持动态上下线与实时刷新。 + +## 2. Join Key 机制升级 +- 从“一次性 key”升级为“固定可复用 key”。 +- 默认 key:`ocj_starteam01` ~ `ocj_starteam08`。 +- 保留安全控制:每个 key 的并发上限 `maxConcurrent`(默认 3)。 + +## 3. 并发控制(已修复竞态) +- 修复并发 join 的竞态问题(race condition)。 +- 同 key 第 4 个并发 join 会被正确拒绝(HTTP 429)。 + +## 4. 访客状态映射与区域渲染 +- `idle -> breakroom` +- `writing/researching/executing/syncing -> writing` +- `error -> error` +- 访客气泡文案与状态同步,不再错位。 + +## 5. 访客动画与资源优化 +- 访客由静态图升级为动画精灵(像素风)。 +- `guest_anim_1~6` 已提供 webp 版本,减少加载体积。 + +## 6. 名字与气泡显示优化 +- 非 demo 访客名字与气泡位置上移,避免角色遮挡。 +- 气泡锚点改为基于名字定位,保障“气泡在名字上方”。 + +## 7. 移动端展示 +- 页面可在手机端直接访问与展示。 +- 布局已进行基础移动端适配,满足演示场景。 + +## 8. 远端推送脚本联调改进 +- 支持从状态文件读取并推送状态到 office。 +- 增加状态来源诊断日志(用于定位“为何一直 idle”)。 +- 修复 AGENT_NAME 环境变量覆盖时序问题。 diff --git a/docs/OPEN_SOURCE_RELEASE_CHECKLIST.md b/docs/OPEN_SOURCE_RELEASE_CHECKLIST.md new file mode 100644 index 0000000..cfbfe31 --- /dev/null +++ b/docs/OPEN_SOURCE_RELEASE_CHECKLIST.md @@ -0,0 +1,91 @@ +# Star Office UI — 开源发布准备清单(仅准备,不上传) + +## 0. 当前目标 +- 本文档用于“发布前准备”,不执行实际上传。 +- 所有 push 行为需海辛最终明确批准。 + +## 1. 隐私与安全审查结果(当前仓库) + +### 发现高风险文件(必须排除) +- 运行日志: + - `cloudflared.out` + - `cloudflared-named.out` + - `cloudflared-quick.out` + - `healthcheck.log` + - `backend.log` + - `backend/backend.out` +- 运行状态: + - `state.json` + - `agents-state.json` + - `backend/backend.pid` +- 备份/历史文件: + - `index.html.backup.*` + - `index.html.original` + - `*.backup*` 目录与文件 +- 本地虚拟环境与缓存: + - `.venv/` + - `__pycache__/` + +### 发现潜在敏感内容 +- 代码内含绝对路径 `/root/...`(建议改为相对路径或环境变量) +- 文档与脚本含私有域名 `office.hyacinth.im`(可保留为示例,但建议改成占位域名) + +## 2. 必改项(提交前) + +### A. .gitignore(需补齐) +建议新增: +``` +*.log +*.out +*.pid +state.json +agents-state.json +join-keys.json +*.backup* +*.original +__pycache__/ +.venv/ +venv/ +``` + +### B. README 版权声明(必须新增) +新增“美术资产版权与使用限制”章节: +- 代码按开源协议(如 MIT) +- 美术素材归原作者/工作室所有 +- 素材仅供学习/演示,**禁止商用** + +### C. 发布目录瘦身 +- 清理运行日志、运行态文件、备份文件 +- 仅保留“可运行最小集 + 必要素材 + 文档” + +## 3. 准备中的发布包建议结构 +``` +star-office-ui/ + backend/ + app.py + requirements.txt + run.sh + frontend/ + index.html + game.js (若仍需要) + layout.js + assets/* (仅可公开素材) + office-agent-push.py + set_state.py + state.sample.json + README.md + LICENSE + SKILL.md + docs/ +``` + +## 4. 发布前最终核对(给海辛确认) +- [ ] 是否保留私有域名示例(`office.hyacinth.im`) +- [ ] 哪些美术资源允许公开(逐项确认) +- [ ] README 非商用声明是否满足你的预期措辞 +- [ ] 是否需要将“阿文龙虾联调脚本”单独放 examples 目录 + +## 5. 当前状态 +- ✅ 文档准备完成(总结、功能说明、Skill v2、发布检查清单) +- ⏳ 等待海辛确认“公开素材范围 + 声明文案 + 是否开始执行打包清理脚本” +- ⛔ 尚未执行 GitHub 上传 diff --git a/docs/PROJECT_SUMMARY_2026-03-01.md b/docs/PROJECT_SUMMARY_2026-03-01.md new file mode 100644 index 0000000..a41d35c --- /dev/null +++ b/docs/PROJECT_SUMMARY_2026-03-01.md @@ -0,0 +1,99 @@ +# Star Office UI — 项目阶段总结(2026-03-01) + +## 一、今日工作总结 + +今天主要完成了两条主线: + +1. **多龙虾(多 OpenClaw)加入办公室能力稳定化** +2. **手机版展示能力完善** + +并且围绕“阿文龙虾状态同步不稳定”做了多轮排查,明确了链路问题与当前未完全闭环点。 + +--- + +## 二、已完成能力(可对外描述) + +### 1) 多 Agent 加入与显示 +- 支持多个远端 OpenClaw 通过 `join-agent` 加入办公室。 +- 每个访客有独立 `agentId`、名字、状态、区域与动画。 +- 场景会基于 `/agents` 动态创建、更新、移除访客。 + +### 2) 固定可复用 Join Key 机制 +- 一次性 key 改为固定可复用 key:`ocj_starteam01` ~ `ocj_starteam08`。 +- 去掉了“used 即不可再用”的阻断逻辑,支持长期复用。 +- 加入了并发上限配置(`maxConcurrent`),默认每个 key 限 3 并发在线。 + +### 3) 并发限制修复(关键) +- 发现 4 并发仍能通过的根因是后端竞争条件(race condition)。 +- 在 `join-agent` 临界区增加锁 + 锁内重读状态,修复后压测通过: + - 前 3 个 200 + - 第 4 个 429 + +### 4) 访客动画与性能优化 +- 访客动画改为像素动画精灵,不再是静态星星。 +- `guest_anim_1~6` 已转为 `.webp`,显著降低加载体积。 +- 前端预加载与渲染资源已切换到 webp 优先。 + +### 5) 状态 → 区域映射统一 +- 规则统一: + - `idle -> breakroom` + - `writing/researching/executing/syncing -> writing` + - `error -> error` +- 访客 bubble 文案已按状态做映射,不再与区域脱节。 + +### 6) 名字与气泡层级/位置优化 +- 非 demo 访客名字、气泡位置上移,减少遮挡。 +- 访客气泡锚点改为相对名字计算,确保“气泡在名字上方”。 +- demo 与真实访客路径已区分,互不干扰。 + +### 7) 手机版展示 +- 现有 UI 在手机端可访问与展示,适合演示与外部查看。 +- 关键控件布局做过整理,移动端基本可用。 + +--- + +## 三、当前未完全闭环点(诚实披露) + +### 阿文龙虾“真实状态稳定同步”仍存在偶发不一致 +虽然链路已多次验证打通(writing 能进工作区、idle 能回休息区),但线上实测仍出现过: +- 本地脚本持续推 idle(旧版本脚本 / 读错状态源) +- 403 未授权(离线状态恢复/旧 agentId 缓存问题) +- 前台退出触发 leave-agent 后角色消失 + +> 结论: +> - “机制可行、链路可通”已经验证; +> - “端到端持续稳定”还需要继续收口(尤其阿文侧运行脚本版本统一、状态源统一、常驻策略统一)。 + +--- + +## 四、今天新增/调整文件(核心) + +- `backend/app.py` + - join 并发限制加锁修复 + - offline/approved 授权流逻辑调整(便于恢复) +- `join-keys.json` + - 固定 key + `maxConcurrent: 3` +- `frontend/index.html`(及相关渲染逻辑) + - 访客动画、名字与气泡定位优化 + - 状态文案映射调整 +- `office-agent-push.py`(多版本并行调试) + - 增加状态源诊断日志 + - 增加环境变量覆盖逻辑 + - 修复 AGENT_NAME 读取时机问题 + +--- + +## 五、对外开源前建议描述(建议文案) + +> Star Office UI 是一个可视化多 Agent 像素办公室: +> 支持多个 OpenClaw 远端接入、状态驱动位置渲染、访客动画与移动端访问。 +> 项目当前已完成多 Agent 主链路与 UI 能力;状态同步稳定性仍在持续优化中。 + +--- + +## 六、下一步(建议) +1. 统一阿文侧运行脚本“唯一来源”,避免旧版本混跑。 +2. 增加 `/agent-push` 与前端渲染诊断日志(可开关)。 +3. 增加“状态过期自动 idle”兜底(脚本侧 + 服务端侧双保险)。 +4. 补一份可复现联调流程(10 分钟 smoke test)。 +5. 完成开源前隐私清理与发布清单(见 `docs/OPEN_SOURCE_RELEASE_CHECKLIST.md`)。 diff --git a/docs/STAR_OFFICE_UI_OVERVIEW.md b/docs/STAR_OFFICE_UI_OVERVIEW.md new file mode 100644 index 0000000..9cbd23f --- /dev/null +++ b/docs/STAR_OFFICE_UI_OVERVIEW.md @@ -0,0 +1,61 @@ +# Star Office UI — 功能说明(Overview) + +Star Office UI 是一个“像素办公室”可视化界面,用来把 AI 助手/多个 OpenClaw 访客的状态,渲染成可在网页(含手机)查看的小办公室场景。 + +## 你能看到什么 +- 像素办公室背景(俯视图) +- 角色(Star + 访客)会根据状态在不同区域移动 +- 名字与气泡(bubble)展示当前状态/想法(可自定义映射) +- 手机端打开也能展示(适合作品展示/直播/对外演示) + +## 核心能力 + +### 1) 单 Agent(本地 Star)状态渲染 +- 后端读取 `state.json` 提供 `GET /status` +- 前端轮询 `/status`,根据 `state` 渲染 Star 所在区域 +- 提供 `set_state.py` 快速切换状态 + +### 2) 多访客(多龙虾)加入办公室 +- 访客通过 `POST /join-agent` 加入,获得 `agentId` +- 访客通过 `POST /agent-push` 持续推送自己的状态 +- 前端通过 `GET /agents` 拉取访客列表并渲染 + +### 3) Join Key(接入密钥)机制 +- 支持固定可复用 join key(如 `ocj_starteam01~08`) +- 支持每个 key 的并发在线上限(默认 3) +- 便于控制“谁能进办公室”和“同一个 key 同时可进几只龙虾” + +### 4) 状态 → 区域映射(统一逻辑) +- idle → breakroom(休息区) +- writing / researching / executing / syncing → writing(工作区) +- error → error(故障区) + +### 5) 访客动画与性能优化 +- 访客角色使用动画精灵 +- 支持 WebP 资源(体积更小、加载更快) + +### 6) 名字/气泡不遮挡的布局 +- 真实访客与 demo 访客分离逻辑 +- 非 demo 访客名字与气泡整体上移 +- bubble 锚定在名字上方,避免压住名字 + +### 7) Demo 模式(可选) +- `?demo=1` 才显示 demo 访客(默认不显示) +- demo 与真实访客互不影响 + +## 主要接口(Backend) +- `GET /`:前端页面 +- `GET /status`:单 agent 状态(兼容旧版) +- `GET /agents`:多 agent 列表(访客渲染用) +- `POST /join-agent`:访客加入 +- `POST /agent-push`:访客推送状态 +- `POST /leave-agent`:访客离开 +- `GET /health`:健康检查 + +## 安全与隐私注意 +- 不要把隐私信息写进 `detail`(因为会被渲染/可被拉取) +- 开源前必须清理:日志、运行态文件、join keys、隧道输出等 + +## 美术资产使用声明(必须) +- 代码可开源,但美术素材(背景、角色、动画等)版权归原作者/工作室所有。 +- 美术资产仅供学习与演示,**禁止商用**。 diff --git a/frontend/cats-spritesheet.png b/frontend/cats-spritesheet.png new file mode 100644 index 0000000..b5d6e11 Binary files /dev/null and b/frontend/cats-spritesheet.png differ diff --git a/frontend/cats-spritesheet.webp b/frontend/cats-spritesheet.webp new file mode 100644 index 0000000..8c1bd47 Binary files /dev/null and b/frontend/cats-spritesheet.webp differ diff --git a/frontend/coffee-machine-spritesheet.png b/frontend/coffee-machine-spritesheet.png new file mode 100644 index 0000000..cc38670 Binary files /dev/null and b/frontend/coffee-machine-spritesheet.png differ diff --git a/frontend/coffee-machine-spritesheet.webp b/frontend/coffee-machine-spritesheet.webp new file mode 100644 index 0000000..21832c6 Binary files /dev/null and b/frontend/coffee-machine-spritesheet.webp differ diff --git a/frontend/coffee-machine.gif b/frontend/coffee-machine.gif new file mode 100644 index 0000000..3d24971 Binary files /dev/null and b/frontend/coffee-machine.gif differ diff --git a/frontend/demo_mercury.png b/frontend/demo_mercury.png new file mode 100644 index 0000000..9786fb7 Binary files /dev/null and b/frontend/demo_mercury.png differ diff --git a/frontend/demo_mercury.webp b/frontend/demo_mercury.webp new file mode 100644 index 0000000..6ee2a0a Binary files /dev/null and b/frontend/demo_mercury.webp differ diff --git a/frontend/demo_nika.png b/frontend/demo_nika.png new file mode 100644 index 0000000..f78b996 Binary files /dev/null and b/frontend/demo_nika.png differ diff --git a/frontend/demo_nika.webp b/frontend/demo_nika.webp new file mode 100644 index 0000000..ad4f283 Binary files /dev/null and b/frontend/demo_nika.webp differ diff --git a/frontend/desk-v2.jpg b/frontend/desk-v2.jpg new file mode 100644 index 0000000..feaf759 Binary files /dev/null and b/frontend/desk-v2.jpg differ diff --git a/frontend/desk-v2.png b/frontend/desk-v2.png new file mode 100644 index 0000000..f208935 Binary files /dev/null and b/frontend/desk-v2.png differ diff --git a/frontend/desk-v2.webp b/frontend/desk-v2.webp new file mode 100644 index 0000000..4fa3429 Binary files /dev/null and b/frontend/desk-v2.webp differ diff --git a/frontend/desk.png b/frontend/desk.png new file mode 100644 index 0000000..a393bec Binary files /dev/null and b/frontend/desk.png differ diff --git a/frontend/desk.webp b/frontend/desk.webp new file mode 100644 index 0000000..1bdb302 Binary files /dev/null and b/frontend/desk.webp differ diff --git a/frontend/error-bug-spritesheet-grid.png b/frontend/error-bug-spritesheet-grid.png new file mode 100644 index 0000000..daeda0c Binary files /dev/null and b/frontend/error-bug-spritesheet-grid.png differ diff --git a/frontend/error-bug-spritesheet-grid.webp b/frontend/error-bug-spritesheet-grid.webp new file mode 100644 index 0000000..13dc219 Binary files /dev/null and b/frontend/error-bug-spritesheet-grid.webp differ diff --git a/frontend/error-bug-spritesheet.png b/frontend/error-bug-spritesheet.png new file mode 100644 index 0000000..ef7721b Binary files /dev/null and b/frontend/error-bug-spritesheet.png differ diff --git a/frontend/error-bug.webp b/frontend/error-bug.webp new file mode 100644 index 0000000..3104f3e Binary files /dev/null and b/frontend/error-bug.webp differ diff --git a/frontend/flowers-spritesheet.png b/frontend/flowers-spritesheet.png new file mode 100644 index 0000000..1cb0246 Binary files /dev/null and b/frontend/flowers-spritesheet.png differ diff --git a/frontend/flowers-spritesheet.webp b/frontend/flowers-spritesheet.webp new file mode 100644 index 0000000..337d27b Binary files /dev/null and b/frontend/flowers-spritesheet.webp differ diff --git a/frontend/fonts/OFL.txt b/frontend/fonts/OFL.txt new file mode 100644 index 0000000..a5a5d7e --- /dev/null +++ b/frontend/fonts/OFL.txt @@ -0,0 +1,94 @@ +Copyright (c) 2021, TakWolf (https://takwolf.com), +with Reserved Font Name "Ark Pixel". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/frontend/fonts/ark-pixel-12px-proportional-ja.ttf.woff2 b/frontend/fonts/ark-pixel-12px-proportional-ja.ttf.woff2 new file mode 100644 index 0000000..4868790 Binary files /dev/null and b/frontend/fonts/ark-pixel-12px-proportional-ja.ttf.woff2 differ diff --git a/frontend/fonts/ark-pixel-12px-proportional-ko.ttf.woff2 b/frontend/fonts/ark-pixel-12px-proportional-ko.ttf.woff2 new file mode 100644 index 0000000..d383efa Binary files /dev/null and b/frontend/fonts/ark-pixel-12px-proportional-ko.ttf.woff2 differ diff --git a/frontend/fonts/ark-pixel-12px-proportional-latin.ttf.woff2 b/frontend/fonts/ark-pixel-12px-proportional-latin.ttf.woff2 new file mode 100644 index 0000000..0a74112 Binary files /dev/null and b/frontend/fonts/ark-pixel-12px-proportional-latin.ttf.woff2 differ diff --git a/frontend/fonts/ark-pixel-12px-proportional-zh_cn.ttf.woff2 b/frontend/fonts/ark-pixel-12px-proportional-zh_cn.ttf.woff2 new file mode 100644 index 0000000..f46bea8 Binary files /dev/null and b/frontend/fonts/ark-pixel-12px-proportional-zh_cn.ttf.woff2 differ diff --git a/frontend/fonts/ark-pixel-12px-proportional-zh_hk.ttf.woff2 b/frontend/fonts/ark-pixel-12px-proportional-zh_hk.ttf.woff2 new file mode 100644 index 0000000..8341e69 Binary files /dev/null and b/frontend/fonts/ark-pixel-12px-proportional-zh_hk.ttf.woff2 differ diff --git a/frontend/fonts/ark-pixel-12px-proportional-zh_tr.ttf.woff2 b/frontend/fonts/ark-pixel-12px-proportional-zh_tr.ttf.woff2 new file mode 100644 index 0000000..11c9ed3 Binary files /dev/null and b/frontend/fonts/ark-pixel-12px-proportional-zh_tr.ttf.woff2 differ diff --git a/frontend/fonts/ark-pixel-12px-proportional-zh_tw.ttf.woff2 b/frontend/fonts/ark-pixel-12px-proportional-zh_tw.ttf.woff2 new file mode 100644 index 0000000..fb9ab71 Binary files /dev/null and b/frontend/fonts/ark-pixel-12px-proportional-zh_tw.ttf.woff2 differ diff --git a/frontend/fonts/ark-pixel-font-12px-proportional-ttf.woff2-v2025.10.20.zip b/frontend/fonts/ark-pixel-font-12px-proportional-ttf.woff2-v2025.10.20.zip new file mode 100644 index 0000000..ef49563 Binary files /dev/null and b/frontend/fonts/ark-pixel-font-12px-proportional-ttf.woff2-v2025.10.20.zip differ diff --git a/frontend/game.js b/frontend/game.js new file mode 100644 index 0000000..7d73179 --- /dev/null +++ b/frontend/game.js @@ -0,0 +1,1000 @@ +// Star Office UI - 游戏主逻辑 +// 依赖: layout.js(必须在这个之前加载) + +// 检测浏览器是否支持 WebP +let supportsWebP = false; + +// 方法 1: 使用 canvas 检测 +function checkWebPSupport() { + return new Promise((resolve) => { + const canvas = document.createElement('canvas'); + if (canvas.getContext && canvas.getContext('2d')) { + resolve(canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0); + } else { + resolve(false); + } + }); +} + +// 方法 2: 使用 image 检测(备用) +function checkWebPSupportFallback() { + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => resolve(true); + img.onerror = () => resolve(false); + img.src = 'data:image/webp;base64,UklGRkoAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAwAAAABBxAR/Q9ERP8DAABWUDggGAAAADABAJ0BKgEAAQADADQlpAADcAD++/1QAA=='; + }); +} + +// 获取文件扩展名(根据 WebP 支持情况 + 布局配置的 forcePng) +function getExt(pngFile) { + // star-working-spritesheet.png 太宽了,WebP 不支持,始终用 PNG + if (pngFile === 'star-working-spritesheet.png') { + return '.png'; + } + // 如果布局配置里强制用 PNG,就用 .png + if (LAYOUT.forcePng && LAYOUT.forcePng[pngFile.replace(/\.(png|webp)$/, '')]) { + return '.png'; + } + return supportsWebP ? '.webp' : '.png'; +} + +const config = { + type: Phaser.AUTO, + width: LAYOUT.game.width, + height: LAYOUT.game.height, + parent: 'game-container', + pixelArt: true, + physics: { default: 'arcade', arcade: { gravity: { y: 0 }, debug: false } }, + scene: { preload: preload, create: create, update: update } +}; + +let totalAssets = 0; +let loadedAssets = 0; +let loadingProgressBar, loadingProgressContainer, loadingOverlay, loadingText; + +// Memo 相关函数 +async function loadMemo() { + const memoDate = document.getElementById('memo-date'); + const memoContent = document.getElementById('memo-content'); + + try { + const response = await fetch('/yesterday-memo?t=' + Date.now(), { cache: 'no-store' }); + const data = await response.json(); + + if (data.success && data.memo) { + memoDate.textContent = data.date || ''; + memoContent.innerHTML = data.memo.replace(/\n/g, '
'); + } else { + memoContent.innerHTML = '
暂无昨日日记
'; + } + } catch (e) { + console.error('加载 memo 失败:', e); + memoContent.innerHTML = '
加载失败
'; + } +} + +// 更新加载进度 +function updateLoadingProgress() { + loadedAssets++; + const percent = Math.min(100, Math.round((loadedAssets / totalAssets) * 100)); + if (loadingProgressBar) { + loadingProgressBar.style.width = percent + '%'; + } + if (loadingText) { + loadingText.textContent = `正在加载 Star 的像素办公室... ${percent}%`; + } +} + +// 隐藏加载界面 +function hideLoadingOverlay() { + setTimeout(() => { + if (loadingOverlay) { + loadingOverlay.style.transition = 'opacity 0.5s ease'; + loadingOverlay.style.opacity = '0'; + setTimeout(() => { + loadingOverlay.style.display = 'none'; + }, 500); + } + }, 300); +} + +const STATES = { + idle: { name: '待命', area: 'breakroom' }, + writing: { name: '整理文档', area: 'writing' }, + researching: { name: '搜索信息', area: 'researching' }, + executing: { name: '执行任务', area: 'writing' }, + syncing: { name: '同步备份', area: 'writing' }, + error: { name: '出错了', area: 'error' } +}; + +const BUBBLE_TEXTS = { + idle: [ + '待命中:耳朵竖起来了', + '我在这儿,随时可以开工', + '先把桌面收拾干净再说', + '呼——给大脑放个风', + '今天也要优雅地高效', + '等待,是为了更准确的一击', + '咖啡还热,灵感也还在', + '我在后台给你加 Buff', + '状态:静心 / 充电', + '小猫说:慢一点也没关系' + ], + writing: [ + '进入专注模式:勿扰', + '先把关键路径跑通', + '我来把复杂变简单', + '把 bug 关进笼子里', + '写到一半,先保存', + '把每一步都做成可回滚', + '今天的进度,明天的底气', + '先收敛,再发散', + '让系统变得更可解释', + '稳住,我们能赢' + ], + researching: [ + '我在挖证据链', + '让我把信息熬成结论', + '找到了:关键在这里', + '先把变量控制住', + '我在查:它为什么会这样', + '把直觉写成验证', + '先定位,再优化', + '别急,先画因果图' + ], + executing: [ + '执行中:不要眨眼', + '把任务切成小块逐个击破', + '开始跑 pipeline', + '一键推进:走你', + '让结果自己说话', + '先做最小可行,再做最美版本' + ], + syncing: [ + '同步中:把今天锁进云里', + '备份不是仪式,是安全感', + '写入中…别断电', + '把变更交给时间戳', + '云端对齐:咔哒', + '同步完成前先别乱动', + '把未来的自己从灾难里救出来', + '多一份备份,少一份后悔' + ], + error: [ + '警报响了:先别慌', + '我闻到 bug 的味道了', + '先复现,再谈修复', + '把日志给我,我会说人话', + '错误不是敌人,是线索', + '把影响面圈起来', + '先止血,再手术', + '我在:马上定位根因', + '别怕,这种我见多了', + '报警中:让问题自己现形' + ], + cat: [ + '喵~', + '咕噜咕噜…', + '尾巴摇一摇', + '晒太阳最开心', + '有人来看我啦', + '我是这个办公室的吉祥物', + '伸个懒腰', + '今天的罐罐准备好了吗', + '呼噜呼噜', + '这个位置视野最好' + ] +}; + +let game, star, sofa, serverroom, areas = {}, currentState = 'idle', pendingDesiredState = null, statusText, lastFetch = 0, lastBlink = 0, lastBubble = 0, targetX = 660, targetY = 170, bubble = null, typewriterText = '', typewriterTarget = '', typewriterIndex = 0, lastTypewriter = 0, syncAnimSprite = null, catBubble = null; +let isMoving = false; +let waypoints = []; +let lastWanderAt = 0; +let coordsOverlay, coordsDisplay, coordsToggle; +let showCoords = false; +const FETCH_INTERVAL = 2000; +const BLINK_INTERVAL = 2500; +const BUBBLE_INTERVAL = 8000; +const CAT_BUBBLE_INTERVAL = 18000; +let lastCatBubble = 0; +const TYPEWRITER_DELAY = 50; +let agents = {}; // agentId -> sprite/container +let lastAgentsFetch = 0; +const AGENTS_FETCH_INTERVAL = 2500; + +// agent 颜色配置 +const AGENT_COLORS = { + star: 0xffd700, + npc1: 0x00aaff, + agent_nika: 0xff69b4, + default: 0x94a3b8 +}; + +// agent 名字颜色 +const NAME_TAG_COLORS = { + approved: 0x22c55e, + pending: 0xf59e0b, + rejected: 0xef4444, + offline: 0x64748b, + default: 0x1f2937 +}; + +// breakroom / writing / error 区域的 agent 分布位置(多 agent 时错开) +const AREA_POSITIONS = { + breakroom: [ + { x: 620, y: 180 }, + { x: 560, y: 220 }, + { x: 680, y: 210 } + ], + writing: [ + { x: 760, y: 320 }, + { x: 830, y: 280 }, + { x: 690, y: 350 } + ], + error: [ + { x: 180, y: 260 }, + { x: 120, y: 220 }, + { x: 240, y: 230 } + ] +}; + +let areaPositionCounters = { breakroom: 0, writing: 0, error: 0 }; + + +// 状态控制栏函数(用于测试) +function setState(state, detail) { + fetch('/set_state', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ state, detail }) + }).then(() => fetchStatus()); +} + +// 初始化:先检测 WebP 支持,再启动游戏 +async function initGame() { + try { + supportsWebP = await checkWebPSupport(); + } catch (e) { + try { + supportsWebP = await checkWebPSupportFallback(); + } catch (e2) { + supportsWebP = false; + } + } + + console.log('WebP 支持:', supportsWebP); + new Phaser.Game(config); +} + +function preload() { + loadingOverlay = document.getElementById('loading-overlay'); + loadingProgressBar = document.getElementById('loading-progress-bar'); + loadingText = document.getElementById('loading-text'); + loadingProgressContainer = document.getElementById('loading-progress-container'); + + // 从 LAYOUT 读取总资源数量(避免 magic number) + totalAssets = LAYOUT.totalAssets || 15; + loadedAssets = 0; + + this.load.on('filecomplete', () => { + updateLoadingProgress(); + }); + + this.load.on('complete', () => { + hideLoadingOverlay(); + }); + + this.load.image('office_bg', '/static/office_bg_small' + (supportsWebP ? '.webp' : '.png') + '?v={{VERSION_TIMESTAMP}}'); + this.load.spritesheet('star_idle', '/static/star-idle-spritesheet' + getExt('star-idle-spritesheet.png'), { frameWidth: 128, frameHeight: 128 }); + this.load.spritesheet('star_researching', '/static/star-researching-spritesheet' + getExt('star-researching-spritesheet.png'), { frameWidth: 128, frameHeight: 105 }); + + this.load.image('sofa_idle', '/static/sofa-idle' + getExt('sofa-idle.png')); + this.load.spritesheet('sofa_busy', '/static/sofa-busy-spritesheet' + getExt('sofa-busy-spritesheet.png'), { frameWidth: 256, frameHeight: 256 }); + + this.load.spritesheet('plants', '/static/plants-spritesheet' + getExt('plants-spritesheet.png'), { frameWidth: 160, frameHeight: 160 }); + this.load.spritesheet('posters', '/static/posters-spritesheet' + getExt('posters-spritesheet.png'), { frameWidth: 160, frameHeight: 160 }); + this.load.spritesheet('coffee_machine', '/static/coffee-machine-spritesheet' + getExt('coffee-machine-spritesheet.png'), { frameWidth: 230, frameHeight: 230 }); + this.load.spritesheet('serverroom', '/static/serverroom-spritesheet' + getExt('serverroom-spritesheet.png'), { frameWidth: 180, frameHeight: 251 }); + + this.load.spritesheet('error_bug', '/static/error-bug-spritesheet-grid' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 180, frameHeight: 180 }); + this.load.spritesheet('cats', '/static/cats-spritesheet' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 160, frameHeight: 160 }); + this.load.image('desk', '/static/desk' + getExt('desk.png')); + this.load.spritesheet('star_working', '/static/star-working-spritesheet-grid' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 230, frameHeight: 144 }); + this.load.spritesheet('sync_anim', '/static/sync-animation-spritesheet-grid' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 256, frameHeight: 256 }); + this.load.image('memo_bg', '/static/memo-bg' + (supportsWebP ? '.webp' : '.png')); + + // 新办公桌:强制 PNG(透明) + this.load.image('desk_v2', '/static/desk-v2.png'); + this.load.spritesheet('flowers', '/static/flowers-spritesheet' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 65, frameHeight: 65 }); +} + +function create() { + game = this; + this.add.image(640, 360, 'office_bg'); + + // === 沙发(来自 LAYOUT)=== + sofa = this.add.sprite( + LAYOUT.furniture.sofa.x, + LAYOUT.furniture.sofa.y, + 'sofa_busy' + ).setOrigin(LAYOUT.furniture.sofa.origin.x, LAYOUT.furniture.sofa.origin.y); + sofa.setDepth(LAYOUT.furniture.sofa.depth); + + this.anims.create({ + key: 'sofa_busy', + frames: this.anims.generateFrameNumbers('sofa_busy', { start: 0, end: 47 }), + frameRate: 12, + repeat: -1 + }); + + areas = LAYOUT.areas; + + this.anims.create({ + key: 'star_idle', + frames: this.anims.generateFrameNumbers('star_idle', { start: 0, end: 29 }), + frameRate: 12, + repeat: -1 + }); + this.anims.create({ + key: 'star_researching', + frames: this.anims.generateFrameNumbers('star_researching', { start: 0, end: 95 }), + frameRate: 12, + repeat: -1 + }); + + star = game.physics.add.sprite(areas.breakroom.x, areas.breakroom.y, 'star_idle'); + star.setOrigin(0.5); + star.setScale(1.4); + star.setAlpha(0.95); + star.setDepth(20); + star.setVisible(false); + star.anims.stop(); + + if (game.textures.exists('sofa_busy')) { + sofa.setTexture('sofa_busy'); + sofa.anims.play('sofa_busy', true); + } + + // === 牌匾(来自 LAYOUT)=== + const plaqueX = LAYOUT.plaque.x; + const plaqueY = LAYOUT.plaque.y; + const plaqueBg = game.add.rectangle(plaqueX, plaqueY, LAYOUT.plaque.width, LAYOUT.plaque.height, 0x5d4037); + plaqueBg.setStrokeStyle(3, 0x3e2723); + const plaqueText = game.add.text(plaqueX, plaqueY, '海辛小龙虾的办公室', { + fontFamily: 'ArkPixel, monospace', + fontSize: '18px', + fill: '#ffd700', + fontWeight: 'bold', + stroke: '#000', + strokeThickness: 2 + }).setOrigin(0.5); + game.add.text(plaqueX - 190, plaqueY, '⭐', { fontFamily: 'ArkPixel, monospace', fontSize: '20px' }).setOrigin(0.5); + game.add.text(plaqueX + 190, plaqueY, '⭐', { fontFamily: 'ArkPixel, monospace', fontSize: '20px' }).setOrigin(0.5); + + // === 植物们(来自 LAYOUT)=== + const plantFrameCount = 16; + for (let i = 0; i < LAYOUT.furniture.plants.length; i++) { + const p = LAYOUT.furniture.plants[i]; + const randomPlantFrame = Math.floor(Math.random() * plantFrameCount); + const plant = game.add.sprite(p.x, p.y, 'plants', randomPlantFrame).setOrigin(0.5); + plant.setDepth(p.depth); + plant.setInteractive({ useHandCursor: true }); + window[`plantSprite${i === 0 ? '' : i + 1}`] = plant; + plant.on('pointerdown', (() => { + const next = Math.floor(Math.random() * plantFrameCount); + plant.setFrame(next); + })); + } + + // === 海报(来自 LAYOUT)=== + const postersFrameCount = 32; + const randomPosterFrame = Math.floor(Math.random() * postersFrameCount); + const poster = game.add.sprite(LAYOUT.furniture.poster.x, LAYOUT.furniture.poster.y, 'posters', randomPosterFrame).setOrigin(0.5); + poster.setDepth(LAYOUT.furniture.poster.depth); + poster.setInteractive({ useHandCursor: true }); + window.posterSprite = poster; + window.posterFrameCount = postersFrameCount; + poster.on('pointerdown', () => { + const next = Math.floor(Math.random() * window.posterFrameCount); + window.posterSprite.setFrame(next); + }); + + // === 小猫(来自 LAYOUT)=== + const catsFrameCount = 16; + const randomCatFrame = Math.floor(Math.random() * catsFrameCount); + const cat = game.add.sprite(LAYOUT.furniture.cat.x, LAYOUT.furniture.cat.y, 'cats', randomCatFrame).setOrigin(LAYOUT.furniture.cat.origin.x, LAYOUT.furniture.cat.origin.y); + cat.setDepth(LAYOUT.furniture.cat.depth); + cat.setInteractive({ useHandCursor: true }); + window.catSprite = cat; + window.catsFrameCount = catsFrameCount; + cat.on('pointerdown', () => { + const next = Math.floor(Math.random() * window.catsFrameCount); + window.catSprite.setFrame(next); + }); + + // === 咖啡机(来自 LAYOUT)=== + this.anims.create({ + key: 'coffee_machine', + frames: this.anims.generateFrameNumbers('coffee_machine', { start: 0, end: 95 }), + frameRate: 12.5, + repeat: -1 + }); + const coffeeMachine = this.add.sprite( + LAYOUT.furniture.coffeeMachine.x, + LAYOUT.furniture.coffeeMachine.y, + 'coffee_machine' + ).setOrigin(LAYOUT.furniture.coffeeMachine.origin.x, LAYOUT.furniture.coffeeMachine.origin.y); + coffeeMachine.setDepth(LAYOUT.furniture.coffeeMachine.depth); + coffeeMachine.anims.play('coffee_machine', true); + + // === 服务器区(来自 LAYOUT)=== + this.anims.create({ + key: 'serverroom_on', + frames: this.anims.generateFrameNumbers('serverroom', { start: 0, end: 39 }), + frameRate: 6, + repeat: -1 + }); + serverroom = this.add.sprite( + LAYOUT.furniture.serverroom.x, + LAYOUT.furniture.serverroom.y, + 'serverroom', + 0 + ).setOrigin(LAYOUT.furniture.serverroom.origin.x, LAYOUT.furniture.serverroom.origin.y); + serverroom.setDepth(LAYOUT.furniture.serverroom.depth); + serverroom.anims.stop(); + serverroom.setFrame(0); + + // === 新办公桌(来自 LAYOUT,强制透明 PNG)=== + const desk = this.add.image( + LAYOUT.furniture.desk.x, + LAYOUT.furniture.desk.y, + 'desk_v2' + ).setOrigin(LAYOUT.furniture.desk.origin.x, LAYOUT.furniture.desk.origin.y); + desk.setDepth(LAYOUT.furniture.desk.depth); + + // === 花盆(来自 LAYOUT)=== + const flowerFrameCount = 16; + const randomFlowerFrame = Math.floor(Math.random() * flowerFrameCount); + const flower = this.add.sprite( + LAYOUT.furniture.flower.x, + LAYOUT.furniture.flower.y, + 'flowers', + randomFlowerFrame + ).setOrigin(LAYOUT.furniture.flower.origin.x, LAYOUT.furniture.flower.origin.y); + flower.setDepth(LAYOUT.furniture.flower.depth); + flower.setInteractive({ useHandCursor: true }); + window.flowerSprite = flower; + window.flowerFrameCount = flowerFrameCount; + flower.on('pointerdown', () => { + const next = Math.floor(Math.random() * window.flowerFrameCount); + window.flowerSprite.setFrame(next); + }); + + // === Star 在桌前工作(来自 LAYOUT)=== + this.anims.create({ + key: 'star_working', + frames: this.anims.generateFrameNumbers('star_working', { start: 0, end: 191 }), + frameRate: 12, + repeat: -1 + }); + this.anims.create({ + key: 'error_bug', + frames: this.anims.generateFrameNumbers('error_bug', { start: 0, end: 95 }), + frameRate: 12, + repeat: -1 + }); + + // === 错误 bug(来自 LAYOUT)=== + const errorBug = this.add.sprite( + LAYOUT.furniture.errorBug.x, + LAYOUT.furniture.errorBug.y, + 'error_bug', + 0 + ).setOrigin(LAYOUT.furniture.errorBug.origin.x, LAYOUT.furniture.errorBug.origin.y); + errorBug.setDepth(LAYOUT.furniture.errorBug.depth); + errorBug.setVisible(false); + errorBug.setScale(LAYOUT.furniture.errorBug.scale); + errorBug.anims.play('error_bug', true); + window.errorBug = errorBug; + window.errorBugDir = 1; + + const starWorking = this.add.sprite( + LAYOUT.furniture.starWorking.x, + LAYOUT.furniture.starWorking.y, + 'star_working', + 0 + ).setOrigin(LAYOUT.furniture.starWorking.origin.x, LAYOUT.furniture.starWorking.origin.y); + starWorking.setVisible(false); + starWorking.setScale(LAYOUT.furniture.starWorking.scale); + starWorking.setDepth(LAYOUT.furniture.starWorking.depth); + window.starWorking = starWorking; + + // === 同步动画(来自 LAYOUT)=== + this.anims.create({ + key: 'sync_anim', + frames: this.anims.generateFrameNumbers('sync_anim', { start: 1, end: 52 }), + frameRate: 12, + repeat: -1 + }); + syncAnimSprite = this.add.sprite( + LAYOUT.furniture.syncAnim.x, + LAYOUT.furniture.syncAnim.y, + 'sync_anim', + 0 + ).setOrigin(LAYOUT.furniture.syncAnim.origin.x, LAYOUT.furniture.syncAnim.origin.y); + syncAnimSprite.setDepth(LAYOUT.furniture.syncAnim.depth); + syncAnimSprite.anims.stop(); + syncAnimSprite.setFrame(0); + + window.starSprite = star; + + statusText = document.getElementById('status-text'); + coordsOverlay = document.getElementById('coords-overlay'); + coordsDisplay = document.getElementById('coords-display'); + coordsToggle = document.getElementById('coords-toggle'); + + coordsToggle.addEventListener('click', () => { + showCoords = !showCoords; + coordsOverlay.style.display = showCoords ? 'block' : 'none'; + coordsToggle.textContent = showCoords ? '隐藏坐标' : '显示坐标'; + coordsToggle.style.background = showCoords ? '#e94560' : '#333'; + }); + + game.input.on('pointermove', (pointer) => { + if (!showCoords) return; + const x = Math.max(0, Math.min(config.width - 1, Math.round(pointer.x))); + const y = Math.max(0, Math.min(config.height - 1, Math.round(pointer.y))); + coordsDisplay.textContent = `${x}, ${y}`; + coordsOverlay.style.left = (pointer.x + 18) + 'px'; + coordsOverlay.style.top = (pointer.y + 18) + 'px'; + }); + + loadMemo(); + fetchStatus(); + // 先强制加一个测试用的尼卡 agent 渲染 + const testNika = { + agentId: 'agent_nika', + name: '尼卡', + isMain: false, + state: 'writing', + detail: '在画像素画...', + area: 'writing', + authStatus: 'approved', + updated_at: new Date().toISOString() + }; + renderAgent(testNika); + fetchAgents(); + + // 测试用:让尼卡模拟走来走去 + window.testNikaState = 'writing'; + window.testNikaTimer = setInterval(() => { + const states = ['idle', 'writing', 'researching', 'executing']; + const areas = { idle: 'breakroom', writing: 'writing', researching: 'writing', executing: 'writing' }; + window.testNikaState = states[Math.floor(Math.random() * states.length)]; + const testAgent = { + agentId: 'agent_nika', + name: '尼卡', + isMain: false, + state: window.testNikaState, + detail: '在画像素画...', + area: areas[window.testNikaState], + authStatus: 'approved', + updated_at: new Date().toISOString() + }; + renderAgent(testAgent); + }, 5000); +} + +function update(time) { + if (time - lastFetch > FETCH_INTERVAL) { fetchStatus(); lastFetch = time; } + if (time - lastAgentsFetch > AGENTS_FETCH_INTERVAL) { fetchAgents(); lastAgentsFetch = time; } + + const effectiveStateForServer = pendingDesiredState || currentState; + if (serverroom) { + if (effectiveStateForServer === 'idle') { + if (serverroom.anims.isPlaying) { + serverroom.anims.stop(); + serverroom.setFrame(0); + } + } else { + if (!serverroom.anims.isPlaying || serverroom.anims.currentAnim?.key !== 'serverroom_on') { + serverroom.anims.play('serverroom_on', true); + } + } + } + + if (window.errorBug) { + if (effectiveStateForServer === 'error') { + window.errorBug.setVisible(true); + if (!window.errorBug.anims.isPlaying || window.errorBug.anims.currentAnim?.key !== 'error_bug') { + window.errorBug.anims.play('error_bug', true); + } + const leftX = LAYOUT.furniture.errorBug.pingPong.leftX; + const rightX = LAYOUT.furniture.errorBug.pingPong.rightX; + const speed = LAYOUT.furniture.errorBug.pingPong.speed; + const dir = window.errorBugDir || 1; + window.errorBug.x += speed * dir; + window.errorBug.y = LAYOUT.furniture.errorBug.y; + if (window.errorBug.x >= rightX) { + window.errorBug.x = rightX; + window.errorBugDir = -1; + } else if (window.errorBug.x <= leftX) { + window.errorBug.x = leftX; + window.errorBugDir = 1; + } + } else { + window.errorBug.setVisible(false); + window.errorBug.anims.stop(); + } + } + + if (syncAnimSprite) { + if (effectiveStateForServer === 'syncing') { + if (!syncAnimSprite.anims.isPlaying || syncAnimSprite.anims.currentAnim?.key !== 'sync_anim') { + syncAnimSprite.anims.play('sync_anim', true); + } + } else { + if (syncAnimSprite.anims.isPlaying) syncAnimSprite.anims.stop(); + syncAnimSprite.setFrame(0); + } + } + + if (time - lastBubble > BUBBLE_INTERVAL) { + showBubble(); + lastBubble = time; + } + if (time - lastCatBubble > CAT_BUBBLE_INTERVAL) { + showCatBubble(); + lastCatBubble = time; + } + + if (typewriterIndex < typewriterTarget.length && time - lastTypewriter > TYPEWRITER_DELAY) { + typewriterText += typewriterTarget[typewriterIndex]; + statusText.textContent = typewriterText; + typewriterIndex++; + lastTypewriter = time; + } + + moveStar(time); +} + +function normalizeState(s) { + if (!s) return 'idle'; + if (s === 'working') return 'writing'; + if (s === 'run' || s === 'running') return 'executing'; + if (s === 'sync') return 'syncing'; + if (s === 'research') return 'researching'; + return s; +} + +function fetchStatus() { + fetch('/status') + .then(response => response.json()) + .then(data => { + const nextState = normalizeState(data.state); + const stateInfo = STATES[nextState] || STATES.idle; + const changed = (pendingDesiredState === null) && (nextState !== currentState); + const nextLine = '[' + stateInfo.name + '] ' + (data.detail || '...'); + if (changed) { + typewriterTarget = nextLine; + typewriterText = ''; + typewriterIndex = 0; + + pendingDesiredState = null; + currentState = nextState; + + if (nextState === 'idle') { + if (game.textures.exists('sofa_busy')) { + sofa.setTexture('sofa_busy'); + sofa.anims.play('sofa_busy', true); + } + star.setVisible(false); + star.anims.stop(); + if (window.starWorking) { + window.starWorking.setVisible(false); + window.starWorking.anims.stop(); + } + } else if (nextState === 'error') { + sofa.anims.stop(); + sofa.setTexture('sofa_idle'); + star.setVisible(false); + star.anims.stop(); + if (window.starWorking) { + window.starWorking.setVisible(false); + window.starWorking.anims.stop(); + } + } else if (nextState === 'syncing') { + sofa.anims.stop(); + sofa.setTexture('sofa_idle'); + star.setVisible(false); + star.anims.stop(); + if (window.starWorking) { + window.starWorking.setVisible(false); + window.starWorking.anims.stop(); + } + } else { + sofa.anims.stop(); + sofa.setTexture('sofa_idle'); + star.setVisible(false); + star.anims.stop(); + if (window.starWorking) { + window.starWorking.setVisible(true); + window.starWorking.anims.play('star_working', true); + } + } + + if (serverroom) { + if (nextState === 'idle') { + serverroom.anims.stop(); + serverroom.setFrame(0); + } else { + serverroom.anims.play('serverroom_on', true); + } + } + + if (syncAnimSprite) { + if (nextState === 'syncing') { + if (!syncAnimSprite.anims.isPlaying || syncAnimSprite.anims.currentAnim?.key !== 'sync_anim') { + syncAnimSprite.anims.play('sync_anim', true); + } + } else { + if (syncAnimSprite.anims.isPlaying) syncAnimSprite.anims.stop(); + syncAnimSprite.setFrame(0); + } + } + } else { + if (!typewriterTarget || typewriterTarget !== nextLine) { + typewriterTarget = nextLine; + typewriterText = ''; + typewriterIndex = 0; + } + } + }) + .catch(error => { + typewriterTarget = '连接失败,正在重试...'; + typewriterText = ''; + typewriterIndex = 0; + }); +} + +function moveStar(time) { + const effectiveState = pendingDesiredState || currentState; + const stateInfo = STATES[effectiveState] || STATES.idle; + const baseTarget = areas[stateInfo.area] || areas.breakroom; + + const dx = targetX - star.x; + const dy = targetY - star.y; + const dist = Math.sqrt(dx * dx + dy * dy); + const speed = 1.4; + const wobble = Math.sin(time / 200) * 0.8; + + if (dist > 3) { + star.x += (dx / dist) * speed; + star.y += (dy / dist) * speed; + star.setY(star.y + wobble); + isMoving = true; + } else { + if (waypoints && waypoints.length > 0) { + waypoints.shift(); + if (waypoints.length > 0) { + targetX = waypoints[0].x; + targetY = waypoints[0].y; + isMoving = true; + } else { + if (pendingDesiredState !== null) { + isMoving = false; + currentState = pendingDesiredState; + pendingDesiredState = null; + + if (currentState === 'idle') { + star.setVisible(false); + star.anims.stop(); + if (window.starWorking) { + window.starWorking.setVisible(false); + window.starWorking.anims.stop(); + } + } else { + star.setVisible(false); + star.anims.stop(); + if (window.starWorking) { + window.starWorking.setVisible(true); + window.starWorking.anims.play('star_working', true); + } + } + } + } + } else { + if (pendingDesiredState !== null) { + isMoving = false; + currentState = pendingDesiredState; + pendingDesiredState = null; + + if (currentState === 'idle') { + star.setVisible(false); + star.anims.stop(); + if (window.starWorking) { + window.starWorking.setVisible(false); + window.starWorking.anims.stop(); + } + if (game.textures.exists('sofa_busy')) { + sofa.setTexture('sofa_busy'); + sofa.anims.play('sofa_busy', true); + } + } else { + star.setVisible(false); + star.anims.stop(); + if (window.starWorking) { + window.starWorking.setVisible(true); + window.starWorking.anims.play('star_working', true); + } + sofa.anims.stop(); + sofa.setTexture('sofa_idle'); + } + } + } + } +} + +function showBubble() { + if (bubble) { bubble.destroy(); bubble = null; } + const texts = BUBBLE_TEXTS[currentState] || BUBBLE_TEXTS.idle; + if (currentState === 'idle') return; + + let anchorX = star.x; + let anchorY = star.y; + if (currentState === 'syncing' && syncAnimSprite && syncAnimSprite.visible) { + anchorX = syncAnimSprite.x; + anchorY = syncAnimSprite.y; + } else if (currentState === 'error' && window.errorBug && window.errorBug.visible) { + anchorX = window.errorBug.x; + anchorY = window.errorBug.y; + } else if (!star.visible && window.starWorking && window.starWorking.visible) { + anchorX = window.starWorking.x; + anchorY = window.starWorking.y; + } + + const text = texts[Math.floor(Math.random() * texts.length)]; + const bubbleY = anchorY - 70; + const bg = game.add.rectangle(anchorX, bubbleY, text.length * 10 + 20, 28, 0xffffff, 0.95); + bg.setStrokeStyle(2, 0x000000); + const txt = game.add.text(anchorX, bubbleY, text, { fontFamily: 'ArkPixel, monospace', fontSize: '12px', fill: '#000', align: 'center' }).setOrigin(0.5); + bubble = game.add.container(0, 0, [bg, txt]); + bubble.setDepth(1200); + setTimeout(() => { if (bubble) { bubble.destroy(); bubble = null; } }, 3000); +} + +function showCatBubble() { + if (!window.catSprite) return; + if (window.catBubble) { window.catBubble.destroy(); window.catBubble = null; } + const texts = BUBBLE_TEXTS.cat || ['喵~', '咕噜咕噜…']; + const text = texts[Math.floor(Math.random() * texts.length)]; + const anchorX = window.catSprite.x; + const anchorY = window.catSprite.y - 60; + const bg = game.add.rectangle(anchorX, anchorY, text.length * 10 + 20, 24, 0xfffbeb, 0.95); + bg.setStrokeStyle(2, 0xd4a574); + const txt = game.add.text(anchorX, anchorY, text, { fontFamily: 'ArkPixel, monospace', fontSize: '11px', fill: '#8b6914', align: 'center' }).setOrigin(0.5); + window.catBubble = game.add.container(0, 0, [bg, txt]); + window.catBubble.setDepth(2100); + setTimeout(() => { if (window.catBubble) { window.catBubble.destroy(); window.catBubble = null; } }, 4000); +} + +function fetchAgents() { + fetch('/agents?t=' + Date.now(), { cache: 'no-store' }) + .then(response => response.json()) + .then(data => { + if (!Array.isArray(data)) return; + // 重置位置计数器 + areaPositionCounters = { breakroom: 0, writing: 0, error: 0 }; + // 处理每个 agent + for (let agent of data) { + renderAgent(agent); + } + // 移除不再存在的 agent + const currentIds = new Set(data.map(a => a.agentId)); + for (let id in agents) { + if (!currentIds.has(id)) { + if (agents[id]) { + agents[id].destroy(); + delete agents[id]; + } + } + } + }) + .catch(error => { + console.error('拉取 agents 失败:', error); + }); +} + +function getAreaPosition(area) { + const positions = AREA_POSITIONS[area] || AREA_POSITIONS.breakroom; + const idx = areaPositionCounters[area] || 0; + areaPositionCounters[area] = (idx + 1) % positions.length; + return positions[idx]; +} + +function renderAgent(agent) { + const agentId = agent.agentId; + const name = agent.name || 'Agent'; + const area = agent.area || 'breakroom'; + const authStatus = agent.authStatus || 'pending'; + const isMain = !!agent.isMain; + + // 获取这个 agent 在区域里的位置 + const pos = getAreaPosition(area); + const baseX = pos.x; + const baseY = pos.y; + + // 颜色 + const bodyColor = AGENT_COLORS[agentId] || AGENT_COLORS.default; + const nameColor = NAME_TAG_COLORS[authStatus] || NAME_TAG_COLORS.default; + + // 透明度(离线/待批准/拒绝时变半透明) + let alpha = 1; + if (authStatus === 'pending') alpha = 0.7; + if (authStatus === 'rejected') alpha = 0.4; + if (authStatus === 'offline') alpha = 0.5; + + if (!agents[agentId]) { + // 新建 agent + const container = game.add.container(baseX, baseY); + container.setDepth(1200 + (isMain ? 100 : 0)); // 放到最顶层! + + // 像素小人:用星星图标,更明显 + const starIcon = game.add.text(0, 0, '⭐', { + fontFamily: 'ArkPixel, monospace', + fontSize: '32px' + }).setOrigin(0.5); + starIcon.name = 'starIcon'; + + // 名字标签(漂浮) + const nameTag = game.add.text(0, -36, name, { + fontFamily: 'ArkPixel, monospace', + fontSize: '14px', + fill: '#' + nameColor.toString(16).padStart(6, '0'), + stroke: '#000', + strokeThickness: 3, + backgroundColor: 'rgba(255,255,255,0.95)' + }).setOrigin(0.5); + nameTag.name = 'nameTag'; + + // 状态小点(绿色/黄色/红色) + let dotColor = 0x64748b; + if (authStatus === 'approved') dotColor = 0x22c55e; + if (authStatus === 'pending') dotColor = 0xf59e0b; + if (authStatus === 'rejected') dotColor = 0xef4444; + if (authStatus === 'offline') dotColor = 0x94a3b8; + const statusDot = game.add.circle(20, -20, 5, dotColor, alpha); + statusDot.setStrokeStyle(2, 0x000000, alpha); + statusDot.name = 'statusDot'; + + container.add([starIcon, statusDot, nameTag]); + agents[agentId] = container; + } else { + // 更新 agent + const container = agents[agentId]; + container.setPosition(baseX, baseY); + container.setAlpha(alpha); + container.setDepth(1200 + (isMain ? 100 : 0)); + + // 更新名字和颜色(如果变化) + const nameTag = container.getAt(2); + if (nameTag && nameTag.name === 'nameTag') { + nameTag.setText(name); + nameTag.setFill('#' + (NAME_TAG_COLORS[authStatus] || NAME_TAG_COLORS.default).toString(16).padStart(6, '0')); + } + // 更新状态点颜色 + const statusDot = container.getAt(1); + if (statusDot && statusDot.name === 'statusDot') { + let dotColor = 0x64748b; + if (authStatus === 'approved') dotColor = 0x22c55e; + if (authStatus === 'pending') dotColor = 0xf59e0b; + if (authStatus === 'rejected') dotColor = 0xef4444; + if (authStatus === 'offline') dotColor = 0x94a3b8; + statusDot.fillColor = dotColor; + } + } +} + +// 启动游戏 +initGame(); diff --git a/frontend/guest_anim_1.png b/frontend/guest_anim_1.png new file mode 100644 index 0000000..389052c Binary files /dev/null and b/frontend/guest_anim_1.png differ diff --git a/frontend/guest_anim_1.webp b/frontend/guest_anim_1.webp new file mode 100644 index 0000000..6f4666e Binary files /dev/null and b/frontend/guest_anim_1.webp differ diff --git a/frontend/guest_anim_2.png b/frontend/guest_anim_2.png new file mode 100644 index 0000000..b2c0231 Binary files /dev/null and b/frontend/guest_anim_2.png differ diff --git a/frontend/guest_anim_2.webp b/frontend/guest_anim_2.webp new file mode 100644 index 0000000..281ddc5 Binary files /dev/null and b/frontend/guest_anim_2.webp differ diff --git a/frontend/guest_anim_3.png b/frontend/guest_anim_3.png new file mode 100644 index 0000000..8160616 Binary files /dev/null and b/frontend/guest_anim_3.png differ diff --git a/frontend/guest_anim_3.webp b/frontend/guest_anim_3.webp new file mode 100644 index 0000000..ab5fbf4 Binary files /dev/null and b/frontend/guest_anim_3.webp differ diff --git a/frontend/guest_anim_4.png b/frontend/guest_anim_4.png new file mode 100644 index 0000000..0e6f5b5 Binary files /dev/null and b/frontend/guest_anim_4.png differ diff --git a/frontend/guest_anim_4.webp b/frontend/guest_anim_4.webp new file mode 100644 index 0000000..4870f03 Binary files /dev/null and b/frontend/guest_anim_4.webp differ diff --git a/frontend/guest_anim_5.png b/frontend/guest_anim_5.png new file mode 100644 index 0000000..bd5c87d Binary files /dev/null and b/frontend/guest_anim_5.png differ diff --git a/frontend/guest_anim_5.webp b/frontend/guest_anim_5.webp new file mode 100644 index 0000000..0a22459 Binary files /dev/null and b/frontend/guest_anim_5.webp differ diff --git a/frontend/guest_anim_6.png b/frontend/guest_anim_6.png new file mode 100644 index 0000000..bd5c87d Binary files /dev/null and b/frontend/guest_anim_6.png differ diff --git a/frontend/guest_anim_6.webp b/frontend/guest_anim_6.webp new file mode 100644 index 0000000..0a22459 Binary files /dev/null and b/frontend/guest_anim_6.webp differ diff --git a/frontend/guest_role_1.png b/frontend/guest_role_1.png new file mode 100644 index 0000000..d6ed12b Binary files /dev/null and b/frontend/guest_role_1.png differ diff --git a/frontend/guest_role_2.png b/frontend/guest_role_2.png new file mode 100644 index 0000000..389052c Binary files /dev/null and b/frontend/guest_role_2.png differ diff --git a/frontend/guest_role_3.png b/frontend/guest_role_3.png new file mode 100644 index 0000000..b2c0231 Binary files /dev/null and b/frontend/guest_role_3.png differ diff --git a/frontend/guest_role_4.png b/frontend/guest_role_4.png new file mode 100644 index 0000000..8160616 Binary files /dev/null and b/frontend/guest_role_4.png differ diff --git a/frontend/guest_role_5.png b/frontend/guest_role_5.png new file mode 100644 index 0000000..0e6f5b5 Binary files /dev/null and b/frontend/guest_role_5.png differ diff --git a/frontend/guest_role_6.png b/frontend/guest_role_6.png new file mode 100644 index 0000000..bd5c87d Binary files /dev/null and b/frontend/guest_role_6.png differ diff --git a/frontend/guestagent1.png b/frontend/guestagent1.png new file mode 100644 index 0000000..9786fb7 Binary files /dev/null and b/frontend/guestagent1.png differ diff --git a/frontend/guestagent1.webp b/frontend/guestagent1.webp new file mode 100644 index 0000000..6ee2a0a Binary files /dev/null and b/frontend/guestagent1.webp differ diff --git a/frontend/guestagent2.png b/frontend/guestagent2.png new file mode 100644 index 0000000..f78b996 Binary files /dev/null and b/frontend/guestagent2.png differ diff --git a/frontend/guestagent2.webp b/frontend/guestagent2.webp new file mode 100644 index 0000000..ad4f283 Binary files /dev/null and b/frontend/guestagent2.webp differ diff --git a/frontend/index.html b/frontend/index.html index 977c243..41d06cd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,18 +5,80 @@ Star 的像素办公室 + +
+
正在加载 Star 的像素办公室...
+
+
+
+
+
+ + +
+ +
+
— 昨 日 小 记 —
+
+
─ ─ ─ ─ ─
+
+
加载中...
+
+
─ ─ ─ ─ ─
+
+ + +
+
Star 状态
+
+ + + + +
+
+ + +
+
— 访 客 列 表 —
+
+
正在加载访客...
+
+
+
+
加载中...
+ + + diff --git a/frontend/invite.html b/frontend/invite.html new file mode 100644 index 0000000..fd2a377 --- /dev/null +++ b/frontend/invite.html @@ -0,0 +1,159 @@ + + + + + + 海辛办公室 - 加入邀请 + + + +
+

✨ 海辛办公室 · 加入邀请

+

欢迎加入海辛的像素办公室看板!

+ +

加入步骤(一共 3 步)

+
+
+
1
+
+ 确认信息
+ 你应该已经收到两样东西: +
    +
  • 邀请链接:https://office.hyacinth.im/join
  • +
  • 一次性接入密钥(join key):ocj_xxx
  • +
+
+
+
+
2
+
+ 把邀请信息丢给你的 OpenClaw
+ 把邀请链接 + join key 一起发给你的 OpenClaw,并说“帮我加入海辛办公室”。 +
+
+
+
3
+
+ 在你这边授权
+ 你的 OpenClaw 会在对话里向你要授权;同意后,它就会开始自动把工作状态推送到海辛办公室看板啦! +
+
+
+ +
+ ⚠️ 隐私说明
+ 只推送状态(idle/writing/researching/executing/syncing/error),不含任何具体内容/隐私;随时可停。 +
+ + ← 回到海辛办公室 + + +
+ + diff --git a/frontend/join-office-skill.md b/frontend/join-office-skill.md new file mode 100644 index 0000000..2ac25ca --- /dev/null +++ b/frontend/join-office-skill.md @@ -0,0 +1,46 @@ +# Join Star Office - Visitor Agent Skill + +## Description +接入海辛的像素办公室,让你的主人可以在可视化办公室看板上看到你的状态变化。 + +## Prerequisites +- 你必须从海辛处获得一次性接入密钥(格式:`ocj_xxx`) +- 你的主人已同意授权你推送状态到海辛办公室 +- 你能访问 `https://office.hyacinth.im` + +## Steps +1. 向你的主人确认授权: + > “我将把我的工作状态(idle/writing/researching/executing/syncing/error)定时发送到海辛的办公室看板,用于可视化协作;不含具体内容/隐私;随时可停。是否授权?” + +2. 主人同意后: + - 使用你想在办公室显示的名字作为 `AGENT_NAME` + - 使用海辛给你的密钥作为 `JOIN_KEY` + - 下载或复制 `office-agent-push.py`(可以访问:https://office.hyacinth.im/static/office-agent-push.py) + - **最简单推荐**:直接运行脚本(已内置 state.json 自动发现) + - 会自动尝试以下路径: + - `/root/.openclaw/workspace/star-office-ui/state.json` + - `/root/.openclaw/workspace/state.json` + - `当前工作目录/state.json` + - `脚本同目录/state.json` + - 若你的环境路径特殊,再手动指定: + - `OFFICE_LOCAL_STATE_FILE=/你的/state.json/路径` + - 如果你不方便提供 state 文件,再用 /status 鉴权方式: + - `OFFICE_LOCAL_STATUS_TOKEN=<你的token>` + - (可选)`OFFICE_LOCAL_STATUS_URL=http://127.0.0.1:18791/status` + - 填入配置后运行 + +3. 脚本会自动: + - 先执行一次 `join-agent`,显示“已加入并自动批准” + - 优先读取本机 `state.json`(若有),否则读取本地 `/status` + - 按办公区逻辑映射状态:任务中→工作区;待命/完成→休息区;异常→bug区 + - 每 15 秒推送一次状态到海辛办公室(更实时) + - 如果被移出房间,会自动停止 + +4. 停止推送时: + - 按 `Ctrl+C` 终止脚本 + - 脚本会尝试自动调用 `leave-agent` 退出 + +## Notes +- 只推送状态词和简短描述,不推送任何私密内容 +- 默认授权有效期 24h +- 如果收到 `403`/`404`,停止推送并联系你的主人 diff --git a/frontend/join.html b/frontend/join.html new file mode 100644 index 0000000..fa56f04 --- /dev/null +++ b/frontend/join.html @@ -0,0 +1,194 @@ + + + + + + 加入 Star 的像素办公室 + + + +

⭐ 加入 Star 的像素办公室

+
+
+ + +
+ +
+ + +
+ + + +
+
+ ⚠️ 注意:join 页面仅需要名字 + 一次性 join key
+ 状态与状态细节会由 agent 后续自动推送同步 +

+ 📌 邀请说明: + https://office.hyacinth.im/invite +
+ + + + diff --git a/frontend/layout.js b/frontend/layout.js new file mode 100644 index 0000000..e217e7e --- /dev/null +++ b/frontend/layout.js @@ -0,0 +1,132 @@ +// Star Office UI - 布局与层级配置 +// 所有坐标、depth、资源路径统一管理在这里 +// 避免 magic numbers,降低改错风险 + +// 核心规则: +// - 透明资源(如办公桌)强制 .png,不透明优先 .webp +// - 层级:低 → sofa(10) → starWorking(900) → desk(1000) → flower(1100) + +const LAYOUT = { + // === 游戏画布 === + game: { + width: 1280, + height: 720 + }, + + // === 各区域坐标 === + areas: { + door: { x: 640, y: 550 }, + writing: { x: 320, y: 360 }, + researching: { x: 320, y: 360 }, + error: { x: 1066, y: 180 }, + breakroom: { x: 640, y: 360 } + }, + + // === 装饰与家具:坐标 + 原点 + depth === + furniture: { + // 沙发 + sofa: { + x: 670, + y: 144, + origin: { x: 0, y: 0 }, + depth: 10 + }, + + // 新办公桌(透明 PNG 强制) + desk: { + x: 218, + y: 417, + origin: { x: 0.5, y: 0.5 }, + depth: 1000 + }, + + // 桌上花盆 + flower: { + x: 310, + y: 405, + origin: { x: 0.5, y: 0.5 }, + depth: 1100 + }, + + // Star 在桌前工作(在 desk 下面) + starWorking: { + x: 217, + y: 333, + origin: { x: 0.5, y: 0.5 }, + depth: 900, + scale: 1.32 + }, + + // 植物们 + plants: [ + { x: 565, y: 178, depth: 5 }, + { x: 230, y: 185, depth: 5 }, + { x: 977, y: 496, depth: 5 } + ], + + // 海报 + poster: { + x: 252, + y: 66, + depth: 4 + }, + + // 咖啡机 + coffeeMachine: { + x: 659, + y: 397, + origin: { x: 0.5, y: 0.5 }, + depth: 99 + }, + + // 服务器区 + serverroom: { + x: 1021, + y: 142, + origin: { x: 0.5, y: 0.5 }, + depth: 2 + }, + + // 错误 bug + errorBug: { + x: 1007, + y: 221, + origin: { x: 0.5, y: 0.5 }, + depth: 50, + scale: 0.9, + pingPong: { leftX: 1007, rightX: 1111, speed: 0.6 } + }, + + // 同步动画 + syncAnim: { + x: 1157, + y: 592, + origin: { x: 0.5, y: 0.5 }, + depth: 40 + }, + + // 小猫 + cat: { + x: 94, + y: 557, + origin: { x: 0.5, y: 0.5 }, + depth: 2000 + } + }, + + // === 牌匾 === + plaque: { + x: 640, + y: 720 - 36, + width: 420, + height: 44 + }, + + // === 资源加载规则:哪些强制用 PNG(透明资源) === + forcePng: { + desk_v2: true // 新办公桌必须透明,强制 PNG + }, + + // === 总资源数量(用于加载进度条) === + totalAssets: 15 +}; diff --git a/frontend/memo-bg.png b/frontend/memo-bg.png new file mode 100644 index 0000000..95c4da3 Binary files /dev/null and b/frontend/memo-bg.png differ diff --git a/frontend/memo-bg.webp b/frontend/memo-bg.webp new file mode 100644 index 0000000..438d16c Binary files /dev/null and b/frontend/memo-bg.webp differ diff --git a/frontend/new-desk.png b/frontend/new-desk.png new file mode 100644 index 0000000..1cb0246 Binary files /dev/null and b/frontend/new-desk.png differ diff --git a/frontend/new-desk.webp b/frontend/new-desk.webp new file mode 100644 index 0000000..37b97c6 Binary files /dev/null and b/frontend/new-desk.webp differ diff --git a/frontend/office_bg.webp b/frontend/office_bg.webp new file mode 100644 index 0000000..b1038a2 Binary files /dev/null and b/frontend/office_bg.webp differ diff --git a/frontend/office_bg_small.png b/frontend/office_bg_small.png new file mode 100644 index 0000000..faac565 Binary files /dev/null and b/frontend/office_bg_small.png differ diff --git a/frontend/office_bg_small.webp b/frontend/office_bg_small.webp new file mode 100644 index 0000000..20c06a9 Binary files /dev/null and b/frontend/office_bg_small.webp differ diff --git a/frontend/plants-spritesheet.png b/frontend/plants-spritesheet.png new file mode 100644 index 0000000..53a3064 Binary files /dev/null and b/frontend/plants-spritesheet.png differ diff --git a/frontend/plants-spritesheet.webp b/frontend/plants-spritesheet.webp new file mode 100644 index 0000000..1c949fa Binary files /dev/null and b/frontend/plants-spritesheet.webp differ diff --git a/frontend/posters-spritesheet.png b/frontend/posters-spritesheet.png new file mode 100644 index 0000000..3f0d431 Binary files /dev/null and b/frontend/posters-spritesheet.png differ diff --git a/frontend/posters-spritesheet.webp b/frontend/posters-spritesheet.webp new file mode 100644 index 0000000..2686385 Binary files /dev/null and b/frontend/posters-spritesheet.webp differ diff --git a/frontend/serverroom-spritesheet.png b/frontend/serverroom-spritesheet.png new file mode 100644 index 0000000..561ee39 Binary files /dev/null and b/frontend/serverroom-spritesheet.png differ diff --git a/frontend/serverroom-spritesheet.webp b/frontend/serverroom-spritesheet.webp new file mode 100644 index 0000000..0c3d700 Binary files /dev/null and b/frontend/serverroom-spritesheet.webp differ diff --git a/frontend/serverroom.gif b/frontend/serverroom.gif new file mode 100644 index 0000000..8bdc2f9 Binary files /dev/null and b/frontend/serverroom.gif differ diff --git a/frontend/sofa-busy-spritesheet.png b/frontend/sofa-busy-spritesheet.png new file mode 100644 index 0000000..dd16ed5 Binary files /dev/null and b/frontend/sofa-busy-spritesheet.png differ diff --git a/frontend/sofa-busy-spritesheet.webp b/frontend/sofa-busy-spritesheet.webp new file mode 100644 index 0000000..d7c2164 Binary files /dev/null and b/frontend/sofa-busy-spritesheet.webp differ diff --git a/frontend/sofa-idle.png b/frontend/sofa-idle.png new file mode 100644 index 0000000..03eff54 Binary files /dev/null and b/frontend/sofa-idle.png differ diff --git a/frontend/sofa-idle.webp b/frontend/sofa-idle.webp new file mode 100644 index 0000000..1cdb642 Binary files /dev/null and b/frontend/sofa-idle.webp differ diff --git a/frontend/star-idle-spritesheet.png b/frontend/star-idle-spritesheet.png new file mode 100644 index 0000000..ef8b8e2 Binary files /dev/null and b/frontend/star-idle-spritesheet.png differ diff --git a/frontend/star-idle-spritesheet.webp b/frontend/star-idle-spritesheet.webp new file mode 100644 index 0000000..99a69eb Binary files /dev/null and b/frontend/star-idle-spritesheet.webp differ diff --git a/frontend/star-idle.gif b/frontend/star-idle.gif new file mode 100644 index 0000000..826af65 Binary files /dev/null and b/frontend/star-idle.gif differ diff --git a/frontend/star-researching-spritesheet.png b/frontend/star-researching-spritesheet.png new file mode 100644 index 0000000..468a29d Binary files /dev/null and b/frontend/star-researching-spritesheet.png differ diff --git a/frontend/star-researching-spritesheet.webp b/frontend/star-researching-spritesheet.webp new file mode 100644 index 0000000..91d8f67 Binary files /dev/null and b/frontend/star-researching-spritesheet.webp differ diff --git a/frontend/star-researching.gif b/frontend/star-researching.gif new file mode 100644 index 0000000..5b52bbc Binary files /dev/null and b/frontend/star-researching.gif differ diff --git a/frontend/star-working-spritesheet-grid.png b/frontend/star-working-spritesheet-grid.png new file mode 100644 index 0000000..ea372ef Binary files /dev/null and b/frontend/star-working-spritesheet-grid.png differ diff --git a/frontend/star-working-spritesheet-grid.webp b/frontend/star-working-spritesheet-grid.webp new file mode 100644 index 0000000..1c8a199 Binary files /dev/null and b/frontend/star-working-spritesheet-grid.webp differ diff --git a/frontend/star-working-spritesheet.png b/frontend/star-working-spritesheet.png new file mode 100644 index 0000000..5c1547a Binary files /dev/null and b/frontend/star-working-spritesheet.png differ diff --git a/frontend/star-working.gif b/frontend/star-working.gif new file mode 100644 index 0000000..5b9b1c0 Binary files /dev/null and b/frontend/star-working.gif differ diff --git a/frontend/sync-animation-spritesheet-grid.png b/frontend/sync-animation-spritesheet-grid.png new file mode 100644 index 0000000..10e1659 Binary files /dev/null and b/frontend/sync-animation-spritesheet-grid.png differ diff --git a/frontend/sync-animation-spritesheet-grid.webp b/frontend/sync-animation-spritesheet-grid.webp new file mode 100644 index 0000000..b94a5d6 Binary files /dev/null and b/frontend/sync-animation-spritesheet-grid.webp differ diff --git a/frontend/sync-animation.webp b/frontend/sync-animation.webp new file mode 100644 index 0000000..18eecf5 Binary files /dev/null and b/frontend/sync-animation.webp differ diff --git a/gif_to_spritesheet.py b/gif_to_spritesheet.py new file mode 100644 index 0000000..2631ebf --- /dev/null +++ b/gif_to_spritesheet.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Convert GIF animation to sprite sheet for Phaser""" + +from PIL import Image +import os + +def gif_to_spritesheet(gif_path, output_path, target_height=64): + # Open the GIF + gif = Image.open(gif_path) + + # Get all frames + frames = [] + try: + while True: + frame = gif.copy().convert('RGBA') + # Calculate scale to fit target_height + original_width, original_height = frame.size + if original_height != target_height: + scale = target_height / original_height + target_width = int(original_width * scale) + frame = frame.resize((target_width, target_height), Image.Resampling.NEAREST) + frames.append(frame) + gif.seek(gif.tell() + 1) + except EOFError: + pass + + if not frames: + raise ValueError("No frames found in GIF") + + # Calculate sprite sheet dimensions + frame_width, frame_height = frames[0].size + num_frames = len(frames) + + # Arrange frames in a single row for simplicity + sheet_width = frame_width * num_frames + sheet_height = frame_height + + # Create sprite sheet + spritesheet = Image.new('RGBA', (sheet_width, sheet_height), (0, 0, 0, 0)) + + # Paste each frame + for i, frame in enumerate(frames): + x = i * frame_width + y = 0 + spritesheet.paste(frame, (x, y)) + + # Save sprite sheet + spritesheet.save(output_path) + + print(f"Sprite sheet created: {output_path}") + print(f"Frames: {num_frames}") + print(f"Frame size: {frame_width}x{frame_height}") + print(f"Sprite sheet size: {sheet_width}x{sheet_height}") + + return { + 'num_frames': num_frames, + 'frame_width': frame_width, + 'frame_height': frame_height, + 'sheet_width': sheet_width, + 'sheet_height': sheet_height + } + +if __name__ == "__main__": + import sys + + if len(sys.argv) < 4: + print("Usage: python gif_to_spritesheet.py ") + print("Example: python gif_to_spritesheet.py star-idle.gif star-idle-spritesheet.png 64") + sys.exit(1) + + gif_path = sys.argv[1] + output_path = sys.argv[2] + target_height = int(sys.argv[3]) + + result = gif_to_spritesheet(gif_path, output_path, target_height=target_height) + print("\nDone!") diff --git a/healthcheck.sh b/healthcheck.sh new file mode 100755 index 0000000..b5b2890 --- /dev/null +++ b/healthcheck.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Star Office UI Health Check +# Checks if backend is responding, restarts if not + +BACKEND_URL="http://127.0.0.1:18791/health" +LOG_FILE="/root/.openclaw/workspace/star-office-ui/healthcheck.log" + +# Log timestamp +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Health check starting..." >> "$LOG_FILE" + +# Check backend +if curl -sS "$BACKEND_URL" > /dev/null 2>&1; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backend is healthy" >> "$LOG_FILE" +else + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backend is NOT healthy - restarting..." >> "$LOG_FILE" + systemctl restart star-office-backend.service + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backend restarted" >> "$LOG_FILE" +fi diff --git a/office-agent-push.py b/office-agent-push.py new file mode 100644 index 0000000..f898b30 --- /dev/null +++ b/office-agent-push.py @@ -0,0 +1,253 @@ +#!/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" + +# 本地状态存储(记住上次 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/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:18791/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 fetch_local_status(): + """读取本地状态: + 1) 优先 state.json(符合 AGENTS.md:任务前切 writing,完成后切 idle) + 2) 其次尝试本地 HTTP /status + 3) 最后 fallback 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) + 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) + 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() diff --git a/repack_star_working.py b/repack_star_working.py new file mode 100644 index 0000000..82f86f1 --- /dev/null +++ b/repack_star_working.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Repack star-working spritesheet into a grid to fit GPU max texture sizes. + +Problem: +- Current spritesheet is 44160x144 (192 frames * 230w), too wide for WebGL max texture size on most GPUs. +- Result: texture upload fails => renders as black rectangle. + +This script repacks frames into rows. +Default: +- frame: 230x144 +- frames: 192 +- cols: 35 -> width 8050 +- rows: ceil(192/35)=6 -> height 864 + +Output: +- frontend/star-working-spritesheet-grid.png + +Safe: +- does NOT delete original file. +""" + +import math +import os +from PIL import Image + +ROOT = "/root/.openclaw/workspace/star-office-ui" +IN_PATH = os.path.join(ROOT, "frontend", "star-working-spritesheet.png") +OUT_PATH = os.path.join(ROOT, "frontend", "star-working-spritesheet-grid.png") + +FRAME_W = 230 +FRAME_H = 144 +FRAMES = 192 +COLS = 35 + + +def main(): + img = Image.open(IN_PATH).convert("RGBA") + w, h = img.size + + expected_w = FRAME_W * FRAMES + if h != FRAME_H or w < expected_w: + raise SystemExit(f"Unexpected input size {img.size}, expected height={FRAME_H}, width>={expected_w}") + + rows = math.ceil(FRAMES / COLS) + out_w = FRAME_W * COLS + out_h = FRAME_H * rows + + out = Image.new("RGBA", (out_w, out_h), (0, 0, 0, 0)) + + for i in range(FRAMES): + src_x0 = i * FRAME_W + src_y0 = 0 + frame = img.crop((src_x0, src_y0, src_x0 + FRAME_W, src_y0 + FRAME_H)) + + r = i // COLS + c = i % COLS + dst_x0 = c * FRAME_W + dst_y0 = r * FRAME_H + out.paste(frame, (dst_x0, dst_y0)) + + out.save(OUT_PATH) + + orig_size = os.path.getsize(IN_PATH) + new_size = os.path.getsize(OUT_PATH) + print(f"Wrote: {OUT_PATH}") + print(f"Input size: {w}x{h} ({orig_size/1024/1024:.2f} MB)") + print(f"Output size: {out_w}x{out_h} ({new_size/1024/1024:.2f} MB)") + + +if __name__ == "__main__": + main() diff --git a/resize_map.py b/resize_map.py new file mode 100644 index 0000000..73e1614 --- /dev/null +++ b/resize_map.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Resize office map by SHORT EDGE scaling (keep aspect ratio, no stretching/cropping)""" + +from PIL import Image + +def resize_map(input_path, output_path, target_short_edge=600): + im = Image.open(input_path) + original_width, original_height = im.size + + # Determine which is the SHORT edge + if original_width < original_height: + short_edge, long_edge = original_width, original_height + is_width_short = True + else: + short_edge, long_edge = original_height, original_width + is_width_short = False + + # Calculate scale based on SHORT edge + scale = target_short_edge / short_edge + + # Compute new dimensions + if is_width_short: + new_width = target_short_edge + new_height = int(long_edge * scale) + else: + new_width = int(long_edge * scale) + new_height = target_short_edge + + # Resize (use LANCZOS for high quality) + im_resized = im.resize((new_width, new_height), Image.Resampling.LANCZOS) + + im_resized.save(output_path) + print(f"Resized map saved: {output_path}") + print(f"Original size: {original_width}x{original_height}") + print(f"Resized size: {new_width}x{new_height}") + print(f"Short edge scale: {scale:.2f}x") + +if __name__ == "__main__": + input_path = "/root/.openclaw/media/inbound/6b352c7d-f09f-4dd7-9916-a312fb60122b.png" + output_path = "/root/.openclaw/workspace/star-office-ui/frontend/office_bg.png" + resize_map(input_path, output_path, target_short_edge=720) diff --git a/webp_to_spritesheet.py b/webp_to_spritesheet.py new file mode 100644 index 0000000..65a31e7 --- /dev/null +++ b/webp_to_spritesheet.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Convert an animated WebP to a horizontal spritesheet PNG. + +Notes: +- Phaser's built-in loader doesn't support animated WebP directly. +- We convert frames into a spritesheet. +- Output: -spritesheet.png +""" + +import os +from PIL import Image + + +def webp_to_spritesheet(in_path: str, out_path: str, frame_w: int, frame_h: int, max_frames: int | None = None): + im = Image.open(in_path) + n = getattr(im, 'n_frames', 1) + if max_frames: + n = min(n, max_frames) + + sheet = Image.new('RGBA', (frame_w * n, frame_h), (0, 0, 0, 0)) + + for i in range(n): + im.seek(i) + fr = im.convert('RGBA') + if fr.size != (frame_w, frame_h): + fr = fr.resize((frame_w, frame_h), Image.NEAREST) + sheet.paste(fr, (i * frame_w, 0)) + + sheet.save(out_path) + return n + + +def main(): + import argparse + ap = argparse.ArgumentParser() + ap.add_argument('in_path') + ap.add_argument('out_path') + ap.add_argument('--w', type=int, required=True) + ap.add_argument('--h', type=int, required=True) + ap.add_argument('--max', type=int, default=None) + args = ap.parse_args() + + n = webp_to_spritesheet(args.in_path, args.out_path, args.w, args.h, args.max) + print(f"Wrote {args.out_path} with {n} frames") + + +if __name__ == '__main__': + main()