mirror of
https://github.com/ringhyacinth/Star-Office-UI
synced 2026-04-21 13:27:19 +00:00
Initial open-source release
This commit is contained in:
commit
e59c1bcd0d
10 changed files with 2208 additions and 0 deletions
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal 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
21
LICENSE
Normal 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
88
README.md
Normal 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
|
||||
```
|
||||
|
||||
You’ll get a `https://xxx.trycloudflare.com` URL.
|
||||
|
||||
## Security notes
|
||||
|
||||
- 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).
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
164
SKILL.md
Normal file
164
SKILL.md
Normal 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(公网访问)
|
||||
- 下载 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/`(包含完整的前端 + 后端 + 状态脚本)
|
||||
114
backend/app.py
Normal file
114
backend/app.py
Normal 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
1504
backend/backend.out
Normal file
File diff suppressed because it is too large
Load diff
1
backend/backend.pid
Normal file
1
backend/backend.pid
Normal file
|
|
@ -0,0 +1 @@
|
|||
362864
|
||||
233
frontend/index.html
Normal file
233
frontend/index.html
Normal 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
59
set_state.py
Normal 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
6
state.sample.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"state": "idle",
|
||||
"detail": "Waiting...",
|
||||
"progress": 0,
|
||||
"updated_at": "2026-02-26T00:00:00"
|
||||
}
|
||||
Loading…
Reference in a new issue