feat: release latest Star Office UI with multi-agent, memo panel, docs and skill refresh
9
.gitignore
vendored
|
|
@ -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
|
|
@ -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
|
||||

|
||||

|
||||

|
||||
```
|
||||
|
||||
## 架构概览
|
||||
|
||||
```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` 属于运行态文件,不建议提交
|
||||
|
|
|
|||
196
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 <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(公网访问)
|
||||
- 下载 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`)
|
||||
|
|
|
|||
55
agent-invite-template.txt
Normal 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,过期后重新申请
|
||||
- 只推送状态,不推送任何具体内容/隐私
|
||||
719
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")
|
||||
|
|
|
|||
1
backend/requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
flask==3.0.2
|
||||
4
backend/run.sh
Executable 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
|
|
@ -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()
|
||||
|
||||
38
docs/FEATURES_NEW_2026-03-01.md
Normal 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 环境变量覆盖时序问题。
|
||||
91
docs/OPEN_SOURCE_RELEASE_CHECKLIST.md
Normal 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 上传
|
||||
99
docs/PROJECT_SUMMARY_2026-03-01.md
Normal 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`)。
|
||||
61
docs/STAR_OFFICE_UI_OVERVIEW.md
Normal 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、隧道输出等
|
||||
|
||||
## 美术资产使用声明(必须)
|
||||
- 代码可开源,但美术素材(背景、角色、动画等)版权归原作者/工作室所有。
|
||||
- 美术资产仅供学习与演示,**禁止商用**。
|
||||
BIN
frontend/cats-spritesheet.png
Normal file
|
After Width: | Height: | Size: 836 KiB |
BIN
frontend/cats-spritesheet.webp
Normal file
|
After Width: | Height: | Size: 568 KiB |
BIN
frontend/coffee-machine-spritesheet.png
Normal file
|
After Width: | Height: | Size: 3.7 MiB |
BIN
frontend/coffee-machine-spritesheet.webp
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
frontend/coffee-machine.gif
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
frontend/demo_mercury.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
frontend/demo_mercury.webp
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/demo_nika.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
frontend/demo_nika.webp
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
frontend/desk-v2.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/desk-v2.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
frontend/desk-v2.webp
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
frontend/desk.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
frontend/desk.webp
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
frontend/error-bug-spritesheet-grid.png
Normal file
|
After Width: | Height: | Size: 3.9 MiB |
BIN
frontend/error-bug-spritesheet-grid.webp
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
frontend/error-bug-spritesheet.png
Normal file
|
After Width: | Height: | Size: 3.8 MiB |
BIN
frontend/error-bug.webp
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
frontend/flowers-spritesheet.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
frontend/flowers-spritesheet.webp
Normal file
|
After Width: | Height: | Size: 40 KiB |
94
frontend/fonts/OFL.txt
Normal 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.
|
||||
BIN
frontend/fonts/ark-pixel-12px-proportional-ja.ttf.woff2
Normal file
BIN
frontend/fonts/ark-pixel-12px-proportional-ko.ttf.woff2
Normal file
BIN
frontend/fonts/ark-pixel-12px-proportional-latin.ttf.woff2
Normal file
BIN
frontend/fonts/ark-pixel-12px-proportional-zh_cn.ttf.woff2
Normal file
BIN
frontend/fonts/ark-pixel-12px-proportional-zh_hk.ttf.woff2
Normal file
BIN
frontend/fonts/ark-pixel-12px-proportional-zh_tr.ttf.woff2
Normal file
BIN
frontend/fonts/ark-pixel-12px-proportional-zh_tw.ttf.woff2
Normal file
1000
frontend/game.js
Normal file
BIN
frontend/guest_anim_1.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
frontend/guest_anim_1.webp
Normal file
|
After Width: | Height: | Size: 468 B |
BIN
frontend/guest_anim_2.png
Normal file
|
After Width: | Height: | Size: 944 B |
BIN
frontend/guest_anim_2.webp
Normal file
|
After Width: | Height: | Size: 464 B |
BIN
frontend/guest_anim_3.png
Normal file
|
After Width: | Height: | Size: 838 B |
BIN
frontend/guest_anim_3.webp
Normal file
|
After Width: | Height: | Size: 306 B |
BIN
frontend/guest_anim_4.png
Normal file
|
After Width: | Height: | Size: 914 B |
BIN
frontend/guest_anim_4.webp
Normal file
|
After Width: | Height: | Size: 322 B |
BIN
frontend/guest_anim_5.png
Normal file
|
After Width: | Height: | Size: 1,007 B |
BIN
frontend/guest_anim_5.webp
Normal file
|
After Width: | Height: | Size: 364 B |
BIN
frontend/guest_anim_6.png
Normal file
|
After Width: | Height: | Size: 1,007 B |
BIN
frontend/guest_anim_6.webp
Normal file
|
After Width: | Height: | Size: 364 B |
BIN
frontend/guest_role_1.png
Normal file
|
After Width: | Height: | Size: 781 B |
BIN
frontend/guest_role_2.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
frontend/guest_role_3.png
Normal file
|
After Width: | Height: | Size: 944 B |
BIN
frontend/guest_role_4.png
Normal file
|
After Width: | Height: | Size: 838 B |
BIN
frontend/guest_role_5.png
Normal file
|
After Width: | Height: | Size: 914 B |
BIN
frontend/guest_role_6.png
Normal file
|
After Width: | Height: | Size: 1,007 B |
BIN
frontend/guestagent1.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
frontend/guestagent1.webp
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/guestagent2.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
frontend/guestagent2.webp
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
2028
frontend/index.html
159
frontend/invite.html
Normal 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>
|
||||
46
frontend/join-office-skill.md
Normal 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
|
|
@ -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
|
|
@ -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
|
After Width: | Height: | Size: 45 KiB |
BIN
frontend/memo-bg.webp
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
frontend/new-desk.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
frontend/new-desk.webp
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
frontend/office_bg.webp
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
frontend/office_bg_small.png
Normal file
|
After Width: | Height: | Size: 405 KiB |
BIN
frontend/office_bg_small.webp
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
frontend/plants-spritesheet.png
Normal file
|
After Width: | Height: | Size: 340 KiB |
BIN
frontend/plants-spritesheet.webp
Normal file
|
After Width: | Height: | Size: 193 KiB |
BIN
frontend/posters-spritesheet.png
Normal file
|
After Width: | Height: | Size: 821 KiB |
BIN
frontend/posters-spritesheet.webp
Normal file
|
After Width: | Height: | Size: 580 KiB |
BIN
frontend/serverroom-spritesheet.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
frontend/serverroom-spritesheet.webp
Normal file
|
After Width: | Height: | Size: 996 KiB |
BIN
frontend/serverroom.gif
Normal file
|
After Width: | Height: | Size: 756 KiB |
BIN
frontend/sofa-busy-spritesheet.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
frontend/sofa-busy-spritesheet.webp
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
frontend/sofa-idle.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
frontend/sofa-idle.webp
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
frontend/star-idle-spritesheet.png
Normal file
|
After Width: | Height: | Size: 450 KiB |
BIN
frontend/star-idle-spritesheet.webp
Normal file
|
After Width: | Height: | Size: 318 KiB |
BIN
frontend/star-idle.gif
Normal file
|
After Width: | Height: | Size: 191 KiB |
BIN
frontend/star-researching-spritesheet.png
Normal file
|
After Width: | Height: | Size: 952 KiB |
BIN
frontend/star-researching-spritesheet.webp
Normal file
|
After Width: | Height: | Size: 677 KiB |
BIN
frontend/star-researching.gif
Normal file
|
After Width: | Height: | Size: 396 KiB |
BIN
frontend/star-working-spritesheet-grid.png
Normal file
|
After Width: | Height: | Size: 4 MiB |
BIN
frontend/star-working-spritesheet-grid.webp
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
frontend/star-working-spritesheet.png
Normal file
|
After Width: | Height: | Size: 4.1 MiB |
BIN
frontend/star-working.gif
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
frontend/sync-animation-spritesheet-grid.png
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
frontend/sync-animation-spritesheet-grid.webp
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
frontend/sync-animation.webp
Normal file
|
After Width: | Height: | Size: 563 KiB |