Initial open-source release

This commit is contained in:
Star 2026-02-26 11:13:51 +08:00
commit e59c1bcd0d
10 changed files with 2208 additions and 0 deletions

18
.gitignore vendored Normal file
View file

@ -0,0 +1,18 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
*.egg-info/
.venv/
venv/
.env
# OS
.DS_Store
# Runtime / local files
state.json
cloudflared.pid
cloudflared.out
frontend/office_bg.png

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ring Hyacinth
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

88
README.md Normal file
View file

@ -0,0 +1,88 @@
# Star Office UI
A tiny “pixel office” status UI for your AI assistant.
- 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.
## What it looks like
- `idle / syncing / error` → breakroom area
- `writing / researching / executing` → desk area
The UI polls `/status` and renders the assistant avatar accordingly.
## Folder structure
```
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
```
## Requirements
- Python 3.9+
- Flask
## Quick start (local)
### 1) Install dependencies
```bash
pip install flask
```
### 2) Put your background image
Put a **800×600 PNG** at:
```
star-office-ui/frontend/office_bg.png
```
### 3) Start backend
```bash
cd star-office-ui/backend
python app.py
```
Then open:
- http://127.0.0.1:18791
### 4) Update status
From the project root:
```bash
python3 star-office-ui/set_state.py writing "Working on a task..."
python3 star-office-ui/set_state.py idle "Standing by"
```
## Public access (Cloudflare quick tunnel)
Install `cloudflared`, then:
```bash
cloudflared tunnel --url http://127.0.0.1:18791
```
Youll get a `https://xxx.trycloudflare.com` URL.
## Security notes
- 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).
## License
MIT

164
SKILL.md Normal file
View file

@ -0,0 +1,164 @@
---
name: star-office-ui
description: 为你的 AI 助手创建一个“像素办公室”可视化界面,手机可通过 Cloudflare Tunnel 公网访问!
metadata:
{
"openclaw": { "emoji": "🏢", "title": "Star 像素办公室", "color": "#ff6b35" }
}
---
# Star Office UI Skill
## 效果预览
- 俯视像素办公室背景(可自己画/AI 生成/找素材)
- 像素小人代表助手:会根据 `state` 在不同区域移动,并带眨眼/气泡/打字机等动态
- 手机可通过 Cloudflare Tunnel quick tunnel 公网访问
## 前置条件
- 有一台能跑 Python 的服务器(或本地电脑)
- 一张 800×600 的 PNG 办公室背景图(俯视像素风最佳)
- 有 Python 3 + Flask
- 有 Phaser CDN前端直接用无需安装
## 快速开始
### 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
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")
DEFAULT_STATE = {
"state": "idle",
"detail": "等待任务中...",
"progress": 0,
"updated_at": datetime.now().isoformat()
}
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)
@app.route("/")
def index():
return send_from_directory(FRONTEND_DIR, "index.html")
@app.route("/status")
def get_status():
return jsonify(load_state())
@app.route("/health")
def health():
return jsonify({"status": "ok", "timestamp": datetime.now().isoformat()})
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"]
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()}
def save_state(state):
with open(STATE_FILE, "w", encoding="utf-8") as f:
json.dump(state, f, ensure_ascii=False, indent=2)
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/`(包含完整的前端 + 后端 + 状态脚本)

114
backend/app.py Normal file
View file

@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Star Office UI - Backend State Service"""
from flask import Flask, jsonify, send_from_directory
from datetime import datetime
import json
import os
# Paths
ROOT_DIR = "/root/.openclaw/workspace/star-office-ui"
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")
# Default state
DEFAULT_STATE = {
"state": "idle",
"detail": "等待任务中...",
"progress": 0,
"updated_at": datetime.now().isoformat()
}
def load_state():
"""Load state from file.
Includes a simple auto-idle mechanism:
- If the last update is older than ttl_seconds (default 25s)
and the state is a "working" state, we fall back to idle.
This avoids the UI getting stuck at the desk when no new updates arrive.
"""
state = None
if os.path.exists(STATE_FILE):
try:
with open(STATE_FILE, "r", encoding="utf-8") as f:
state = json.load(f)
except Exception:
state = None
if not isinstance(state, dict):
state = dict(DEFAULT_STATE)
# Auto-idle
try:
ttl = int(state.get("ttl_seconds", 25))
updated_at = state.get("updated_at")
s = state.get("state", "idle")
working_states = {"writing", "researching", "executing"}
if updated_at and s in working_states:
# tolerate both with/without timezone
dt = datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
# Use UTC for aware datetimes; local time for naive.
if dt.tzinfo:
from datetime import timezone
age = (datetime.now(timezone.utc) - dt.astimezone(timezone.utc)).total_seconds()
else:
age = (datetime.now() - dt).total_seconds()
if age > ttl:
state["state"] = "idle"
state["detail"] = "待命中(自动回到休息区)"
state["progress"] = 0
state["updated_at"] = datetime.now().isoformat()
# persist the auto-idle so every client sees it consistently
try:
save_state(state)
except Exception:
pass
except Exception:
pass
return state
def save_state(state: dict):
"""Save state to file"""
with open(STATE_FILE, "w", encoding="utf-8") as f:
json.dump(state, f, ensure_ascii=False, indent=2)
# Initialize state
if not os.path.exists(STATE_FILE):
save_state(DEFAULT_STATE)
@app.route("/", methods=["GET"])
def index():
"""Serve the pixel office UI"""
return send_from_directory(FRONTEND_DIR, "index.html")
@app.route("/status", methods=["GET"])
def get_status():
"""Get current state"""
state = load_state()
return jsonify(state)
@app.route("/health", methods=["GET"])
def health():
"""Health check"""
return jsonify({"status": "ok", "timestamp": datetime.now().isoformat()})
if __name__ == "__main__":
print("=" * 50)
print("Star Office UI - Backend State Service")
print("=" * 50)
print(f"State file: {STATE_FILE}")
print("Listening on: http://0.0.0.0:18791")
print("=" * 50)
app.run(host="0.0.0.0", port=18791, debug=False)

1504
backend/backend.out Normal file

File diff suppressed because it is too large Load diff

1
backend/backend.pid Normal file
View file

@ -0,0 +1 @@
362864

233
frontend/index.html Normal file
View file

@ -0,0 +1,233 @@
<!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>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #1a1a2e;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
font-family: 'Courier New', monospace;
}
#game-container {
border: 4px solid #e94560;
image-rendering: pixelated;
}
#status-text {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
color: #eee;
font-size: 14px;
background: rgba(0,0,0,0.7);
padding: 10px 20px;
border-radius: 4px;
max-width: 90%;
text-align: center;
}
</style>
</head>
<body>
<div id="game-container"></div>
<div id="status-text">加载中...</div>
<script src="https://cdn.jsdelivr.net/npm/phaser@3.80.1/dist/phaser.min.js"></script>
<script>
const config = {
type: Phaser.AUTO,
width: 800,
height: 600,
parent: 'game-container',
pixelArt: true,
physics: { default: 'arcade', arcade: { gravity: { y: 0 }, debug: false } },
scene: { preload: preload, create: create, update: update }
};
const STATES = {
idle: { name: '待命', area: 'breakroom' },
writing: { name: '整理文档', area: 'workdesk' },
researching: { name: '搜索信息', area: 'workdesk' },
executing: { name: '执行任务', area: 'workdesk' },
syncing: { name: '同步备份', area: 'breakroom' },
error: { name: '出错了', area: 'breakroom' }
};
const BUBBLE_TEXTS = {
idle: ['摸鱼中…', '有没有新任务?', '咖啡真好喝', '伸个懒腰'],
writing: ['这个要记下来', '写得手酸', '再检查一遍', '好记性不如烂笔头'],
researching: ['让我搜一下', '找到线索了', '这个有意思', '再深挖一点'],
executing: ['冲鸭!', '这个简单', '加油加油', '马上搞定'],
syncing: ['备份备份', '安全第一', '同步中…', '云端见'],
error: ['啊哦…', '出问题了', '让我看看', '马上修好']
};
let game, star, areas = {}, currentState = 'idle', statusText, lastFetch = 0, lastBlink = 0, lastBubble = 0, targetX = 660, targetY = 170, bubble = null, typewriterText = '', typewriterTarget = '', typewriterIndex = 0, lastTypewriter = 0;
const FETCH_INTERVAL = 2000;
const BLINK_INTERVAL = 2500;
const BUBBLE_INTERVAL = 8000;
const TYPEWRITER_DELAY = 50;
function preload() {
this.load.image('office_bg', '/static/office_bg.png');
}
function create() {
game = this;
this.add.image(400, 300, 'office_bg');
areas = {
workdesk: { x: 260, y: 340 },
breakroom: { x: 660, y: 170 },
alert: { x: 620, y: 490 }
};
// 创建 Star 角色(睁眼 + 闭眼两个纹理)
const graphicsOpen = game.make.graphics();
graphicsOpen.fillStyle(0xff6b35, 1);
graphicsOpen.fillRect(10, 10, 12, 12);
graphicsOpen.fillStyle(0xffffff, 1);
graphicsOpen.fillRect(13, 13, 3, 3);
graphicsOpen.fillRect(16, 13, 3, 3);
graphicsOpen.fillStyle(0x000000, 1);
graphicsOpen.fillRect(14, 14, 1, 1);
graphicsOpen.fillRect(17, 14, 1, 1);
graphicsOpen.generateTexture('star_open', 24, 24);
const graphicsClosed = game.make.graphics();
graphicsClosed.fillStyle(0xff6b35, 1);
graphicsClosed.fillRect(10, 10, 12, 12);
graphicsClosed.fillStyle(0x000000, 1);
graphicsClosed.fillRect(13, 14, 3, 1);
graphicsClosed.fillRect(16, 14, 3, 1);
graphicsClosed.generateTexture('star_closed', 24, 24);
star = game.physics.add.sprite(660, 170, 'star_open');
star.setOrigin(0.5);
star.setScale(1.8);
star.setAlpha(0.95);
// 加像素风小牌匾:海辛小龙虾的办公室
const plaqueBg = game.add.rectangle(400, 570, 380, 40, 0x5d4037);
plaqueBg.setStrokeStyle(3, 0x3e2723);
const plaqueText = game.add.text(400, 570, '海辛小龙虾的办公室', {
font: '18px monospace',
fill: '#ffd700',
fontWeight: 'bold',
stroke: '#000',
strokeThickness: 2
}).setOrigin(0.5);
// 牌匾两边加个小装饰
game.add.text(230, 570, '⭐', { font: '20px' }).setOrigin(0.5);
game.add.text(570, 570, '⭐', { font: '20px' }).setOrigin(0.5);
statusText = document.getElementById('status-text');
fetchStatus();
}
function update(time) {
if (time - lastFetch > FETCH_INTERVAL) { fetchStatus(); lastFetch = time; }
// 眨眼
if (time - lastBlink > BLINK_INTERVAL) {
star.setTexture('star_closed');
lastBlink = time;
setTimeout(() => { star.setTexture('star_open'); }, 150);
}
// 冒气泡
if (time - lastBubble > BUBBLE_INTERVAL) {
showBubble();
lastBubble = time;
}
// 打字机效果
if (typewriterIndex < typewriterTarget.length && time - lastTypewriter > TYPEWRITER_DELAY) {
typewriterText += typewriterTarget[typewriterIndex];
statusText.textContent = typewriterText;
typewriterIndex++;
lastTypewriter = time;
}
// 移动 + 小踱步
moveStar(time);
}
function normalizeState(s) {
if (!s) return 'idle';
if (s === 'working') return 'writing';
if (s === 'run' || s === 'running') return 'executing';
if (s === 'sync') return 'syncing';
if (s === 'research') return 'researching';
return s;
}
function fetchStatus() {
fetch('/status')
.then(response => response.json())
.then(data => {
const nextState = normalizeState(data.state);
const stateInfo = STATES[nextState] || STATES.idle;
const changed = nextState !== currentState;
currentState = nextState;
const nextLine = '[' + stateInfo.name + '] ' + (data.detail || '...');
if (changed) {
typewriterTarget = nextLine;
typewriterText = '';
typewriterIndex = 0;
const targetArea = areas[stateInfo.area] || areas.breakroom;
targetX = targetArea.x + (Math.random() - 0.5) * 40;
targetY = targetArea.y + (Math.random() - 0.5) * 40;
} else {
if (!typewriterTarget || typewriterTarget !== nextLine) {
typewriterTarget = nextLine;
typewriterText = '';
typewriterIndex = 0;
}
}
})
.catch(error => {
typewriterTarget = '连接失败,正在重试...';
typewriterText = '';
typewriterIndex = 0;
});
}
function moveStar(time) {
const stateInfo = STATES[currentState] || STATES.idle;
const baseTarget = areas[stateInfo.area] || areas.breakroom;
if (Math.random() < 0.005) {
targetX = baseTarget.x + (Math.random() - 0.5) * 40;
targetY = baseTarget.y + (Math.random() - 0.5) * 40;
}
const dx = targetX - star.x;
const dy = targetY - star.y;
const speed = 1.2;
const wobble = Math.sin(time / 200) * 0.8;
if (Math.abs(dx) > 3) star.x += Math.sign(dx) * speed;
if (Math.abs(dy) > 3) star.y += Math.sign(dy) * speed;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
star.setY(star.y + wobble);
}
}
function showBubble() {
if (bubble) { bubble.destroy(); bubble = null; }
const texts = BUBBLE_TEXTS[currentState] || BUBBLE_TEXTS.idle;
const text = texts[Math.floor(Math.random() * texts.length)];
const bg = game.add.rectangle(star.x, star.y - 45, text.length * 10 + 20, 28, 0xffffff, 0.95);
bg.setStrokeStyle(2, 0x000000);
const txt = game.add.text(star.x, star.y - 45, text, { font: '12px monospace', fill: '#000', align: 'center' }).setOrigin(0.5);
bubble = game.add.container(0, 0, [bg, txt]);
bubble.setDepth(100);
setTimeout(() => { if (bubble) { bubble.destroy(); bubble = null; } }, 3000);
}
new Phaser.Game(config);
</script>
</body>
</html>

59
set_state.py Normal file
View file

@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""简单的状态更新工具,用于测试 Star Office UI"""
import json
import os
import sys
from datetime import datetime
STATE_FILE = "/root/.openclaw/workspace/star-office-ui/state.json"
VALID_STATES = [
"idle",
"writing",
"researching",
"executing",
"syncing",
"error"
]
def load_state():
if os.path.exists(STATE_FILE):
with open(STATE_FILE, "r", encoding="utf-8") as f:
return json.load(f)
return {
"state": "idle",
"detail": "待命中...",
"progress": 0,
"updated_at": datetime.now().isoformat()
}
def save_state(state):
with open(STATE_FILE, "w", encoding="utf-8") as f:
json.dump(state, f, ensure_ascii=False, indent=2)
if __name__ == "__main__":
if len(sys.argv) < 2:
print("用法: python set_state.py <state> [detail]")
print(f"状态选项: {', '.join(VALID_STATES)}")
print("\n例子:")
print(" python set_state.py idle")
print(" python set_state.py researching \"在查 Godot MCP...\"")
print(" python set_state.py writing \"在写热点日报模板...\"")
sys.exit(1)
state_name = sys.argv[1]
detail = sys.argv[2] if len(sys.argv) > 2 else ""
if state_name not in VALID_STATES:
print(f"无效状态: {state_name}")
print(f"有效选项: {', '.join(VALID_STATES)}")
sys.exit(1)
state = load_state()
state["state"] = state_name
state["detail"] = detail
state["updated_at"] = datetime.now().isoformat()
save_state(state)
print(f"状态已更新: {state_name} - {detail}")

6
state.sample.json Normal file
View file

@ -0,0 +1,6 @@
{
"state": "idle",
"detail": "Waiting...",
"progress": 0,
"updated_at": "2026-02-26T00:00:00"
}