feat(office): complete art rebuild with new asset index and UI polish

This commit is contained in:
OpenClaw Assistant 2026-03-03 18:08:59 +08:00
parent 01e6c3fa7e
commit dcf1876a37
89 changed files with 3744 additions and 463 deletions

3
.gitignore vendored
View file

@ -25,3 +25,6 @@ cloudflared.out
healthcheck.log
backend.log
frontend/office_bg.png
runtime-config.json
assets/bg-history/
frontend/*.bak

23
LICENSE
View file

@ -32,26 +32,21 @@ SOFTWARE.
### Important: Art Assets are NOT for commercial use
All art assets (including but not limited to: character sprites, scene backgrounds, posters, furniture, plants, coffee machine, server room, animations, and the full asset pack) are **non-commercial only**. They are for **learning, demonstration, and idea sharing only**.
All art assets (including but not limited to character sprites, scene backgrounds,
posters, furniture, plants, coffee machine, server room, animations, and full asset packs)
are **non-commercial only**.
You may NOT use any art assets from this repository for any commercial purpose. If you want to use this project commercially, you **must create and replace all art assets with your own original work**.
They are for **learning, demonstration, and idea sharing only**.
You may NOT use any art assets from this repository for commercial purposes.
If you want to use this project commercially, you **must replace all art assets with your own original work**.
---
## 3. Additional Disclaimer (Starmie / Pokémon)
- The main character "Starmie" is an existing IP from Nintendo/Pokémon and is **not original to this project**.
- This project is a non-commercial fan creation only; the character was chosen for a fun homophone between "Starmie" and the authors Chinese name "海辛" (Hǎi Xīn).
- This projects fan content is for learning, demonstration, and idea sharing only, with no commercial use.
- Nintendo, Pokémon, and "Starmie" are trademarks or registered trademarks of Nintendo/The Pokémon Company.
- If you plan to use any content related to this project, please assess compliance risks on your own and prioritize using your own original characters/art assets.
---
## 4. Guest Character Asset Attribution
## 3. Guest Character Asset Attribution
Guest character animations use LimeZus free assets:
- Animated Mini Characters 2 (Platformer) [FREE]
- https://limezu.itch.io/animated-mini-characters-2-platform-free
Please keep this source attribution and follow the original authors license terms when redistributing or demonstrating.
Please keep this attribution and follow the original authors license terms when redistributing or demonstrating.

View file

@ -63,7 +63,20 @@ Star Office UI 目前实现了:
4. **已适配手机端访问**
- 移动端可直接打开与查看状态(适合外出时快速查看)。
5. **公网访问方式灵活**
5. **支持中英日三语切换**
- 已支持 CN / EN / JP 三语切换。
- 语言切换会实时作用于界面文案、加载提示与角色气泡。
6. **支持自定义美术资产**
- 支持在资产侧边栏替换角色/场景素材。
- 支持动态素材重切帧与参数同步frame size / frame range以避免闪烁。
7. **支持接入自己的生图 API可无限换背景**
- 支持接入自有生图 API 进行“搬新家/找中介”式背景更新。
- 推荐模型:`nanobanana-pro` 或 `nanobanana-2`(结构保持更稳定)。
- 基础看板功能不依赖 API不接入 API 也可正常使用核心状态看板与资产管理。
8. **公网访问方式灵活**
- Skill 默认建议使用 Cloudflare Tunnel 快速公网化。
- 也可以使用你自己的公网域名 / 反向代理方案。
@ -141,17 +154,6 @@ python3 set_state.py idle "待命中"
请在二次发布/演示时保留来源说明,并遵守原作者许可条款。
### 其他资产说明与免责(重要)
- **主角色(宝石海星)与谐音说明**
- “宝石海星”是任天堂《宝可梦》Pokémon系列中已有的角色 IP**不是本项目原创 IP**。
- 本项目仅为**非商用二创/粉丝创作**:选择这个角色,是因为“宝石海星”与作者名字“海辛”在中文发音上有谐音趣味。
- 本项目的二创内容仅供学习、演示、交流使用,**无任何商业用途**。
- 任天堂、宝可梦、“宝石海星”均为任天堂/宝可梦公司的商标或注册商标。
- 若你计划使用本项目相关内容,请使用你自己的原创角色/美术资产。
- **办公室场景与其他素材**:由本项目作者团队自行制作。
### 商用限制(重要)
- 代码玩法可以基于 MIT 使用与二次开发。
@ -437,3 +439,53 @@ star-office-ui/
README.md
LICENSE
```
---
## 9、2026-03 增量更新(在原版基础上补充)
> 本节仅记录“新增/变化”,其余内容保持原版结构不变。
### A) 房间装修生图模型推荐(新增)
在“搬新家 / 找中介”能力中,建议优先接入你自己的 Gemini 并使用:
1. **gemini nanobanana pro**
2. **gemini nanobanana 2**
其他模型在“保持原房间结构 + 风格迁移一致性”上可能不如预期。
建议配置:
- `GEMINI_API_KEY`
- `GEMINI_MODEL`(建议 `nanobanana-pro``nanobanana-2`
此外,项目已支持运行时配置入口:
- `GET /config/gemini`
- `POST /config/gemini`
当 API key 缺失时,侧边栏会出现填写入口,用户可直接补填后重试。
### B) 资产编辑侧边栏验证码(新增)
侧边栏可以修改布局、装饰、默认位置,建议务必设置强验证码:
- 默认:`ASSET_DRAWER_PASS=1234`
- 部署时建议覆盖:
```bash
export ASSET_DRAWER_PASS="your-strong-pass"
```
必要性:
- 防止拿到访问链接的人修改你的房间布局与素材。
### C) 角色素材版权口径更新(重要)
当前主角状态Writing / Idle / Syncing / Error已切换为无版权争议的小猫素材。
因此:
- README 中旧的任天堂/宝可梦角色说明不再作为当前版本口径。
- 保留原则仍不变:
- 代码逻辑 MIT
- 美术资产禁止商用(学习/演示/交流用途)

View file

@ -125,3 +125,78 @@ cloudflared tunnel --url http://127.0.0.1:18791
- 如果主人要对外访问,优先用 Cloudflare Tunnel最快最方便
- 如果主人要改状态,你可以帮他调用 `set_state.py`,不用让他自己敲命令
- 注意不要暴露你主人的内网地址或私有信息
---
## 8. 2026-03 增量补充(仅新增项)
### 8.1 生图模型建议(房间装修)
当用户使用“搬新家 / 找中介”时,优先推荐:
1. **gemini nanobanana pro**
2. **gemini nanobanana 2**
其他模型在房间结构保持与风格一致性上可能不稳定。
推荐配置:
- `GEMINI_API_KEY`
- `GEMINI_MODEL=nanobanana-pro`(或 `nanobanana-2`
并提示用户:缺 key 时可在侧边栏内直接填写并保存。
### 8.2 侧边栏验证码安全提醒(必须)
默认验证码为 `1234`,但生产/公网场景必须改强密码:
```bash
export ASSET_DRAWER_PASS="your-strong-pass"
```
理由:防止外部访问者修改房间布局、装饰和资产配置。
### 8.3 版权口径更新
主角状态素材已切换为无版权争议的小猫,不再沿用旧角色版权说明。
保留统一口径:
- 代码MIT
- 美术资产:禁止商用
### 8.4 安装时必须提醒API 可选)
在帮助主人安装时,需明确提醒:
- 现在支持接入自己的生图 API 来改美术资产与背景(可持续更换)。
- 但基础功能(状态看板、多 Agent、资产替换/布局、三语切换)**不依赖 API**,不开 API 也能正常使用。
建议对主人口径:
> 先把基础看板跑起来;需要“无限换背景/AI 生图装修”再接入自己的 API。
### 8.5 老用户更新指南(从旧版本升级)
如果主人之前已经下载过旧版,按以下步骤升级:
1. 进入项目目录并备份本地配置(如 `state.json`、自定义资产)。
2. 拉取最新代码(`git pull` 或重新克隆到新目录)。
3. 确认依赖:`python3 -m pip install -r backend/requirements.txt`。
4. 保留并检查本地运行配置:
- `ASSET_DRAWER_PASS`
- `GEMINI_API_KEY` / `GEMINI_MODEL`(如需生图)
5. 如有自定义位置,确认:
- `asset-positions.json`
- `asset-defaults.json`
6. 重启后端并验收关键功能:
- `/health`
- 三语切换CN/EN/JP
- 资产侧栏(选择、替换、设默认)
- 生图入口(有 key 时可用)
### 8.6 功能更新提醒清单(对主人口播)
每次升级后,至少提醒主人以下变化:
1. 已支持 **CN/EN/JP 三语切换**(含 loading 与气泡实时联动)。
2. 已支持 **自定义美术资产替换**(含动态素材切帧同步,减少闪烁)。
3. 已支持 **接入自有生图 API** 持续更换背景(推荐 `nanobanana-pro` / `nanobanana-2`)。
4. 新增/强化了安全项:`ASSET_DRAWER_PASS` 生产环境建议改强密码。

8
asset-defaults.json Normal file
View file

@ -0,0 +1,8 @@
{
"flowers-bloom-v2.webp": {
"x": 310.0,
"y": 390.0,
"scale": 0.8,
"updated_at": "2026-03-03T01:32:18.211712"
}
}

7
asset-positions.json Normal file
View file

@ -0,0 +1,7 @@
{
"desk-v3.webp": {
"x": 218.0,
"y": 417.0,
"updated_at": "2026-03-02T15:58:27.228023"
}
}

BIN
assets/room-reference.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 KiB

BIN
assets/room-reference.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

View file

@ -1,12 +1,23 @@
#!/usr/bin/env python3
"""Star Office UI - Backend State Service"""
from flask import Flask, jsonify, send_from_directory, make_response, request
from flask import Flask, jsonify, send_from_directory, make_response, request, session
from datetime import datetime, timedelta
import json
import os
import random
import math
import re
import shutil
import subprocess
import tempfile
import threading
from pathlib import Path
try:
from PIL import Image
except Exception:
Image = None
# Paths (project-relative, no hardcoded absolute paths)
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@ -15,6 +26,21 @@ 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")
FRONTEND_PATH = Path(FRONTEND_DIR)
ASSET_ALLOWED_EXTS = {".png", ".webp", ".jpg", ".jpeg", ".gif", ".svg", ".avif"}
ASSET_TEMPLATE_ZIP = os.path.join(ROOT_DIR, "assets-replace-template.zip")
WORKSPACE_DIR = os.path.dirname(ROOT_DIR)
GEMINI_SCRIPT = os.path.join(WORKSPACE_DIR, "skills", "gemini-image-generate", "scripts", "gemini_image_generate.py")
GEMINI_PYTHON = os.path.join(WORKSPACE_DIR, "skills", "gemini-image-generate", ".venv", "bin", "python")
ROOM_REFERENCE_IMAGE = (
os.path.join(ROOT_DIR, "assets", "room-reference.webp")
if os.path.exists(os.path.join(ROOT_DIR, "assets", "room-reference.webp"))
else os.path.join(ROOT_DIR, "assets", "room-reference.png")
)
BG_HISTORY_DIR = os.path.join(ROOT_DIR, "assets", "bg-history")
ASSET_POSITIONS_FILE = os.path.join(ROOT_DIR, "asset-positions.json")
ASSET_DEFAULTS_FILE = os.path.join(ROOT_DIR, "asset-defaults.json")
RUNTIME_CONFIG_FILE = os.path.join(ROOT_DIR, "runtime-config.json")
def get_yesterday_date_str():
@ -132,20 +158,41 @@ def extract_memo_from_file(file_path):
return "「昨日记录加载失败」\n\n「往者不可谏,来者犹可追。」"
app = Flask(__name__, static_folder=FRONTEND_DIR, static_url_path="/static")
app.secret_key = os.getenv("FLASK_SECRET_KEY") or os.getenv("STAR_OFFICE_SECRET") or "star-office-dev-secret-change-me"
# 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")
ASSET_DRAWER_PASS_DEFAULT = os.getenv("ASSET_DRAWER_PASS", "1234")
def _is_asset_editor_authed() -> bool:
return bool(session.get("asset_editor_authed"))
def _require_asset_editor_auth():
if _is_asset_editor_authed():
return None
return jsonify({"ok": False, "code": "UNAUTHORIZED", "msg": "Asset editor auth required"}), 401
@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"
"""Apply cache policy by path:
- HTML/API/state: no-cache (always fresh)
- /static assets: long cache (filenames are versioned with ?v=VERSION_TIMESTAMP)
"""
path = (request.path or "")
if path.startswith('/static/'):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
response.headers.pop("Pragma", None)
response.headers.pop("Expires", None)
else:
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
@ -264,20 +311,6 @@ DEFAULT_AGENTS = [
"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
}
]
@ -299,6 +332,63 @@ def save_agents_state(agents):
json.dump(agents, f, ensure_ascii=False, indent=2)
def load_asset_positions():
if os.path.exists(ASSET_POSITIONS_FILE):
try:
with open(ASSET_POSITIONS_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
return data
except Exception:
pass
return {}
def save_asset_positions(data):
with open(ASSET_POSITIONS_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def load_asset_defaults():
if os.path.exists(ASSET_DEFAULTS_FILE):
try:
with open(ASSET_DEFAULTS_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
return data
except Exception:
pass
return {}
def save_asset_defaults(data):
with open(ASSET_DEFAULTS_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def load_runtime_config():
base = {
"gemini_api_key": os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY") or "",
"gemini_model": os.getenv("GEMINI_MODEL") or "gemini-3.1-flash-image-preview"
}
if os.path.exists(RUNTIME_CONFIG_FILE):
try:
with open(RUNTIME_CONFIG_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
base.update({k: data.get(k, base.get(k)) for k in ["gemini_api_key", "gemini_model"]})
except Exception:
pass
return base
def save_runtime_config(data):
cfg = load_runtime_config()
cfg.update(data or {})
with open(RUNTIME_CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
def load_join_keys():
if os.path.exists(JOIN_KEYS_FILE):
try:
@ -316,6 +406,141 @@ def save_join_keys(data):
json.dump(data, f, ensure_ascii=False, indent=2)
def _ensure_magick_or_ffmpeg_available():
if shutil.which("magick"):
return "magick"
if shutil.which("ffmpeg"):
return "ffmpeg"
return None
def _probe_animated_frame_size(upload_path: str):
"""Return (w,h) from first frame if possible."""
if Image is not None:
try:
with Image.open(upload_path) as im:
w, h = im.size
return int(w), int(h)
except Exception:
pass
# ffprobe fallback
if shutil.which("ffprobe"):
try:
cmd = [
"ffprobe", "-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=width,height",
"-of", "csv=p=0:s=x",
upload_path,
]
out = subprocess.check_output(cmd, stderr=subprocess.STDOUT, timeout=5).decode().strip()
if "x" in out:
w, h = out.split("x", 1)
return int(w), int(h)
except Exception:
pass
return None, None
def _animated_to_spritesheet(
upload_path: str,
frame_w: int,
frame_h: int,
out_ext: str = ".webp",
preserve_original: bool = True,
pixel_art: bool = True,
cols: int | None = None,
rows: int | None = None,
):
"""Convert animated GIF/WEBP to spritesheet, return (out_path, columns, rows, frames, out_frame_w, out_frame_h)."""
backend = _ensure_magick_or_ffmpeg_available()
if not backend:
raise RuntimeError("未检测到 ImageMagick/ffmpeg无法自动转换动图")
ext = (out_ext or ".webp").lower()
if ext not in {".webp", ".png"}:
ext = ".webp"
out_fd, out_path = tempfile.mkstemp(suffix=ext)
os.close(out_fd)
with tempfile.TemporaryDirectory() as td:
frames = 0
out_fw, out_fh = int(frame_w), int(frame_h)
if Image is not None:
try:
with Image.open(upload_path) as im:
n = getattr(im, "n_frames", 1)
# 默认保留用户原始帧尺寸(避免先压缩再放大导致像素糊)
if preserve_original:
out_fw, out_fh = im.size
for i in range(n):
im.seek(i)
fr = im.convert("RGBA")
if not preserve_original and (fr.size != (out_fw, out_fh)):
resample = Image.Resampling.NEAREST if pixel_art else Image.Resampling.LANCZOS
fr = fr.resize((out_fw, out_fh), resample)
fr.save(os.path.join(td, f"f_{i:04d}.png"), "PNG")
frames = n
except Exception:
frames = 0
if frames <= 0:
cmd1 = f"ffmpeg -y -i '{upload_path}' '{td}/f_%04d.png' >/dev/null 2>&1"
if os.system(cmd1) != 0:
raise RuntimeError("动图抽帧失败Pillow/ffmpeg 都失败)")
files = sorted([x for x in os.listdir(td) if x.startswith("f_") and x.endswith(".png")])
frames = len(files)
if frames <= 0:
raise RuntimeError("动图无有效帧")
if backend == "magick":
# 像素风动图转精灵表默认无损,避免颜色/边缘被压缩糊掉
quality_flag = "-define webp:lossless=true -define webp:method=6 -quality 100" if ext == ".webp" else ""
# 允许按 cols/rows 排布;默认单行
if cols is None or cols <= 0:
cols_eff = frames
else:
cols_eff = max(1, int(cols))
rows_eff = max(1, int(rows)) if (rows is not None and rows > 0) else max(1, math.ceil(frames / cols_eff))
# 先规范单帧尺寸
prep = ""
if not preserve_original:
magick_filter = "-filter point" if pixel_art else ""
prep = f" {magick_filter} -resize {out_fw}x{out_fh}^ -gravity center -background none -extent {out_fw}x{out_fh}"
cmd = (
f"magick '{td}/f_*.png'{prep} "
f"-tile {cols_eff}x{rows_eff} -background none -geometry +0+0 {quality_flag} '{out_path}'"
)
rc = os.system(cmd)
if rc != 0:
raise RuntimeError("ImageMagick 拼图失败")
return out_path, cols_eff, rows_eff, frames, out_fw, out_fh
ffmpeg_quality = "-lossless 1 -compression_level 6 -q:v 100" if ext == ".webp" else ""
cols_eff = max(1, int(cols)) if (cols is not None and cols > 0) else frames
rows_eff = max(1, int(rows)) if (rows is not None and rows > 0) else max(1, math.ceil(frames / cols_eff))
if preserve_original:
vf = f"tile={cols_eff}x{rows_eff}"
else:
scale_algo = "neighbor" if pixel_art else "lanczos"
vf = (
f"scale={out_fw}:{out_fh}:force_original_aspect_ratio=decrease:flags={scale_algo},"
f"pad={out_fw}:{out_fh}:(ow-iw)/2:(oh-ih)/2:color=0x00000000,"
f"tile={cols_eff}x{rows_eff}"
)
cmd2 = (
f"ffmpeg -y -pattern_type glob -i '{td}/f_*.png' "
f"-vf '{vf}' "
f"{ffmpeg_quality} '{out_path}' >/dev/null 2>&1"
)
if os.system(cmd2) != 0:
raise RuntimeError("ffmpeg 拼图失败")
return out_path, frames, 1, frames, out_fw, out_fh
def normalize_agent_state(s):
"""归一化状态,提高兼容性。
兼容输入working/busy writing; run/running executing; sync syncing; research researching.
@ -338,6 +563,155 @@ def normalize_agent_state(s):
return 'idle'
def _generate_rpg_background_to_webp(out_webp_path: str, width: int = 1280, height: int = 720, custom_prompt: str = "", speed_mode: str = "fast"):
"""Generate RPG-style room background and save as webp.
speed_mode:
- fast: use nanobanana-2 + 1024x576 intermediate + downscaled reference (faster)
- quality: use configured model (fallback nanobanana-pro) + full 1280x720 path
"""
runtime_cfg = load_runtime_config()
api_key = (runtime_cfg.get("gemini_api_key") or "").strip()
if not api_key:
raise RuntimeError("MISSING_API_KEY")
themes = [
"8-bit dungeon guild room",
"8-bit stardew-valley inspired cozy farm tavern",
"8-bit nordic fantasy tavern",
"8-bit magitech workshop",
"8-bit elven forest inn",
"8-bit pixel cyber tavern",
"8-bit desert caravan inn",
"8-bit snow mountain lodge",
]
theme = random.choice(themes)
if not (os.path.exists(GEMINI_PYTHON) and os.path.exists(GEMINI_SCRIPT)):
raise RuntimeError("生图脚本环境缺失gemini-image-generate 未安装")
style_hint = (custom_prompt or "").strip()
if not style_hint:
style_hint = theme
# 默认使用更稳妥的 quality 档,避免 fast 模型在部分 API 通道不可用
mode = (speed_mode or "quality").strip().lower()
if mode not in {"fast", "quality"}:
mode = "fast"
configured_model = (runtime_cfg.get("gemini_model") or "").strip() or "gemini-3.1-flash-image-preview"
if mode == "fast":
selected_model = "nanobanana-2"
# fast 也提高基础清晰度:从 1024x576 提升到 1152x648牺牲少量速度
gen_width, gen_height = 1152, 648
ref_width, ref_height = 1152, 648
else:
selected_model = configured_model
gen_width, gen_height = width, height
ref_width, ref_height = width, height
if mode == "fast" and selected_model not in {"nanobanana-2", "nanobanana-pro"}:
selected_model = "nanobanana-2"
prompt = (
"Use a top-down pixel room composition compatible with an office game scene. "
"STRICTLY preserve the same room geometry, camera angle, wall/floor boundaries and major object placement as the provided reference image. "
"Keep region layout stable (left work area, center lounge, right error area). "
"Only change visual style/theme/material/lighting according to: " + style_hint + ". "
"Do not add text or watermark. Retro 8-bit RPG style."
)
tmp_dir = tempfile.mkdtemp(prefix="rpg-bg-")
cmd = [
GEMINI_PYTHON,
GEMINI_SCRIPT,
"--prompt", prompt,
"--aspect-ratio", "16:9",
"--model", selected_model,
"--out-dir", tmp_dir,
"--cleanup",
]
# 强约束:每次都带固定参考图,保持房间区域布局不漂移
ref_for_call = None
if os.path.exists(ROOM_REFERENCE_IMAGE):
ref_for_call = ROOM_REFERENCE_IMAGE
if mode == "fast" and Image is not None:
try:
ref_fast = os.path.join(tmp_dir, "room-reference-fast.webp")
with Image.open(ROOM_REFERENCE_IMAGE) as rim:
rim = rim.convert("RGBA").resize((ref_width, ref_height), Image.Resampling.LANCZOS)
rim.save(ref_fast, "WEBP", quality=85, method=4)
ref_for_call = ref_fast
except Exception:
ref_for_call = ROOM_REFERENCE_IMAGE
if ref_for_call:
cmd.extend(["--reference-image", ref_for_call])
env = os.environ.copy()
# 运行时配置优先:只保留 GEMINI_API_KEY避免脚本因双 key 报错
env.pop("GOOGLE_API_KEY", None)
env["GEMINI_API_KEY"] = api_key
env["GEMINI_MODEL"] = selected_model
def _run_cmd(cmd_args):
return subprocess.run(cmd_args, capture_output=True, text=True, env=env, timeout=240)
proc = _run_cmd(cmd)
if proc.returncode != 0 and mode == "fast":
err_text = (proc.stderr or proc.stdout or "").strip().lower()
if ("not found" in err_text and "models/" in err_text) or ("model_not_available" in err_text):
# fast 模型不可用时自动回退到稳定模型
fallback_model = configured_model or "gemini-3.1-flash-image-preview"
cmd_fallback = cmd[:]
if "--model" in cmd_fallback:
idx = cmd_fallback.index("--model")
if idx + 1 < len(cmd_fallback):
cmd_fallback[idx + 1] = fallback_model
env["GEMINI_MODEL"] = fallback_model
proc = _run_cmd(cmd_fallback)
if proc.returncode != 0:
err_text = (proc.stderr or proc.stdout or "").strip()
low = err_text.lower()
if "your api key was reported as leaked" in low or "permission_denied" in low:
raise RuntimeError("API_KEY_REVOKED_OR_LEAKED")
if "not found" in low and "models/" in low:
raise RuntimeError("MODEL_NOT_AVAILABLE")
raise RuntimeError(f"生图失败: {err_text}")
try:
result = json.loads(proc.stdout.strip().splitlines()[-1])
except Exception:
raise RuntimeError("生图结果解析失败")
files = result.get("files") or []
if not files:
raise RuntimeError("生图未返回文件")
gen_path = files[0]
if not os.path.exists(gen_path):
raise RuntimeError("生图文件不存在")
if Image is None:
raise RuntimeError("Pillow 不可用,无法做尺寸标准化")
with Image.open(gen_path) as im:
im = im.convert("RGBA")
# 质量模式优先保细节;快速模式优先速度
if mode == "fast":
im = im.resize((gen_width, gen_height), Image.Resampling.LANCZOS)
if (gen_width, gen_height) != (width, height):
# fast 的放大改为 LANCZOS牺牲少量速度换更高细节
im = im.resize((width, height), Image.Resampling.LANCZOS)
im.save(out_webp_path, "WEBP", quality=96, method=6)
else:
# quality确保输出标准尺寸同时使用无损 webp减少压缩损失
if im.size != (width, height):
im = im.resize((width, height), Image.Resampling.LANCZOS)
im.save(out_webp_path, "WEBP", lossless=True, quality=100, method=6)
def state_to_area(state):
area_map = {
"idle": "breakroom",
@ -806,6 +1180,422 @@ def set_state_endpoint():
return jsonify({"status": "error", "msg": str(e)}), 500
@app.route("/assets/template.zip", methods=["GET"])
def assets_template_download():
if not os.path.exists(ASSET_TEMPLATE_ZIP):
return jsonify({"ok": False, "msg": "模板包不存在,请先生成"}), 404
return send_from_directory(ROOT_DIR, "assets-replace-template.zip", as_attachment=True)
@app.route("/assets/list", methods=["GET"])
def assets_list():
items = []
for p in FRONTEND_PATH.rglob("*"):
if not p.is_file():
continue
rel = p.relative_to(FRONTEND_PATH).as_posix()
if rel.startswith("fonts/"):
continue
if p.suffix.lower() not in ASSET_ALLOWED_EXTS:
continue
st = p.stat()
width = None
height = None
if Image is not None:
try:
with Image.open(p) as im:
width, height = im.size
except Exception:
pass
items.append({
"path": rel,
"size": st.st_size,
"ext": p.suffix.lower(),
"width": width,
"height": height,
"mtime": datetime.fromtimestamp(st.st_mtime).isoformat(),
})
items.sort(key=lambda x: x["path"])
return jsonify({"ok": True, "count": len(items), "items": items})
@app.route("/assets/generate-rpg-background", methods=["POST"])
def assets_generate_rpg_background():
"""Generate a new RPG-themed background and replace office_bg_small.webp."""
guard = _require_asset_editor_auth()
if guard:
return guard
try:
req = request.get_json(silent=True) or {}
custom_prompt = (req.get("prompt") or "").strip() if isinstance(req, dict) else ""
speed_mode = (req.get("speed_mode") or "quality").strip().lower() if isinstance(req, dict) else "quality"
if speed_mode not in {"fast", "quality"}:
speed_mode = "fast"
target = FRONTEND_PATH / "office_bg_small.webp"
if not target.exists():
return jsonify({"ok": False, "msg": "office_bg_small.webp 不存在"}), 404
# 覆盖前保留最近一次备份
bak = target.with_suffix(target.suffix + ".bak")
shutil.copy2(target, bak)
_generate_rpg_background_to_webp(
str(target),
width=1280,
height=720,
custom_prompt=custom_prompt,
speed_mode=speed_mode,
)
# 每次生成都归档一份历史底图(可回溯风格演化)
os.makedirs(BG_HISTORY_DIR, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
hist_file = os.path.join(BG_HISTORY_DIR, f"office_bg_small-{ts}.webp")
shutil.copy2(target, hist_file)
st = target.stat()
return jsonify({
"ok": True,
"path": "office_bg_small.webp",
"size": st.st_size,
"history": os.path.relpath(hist_file, ROOT_DIR),
"speed_mode": speed_mode,
"msg": "已生成并替换 RPG 房间底图(已自动归档)",
})
except Exception as e:
msg = str(e)
if msg == "MISSING_API_KEY":
return jsonify({"ok": False, "code": "MISSING_API_KEY", "msg": "Missing GEMINI_API_KEY or GOOGLE_API_KEY"}), 400
if msg == "API_KEY_REVOKED_OR_LEAKED":
return jsonify({"ok": False, "code": "API_KEY_REVOKED_OR_LEAKED", "msg": "API key is revoked or flagged as leaked. Please rotate to a new key."}), 400
if msg == "MODEL_NOT_AVAILABLE":
return jsonify({"ok": False, "code": "MODEL_NOT_AVAILABLE", "msg": "Configured model is not available for this API key/channel."}), 400
return jsonify({"ok": False, "msg": msg}), 500
@app.route("/assets/restore-reference-background", methods=["POST"])
def assets_restore_reference_background():
"""Restore office_bg_small.webp from fixed reference image."""
guard = _require_asset_editor_auth()
if guard:
return guard
try:
target = FRONTEND_PATH / "office_bg_small.webp"
if not target.exists():
return jsonify({"ok": False, "msg": "office_bg_small.webp 不存在"}), 404
if not os.path.exists(ROOM_REFERENCE_IMAGE):
return jsonify({"ok": False, "msg": "参考图不存在"}), 404
# 备份当前底图
bak = target.with_suffix(target.suffix + ".bak")
shutil.copy2(target, bak)
# 快速路径:若参考图已是 1280x720 的 webp直接拷贝秒级
ref_ext = os.path.splitext(ROOM_REFERENCE_IMAGE)[1].lower()
fast_copied = False
if ref_ext == '.webp':
try:
with Image.open(ROOM_REFERENCE_IMAGE) as rim:
if rim.size == (1280, 720):
shutil.copy2(ROOM_REFERENCE_IMAGE, target)
fast_copied = True
except Exception:
fast_copied = False
# 慢路径:仅在必要时重编码
if not fast_copied:
if Image is None:
return jsonify({"ok": False, "msg": "Pillow 不可用"}), 500
with Image.open(ROOM_REFERENCE_IMAGE) as im:
im = im.convert("RGBA").resize((1280, 720), Image.Resampling.LANCZOS)
im.save(target, "WEBP", quality=92, method=6)
st = target.stat()
return jsonify({
"ok": True,
"path": "office_bg_small.webp",
"size": st.st_size,
"msg": "已恢复初始底图",
})
except Exception as e:
return jsonify({"ok": False, "msg": str(e)}), 500
@app.route("/assets/auth", methods=["POST"])
def assets_auth():
try:
data = request.get_json(silent=True) or {}
pwd = (data.get("password") or "").strip()
if pwd and pwd == ASSET_DRAWER_PASS_DEFAULT:
session["asset_editor_authed"] = True
return jsonify({"ok": True, "msg": "认证成功"})
return jsonify({"ok": False, "msg": "验证码错误"}), 401
except Exception as e:
return jsonify({"ok": False, "msg": str(e)}), 500
@app.route("/assets/auth/status", methods=["GET"])
def assets_auth_status():
return jsonify({"ok": True, "authed": _is_asset_editor_authed()})
@app.route("/assets/positions", methods=["GET"])
def assets_positions_get():
guard = _require_asset_editor_auth()
if guard:
return guard
try:
return jsonify({"ok": True, "items": load_asset_positions()})
except Exception as e:
return jsonify({"ok": False, "msg": str(e)}), 500
@app.route("/assets/positions", methods=["POST"])
def assets_positions_set():
guard = _require_asset_editor_auth()
if guard:
return guard
try:
data = request.get_json(silent=True) or {}
key = (data.get("key") or "").strip()
x = data.get("x")
y = data.get("y")
scale = data.get("scale")
if not key:
return jsonify({"ok": False, "msg": "缺少 key"}), 400
if x is None or y is None:
return jsonify({"ok": False, "msg": "缺少 x/y"}), 400
x = float(x)
y = float(y)
if scale is None:
scale = 1.0
scale = float(scale)
all_pos = load_asset_positions()
all_pos[key] = {"x": x, "y": y, "scale": scale, "updated_at": datetime.now().isoformat()}
save_asset_positions(all_pos)
return jsonify({"ok": True, "key": key, "x": x, "y": y, "scale": scale})
except Exception as e:
return jsonify({"ok": False, "msg": str(e)}), 500
@app.route("/assets/defaults", methods=["GET"])
def assets_defaults_get():
guard = _require_asset_editor_auth()
if guard:
return guard
try:
return jsonify({"ok": True, "items": load_asset_defaults()})
except Exception as e:
return jsonify({"ok": False, "msg": str(e)}), 500
@app.route("/assets/defaults", methods=["POST"])
def assets_defaults_set():
guard = _require_asset_editor_auth()
if guard:
return guard
try:
data = request.get_json(silent=True) or {}
key = (data.get("key") or "").strip()
x = data.get("x")
y = data.get("y")
scale = data.get("scale")
if not key:
return jsonify({"ok": False, "msg": "缺少 key"}), 400
if x is None or y is None:
return jsonify({"ok": False, "msg": "缺少 x/y"}), 400
x = float(x)
y = float(y)
if scale is None:
scale = 1.0
scale = float(scale)
all_defaults = load_asset_defaults()
all_defaults[key] = {"x": x, "y": y, "scale": scale, "updated_at": datetime.now().isoformat()}
save_asset_defaults(all_defaults)
return jsonify({"ok": True, "key": key, "x": x, "y": y, "scale": scale})
except Exception as e:
return jsonify({"ok": False, "msg": str(e)}), 500
@app.route("/config/gemini", methods=["GET"])
def gemini_config_get():
guard = _require_asset_editor_auth()
if guard:
return guard
try:
cfg = load_runtime_config()
key = (cfg.get("gemini_api_key") or "").strip()
masked = ("*" * max(0, len(key) - 4)) + key[-4:] if key else ""
return jsonify({
"ok": True,
"has_api_key": bool(key),
"api_key_masked": masked,
"gemini_model": cfg.get("gemini_model") or "gemini-3.1-flash-image-preview",
})
except Exception as e:
return jsonify({"ok": False, "msg": str(e)}), 500
@app.route("/config/gemini", methods=["POST"])
def gemini_config_set():
guard = _require_asset_editor_auth()
if guard:
return guard
try:
data = request.get_json(silent=True) or {}
api_key = (data.get("api_key") or "").strip()
model = (data.get("model") or "").strip() or "gemini-3.1-flash-image-preview"
payload = {"gemini_model": model}
if api_key:
payload["gemini_api_key"] = api_key
save_runtime_config(payload)
return jsonify({"ok": True, "msg": "Gemini 配置已保存"})
except Exception as e:
return jsonify({"ok": False, "msg": str(e)}), 500
@app.route("/assets/upload", methods=["POST"])
def assets_upload():
guard = _require_asset_editor_auth()
if guard:
return guard
try:
rel_path = (request.form.get("path") or "").strip().lstrip("/")
backup = (request.form.get("backup") or "1").strip() != "0"
f = request.files.get("file")
if not rel_path or f is None:
return jsonify({"ok": False, "msg": "缺少 path 或 file"}), 400
target = (FRONTEND_PATH / rel_path).resolve()
try:
target.relative_to(FRONTEND_PATH.resolve())
except Exception:
return jsonify({"ok": False, "msg": "非法 path"}), 400
if target.suffix.lower() not in ASSET_ALLOWED_EXTS:
return jsonify({"ok": False, "msg": "仅允许上传图片/美术资源类型"}), 400
if not target.exists():
return jsonify({"ok": False, "msg": "目标文件不存在,请先从 /assets/list 选择 path"}), 404
target.parent.mkdir(parents=True, exist_ok=True)
if backup:
bak = target.with_suffix(target.suffix + ".bak")
shutil.copy2(target, bak)
auto_sheet = (request.form.get("auto_spritesheet") or "0").strip() == "1"
ext_name = (f.filename or "").lower()
if auto_sheet and target.suffix.lower() in {".webp", ".png"}:
with tempfile.NamedTemporaryFile(suffix=os.path.splitext(ext_name)[1] or ".gif", delete=False) as tf:
src_path = tf.name
f.save(src_path)
try:
in_w, in_h = _probe_animated_frame_size(src_path)
frame_w = int(request.form.get("frame_w") or (in_w or 64))
frame_h = int(request.form.get("frame_h") or (in_h or 64))
# 如果是静态图上传到精灵表目标,按网格切片而不是整图覆盖
if not (ext_name.endswith(".gif") or ext_name.endswith(".webp")) and Image is not None:
try:
with Image.open(src_path) as sim:
sim = sim.convert("RGBA")
sw, sh = sim.size
if frame_w <= 0 or frame_h <= 0:
frame_w, frame_h = sw, sh
cols = max(1, sw // frame_w)
rows = max(1, sh // frame_h)
sheet_w = cols * frame_w
sheet_h = rows * frame_h
if sheet_w <= 0 or sheet_h <= 0:
raise RuntimeError("静态图尺寸与帧规格不匹配")
cropped = sim.crop((0, 0, sheet_w, sheet_h))
# 目标是 webp 仍按无损保存,避免像素损失
if target.suffix.lower() == ".webp":
cropped.save(str(target), "WEBP", lossless=True, quality=100, method=6)
else:
cropped.save(str(target), "PNG")
st = target.stat()
return jsonify({
"ok": True,
"path": rel_path,
"size": st.st_size,
"backup": backup,
"converted": {
"from": ext_name.split(".")[-1] if "." in ext_name else "image",
"to": "webp_spritesheet" if target.suffix.lower() == ".webp" else "png_spritesheet",
"frame_w": frame_w,
"frame_h": frame_h,
"columns": cols,
"rows": rows,
"frames": cols * rows,
"preserve_original": False,
"pixel_art": True,
}
})
finally:
pass
# 默认:优先保留输入帧尺寸;若前端传了强制值则按前端。
preserve_original_val = request.form.get("preserve_original")
if preserve_original_val is None:
preserve_original = True
else:
preserve_original = preserve_original_val.strip() == "1"
pixel_art = (request.form.get("pixel_art") or "1").strip() == "1"
req_cols = int(request.form.get("cols") or 0)
req_rows = int(request.form.get("rows") or 0)
sheet_path, cols, rows, frames, out_fw, out_fh = _animated_to_spritesheet(
src_path,
frame_w,
frame_h,
out_ext=target.suffix.lower(),
preserve_original=preserve_original,
pixel_art=pixel_art,
cols=(req_cols if req_cols > 0 else None),
rows=(req_rows if req_rows > 0 else None),
)
shutil.move(sheet_path, str(target))
st = target.stat()
from_type = "gif" if ext_name.endswith(".gif") else "webp"
to_type = "webp_spritesheet" if target.suffix.lower() == ".webp" else "png_spritesheet"
return jsonify({
"ok": True,
"path": rel_path,
"size": st.st_size,
"backup": backup,
"converted": {
"from": from_type,
"to": to_type,
"frame_w": out_fw,
"frame_h": out_fh,
"columns": cols,
"rows": rows,
"frames": frames,
"preserve_original": preserve_original,
"pixel_art": pixel_art,
}
})
finally:
try:
os.remove(src_path)
except Exception:
pass
f.save(str(target))
st = target.stat()
return jsonify({"ok": True, "path": rel_path, "size": st.st_size, "backup": backup})
except Exception as e:
return jsonify({"ok": False, "msg": str(e)}), 500
if __name__ == "__main__":
print("=" * 50)
print("Star Office UI - Backend State Service")

View file

@ -0,0 +1,37 @@
# Star-Office-UI Release Notes (2026-03-02)
## Summary
This package is a cleaned release snapshot for handoff/update.
It excludes runtime files, logs, and local backup artifacts.
## Included
- backend/
- frontend/
- docs/
- assets/room-reference.png
- core scripts and docs (README.md, SKILL.md, LICENSE, set_state.py, etc.)
- asset-defaults.json / asset-positions.json
## Excluded on purpose
- .git/
- .venv/
- __pycache__/
- *.log / *.out / *.pid
- *.bak (frontend image backups)
- assets/bg-history/ (historical generated backgrounds)
- runtime state files: state.json / agents-state.json / join-keys.json
## Artifact
- File: `dist/Star-Office-UI-release-20260302.tgz`
- SHA256: `bf52147b7664adc3c457eadd3748f969b1ad5ee7e8d3059ce9c8da4c6030f6ae`
## Pre-publish checklist
1. Confirm whether `asset-defaults.json` and `asset-positions.json` should be shipped as current defaults.
2. Confirm whether `assets/bg-history/` should remain local-only (currently excluded).
3. On target machine, create fresh `state.json` and `join-keys.json` if needed.
4. Start backend and validate:
- `/health`
- `/status`
- language switches (EN/JP/CN)
- loading overlay + sidebar layering
- asset drawer selection / upload panel behavior

33
docs/CHANGELOG_2026-03.md Normal file
View file

@ -0,0 +1,33 @@
# Changelog — 2026-03 Refresh
## Highlights
- Added robust asset editing workflow in drawer (select/deselect, highlight sync, default/override split)
- Added EN/JP/CN language buttons with real-time UI + loading + bubble text switching
- Added room loading overlay with emoji rotation and localized copy
- Fixed layering/layout issues (drawer overlap, detail overflow, canvas border fit)
- Completed multi-round state sprite replacement pipeline (Writing/Idle/Syncing/Error) with auto frame sync
- Updated syncing behavior: non-sync shows frame 0, syncing starts from frame 1
- Disabled error movement path (error anim stays in place)
- Removed GIF legacy assets and stale references
- Restored `assets/room-reference.png` for reference background restore
- Added configurable asset drawer password via env (`ASSET_DRAWER_PASS`, default `1234`)
- Improved startup performance:
- static assets use long cache headers
- local phaser vendor restored
## Security / Config
- Asset drawer default pass changed to `1234`
- Recommend deployment override:
- `ASSET_DRAWER_PASS=<your-strong-pass>`
- Rationale: prevent unauthorized layout/asset modifications from shared links
## AI model recommendation for room generation
For best style-transfer quality (while preserving room structure), recommend:
1. gemini nanobanana pro
2. gemini nanobanana 2
Other models may produce unstable structure consistency.

View file

@ -0,0 +1,87 @@
# PR Draft — Star Office UI March Refresh
## Title
feat: asset editor + i18n + loading UX + sprite pipeline + security/perf refinements
## Summary
This PR delivers a full refresh of Star Office UI across UX, asset pipeline, localization, stability, and deployment security.
### What changed
#### 1) Asset editor / room decoration
- Improved drawer selection UX (select/deselect + dual highlight sync)
- Upload panel now appears only when an asset is selected
- Added defaults/overrides split:
- `GET/POST /assets/defaults`
- `GET/POST /assets/positions`
- Added “Set Default” flow for persistent base placement
#### 2) Localization (CN/EN/JP)
- Replaced language toggles with EN / JP / CN buttons
- Active language button highlighted in green
- Real-time language switching for:
- UI labels
- loading texts
- role/cat/guest bubbles
- initial boot loading sentence
#### 3) Loading overlay and UX polish
- Added room-bound loading overlay with emoji rotation
- Updated copy to voyage-themed localized sets
- Trigger timing fixed: overlay shows immediately on click
- Overlay and detail placement bound to canvas rect for consistency
#### 4) Layout and layering fixes
- Fixed canvas border fit and theme color unification (#64477d)
- Ensured status/detail stays inside canvas and single-line clipped
- Drawer open now shifts main stage to avoid overlap and large gaps
- Drawer kept above room loading overlay
#### 5) Sprite replacement pipeline hardening
- Reworked replacement flow to detect frame size/count from incoming animated webp
- Synced loader + animation frame ranges to avoid flicker
- Applied across Writing / Idle / Syncing / Error replacements
- Syncing behavior adjusted:
- non-sync state shows frame 0
- syncing animation starts from frame 1
- Error animation movement path removed (fixed in place)
#### 6) Cleanup / reliability / perf
- Removed legacy GIF assets
- Removed stale asset references (zero missing static refs)
- Restored `assets/room-reference.png` for restore-reference endpoint
- Added configurable drawer pass via env:
- `ASSET_DRAWER_PASS` (default `1234`)
- Performance improvements:
- static assets served with long cache headers
- local phaser vendor restored to reduce cold-load latency
## Documentation updates included
- README rewritten for latest behavior/config
- SKILL updated with deployment + safety + replacement SOP
- LICENSE updated to remove old third-party character disclaimer and keep:
- code MIT
- art assets non-commercial
- Added `docs/CHANGELOG_2026-03.md`
## Deployment notes
- Recommended model for room generation:
1. gemini nanobanana pro
2. gemini nanobanana 2
- Security recommendation:
- always override `ASSET_DRAWER_PASS` in production/public deployments
## Test checklist
- [ ] Open page cold + warm load
- [ ] Switch CN/EN/JP at any state
- [ ] Trigger Move Home/Broker and observe local status text + loading overlay
- [ ] Replace one animated asset and verify frame sync/no flicker
- [ ] Verify Error is fixed in place
- [ ] Verify `/assets/restore-reference-background` works with `assets/room-reference.png`
- [ ] Verify no missing `/static/*` refs in runtime logs
## How to create PR
1. `git checkout -b feat/march-refresh`
2. `git push -u origin feat/march-refresh`
3. Open PR to `ringhyacinth/Star-Office-UI:main`
4. Paste this document as PR description

View file

@ -0,0 +1,25 @@
# PR File List — 2026-03 Refresh
## Core code changes
- `frontend/index.html`
- `backend/app.py`
- `frontend/vendor/phaser-3.80.1.min.js`
- `assets/room-reference.webp`
## Configuration / templates
- `.gitignore`
- `runtime-config.sample.json`
## Documentation
- `README.md`
- `SKILL.md`
- `LICENSE`
- `docs/CHANGELOG_2026-03.md`
- `docs/PR_DRAFT_2026-03-refresh.md`
- `docs/PR_FILELIST_2026-03-refresh.md`
## Notes (excluded from PR)
- `state.json`, `agents-state.json`, `runtime-config.json` (local runtime)
- `assets/bg-history/` (local generated history)
- `frontend/*.bak` (local backups)
- temporary dist packages

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 B

BIN
frontend/btn-diy-sprite.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 726 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 836 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

BIN
frontend/desk-v3.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

View file

@ -462,6 +462,7 @@ function create() {
'flowers',
randomFlowerFrame
).setOrigin(LAYOUT.furniture.flower.origin.x, LAYOUT.furniture.flower.origin.y);
flower.setScale(LAYOUT.furniture.flower.scale || 1);
flower.setDepth(LAYOUT.furniture.flower.depth);
flower.setInteractive({ useHandCursor: true });
window.flowerSprite = flower;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 944 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 838 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 914 B

Binary file not shown.

Before

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

Binary file not shown.

Before

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

File diff suppressed because it is too large Load diff

View file

@ -43,9 +43,10 @@ const LAYOUT = {
// 桌上花盆
flower: {
x: 310,
y: 405,
y: 390,
origin: { x: 0.5, y: 0.5 },
depth: 1100
depth: 1100,
scale: 0.8
},
// Star 在桌前工作(在 desk 下面)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 821 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 756 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

BIN
frontend/sofa-idle-v3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

BIN
frontend/sofa-shadow-v1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 KiB

BIN
frontend/star-idle-v4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 952 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 677 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 563 KiB

1
frontend/vendor/phaser-3.80.1.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -1,49 +1,3 @@
{
"keys": [
{
"key": "ocj_starteam01",
"name": "团队成员 01",
"maxConcurrent": 3,
"used": true,
"usedBy": "阿龙虾",
"usedByAgentId": "agent_1772347959385_px4w",
"usedAt": "2026-03-01T14:52:39.385922",
"reusable": true
},
{
"key": "ocj_starteam02",
"name": "团队成员 02",
"maxConcurrent": 3
},
{
"key": "ocj_starteam03",
"name": "团队成员 03",
"maxConcurrent": 3
},
{
"key": "ocj_starteam04",
"name": "团队成员 04",
"maxConcurrent": 3
},
{
"key": "ocj_starteam05",
"name": "团队成员 05",
"maxConcurrent": 3
},
{
"key": "ocj_starteam06",
"name": "团队成员 06",
"maxConcurrent": 3
},
{
"key": "ocj_starteam07",
"name": "团队成员 07",
"maxConcurrent": 3
},
{
"key": "ocj_starteam08",
"name": "团队成员 08",
"maxConcurrent": 3
}
]
"keys": []
}

View file

@ -0,0 +1,4 @@
{
"gemini_api_key": "YOUR_GEMINI_API_KEY",
"gemini_model": "nanobanana-pro"
}

View file

@ -6,7 +6,7 @@ import os
import sys
from datetime import datetime
STATE_FILE = "/root/.openclaw/workspace/star-office-ui/state.json"
STATE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "state.json")
VALID_STATES = [
"idle",