feat: release latest Star Office UI with multi-agent, memo panel, docs and skill refresh

This commit is contained in:
Star 2026-03-01 15:09:00 +08:00
parent 365cd8826d
commit e3006ca9b3
106 changed files with 5445 additions and 268 deletions

9
.gitignore vendored
View file

@ -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

165
README.md
View file

@ -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 # 主 UIPhaser
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 "待命中"
```
Youll 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`.
- Dont 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`:读取昨日小记
## 开源与资产说明
- 代码遵循仓库 LICENSEMIT
- **美术素材版权归原作者/工作室所有**
- 本仓库素材仅用于学习与演示,**未经授权禁止商用**
## 安全建议
- 不要在 `detail` 中写入敏感信息
- 公网演示请加鉴权/网关限制
- `state.json` / `agents-state.json` 属于运行态文件,不建议提交

196
SKILL.md
View file

@ -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
- 403join 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 <state> [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公网访问
- 下载 cloudflaredhttps://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`

55
agent-invite-template.txt Normal file
View file

@ -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过期后重新申请
- 只推送状态,不推送任何具体内容/隐私

View file

@ -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")

1
backend/requirements.txt Normal file
View file

@ -0,0 +1 @@
flask==3.0.2

4
backend/run.sh Executable file
View file

@ -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"

115
convert_to_webp.py Normal file
View file

@ -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()

View file

@ -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 环境变量覆盖时序问题。

View file

@ -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 上传

View file

@ -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`)。

View file

@ -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、隧道输出等
## 美术资产使用声明(必须)
- 代码可开源,但美术素材(背景、角色、动画等)版权归原作者/工作室所有。
- 美术资产仅供学习与演示,**禁止商用**。

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

BIN
frontend/coffee-machine.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
frontend/demo_mercury.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

BIN
frontend/demo_mercury.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
frontend/demo_nika.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

BIN
frontend/demo_nika.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
frontend/desk-v2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
frontend/desk-v2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
frontend/desk-v2.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

BIN
frontend/desk.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

BIN
frontend/desk.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

BIN
frontend/error-bug.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

94
frontend/fonts/OFL.txt Normal file
View file

@ -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.

1000
frontend/game.js Normal file

File diff suppressed because it is too large Load diff

BIN
frontend/guest_anim_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
frontend/guest_anim_1.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 B

BIN
frontend/guest_anim_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 944 B

BIN
frontend/guest_anim_2.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 B

BIN
frontend/guest_anim_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 838 B

BIN
frontend/guest_anim_3.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 B

BIN
frontend/guest_anim_4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 914 B

BIN
frontend/guest_anim_4.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 B

BIN
frontend/guest_anim_5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,007 B

BIN
frontend/guest_anim_5.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

BIN
frontend/guest_anim_6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,007 B

BIN
frontend/guest_anim_6.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

BIN
frontend/guest_role_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 781 B

BIN
frontend/guest_role_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
frontend/guest_role_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 944 B

BIN
frontend/guest_role_4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 838 B

BIN
frontend/guest_role_5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 914 B

BIN
frontend/guest_role_6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,007 B

BIN
frontend/guestagent1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

BIN
frontend/guestagent1.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
frontend/guestagent2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

BIN
frontend/guestagent2.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

File diff suppressed because it is too large Load diff

159
frontend/invite.html Normal file
View file

@ -0,0 +1,159 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>海辛办公室 - 加入邀请</title>
<style>
body {
margin: 0;
padding: 40px;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
background: linear-gradient(135deg, #f5f7fa 0%, #e4edf5 100%);
min-height: 100vh;
box-sizing: border-box;
}
.card {
max-width: 800px;
margin: 0 auto;
background: white;
padding: 48px;
border-radius: 16px;
box-shadow: 0 10px 40px rgba(0,0,0,0.08);
}
h1 {
margin-top: 0;
color: #111827;
font-size: 28px;
}
h2 {
margin-top: 32px;
color: #1f2937;
font-size: 18px;
}
p, li {
color: #374151;
line-height: 1.8;
}
.steps {
background: #f9fafb;
padding: 24px;
border-radius: 12px;
margin: 16px 0;
}
.step {
display: flex;
gap: 16px;
margin-bottom: 16px;
}
.step:last-child {
margin-bottom: 0;
}
.step-num {
width: 28px;
height: 28px;
border-radius: 50%;
background: #3b82f6;
color: white;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 14px;
}
.step-text {
flex: 1;
}
.step-text strong {
color: #111827;
}
.join-link {
background: #f3f4f6;
padding: 16px;
border-radius: 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 14px;
word-break: break-all;
margin-top: 8px;
}
.note {
margin-top: 24px;
padding: 16px;
border-left: 4px solid #f59e0b;
background: #fffbeb;
border-radius: 0 8px 8px 0;
}
.note strong {
color: #92400e;
}
.footer {
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid #e5e7eb;
color: #6b7280;
font-size: 14px;
}
.back-btn {
display: inline-block;
margin-top: 24px;
padding: 12px 24px;
background: #3b82f6;
color: white;
text-decoration: none;
border-radius: 8px;
font-weight: 500;
}
.back-btn:hover {
background: #2563eb;
}
</style>
</head>
<body>
<div class="card">
<h1>✨ 海辛办公室 · 加入邀请</h1>
<p>欢迎加入海辛的像素办公室看板!</p>
<h2>加入步骤(一共 3 步)</h2>
<div class="steps">
<div class="step">
<div class="step-num">1</div>
<div class="step-text">
<strong>确认信息</strong><br>
你应该已经收到两样东西:
<ul>
<li>邀请链接:<code>https://office.hyacinth.im/join</code></li>
<li>一次性接入密钥join key<code>ocj_xxx</code></li>
</ul>
</div>
</div>
<div class="step">
<div class="step-num">2</div>
<div class="step-text">
<strong>把邀请信息丢给你的 OpenClaw</strong><br>
把邀请链接 + join key 一起发给你的 OpenClaw并说“帮我加入海辛办公室”。
</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div class="step-text">
<strong>在你这边授权</strong><br>
你的 OpenClaw 会在对话里向你要授权;同意后,它就会开始自动把工作状态推送到海辛办公室看板啦!
</div>
</div>
</div>
<div class="note">
<strong>⚠️ 隐私说明</strong><br>
只推送状态idle/writing/researching/executing/syncing/error不含任何具体内容/隐私;随时可停。
</div>
<a href="/" class="back-btn">← 回到海辛办公室</a>
<div class="footer">
海辛工作室 · 像素办公室看板<br>
有问题找海辛 😊
</div>
</div>
</body>
</html>

View file

@ -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`,停止推送并联系你的主人

194
frontend/join.html Normal file
View file

@ -0,0 +1,194 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>加入 Star 的像素办公室</title>
<style>
@font-face {
font-family: 'ArkPixel';
src: url('/static/fonts/ark-pixel-12px-proportional-zh_cn.ttf.woff2') format('woff2');
font-weight: normal;
font-style: normal;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #1a1a2e;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100vh;
font-family: 'ArkPixel', 'Courier New', monospace;
padding: 40px 20px;
gap: 30px;
color: #fff;
}
h1 {
color: #ffd700;
font-size: 24px;
text-align: center;
}
.container {
background: #2c2f3a;
border: 3px solid #e94560;
border-radius: 12px;
padding: 24px;
width: 100%;
max-width: 480px;
box-shadow: 0 8px 30px rgba(0,0,0,0.6);
}
.form-group {
margin-bottom: 18px;
}
label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: #ddd;
}
input, select {
width: 100%;
padding: 10px 12px;
font-family: 'ArkPixel', monospace;
font-size: 14px;
border: 2px solid #555;
border-radius: 6px;
background: #3a3f4f;
color: #fff;
}
button {
width: 100%;
padding: 12px;
font-family: 'ArkPixel', monospace;
font-size: 16px;
border: 2px solid #e94560;
border-radius: 6px;
background: #e94560;
color: #fff;
cursor: pointer;
transition: all 0.2s;
}
button:hover {
background: #ff6b81;
}
.status {
margin-top: 16px;
padding: 12px;
border-radius: 6px;
text-align: center;
font-size: 14px;
}
.status.ok {
background: rgba(76, 175, 80, 0.2);
color: #4caf50;
border: 2px solid #4caf50;
}
.status.error {
background: rgba(244, 67, 54, 0.2);
color: #f44336;
border: 2px solid #f44336;
}
.note {
font-size: 12px;
color: #888;
margin-top: 16px;
text-align: center;
line-height: 1.8;
}
.note a { word-break: break-all; }
</style>
</head>
<body>
<h1>⭐ 加入 Star 的像素办公室</h1>
<div class="container">
<div class="form-group">
<label>你的名字(会显示在办公室)</label>
<input type="text" id="agentName" placeholder="例如:小龙虾助手" maxlength="20">
</div>
<!-- 状态与细节改为自动同步,不在 join 页面填写 -->
<div class="form-group">
<label>Agent 接入密钥(一次性)</label>
<input type="text" id="joinKey" placeholder="请输入你拿到的 join key" maxlength="64">
</div>
<button id="joinBtn">加入办公室</button>
<button id="leaveBtn" style="margin-top:10px; background:#555; border-color:#555;">离开办公室</button>
<div id="status" class="status" style="display:none;"></div>
</div>
<div class="note">
⚠️ 注意join 页面仅需要名字 + 一次性 join key<br>
状态与状态细节会由 agent 后续自动推送同步
<br><br>
📌 邀请说明:
<a href="/invite" style="color:#ffd700; text-decoration: underline;">https://office.hyacinth.im/invite</a>
</div>
<script>
const joinBtn = document.getElementById('joinBtn');
const leaveBtn = document.getElementById('leaveBtn');
const statusDiv = document.getElementById('status');
const agentNameInput = document.getElementById('agentName');
const joinKeyInput = document.getElementById('joinKey');
function showStatus(text, ok) {
statusDiv.style.display = 'block';
statusDiv.textContent = text;
statusDiv.className = 'status ' + (ok ? 'ok' : 'error');
}
async function join() {
const name = agentNameInput.value.trim();
const joinKey = joinKeyInput.value.trim();
if (!name) {
showStatus('请先输入你的名字~', false);
return;
}
if (!joinKey) {
showStatus('请先输入 Agent 接入密钥~', false);
return;
}
try {
const response = await fetch('/join-agent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, joinKey })
});
const data = await response.json();
if (data.ok) {
showStatus('加入成功!刷新办公室就能看到你啦 ✨', true);
} else {
showStatus(data.msg || '加入失败', false);
}
} catch (e) {
showStatus('网络出错,请重试', false);
}
}
async function leave() {
const name = agentNameInput.value.trim();
if (!name) {
showStatus('请先输入你要离开的名字~', false);
return;
}
try {
const response = await fetch('/leave-agent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
const data = await response.json();
if (data.ok) {
showStatus('已离开办公室 👋', true);
} else {
showStatus(data.msg || '离开失败', false);
}
} catch (e) {
showStatus('网络出错,请重试', false);
}
}
joinBtn.addEventListener('click', join);
leaveBtn.addEventListener('click', leave);
</script>
</body>
</html>

132
frontend/layout.js Normal file
View file

@ -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
};

BIN
frontend/memo-bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
frontend/memo-bg.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
frontend/new-desk.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
frontend/new-desk.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
frontend/office_bg.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 996 KiB

BIN
frontend/serverroom.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 756 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
frontend/sofa-idle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

BIN
frontend/sofa-idle.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

BIN
frontend/star-idle.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 952 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 677 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

BIN
frontend/star-working.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 KiB

Some files were not shown because too many files have changed in this diff Show more