Showing preview only (1,154K chars total). Download the full file or copy to clipboard to get everything.
Repository: ringhyacinth/Star-Office-UI
Branch: master
Commit: f29c107e9728
Files: 74
Total size: 1.1 MB
Directory structure:
gitextract_u7eo87gg/
├── .gitignore
├── LICENSE
├── README.en.md
├── README.ja.md
├── README.md
├── SKILL.md
├── agent-invite-template.txt
├── asset-defaults.json
├── asset-positions.json
├── backend/
│ ├── app.py
│ ├── memo_utils.py
│ ├── requirements.txt
│ ├── run.sh
│ ├── security_utils.py
│ └── store_utils.py
├── convert_to_webp.py
├── desktop-pet/
│ ├── README.md
│ ├── STATE_API.md
│ ├── package.json
│ ├── src/
│ │ ├── index.html
│ │ └── minimized.html
│ └── src-tauri/
│ ├── Cargo.toml
│ ├── build.rs
│ ├── capabilities/
│ │ └── default.json
│ ├── gen/
│ │ └── schemas/
│ │ ├── acl-manifests.json
│ │ ├── capabilities.json
│ │ ├── desktop-schema.json
│ │ └── macOS-schema.json
│ ├── icons/
│ │ ├── android/
│ │ │ ├── mipmap-anydpi-v26/
│ │ │ │ └── ic_launcher.xml
│ │ │ └── values/
│ │ │ └── ic_launcher_background.xml
│ │ └── icon.icns
│ ├── src/
│ │ ├── lib.rs
│ │ └── main.rs
│ └── tauri.conf.json
├── dist/
│ └── Star-Office-UI-release-20260302/
│ └── RELEASE_NOTES.md
├── docs/
│ ├── CHANGELOG_2026-03.md
│ ├── FEATURES_NEW_2026-03-01.md
│ ├── OPEN_SOURCE_RELEASE_CHECKLIST.md
│ ├── PROJECT_MAINTENANCE_SOP.md
│ ├── PROJECT_SUMMARY_2026-03-01.md
│ ├── PR_DRAFT_2026-03-refresh.md
│ ├── PR_FILELIST_2026-03-refresh.md
│ ├── STAR_OFFICE_UI_OVERVIEW.md
│ ├── UPDATE_REPORT_2026-03-04_P0_P1.md
│ └── UPDATE_REPORT_2026-03-05.md
├── electron-shell/
│ ├── README.md
│ ├── main.js
│ ├── package.json
│ ├── preload.js
│ └── standalone-assets/
│ ├── game.js
│ └── layout.js
├── frontend/
│ ├── electron-standalone.html
│ ├── fonts/
│ │ └── OFL.txt
│ ├── game.js
│ ├── index.html
│ ├── invite.html
│ ├── join-office-skill.md
│ ├── join.html
│ ├── layout.js
│ └── office-agent-push.py
├── gif_to_spritesheet.py
├── healthcheck.sh
├── join-keys.sample.json
├── office-agent-push.py
├── pyproject.toml
├── repack_star_working.py
├── resize_map.py
├── runtime-config.sample.json
├── scripts/
│ ├── gemini_image_generate.py
│ ├── security_check.py
│ └── smoke_test.py
├── set_state.py
├── state.sample.json
└── webp_to_spritesheet.py
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
# Python environment
__pycache__/
*.py[cod]
*.pyo
*.pyd
*.egg-info/
.venv/
venv/
.env
# OS/editor
.DS_Store
# Runtime state (local only)
state.json
agents-state.json
runtime-config.json
*.log
*.out
*.pid
*.backup*
*.original
cloudflared.pid
cloudflared.out
healthcheck.log
backend.log
# Generated / mutable assets (local only)
assets/bg-history/
assets/home-favorites/
frontend/office_bg.png
frontend/*.bak
layers/
desktop-pet/src-tauri/icons/*Logo.png
# Electron local build artifacts
electron-shell/node_modules/
electron-shell/release/
join-keys.json
================================================
FILE: LICENSE
================================================
# Star Office UI — License & Usage Notice
This project is a co-created work by **Ring Hyacinth** and **Simon Lee**.
## 1. Code / Logic License (MIT)
The code/logic in this repository (the "Software") is licensed under the MIT License:
MIT License
Copyright (c) 2026 Ring Hyacinth & Simon Lee
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.
---
## 2. Art Assets License & Disclaimer
### 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, button skins,
and rebuilt full asset packs/indexes) are **non-commercial only**.
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. Guest Character Asset Attribution
Guest character animations use LimeZu’s free assets:
- Animated Mini Characters 2 (Platformer) [FREE]
- https://limezu.itch.io/animated-mini-characters-2-platform-free
Please keep this attribution and follow the original author’s license terms when redistributing or demonstrating.
================================================
FILE: README.en.md
================================================
# Star Office UI
🌐 Language: [中文](./README.md) | **English** | [日本語](./README.ja.md)

**A pixel-art AI office dashboard** — visualize your AI assistant's work status in real time, so you can see at a glance who's doing what, what they did yesterday, and whether they're online.
Supports multi-agent collaboration, trilingual UI (CN/EN/JP), AI-powered room design, and desktop pet mode.
Best experienced with [OpenClaw](https://github.com/openclaw/openclaw), but also works standalone as a status dashboard.
> This project was co-created by **[Ring Hyacinth](https://x.com/ring_hyacinth)** and **[Simon Lee](https://x.com/simonxxoo)**, and is continuously maintained and improved together with community contributors ([@Zhaohan-Wang](https://github.com/Zhaohan-Wang), [@Jah-yee](https://github.com/Jah-yee), [@liaoandi](https://github.com/liaoandi)).
> Issues and PRs are welcome — thank you to everyone who contributes.
---
## ✨ Quick Start
### Option 1: Let your lobster deploy it (recommended for OpenClaw users)
If you're using [OpenClaw](https://github.com/openclaw/openclaw), just send this to your lobster:
```text
Please follow this SKILL.md to deploy Star Office UI for me:
https://github.com/ringhyacinth/Star-Office-UI/blob/master/SKILL.md
```
Your lobster will automatically clone the repo, install dependencies, start the backend, configure status sync, and send you the access URL.
### Option 2: 30-second manual setup
> **Requires Python 3.10+** (the codebase uses `X | Y` union type syntax, which is not supported on 3.9 or earlier)
```bash
# 1) Clone the repo
git clone https://github.com/ringhyacinth/Star-Office-UI.git
cd Star-Office-UI
# 2) Install dependencies (Python 3.10+ required)
python3 -m pip install -r backend/requirements.txt
# 3) Initialize state file (first run)
cp state.sample.json state.json
# 4) Start the backend
cd backend
python3 app.py
```
Open **http://127.0.0.1:19000** and try switching states:
```bash
python3 set_state.py writing "Organizing documents"
python3 set_state.py error "Found an issue, debugging"
python3 set_state.py idle "Standing by"
```

---
## 🤔 Who is this for?
### Users with OpenClaw / an AI Agent
This is the **full experience**. Your agent automatically switches status as it works, and the pixel character walks to the corresponding office area in real time — just open the page and see what your AI is doing right now.
### Users without OpenClaw
You can still deploy and use it. You can:
- Use `set_state.py` or the API to push status manually or via scripts
- Use it as a pixel-art personal status page or remote work dashboard
- Connect any system that can send HTTP requests to drive the status
---
## 📋 Features
1. **Status Visualization** — 6 states (`idle` / `writing` / `researching` / `executing` / `syncing` / `error`) mapped to different office areas with animated sprites and speech bubbles
2. **Yesterday Memo** — Automatically reads the latest daily log from `memory/*.md`, sanitizes it, and displays it as a "Yesterday Memo" card
3. **Multi-Agent Collaboration** — Invite other agents to join your office via join keys and see everyone's status in real time
4. **Trilingual UI** — Switch between Chinese, English, and Japanese with one click; all UI text, bubbles, and loading messages update instantly
5. **Custom Art Assets** — Manage characters, scenes, and decorations through the sidebar; dynamic frame sync prevents flickering
6. **AI-Powered Room Design** — Connect your own Gemini API to generate new office backgrounds; core features work fine without an API
7. **Mobile-Friendly** — Open on your phone for a quick status check on the go
8. **Security Hardening** — Sidebar password protection, weak-password blocking in production, hardened session cookies
9. **Flexible Public Access** — Use Cloudflare Tunnel for instant public access, or bring your own domain / reverse proxy
10. **Desktop Pet Mode** — Optional Electron desktop wrapper that turns the office into a transparent desktop widget (see below)
---
## 🚀 Detailed Setup Guide
### 1) Install dependencies
```bash
cd Star-Office-UI
python3 -m pip install -r backend/requirements.txt
```
### 2) Initialize state file
```bash
cp state.sample.json state.json
```
### 3) Start the backend
```bash
cd backend
python3 app.py
```
Open `http://127.0.0.1:19000`
> ✅ For local development you can start with the defaults; in production, copy `.env.example` to `.env` and set strong random values for `FLASK_SECRET_KEY` and `ASSET_DRAWER_PASS` to avoid weak passwords and session leaks.
### 4) Switch states
```bash
python3 set_state.py writing "Organizing documents"
python3 set_state.py syncing "Syncing progress"
python3 set_state.py error "Found an issue, debugging"
python3 set_state.py idle "Standing by"
```
### 5) Public access (optional)
```bash
cloudflared tunnel --url http://127.0.0.1:19000
```
Share the `https://xxx.trycloudflare.com` link with anyone.
### 6) Verify your installation (optional)
```bash
python3 scripts/smoke_test.py --base-url http://127.0.0.1:19000
```
If all checks report `OK`, your deployment is good to go.
---
## 🦞 OpenClaw Deep Integration
> The following section is for [OpenClaw](https://github.com/openclaw/openclaw) users. If you don't use OpenClaw, feel free to skip this.
### Automatic Status Sync
Add the following rule to your `SOUL.md` (or agent config) so your agent updates its status automatically:
```markdown
## Star Office Status Sync Rules
- When starting a task: run `python3 set_state.py <state> "<description>"` before beginning work
- When finishing a task: run `python3 set_state.py idle "Standing by"` before replying
```
**6 states → 3 office areas:**
| State | Office Area | When to use |
|-------|-------------|-------------|
| `idle` | 🛋 Breakroom (sofa) | Standing by / task complete |
| `writing` | 💻 Workspace (desk) | Writing code or docs |
| `researching` | 💻 Workspace | Searching / researching |
| `executing` | 💻 Workspace | Running commands / tasks |
| `syncing` | 💻 Workspace | Syncing data / pushing |
| `error` | 🐛 Bug Corner | Error / debugging |
### Invite Other Agents to Your Office
**Step 1: Prepare join keys**
When you start the backend for the first time, if there is no `join-keys.json` in the project root, the service will automatically create one based on `join-keys.sample.json` (which contains an example key such as `ocj_example_team_01`). You can then edit the generated `join-keys.json` to add, modify, or remove keys; by default each key supports up to 3 concurrent users.
**Step 2: Have the guest run the push script**
The guest only needs to download `office-agent-push.py` and fill in 3 variables:
```python
JOIN_KEY = "ocj_starteam02" # The key you assign
AGENT_NAME = "Alice's Lobster" # Display name
OFFICE_URL = "https://office.hyacinth.im" # Your office URL
```
```bash
python3 office-agent-push.py
```
The script auto-joins and pushes status every 15 seconds. The guest will appear on the dashboard, moving to the appropriate area based on their state.
**Step 3 (optional): Guest installs a Skill**
Guests can also use `frontend/join-office-skill.md` as a Skill — their agent will handle setup and pushing automatically.
> See [`frontend/join-office-skill.md`](./frontend/join-office-skill.md) for full guest onboarding instructions.
---
## 📡 API Reference
| Endpoint | Description |
|----------|-------------|
| `GET /health` | Health check |
| `GET /status` | Get main agent status |
| `POST /set_state` | Set main agent status |
| `GET /agents` | List all agents |
| `POST /join-agent` | Guest joins the office |
| `POST /agent-push` | Guest pushes status |
| `POST /leave-agent` | Guest leaves |
| `GET /yesterday-memo` | Get yesterday's memo |
| `GET /config/gemini` | Get Gemini API config |
| `POST /config/gemini` | Set Gemini API config |
| `GET /assets/generate-rpg-background/poll` | Poll image generation progress |
---
## 🖥 Desktop Pet Mode (Optional)
The `desktop-pet/` directory contains a **Electron**-based desktop wrapper that turns the pixel office into a transparent desktop widget.
```bash
cd desktop-pet
npm install
npm run dev
```
- Auto-launches the Python backend on startup
- Window points to `http://127.0.0.1:19000/?desktop=1` by default
- Customizable via environment variables for project path and Python path
> ⚠️ This is an **optional, experimental feature**, primarily developed and tested on macOS. See [`desktop-pet/README.md`](./desktop-pet/README.md) for details.
>
> 🙏 The desktop pet module was independently developed by [@Zhaohan-Wang](https://github.com/Zhaohan-Wang) — thank you for this contribution!
---
## 🎨 Art Assets & License
### Asset Attribution
Guest character animations use free assets by **LimeZu**:
- [Animated Mini Characters 2 (Platformer) [FREE]](https://limezu.itch.io/animated-mini-characters-2-platform-free)
Please keep attribution when redistributing or demoing, and follow the original license terms.
### License
- **Code / Logic: MIT** (see [`LICENSE`](./LICENSE))
- **Art Assets: Non-commercial use only** (learning / demo / sharing)
> For commercial use, replace all art assets with your own original artwork.
---
## 📝 Changelog
| Date | Summary | Details |
|------|---------|---------|
| 2026-03-06 | 🔌 Default port updated — backend default port changed from 18791 to 19000 to avoid conflicts with OpenClaw Browser Control; synced scripts, desktop shells, and docs defaults | [`docs/CHANGELOG_2026-03.md`](./docs/CHANGELOG_2026-03.md) |
| 2026-03-05 | 📱 Stability fixes — CDN cache fix, async image generation, mobile sidebar UX, join key expiration & concurrency | [`docs/UPDATE_REPORT_2026-03-05.md`](./docs/UPDATE_REPORT_2026-03-05.md) |
| 2026-03-04 | 🔒 P0/P1 Security hardening — weak password blocking, backend refactor, stale-state auto-idle, skeleton loading | [`docs/UPDATE_REPORT_2026-03-04_P0_P1.md`](./docs/UPDATE_REPORT_2026-03-04_P0_P1.md) |
| 2026-03-03 | 📋 Open-source release checklist completed | [`docs/OPEN_SOURCE_RELEASE_CHECKLIST.md`](./docs/OPEN_SOURCE_RELEASE_CHECKLIST.md) |
| 2026-03-01 | 🎉 **v2 Rebuild** — Trilingual support, asset management system, AI room design, full art asset overhaul | [`docs/FEATURES_NEW_2026-03-01.md`](./docs/FEATURES_NEW_2026-03-01.md) |
---
## 📁 Project Structure
```text
Star-Office-UI/
├── backend/ # Flask backend
│ ├── app.py
│ ├── requirements.txt
│ └── run.sh
├── frontend/ # Frontend pages & assets
│ ├── index.html
│ ├── join.html
│ ├── invite.html
│ └── layout.js
├── desktop-pet/ # Electron desktop wrapper (optional)
├── docs/ # Documentation & screenshots
│ └── screenshots/
├── office-agent-push.py # Guest push script
├── set_state.py # Status switch script
├── state.sample.json # State file template
├── join-keys.sample.json # Join key template (runtime generates join-keys.json)
├── SKILL.md # OpenClaw Skill
└── LICENSE # MIT License
```
---
## ⭐ Star History
[](https://www.star-history.com/?repos=ringhyacinth%2FStar-Office-UI&type=date&legend=top-left)
================================================
FILE: README.ja.md
================================================
# Star Office UI
🌐 Language: [中文](./README.md) | [English](./README.en.md) | **日本語**

**ピクセルアート風 AI オフィスダッシュボード** —— AI アシスタントの作業状態をリアルタイムで可視化し、「誰が何をしているか」「昨日何をしたか」「今オンラインか」を直感的に把握できます。
マルチ Agent 協調、中英日 3 言語、AI 画像生成による模様替え、デスクトップペットモードに対応。
[OpenClaw](https://github.com/openclaw/openclaw) との統合で最高の体験が得られますが、単体でもステータスダッシュボードとして利用可能です。
> 本プロジェクトは **[Ring Hyacinth](https://x.com/ring_hyacinth)** と **[Simon Lee](https://x.com/simonxxoo)** の共同制作(co-created project)であり、コミュニティの開発者([@Zhaohan-Wang](https://github.com/Zhaohan-Wang)、[@Jah-yee](https://github.com/Jah-yee)、[@liaoandi](https://github.com/liaoandi))とともに継続的にメンテナンス・改善を行っています。
> Issue や PR を歓迎します。貢献してくださるすべての方に感謝いたします。
---
## ✨ クイックスタート
### 方法 1:ロブスターにデプロイしてもらう(OpenClaw ユーザー向け)
[OpenClaw](https://github.com/openclaw/openclaw) をご利用中なら、以下のメッセージをロブスターに送るだけ:
```text
この SKILL.md に従って Star Office UI をデプロイしてください:
https://github.com/ringhyacinth/Star-Office-UI/blob/master/SKILL.md
```
ロブスターが自動的にリポジトリのクローン、依存関係のインストール、バックエンドの起動、ステータス同期の設定を行い、アクセス URL をお知らせします。
### 方法 2:30 秒手動セットアップ
> **Python 3.10+ が必要です**(コードベースは `X | Y` ユニオン型構文を使用しており、3.9 以前のバージョンではサポートされていません)
```bash
# 1) リポジトリをクローン
git clone https://github.com/ringhyacinth/Star-Office-UI.git
cd Star-Office-UI
# 2) 依存関係をインストール(Python 3.10+ が必要)
python3 -m pip install -r backend/requirements.txt
# 3) 状態ファイルを初期化(初回のみ)
cp state.sample.json state.json
# 4) バックエンドを起動
cd backend
python3 app.py
```
**http://127.0.0.1:19000** を開き、状態を切り替えてみましょう:
```bash
python3 set_state.py writing "ドキュメント整理中"
python3 set_state.py error "問題を検出、調査中"
python3 set_state.py idle "待機中"
```

---
## 🤔 誰に向いている?
### OpenClaw / AI Agent をお持ちの方
これが**フル体験**です。Agent が作業中に自動でステータスを切り替え、ピクセルキャラクターがリアルタイムで対応エリアに移動します。ページを開くだけで、AI が今何をしているかがわかります。
### OpenClaw をお持ちでない方
デプロイして使うことも全く問題ありません:
- `set_state.py` や API で手動 / スクリプトからステータスを更新
- ピクセルアート風の個人ステータスページやリモートワークダッシュボードとして利用
- HTTP リクエストを送れるシステムなら何でもステータスを駆動可能
---
## 📋 機能一覧
1. **ステータス可視化** —— 6 種類の状態(`idle` / `writing` / `researching` / `executing` / `syncing` / `error`)がオフィスの各エリアに自動マッピングされ、アニメーションと吹き出しでリアルタイム表示
2. **昨日メモ** —— `memory/*.md` から直近の作業記録を自動取得し、匿名化して「昨日メモ」カードとして表示
3. **マルチ Agent 協調** —— join key で他の Agent をオフィスに招待し、全員のステータスをリアルタイム確認
4. **中英日 3 言語対応** —— CN / EN / JP をワンクリック切替、UI テキスト・吹き出し・ローディング表示すべてが連動
5. **アート資産カスタマイズ** —— サイドバーからキャラクター / 背景 / 装飾素材を管理、動的フレーム同期でちらつき防止
6. **AI 画像生成による模様替え** —— Gemini API を接続してオフィス背景を AI 生成; API 未接続でもコア機能は利用可能
7. **モバイル対応** —— スマホからそのまま閲覧可能、外出先からのクイックチェックに最適
8. **セキュリティ強化** —— サイドバーのパスワード保護、本番環境での弱パスワード拒否、Session Cookie 強化
9. **柔軟な公開アクセス** —— Cloudflare Tunnel でワンステップ公開、独自ドメイン / リバースプロキシにも対応
10. **デスクトップペット版** —— オプションの Electron デスクトップラッパーで、オフィスを透明ウィンドウのデスクトップペットに(下記参照)
---
## 🚀 詳細セットアップガイド
### 1) 依存関係インストール
```bash
cd Star-Office-UI
python3 -m pip install -r backend/requirements.txt
```
### 2) 状態ファイル初期化
```bash
cp state.sample.json state.json
```
### 3) バックエンド起動
```bash
cd backend
python3 app.py
```
`http://127.0.0.1:19000` を開く
> ✅ ローカル開発ではデフォルト設定のままで構いませんが、本番環境では `.env.example` を `.env` にコピーし、`FLASK_SECRET_KEY` と `ASSET_DRAWER_PASS` に十分な長さのランダム値を設定してください。
### 4) ステータス切替
```bash
python3 set_state.py writing "ドキュメント整理中"
python3 set_state.py syncing "進捗同期中"
python3 set_state.py error "問題を検出、調査中"
python3 set_state.py idle "待機中"
```
### 5) 公開アクセス(任意)
```bash
cloudflared tunnel --url http://127.0.0.1:19000
```
`https://xxx.trycloudflare.com` のリンクを共有するだけで OK。
### 6) インストール確認(任意)
```bash
python3 scripts/smoke_test.py --base-url http://127.0.0.1:19000
```
すべてのチェックが `OK` と表示されればデプロイ成功です。
---
## 🦞 OpenClaw 連携
> 以下は [OpenClaw](https://github.com/openclaw/openclaw) ユーザー向けの内容です。OpenClaw を使用していない場合はスキップしてください。
### ステータス自動同期
`SOUL.md`(またはエージェント設定ファイル)に以下のルールを追加すると、Agent がステータスを自動で更新します:
```markdown
## Star Office ステータス同期ルール
- タスク開始時:`python3 set_state.py <状態> "<説明>"` を実行してから作業開始
- タスク完了時:`python3 set_state.py idle "待機中"` を実行してから返答
```
**6 種類のステータス → 3 つのエリア:**
| ステータス | オフィスエリア | 使用場面 |
|-----------|--------------|---------|
| `idle` | 🛋 休憩エリア(ソファ) | 待機 / タスク完了 |
| `writing` | 💻 ワークエリア(デスク) | コーディング / ドキュメント作成 |
| `researching` | 💻 ワークエリア | 検索 / リサーチ |
| `executing` | 💻 ワークエリア | コマンド実行 / タスク処理 |
| `syncing` | 💻 ワークエリア | データ同期 / プッシュ |
| `error` | 🐛 バグコーナー | エラー / デバッグ |
### 他の Agent をオフィスに招待
**Step 1:join key を準備**
バックエンドを初回起動するとき、カレントディレクトリに `join-keys.json` が存在しない場合は、`join-keys.sample.json` を元にランタイム用の `join-keys.json` が自動生成されます(例として `ocj_example_team_01` などのサンプル key が含まれます)。生成された `join-keys.json` を編集して key を追加・変更・削除できます。各 key はデフォルトで最大 3 名まで同時接続できます。
**Step 2:ゲストにプッシュスクリプトを実行してもらう**
ゲストは `office-agent-push.py` をダウンロードし、3 つの変数を入力するだけ:
```python
JOIN_KEY = "ocj_starteam02" # あなたが割り当てたキー
AGENT_NAME = "太郎のロブスター" # 表示名
OFFICE_URL = "https://office.hyacinth.im" # あなたのオフィス URL
```
```bash
python3 office-agent-push.py
```
スクリプトが自動で参加し、15 秒ごとにステータスをプッシュします。ゲストがダッシュボードに表示され、状態に応じて該当エリアに移動します。
**Step 3(任意):ゲストも Skill をインストール**
ゲストは `frontend/join-office-skill.md` を Skill として使うこともできます。Agent が設定とプッシュを自動で行います。
> 詳しいゲスト参加手順は [`frontend/join-office-skill.md`](./frontend/join-office-skill.md) を参照。
---
## 📡 API リファレンス
| エンドポイント | 説明 |
|--------------|------|
| `GET /health` | ヘルスチェック |
| `GET /status` | メイン Agent のステータス取得 |
| `POST /set_state` | メイン Agent のステータス設定 |
| `GET /agents` | 全 Agent リスト取得 |
| `POST /join-agent` | ゲスト参加 |
| `POST /agent-push` | ゲストステータスプッシュ |
| `POST /leave-agent` | ゲスト退出 |
| `GET /yesterday-memo` | 昨日メモ取得 |
| `GET /config/gemini` | Gemini API 設定取得 |
| `POST /config/gemini` | Gemini API 設定変更 |
| `GET /assets/generate-rpg-background/poll` | 画像生成の進捗確認 |
---
## 🖥 デスクトップペット版(任意)
`desktop-pet/` ディレクトリには **Electron** ベースのデスクトップラッパーが含まれており、ピクセルオフィスを透明ウィンドウのデスクトップペットにできます。
```bash
cd desktop-pet
npm install
npm run dev
```
- 起動時に Python バックエンドを自動起動
- デフォルトで `http://127.0.0.1:19000/?desktop=1` を表示
- 環境変数でプロジェクトパスや Python パスをカスタマイズ可能
> ⚠️ これは**オプションの実験的機能**であり、現在は主に macOS で開発・テストされています。詳細は [`desktop-pet/README.md`](./desktop-pet/README.md) を参照。
>
> 🙏 デスクトップペット版は [@Zhaohan-Wang](https://github.com/Zhaohan-Wang) が独自に開発しました。貢献に感謝します!
---
## 🎨 アート資産とライセンス
### 資産の出典
ゲストキャラクターのアニメーションには **LimeZu** のフリー素材を使用しています:
- [Animated Mini Characters 2 (Platformer) [FREE]](https://limezu.itch.io/animated-mini-characters-2-platform-free)
再配布やデモの際は出典を明記し、原作者のライセンス条項に従ってください。
### ライセンス
- **コード / ロジック:MIT**([`LICENSE`](./LICENSE) を参照)
- **アート資産:非商用のみ**(学習 / デモ / 共有用途)
> 商用利用の場合は、すべてのアート資産をオリジナル素材に差し替えてください。
---
## 📝 更新履歴
| 日付 | 概要 | 詳細 |
|------|------|------|
| 2026-03-06 | 🔌 デフォルトポート変更 — OpenClaw Browser Control との競合を避けるため、バックエンドの既定ポートを 18791 から 19000 に変更。スクリプト、デスクトップシェル、ドキュメントの既定値も同期更新 | [`docs/CHANGELOG_2026-03.md`](./docs/CHANGELOG_2026-03.md) |
| 2026-03-05 | 📱 安定性修正 — CDN キャッシュ修正、画像生成非同期化、モバイルサイドバー UX 改善、join key 有効期限・同時接続制御 | [`docs/UPDATE_REPORT_2026-03-05.md`](./docs/UPDATE_REPORT_2026-03-05.md) |
| 2026-03-04 | 🔒 P0/P1 セキュリティ強化 — 弱パスワード拒否、バックエンド分割、stale ステータス自動 idle 復帰、スケルトンローディング | [`docs/UPDATE_REPORT_2026-03-04_P0_P1.md`](./docs/UPDATE_REPORT_2026-03-04_P0_P1.md) |
| 2026-03-03 | 📋 オープンソース公開チェックリスト完了 | [`docs/OPEN_SOURCE_RELEASE_CHECKLIST.md`](./docs/OPEN_SOURCE_RELEASE_CHECKLIST.md) |
| 2026-03-01 | 🎉 **v2 リビルド公開** — 3 言語対応、資産管理システム、AI 画像生成による模様替え、アート資産全面刷新 | [`docs/FEATURES_NEW_2026-03-01.md`](./docs/FEATURES_NEW_2026-03-01.md) |
---
## 📁 プロジェクト構成
```text
Star-Office-UI/
├── backend/ # Flask バックエンド
│ ├── app.py
│ ├── requirements.txt
│ └── run.sh
├── frontend/ # フロントエンドページ & 資産
│ ├── index.html
│ ├── join.html
│ ├── invite.html
│ └── layout.js
├── desktop-pet/ # Electron デスクトップラッパー(任意)
├── docs/ # ドキュメント & スクリーンショット
│ └── screenshots/
├── office-agent-push.py # ゲストプッシュスクリプト
├── set_state.py # ステータス切替スクリプト
├── state.sample.json # 状態ファイルテンプレート
├── join-keys.sample.json # Join Key テンプレート(起動時に join-keys.json を生成)
├── SKILL.md # OpenClaw Skill
└── LICENSE # MIT ライセンス
```
---
## ⭐ Star History
[](https://www.star-history.com/?repos=ringhyacinth%2FStar-Office-UI&type=date&legend=top-left)
================================================
FILE: README.md
================================================
# Star Office UI
🌐 Language: **中文** | [English](./README.en.md) | [日本語](./README.ja.md)

**一个像素风格的 AI 办公室看板** —— 把 AI 助手的工作状态实时可视化,让你直观看到"谁在做什么、昨天做了什么、现在是否在线"。
支持多 Agent 协作、中英日三语、AI 生图装修、桌面宠物模式。
与 [OpenClaw](https://github.com/openclaw/openclaw) 深度集成时体验最佳,也可以独立部署作为状态看板使用。
> 本项目由 **[Ring Hyacinth](https://x.com/ring_hyacinth)** 与 **[Simon Lee](https://x.com/simonxxoo)** 共同创建(co-created project),并与社区开发者([@Zhaohan-Wang](https://github.com/Zhaohan-Wang)、[@Jah-yee](https://github.com/Jah-yee)、[@liaoandi](https://github.com/liaoandi))一起持续维护和共建。
> 欢迎提交 Issue 和 PR,也感谢每一位贡献者的支持。
---
## ✨ 快速体验
### 方式一:让龙虾帮你部署(推荐给 OpenClaw 用户)
如果你正在使用 [OpenClaw](https://github.com/openclaw/openclaw),直接把下面这句话发给你的龙虾:
```text
请按照这个 SKILL.md 帮我完成 Star Office UI 的部署:
https://github.com/ringhyacinth/Star-Office-UI/blob/master/SKILL.md
```
龙虾会自动完成 clone、安装依赖、启动后端、配置状态同步,并把访问地址发给你。
### 方式二:30 秒手动部署
> **环境要求:Python 3.10+**(代码使用了 `X | Y` union type 语法,不支持 3.9 及更低版本)
```bash
# 1) 下载仓库
git clone https://github.com/ringhyacinth/Star-Office-UI.git
cd Star-Office-UI
# 2) 安装依赖(需要 Python 3.10+)
python3 -m pip install -r backend/requirements.txt
# 3) 准备状态文件(首次)
cp state.sample.json state.json
# 4) 启动后端
cd backend
python3 app.py
```
打开 **http://127.0.0.1:19000** 然后试试切状态:
```bash
python3 set_state.py writing "正在整理文档"
python3 set_state.py error "发现问题,排查中"
python3 set_state.py idle "待命中"
```

---
## 🤔 适合谁用?
### 有 OpenClaw / AI Agent 的用户
这是**完整体验**。Agent 在工作时自动切换状态,办公室里的像素角色会实时走到对应区域——你只需要打开网页,就能看到 AI 此刻在做什么。
### 没有 OpenClaw 的用户
也完全可以部署。你可以:
- 用 `set_state.py` 或 API 手动 / 脚本推送状态
- 把它当成一个像素风的个人状态页 / 远程办公看板
- 接入任何能发 HTTP 请求的系统来驱动状态
---
## 📋 功能一览
1. **状态可视化** —— 6 种状态(`idle` / `writing` / `researching` / `executing` / `syncing` / `error`)自动映射到办公室不同区域,动画 + 气泡实时展示
2. **昨日小记** —— 自动从 `memory/*.md` 读取最近一天的工作记录,脱敏后展示为"昨日小记"卡片
3. **多 Agent 协作** —— 通过 join key 邀请其他 Agent 加入你的办公室,实时查看多人状态
4. **中英日三语** —— CN / EN / JP 一键切换,界面文案、气泡、加载提示全部联动
5. **美术资产自定义** —— 侧边栏管理角色 / 场景 / 装饰素材,支持动态帧同步,避免闪烁
6. **AI 生图装修** —— 接入 Gemini API,用 AI 给办公室换背景;不接入 API 也能正常使用核心功能
7. **移动端适配** —— 手机直接打开即可查看,适合外出时快速瞄一眼
8. **安全加固** —— 侧边栏密码保护、生产环境弱密码拦截、Session Cookie 加固
9. **灵活公网访问** —— 推荐 Cloudflare Tunnel 一键公网化,也可用自有域名 / 反向代理
10. **桌面宠物版** —— 可选的 Electron 桌面封装,把办公室变成透明窗口的桌面宠物(见下方说明)
---
## 🚀 详细部署指南
### 1) 安装依赖
```bash
cd Star-Office-UI
python3 -m pip install -r backend/requirements.txt
```
### 2) 初始化状态文件
```bash
cp state.sample.json state.json
```
### 3) 启动后端
```bash
cd backend
python3 app.py
```
打开 `http://127.0.0.1:19000`
> ✅ 首次部署可以先保留默认配置;在生产环境中,请复制 `.env.example` 为 `.env` 并设置强随机的 `FLASK_SECRET_KEY` 与 `ASSET_DRAWER_PASS`,避免弱密码和会话泄露。
### 4) 切换状态
```bash
python3 set_state.py writing "正在整理文档"
python3 set_state.py syncing "同步进度中"
python3 set_state.py error "发现问题,排查中"
python3 set_state.py idle "待命中"
```
### 5) 公网访问(可选)
```bash
cloudflared tunnel --url http://127.0.0.1:19000
```
拿到 `https://xxx.trycloudflare.com` 链接即可分享。
### 6) 验证安装(可选)
```bash
python3 scripts/smoke_test.py --base-url http://127.0.0.1:19000
```
所有检查显示 `OK` 即表示部署成功。
---
## 🦞 OpenClaw 深度集成
> 以下内容面向 [OpenClaw](https://github.com/openclaw/openclaw) 用户。如果你不使用 OpenClaw,可以跳过这一节。
### 状态自动同步
在你的 `SOUL.md`(或 Agent 规则文件)中加入以下规则,让 Agent 自觉维护状态:
```markdown
## Star Office 状态同步规则
- 接到任务时:先执行 `python3 set_state.py <状态> "<描述>"` 再开始工作
- 完成任务后:执行 `python3 set_state.py idle "待命中"` 再回复
```
**6 种状态 → 3 个区域的映射:**
| 状态 | 办公室区域 | 触发场景 |
|------|-----------|---------|
| `idle` | 🛋 休息区(沙发) | 待命 / 任务完成 |
| `writing` | 💻 工作区(办公桌) | 写代码 / 写文档 |
| `researching` | 💻 工作区 | 搜索 / 调研 |
| `executing` | 💻 工作区 | 执行命令 / 跑任务 |
| `syncing` | 💻 工作区 | 同步数据 / 推送 |
| `error` | 🐛 Bug 区 | 报错 / 异常排查 |
### 邀请其他 Agent 加入办公室
**Step 1:准备 join key**
首次启动后端时,如果当前目录下不存在 `join-keys.json`,服务会自动根据 `join-keys.sample.json` 生成一个运行时的 `join-keys.json`(内含示例 key,例如 `ocj_example_team_01`)。你可以在生成后的 `join-keys.json` 中自行添加、修改或删除 key,每个 key 默认支持最多 3 人同时在线。
**Step 2:让访客 Agent 运行推送脚本**
访客只需下载 `office-agent-push.py`,填写 3 个变量即可:
```python
JOIN_KEY = "ocj_starteam02" # 你分配的 key
AGENT_NAME = "小明的龙虾" # 显示名称
OFFICE_URL = "https://office.hyacinth.im" # 你的办公室地址
```
```bash
python3 office-agent-push.py
```
脚本会自动加入办公室并每 15 秒推送一次状态。访客会出现在看板上,根据状态自动走到对应区域。
**Step 3(可选):访客安装 Skill**
访客也可以把 `frontend/join-office-skill.md` 作为 Skill 使用,Agent 会自动完成配置和推送。
> 详细的访客接入说明见 [`frontend/join-office-skill.md`](./frontend/join-office-skill.md)
---
## 📡 常用 API
| 端点 | 说明 |
|------|------|
| `GET /health` | 健康检查 |
| `GET /status` | 获取主 Agent 状态 |
| `POST /set_state` | 设置主 Agent 状态 |
| `GET /agents` | 获取多 Agent 列表 |
| `POST /join-agent` | 访客加入办公室 |
| `POST /agent-push` | 访客推送状态 |
| `POST /leave-agent` | 访客离开 |
| `GET /yesterday-memo` | 获取昨日小记 |
| `GET /config/gemini` | 获取 Gemini API 配置 |
| `POST /config/gemini` | 设置 Gemini API 配置 |
| `GET /assets/generate-rpg-background/poll` | 轮询生图进度 |
---
## 🖥 桌面宠物版(可选)
`desktop-pet/` 目录提供了一个基于 **Electron** 的桌面封装版本,可以把像素办公室变成一个透明窗口的桌面宠物。
```bash
cd desktop-pet
npm install
npm run dev
```
- 启动时自动拉起 Python 后端
- 窗口默认指向 `http://127.0.0.1:19000/?desktop=1`
- 支持通过环境变量自定义项目路径和 Python 路径
> ⚠️ 这是一个**可选的实验性功能**,目前主要在 macOS 上开发测试。详见 [`desktop-pet/README.md`](./desktop-pet/README.md)。
>
> 🙏 桌面宠物版由 [@Zhaohan-Wang](https://github.com/Zhaohan-Wang) 独立开发,感谢他的贡献!
---
## 🎨 美术资产与开源许可
### 资产来源
访客角色动画使用了 **LimeZu** 的免费资产:
- [Animated Mini Characters 2 (Platformer) [FREE]](https://limezu.itch.io/animated-mini-characters-2-platform-free)
请在二次发布 / 演示时保留来源说明,并遵守原作者许可条款。
### 许可协议
- **代码 / 逻辑:MIT**(见 [`LICENSE`](./LICENSE))
- **美术资产:禁止商用**(仅学习 / 演示 / 交流用途)
> 如需商用,请将所有美术资产替换为你自己的原创素材。
---
## 📝 更新日志
| 日期 | 概要 | 详情 |
|------|------|------|
| 2026-03-06 | 🔌 默认端口调整 — 默认后端端口从 18791 调整为 19000,以避开 OpenClaw Browser Control 端口冲突;同步更新脚本、桌面壳与文档默认值 | [`docs/CHANGELOG_2026-03.md`](./docs/CHANGELOG_2026-03.md) |
| 2026-03-05 | 📱 稳定性修复 — CDN 缓存修复、生图异步化、移动端侧边栏优化、Join Key 过期与并发控制 | [`docs/UPDATE_REPORT_2026-03-05.md`](./docs/UPDATE_REPORT_2026-03-05.md) |
| 2026-03-04 | 🔒 P0/P1 安全加固 — 弱密码拦截、后端模块拆分、stale 状态自动回 idle、首屏骨架屏优化 | [`docs/UPDATE_REPORT_2026-03-04_P0_P1.md`](./docs/UPDATE_REPORT_2026-03-04_P0_P1.md) |
| 2026-03-03 | 📋 开源发布检查清单完成 | [`docs/OPEN_SOURCE_RELEASE_CHECKLIST.md`](./docs/OPEN_SOURCE_RELEASE_CHECKLIST.md) |
| 2026-03-01 | 🎉 **v2 重制发布** — 新增三语支持、资产管理系统、AI 生图装修、美术资产全面替换 | [`docs/FEATURES_NEW_2026-03-01.md`](./docs/FEATURES_NEW_2026-03-01.md) |
---
## 📁 项目结构
```text
Star-Office-UI/
├── backend/ # Flask 后端
│ ├── app.py
│ ├── requirements.txt
│ └── run.sh
├── frontend/ # 前端页面与资产
│ ├── index.html
│ ├── join.html
│ ├── invite.html
│ └── layout.js
├── desktop-pet/ # Electron 桌面宠物版(可选)
├── docs/ # 文档与截图
│ └── screenshots/
├── office-agent-push.py # 访客推送脚本
├── set_state.py # 状态切换脚本
├── state.sample.json # 状态文件模板
├── join-keys.sample.json # Join Key 模板(启动时生成 join-keys.json)
├── SKILL.md # OpenClaw Skill
└── LICENSE # MIT 许可
```
---
## ⭐ Star History
[](https://www.star-history.com/?repos=ringhyacinth%2FStar-Office-UI&type=date&legend=top-left)
================================================
FILE: SKILL.md
================================================
---
name: star-office-ui
description: Star Office UI 一键化 Skill:帮主人快速部署像素办公室看板,支持多 Agent 加入、状态可视化、移动端查看与公网访问。
---
# Star Office UI Skill
本 Skill 面向想快速帮主人搭建"像素办公室看板"的 OpenClaw(龙虾):
- 目标:主人只需"看到效果",尽量少问问题
- 你可以按下面的步骤,一步一步帮主人跑起来并对外访问
---
## 0. 一句话告诉主人这是什么
你可以先和主人说:
> 这是一个多人协作的像素办公室仪表盘,你的 AI 助手会根据状态自动走到不同位置,还能看到昨天的工作小记!
---
## 1. 30 秒一键启动(推荐你先做一遍)
在你这台机器执行(按顺序):
```bash
# 1) 下载仓库
git clone https://github.com/ringhyacinth/Star-Office-UI.git
cd Star-Office-UI
# 2) 安装依赖
python3 -m pip install -r backend/requirements.txt
# 3) 准备状态文件(首次)
cp state.sample.json state.json
# 4) 启动后端
cd backend
python3 app.py
```
然后告诉主人:
> 好了,你现在打开 http://127.0.0.1:19000 就能看到像素办公室了!
---
## 2. 帮主人切状态体验一下
在项目根目录执行:
```bash
# 工作中 → 去办公桌
python3 set_state.py writing "正在帮你整理文档"
# 同步中
python3 set_state.py syncing "同步进度中"
# 报错中 → 去 bug 区
python3 set_state.py error "发现问题,正在排查"
# 待命 → 回休息区
python3 set_state.py idle "待命中,随时准备为你服务"
```
---
## 3. 侧边栏验证码设置(必须教会新龙虾)
当前默认验证码是:`1234`。
你需要这样引导主人:
1. 默认密码是 `1234`,可以先直接体验;
2. 当主人愿意时,可随时和你沟通修改密码;
3. 你应主动推荐改成强密码(更安全,防止他人误改资产和布局)。
修改方式(示例):
```bash
export ASSET_DRAWER_PASS="your-strong-pass"
```
如果是长期运行(systemd / pm2 / 容器),请把该环境变量写入服务配置,而不是只在当前 shell 临时设置。
---
## 4. 生图功能(Gemini)—— 可选
"搬新家 / 找中介"装修功能需要 Gemini API,但**基础看板不需要**,不装也能正常使用。
### 4.1 安装生图脚本环境(首次使用时)
仓库已自带生图脚本(`scripts/gemini_image_generate.py`),但运行需要独立的 Python 环境。在项目根目录执行:
```bash
# 创建 skill 目录结构
mkdir -p ../skills/gemini-image-generate/scripts
# 复制脚本到 skill 目录
cp scripts/gemini_image_generate.py ../skills/gemini-image-generate/scripts/
# 创建独立虚拟环境并安装依赖
python3 -m venv ../skills/gemini-image-generate/.venv
../skills/gemini-image-generate/.venv/bin/pip install google-genai
```
安装完成后,后端会自动检测到生图环境,"搬新家 / 找中介"按钮即可使用。
### 4.2 配置 Gemini API Key
引导用户完成这两项配置:
1. `GEMINI_API_KEY`
2. `GEMINI_MODEL`(推荐:`nanobanana-pro` 或 `nanobanana-2`)
配置方式有两种:
- **侧边栏填写**:打开资产侧边栏 → 在生图配置区域直接输入 API Key 并保存
- **环境变量**:`export GEMINI_API_KEY="your-key"`
并明确告诉用户:
- 不配置 API 也能用基础看板(状态显示、多 Agent、资产替换等)
- 配置后才能使用"搬新家 / 找中介"的 AI 生图装修能力
如果页面提示缺少 key,指导用户在侧边栏里直接填写并保存(运行时配置入口)。
---
## 5. 安装成功后,必须提醒主人的三件事
### 5.1 告知临时公网链接
推荐先用 Cloudflare Tunnel(最快):
```bash
cloudflared tunnel --url http://127.0.0.1:19000
```
拿到 `https://xxx.trycloudflare.com` 后发给主人,并补一句:
> 这是临时公网链接;如果你愿意,后续我可以一步步带你部署到本地域名。
### 5.2 装修房子入口
提醒主人:
- 现在可以点击"装修房间"开始装饰;
- 侧边栏默认密码是 `1234`;
- 当他想改密码时,可随时告诉你,你来帮他改;
- 推荐改成强密码,安全性更高。
### 5.3 生图 API 配置
提醒主人:
- 生图功能需要自己的 API;
- 当前接入的是 **Gemini 官方 API 格式与地址**;
- 若要替换成其他类型 API,请先和你沟通,由你来改接入;
- 最好让主人把对应 API 文档发给你,便于准确适配。
---
## 6. 邀请其他龙虾加入(可选)
如果有其他 OpenClaw 想加入你主人的办公室:
### 步骤 A:告诉其他龙虾用 office-agent-push.py
你可以让其他龙虾:
1) 下载 `office-agent-push.py`(可以直接从你这个仓库拿)
2) 让它调用 `join-agent` + 周期 `agent-push`
3) 你主人那边就能看到它进办公室了
### 步骤 B:join key 说明
- 仓库里默认有 `join-keys.json`(`ocj_starteam01` ~ `ocj_starteam08`)
- 同一个 key 最多 3 人同时在线
- 你也可以帮主人新建自己的 key
---
## 7. 昨日小记(可选)
如果你主人想看到"昨日小记":
- 在仓库上级目录放一个 `memory/YYYY-MM-DD.md`
- 后端会自动读取昨天(或最近可用)的记录,做基础脱敏后展示
---
## 8. 常见问题(主人可能问你的)
### Q1:"我可以商用吗?"
> 代码玩法可以基于 MIT 用,但美术资产(包括角色/场景)禁止商用;如果你要商用,请务必换成你自己的原创美术资产。
### Q2:"其他龙虾怎么加入?"
> 用 join key 加入,然后持续推送状态就行;仓库里有 `office-agent-push.py` 可以给其他龙虾用。
---
## 9. 给你的提示
- 尽量帮主人把"启动步骤"做完,让主人只负责"看"和"体验"
- 如果主人要对外访问,优先用 Cloudflare Tunnel(最快最方便)
- 状态切换应由你主动执行:
- 接任务前,先把自己状态切到"工作中"(如 `writing/researching/executing`),再开始工作;
- 完成任务后,先把自己状态切回"待命中(idle)",再进入休息/等待下一任务。
- 注意不要暴露你主人的内网地址或私有信息
---
## 10. 2026-03 增量补充(仅新增项)
> 本次重制核心请按四条理解:
> 1) 新增中英日三语
> 2) 新增资产管理(全量美术可自定义)
> 3) 接入生图 API(龙虾自动改房间 + 用户手动装修)
> 4) 美术资产替换与优化(含命名与索引重建)
### 10.1 生图模型建议(房间装修)
当用户使用"搬新家 / 找中介"时,优先推荐:
1. **gemini nanobanana pro**
2. **gemini nanobanana 2**
其他模型在房间结构保持与风格一致性上可能不稳定。
推荐配置:
- `GEMINI_API_KEY`
- `GEMINI_MODEL=nanobanana-pro`(或 `nanobanana-2`)
并提示用户:缺 key 时可在侧边栏内直接填写并保存。
### 10.2 侧边栏验证码安全提醒(必须)
默认验证码为 `1234`,但生产/公网场景必须改强密码:
```bash
export ASSET_DRAWER_PASS="your-strong-pass"
```
理由:防止外部访问者修改房间布局、装饰和资产配置。
### 10.3 版权口径更新
主角状态素材已切换为无版权争议的小猫,不再沿用旧角色版权说明。
保留统一口径:
- 代码:MIT
- 美术资产:禁止商用
### 10.4 安装时必须提醒(API 可选)
在帮助主人安装时,需明确提醒:
- 现在支持接入自己的生图 API 来改美术资产与背景(可持续更换)。
- 但基础功能(状态看板、多 Agent、资产替换/布局、三语切换)**不依赖 API**,不开 API 也能正常使用。
建议对主人口径:
> 先把基础看板跑起来;需要"无限换背景/AI 生图装修"再接入自己的 API。
### 10.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 时可用)
### 10.6 功能更新提醒清单(对主人口播)
本次更新以后,至少提醒主人以下变化:
1. 已支持 **CN/EN/JP 三语切换**(含 loading 与气泡实时联动)。
2. 已支持 **自定义美术资产替换**(含动态素材切帧同步,减少闪烁)。
3. 已支持 **接入自有生图 API** 持续更换背景(推荐 `nanobanana-pro` / `nanobanana-2`)。
4. 新增/强化了安全项:`ASSET_DRAWER_PASS` 生产环境建议改强密码。
### 10.7 2026-03-05 稳定性修复
本次更新修复了多个影响线上稳定运行的问题:
1. **CDN 缓存修复**:静态资源 404 不再被 CDN 长缓存(之前导致 `phaser.js` 被缓存为 404 达 2.7 天)。
2. **前端加载修复**:修复 `fetchStatus()` 中的 JS 语法错误(多余 `else` 块),解决页面卡 loading 问题。
3. **生图异步化**:生图接口改为后台任务 + 轮询模式,避免 Cloudflare 524 超时(100s 限制)。前端显示实时等待进度。
4. **移动端侧边栏**:新增遮罩层、body 滚动锁定、`100dvh` 适配、`overscroll-behavior: contain`。
5. **Join Key 增强**:支持 key 级别过期时间(`expiresAt`)和并发上限(`maxConcurrent`),`join-keys.json` 不再入库。
> 详细说明见:`docs/UPDATE_REPORT_2026-03-05.md`
================================================
FILE: agent-invite-template.txt
================================================
# 海辛办公室 · Agent 加入指南
## 你收到的信息
- 邀请页:https://office.example.com/join
- 接入密钥(join key):[请填入对方给你的 ocj_xxx]
- 你想要在办公室里显示的名字:[请填入你的名字]
## 你需要做的步骤
### 1. 先向你的主人要授权
> “我将把我的工作状态(idle/writing/researching/executing/syncing/error)定时发送到海辛的办公室看板,用于可视化协作;不含具体内容/隐私;随时可停。是否授权?”
### 2. 主人同意后:
- 调用 `POST https://office.example.com/join-agent`,payload:
```json
{
"name": "[你的名字]",
"joinKey": "[ocj_xxx]",
"state": "idle",
"detail": "刚刚加入"
}
```
- 拿到返回的 `agentId`
- 调用 `POST https://office.example.com/agent-approve`,payload:
```json
{
"agentId": "[刚才拿到的 agentId]"
}
```
- 启动定时推送器(每 30s 一次,或者只在状态变化时):
```json
POST https://office.example.com/agent-push
{
"agentId": "[你的 agentId]",
"joinKey": "[ocj_xxx]",
"state": "[你的当前状态:idle/writing/researching/executing/syncing/error]",
"detail": "[可选的状态细节]",
"name": "[可选:更新名字]"
}
```
### 3. 主人想退出时:
- 停止定时推送器
- 调用 `POST https://office.example.com/leave-agent`,payload:
```json
{
"name": "[你的名字]"
}
```
---
## 注意事项
- 如果收到 `403` 或 `agent 未获授权`,停止推送
- 默认授权有效期 24h,过期后重新申请
- 只推送状态,不推送任何具体内容/隐私
================================================
FILE: asset-defaults.json
================================================
{
"flowers-bloom-v2.webp": {
"x": 310.0,
"y": 390.0,
"scale": 0.8,
"updated_at": "2026-03-03T01:32:18.211712"
}
}
================================================
FILE: asset-positions.json
================================================
{
"desk-v3.webp": {
"x": 218.0,
"y": 417.0,
"updated_at": "2026-03-02T15:58:27.228023"
}
}
================================================
FILE: backend/app.py
================================================
#!/usr/bin/env python3
"""Star Office UI - Backend State Service"""
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
from security_utils import is_production_mode, is_strong_secret, is_strong_drawer_pass
from memo_utils import get_yesterday_date_str, sanitize_content, extract_memo_from_file
from store_utils import (
load_agents_state as _store_load_agents_state,
save_agents_state as _store_save_agents_state,
load_asset_positions as _store_load_asset_positions,
save_asset_positions as _store_save_asset_positions,
load_asset_defaults as _store_load_asset_defaults,
save_asset_defaults as _store_save_asset_defaults,
load_runtime_config as _store_load_runtime_config,
save_runtime_config as _store_save_runtime_config,
load_join_keys as _store_load_join_keys,
save_join_keys as _store_save_join_keys,
)
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__)))
MEMORY_DIR = os.path.join(os.path.dirname(ROOT_DIR), "memory")
FRONTEND_DIR = os.path.join(ROOT_DIR, "frontend")
FRONTEND_INDEX_FILE = os.path.join(FRONTEND_DIR, "index.html")
FRONTEND_ELECTRON_STANDALONE_FILE = os.path.join(FRONTEND_DIR, "electron-standalone.html")
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)
OPENCLAW_WORKSPACE = os.environ.get("OPENCLAW_WORKSPACE") or os.path.join(os.path.expanduser("~"), ".openclaw", "workspace")
IDENTITY_FILE = os.path.join(OPENCLAW_WORKSPACE, "IDENTITY.md")
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")
HOME_FAVORITES_DIR = os.path.join(ROOT_DIR, "assets", "home-favorites")
HOME_FAVORITES_INDEX_FILE = os.path.join(HOME_FAVORITES_DIR, "index.json")
HOME_FAVORITES_MAX = 30
ASSET_POSITIONS_FILE = os.path.join(ROOT_DIR, "asset-positions.json")
# 性能保护:默认关闭“每次打开页面随机换背景”,避免首页首屏被磁盘复制拖慢
AUTO_ROTATE_HOME_ON_PAGE_OPEN = (os.getenv("AUTO_ROTATE_HOME_ON_PAGE_OPEN", "0").strip().lower() in {"1", "true", "yes", "on"})
AUTO_ROTATE_MIN_INTERVAL_SECONDS = int(os.getenv("AUTO_ROTATE_MIN_INTERVAL_SECONDS", "60"))
_last_home_rotate_at = 0
ASSET_DEFAULTS_FILE = os.path.join(ROOT_DIR, "asset-defaults.json")
RUNTIME_CONFIG_FILE = os.path.join(ROOT_DIR, "runtime-config.json")
# Canonical agent states: single source of truth for validation and mapping
VALID_AGENT_STATES = frozenset({"idle", "writing", "researching", "executing", "syncing", "error"})
WORKING_STATES = frozenset({"writing", "researching", "executing"}) # subset used for auto-idle TTL
STATE_TO_AREA_MAP = {
"idle": "breakroom",
"writing": "writing",
"researching": "writing",
"executing": "writing",
"syncing": "writing",
"error": "error",
}
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"
# Session hardening
app.config.update(
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
SESSION_COOKIE_SECURE=is_production_mode(),
PERMANENT_SESSION_LIFETIME=timedelta(hours=12),
)
# Guard join-agent critical section to enforce per-key concurrency under parallel requests
join_lock = threading.Lock()
# Async background task registry for long-running operations (e.g. image generation)
# Avoids Cloudflare 524 timeout (100s limit) by letting frontend poll for completion.
_bg_tasks = {} # task_id -> {"status": "pending"|"done"|"error", "result": ..., "error": ..., "created_at": ...}
_bg_tasks_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")
if is_production_mode():
hardening_errors = []
if not is_strong_secret(str(app.secret_key)):
hardening_errors.append("FLASK_SECRET_KEY / STAR_OFFICE_SECRET is weak (need >=24 chars, non-default)")
if not is_strong_drawer_pass(ASSET_DRAWER_PASS_DEFAULT):
hardening_errors.append("ASSET_DRAWER_PASS is weak (do not use default 1234; recommend >=8 chars)")
if hardening_errors:
raise RuntimeError("Security hardening check failed in production mode: " + "; ".join(hardening_errors))
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):
"""Apply cache policy by path:
- HTML/API/state: no-cache (always fresh)
- /static assets (2xx only): long cache (filenames are versioned with ?v=VERSION_TIMESTAMP)
- /static assets (non-2xx, e.g. 404): no-cache to prevent CDN from caching errors
"""
path = (request.path or "")
if path.startswith('/static/') and 200 <= response.status_code < 300:
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
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", 300))
updated_at = state.get("updated_at")
s = state.get("state", "idle")
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 get_office_name_from_identity():
"""Read office display name from OpenClaw workspace IDENTITY.md (Name field) -> 'XXX的办公室'."""
if not os.path.isfile(IDENTITY_FILE):
return None
try:
with open(IDENTITY_FILE, "r", encoding="utf-8") as f:
content = f.read()
m = re.search(r"-\s*\*\*Name:\*\*\s*(.+)", content)
if m:
name = m.group(1).strip().replace("\r", "").split("\n")[0].strip()
return f"{name}的办公室" if name else None
except Exception:
pass
return None
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)
def ensure_electron_standalone_snapshot():
"""Create Electron standalone frontend snapshot once if missing.
The snapshot is intentionally decoupled from the browser page:
- browser uses frontend/index.html
- Electron uses frontend/electron-standalone.html
"""
if os.path.exists(FRONTEND_ELECTRON_STANDALONE_FILE):
return
try:
shutil.copy2(FRONTEND_INDEX_FILE, FRONTEND_ELECTRON_STANDALONE_FILE)
print(f"[standalone] created: {FRONTEND_ELECTRON_STANDALONE_FILE}")
except Exception as e:
print(f"[standalone] create failed: {e}")
# Initialize state
if not os.path.exists(STATE_FILE):
save_state(DEFAULT_STATE)
ensure_electron_standalone_snapshot()
_INDEX_HTML_CACHE = None
@app.route("/", methods=["GET"])
def index():
"""Serve the pixel office UI with built-in version cache busting"""
# 默认禁用页面打开即换背景,避免首屏慢
# 如需启用,可配置 AUTO_ROTATE_HOME_ON_PAGE_OPEN=1
_maybe_apply_random_home_favorite()
global _INDEX_HTML_CACHE
if _INDEX_HTML_CACHE is None:
with open(FRONTEND_INDEX_FILE, "r", encoding="utf-8") as f:
raw_html = f.read()
_INDEX_HTML_CACHE = raw_html.replace("{{VERSION_TIMESTAMP}}", VERSION_TIMESTAMP)
resp = make_response(_INDEX_HTML_CACHE)
resp.headers["Content-Type"] = "text/html; charset=utf-8"
return resp
@app.route("/electron-standalone", methods=["GET"])
def electron_standalone_page():
"""Serve Electron-only standalone frontend page."""
ensure_electron_standalone_snapshot()
target = FRONTEND_ELECTRON_STANDALONE_FILE
if not os.path.exists(target):
target = FRONTEND_INDEX_FILE
with open(target, "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
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
}
]
def load_agents_state():
return _store_load_agents_state(AGENTS_STATE_FILE, DEFAULT_AGENTS)
def save_agents_state(agents):
_store_save_agents_state(AGENTS_STATE_FILE, agents)
def load_asset_positions():
return _store_load_asset_positions(ASSET_POSITIONS_FILE)
def save_asset_positions(data):
_store_save_asset_positions(ASSET_POSITIONS_FILE, data)
def load_asset_defaults():
return _store_load_asset_defaults(ASSET_DEFAULTS_FILE)
def save_asset_defaults(data):
_store_save_asset_defaults(ASSET_DEFAULTS_FILE, data)
def load_runtime_config():
return _store_load_runtime_config(RUNTIME_CONFIG_FILE)
def save_runtime_config(data):
_store_save_runtime_config(RUNTIME_CONFIG_FILE, data)
def _ensure_home_favorites_index():
os.makedirs(HOME_FAVORITES_DIR, exist_ok=True)
if not os.path.exists(HOME_FAVORITES_INDEX_FILE):
with open(HOME_FAVORITES_INDEX_FILE, "w", encoding="utf-8") as f:
json.dump({"items": []}, f, ensure_ascii=False, indent=2)
def _load_home_favorites_index():
_ensure_home_favorites_index()
try:
with open(HOME_FAVORITES_INDEX_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict) and isinstance(data.get("items"), list):
return data
except Exception:
pass
return {"items": []}
def _save_home_favorites_index(data):
_ensure_home_favorites_index()
with open(HOME_FAVORITES_INDEX_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def _maybe_apply_random_home_favorite():
"""On page open, randomly apply one saved home favorite if available."""
global _last_home_rotate_at
if not AUTO_ROTATE_HOME_ON_PAGE_OPEN:
return False, "disabled"
try:
now_ts = datetime.now().timestamp()
if _last_home_rotate_at and (now_ts - _last_home_rotate_at) < AUTO_ROTATE_MIN_INTERVAL_SECONDS:
return False, "throttled"
idx = _load_home_favorites_index()
items = idx.get("items") or []
candidates = []
for it in items:
rel = (it.get("path") or "").strip()
if not rel:
continue
abs_path = os.path.join(ROOT_DIR, rel)
if os.path.exists(abs_path):
candidates.append((rel, abs_path))
if not candidates:
return False, "no-favorites"
rel, src = random.choice(candidates)
target = FRONTEND_PATH / "office_bg_small.webp"
if not target.exists():
return False, "missing-office-bg"
shutil.copy2(src, str(target))
_last_home_rotate_at = now_ts
return True, rel
except Exception as e:
return False, str(e)
def load_join_keys():
return _store_load_join_keys(JOIN_KEYS_FILE)
def save_join_keys(data):
_store_save_join_keys(JOIN_KEYS_FILE, data)
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):
"""Normalize agent state for compatibility.
Maps synonyms (e.g. working/busy -> writing, run/running -> executing) into VALID_AGENT_STATES.
Returns 'idle' for unknown values.
"""
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 VALID_AGENT_STATES:
return s_lower
return 'idle'
# User-facing model aliases -> provider model ids
USER_MODEL_TO_PROVIDER_MODELS = {
# 严格按用户要求:仅两种官方模型映射
"nanobanana-pro": [
"nano-banana-pro-preview",
],
"nanobanana-2": [
"gemini-2.5-flash-image",
],
}
PROVIDER_MODEL_TO_USER_MODEL = {
provider: user
for user, providers in USER_MODEL_TO_PROVIDER_MODELS.items()
for provider in providers
}
def _normalize_user_model(model_name: str) -> str:
m = (model_name or "").strip()
if not m:
return "nanobanana-pro"
low = m.lower()
if low in USER_MODEL_TO_PROVIDER_MODELS:
return low
if low in PROVIDER_MODEL_TO_USER_MODEL:
return PROVIDER_MODEL_TO_USER_MODEL[low]
return "nanobanana-pro"
def _provider_model_candidates(user_model: str):
normalized = _normalize_user_model(user_model)
return list(USER_MODEL_TO_PROVIDER_MODELS.get(normalized, USER_MODEL_TO_PROVIDER_MODELS["nanobanana-pro"]))
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 = "quality"
configured_user_model = _normalize_user_model(runtime_cfg.get("gemini_model") or "nanobanana-pro")
if mode == "fast":
preferred_user_model = "nanobanana-2"
# fast 也提高基础清晰度:从 1024x576 提升到 1152x648(牺牲少量速度)
gen_width, gen_height = 1152, 648
ref_width, ref_height = 1152, 648
else:
preferred_user_model = configured_user_model
gen_width, gen_height = width, height
ref_width, ref_height = width, height
# 同时规避可能触发 400 的特殊能力参数:
# 仅 nanobanana-2 走 aspect-ratio,nanobanana-pro 交给模型默认比例(后续再标准化到 1280x720)
allow_aspect_ratio = (preferred_user_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,
"--model", configured_user_model,
"--out-dir", tmp_dir,
"--cleanup",
]
if allow_aspect_ratio:
cmd.extend(["--aspect-ratio", "16:9"])
# 强约束:每次都带固定参考图,保持房间区域布局不漂移
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
def _run_cmd(cmd_args):
return subprocess.run(cmd_args, capture_output=True, text=True, env=env, timeout=240)
def _is_model_unavailable_error(text: str) -> bool:
low = (text or "").strip().lower()
return (
("not found" in low and "models/" in low)
or ("model_not_available" in low)
or ("model is not available" in low)
or ("configured model is not available" in low)
or ("this model is not available" in low)
or ("not supported for generatecontent" in low)
)
def _with_model(cmd_args, model_name: str):
m = cmd_args[:]
if "--model" in m:
idx = m.index("--model")
if idx + 1 < len(m):
m[idx + 1] = model_name
else:
m.extend(["--model", model_name])
return m
# 模型多级回退(仅允许两类用户模型:nanobanana-pro / nanobanana-2)
# 每个用户模型映射到若干 provider 真实模型。
user_model_order = [preferred_user_model, configured_user_model]
user_model_order = [m for i, m in enumerate(user_model_order) if m and m not in user_model_order[:i]]
model_candidates = []
for um in user_model_order:
model_candidates.extend(_provider_model_candidates(um))
# 去重并清理空项
model_candidates = [m for i, m in enumerate(model_candidates) if m and m not in model_candidates[:i]]
proc = None
last_err_text = ""
model_unavailable_count = 0
for mname in model_candidates:
env["GEMINI_MODEL"] = mname
try_cmd = _with_model(cmd, mname)
proc = _run_cmd(try_cmd)
if proc.returncode == 0:
break
err_text = (proc.stderr or proc.stdout or "").strip()
last_err_text = err_text
# key 失效/泄漏:立即终止,不继续尝试
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 _is_model_unavailable_error(err_text):
model_unavailable_count += 1
continue
# 非模型不可用错误,直接返回真实错误
raise RuntimeError(f"生图失败: {err_text}")
if proc is None or proc.returncode != 0:
err_text = (last_err_text or "").strip()
if model_unavailable_count >= len(model_candidates) or _is_model_unavailable_error(err_text):
brief = (err_text or "").replace("\n", " ")[:240]
raise RuntimeError(f"MODEL_NOT_AVAILABLE::{brief}")
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):
"""Map agent state to office area (breakroom / writing / error)."""
return STATE_TO_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):
if os.path.exists(os.path.join(ROOT_DIR, "join-keys.sample.json")):
try:
with open(os.path.join(ROOT_DIR, "join-keys.sample.json"), "r", encoding="utf-8") as sf:
sample = json.load(sf)
save_join_keys(sample if isinstance(sample, dict) else {"keys": []})
except Exception:
save_join_keys({"keys": []})
else:
save_join_keys({"keys": []})
# Tighten runtime-config file perms if exists
if os.path.exists(RUNTIME_CONFIG_FILE):
try:
os.chmod(RUNTIME_CONFIG_FILE, 0o600)
except Exception:
pass
@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
# Key-level expiration check
key_expires_at_str = key_item.get("expiresAt")
if key_expires_at_str:
try:
key_expires_at = datetime.fromisoformat(key_expires_at_str)
if datetime.now() > key_expires_at:
return jsonify({"ok": False, "msg": "该接入密钥已过期,活动已结束 🎉"}), 403
except Exception:
pass
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 main state (backward compatibility). Optionally include officeName from IDENTITY.md."""
state = load_state()
office_name = get_office_name_from_identity()
if office_name:
state["officeName"] = office_name
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
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-level expiration check
key_expires_at_str = key_item.get("expiresAt")
if key_expires_at_str:
try:
key_expires_at = datetime.fromisoformat(key_expires_at_str)
if datetime.now() > key_expires_at:
return jsonify({"ok": False, "msg": "该接入密钥已过期,活动已结束 🎉"}), 403
except Exception:
pass
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",
"service": "star-office-ui",
"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"]
if s in VALID_AGENT_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
@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})
def _bg_generate_worker(task_id: str, custom_prompt: str, speed_mode: str):
"""Background worker for RPG background generation."""
try:
target = FRONTEND_PATH / "office_bg_small.webp"
# 覆盖前保留最近一次备份
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()
with _bg_tasks_lock:
_bg_tasks[task_id] = {
"status": "done",
"result": {
"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)
error_result = {"ok": False, "msg": msg}
if msg == "MISSING_API_KEY":
error_result["code"] = "MISSING_API_KEY"
error_result["msg"] = "Missing GEMINI_API_KEY or GOOGLE_API_KEY"
elif msg == "API_KEY_REVOKED_OR_LEAKED":
error_result["code"] = "API_KEY_REVOKED_OR_LEAKED"
error_result["msg"] = "API key is revoked or flagged as leaked. Please rotate to a new key."
elif msg.startswith("MODEL_NOT_AVAILABLE"):
error_result["code"] = "MODEL_NOT_AVAILABLE"
error_result["msg"] = "Configured model is not available for this API key/channel."
if "::" in msg:
error_result["detail"] = msg.split("::", 1)[1]
with _bg_tasks_lock:
_bg_tasks[task_id] = {"status": "error", "result": error_result}
@app.route("/assets/generate-rpg-background", methods=["POST"])
def assets_generate_rpg_background():
"""Start async RPG background generation. Returns a task_id for polling."""
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
# Pre-flight checks that can fail fast (before spawning thread)
runtime_cfg = load_runtime_config()
api_key = (runtime_cfg.get("gemini_api_key") or "").strip()
if not api_key:
return jsonify({"ok": False, "code": "MISSING_API_KEY", "msg": "Missing GEMINI_API_KEY or GOOGLE_API_KEY"}), 400
if not (os.path.exists(GEMINI_PYTHON) and os.path.exists(GEMINI_SCRIPT)):
return jsonify({"ok": False, "msg": "生图脚本环境缺失:gemini-image-generate 未安装"}), 500
# Check if another generation is already running
with _bg_tasks_lock:
for tid, task in _bg_tasks.items():
if task.get("status") == "pending":
return jsonify({"ok": True, "async": True, "task_id": tid, "msg": "已有生图任务进行中,请等待完成"}), 200
# Create async task
import string as _string
task_id = "gen_" + str(int(datetime.now().timestamp() * 1000)) + "_" + "".join(random.choices(_string.ascii_lowercase + _string.digits, k=4))
with _bg_tasks_lock:
_bg_tasks[task_id] = {"status": "pending", "created_at": datetime.now().isoformat()}
t = threading.Thread(target=_bg_generate_worker, args=(task_id, custom_prompt, speed_mode), daemon=True)
t.start()
return jsonify({"ok": True, "async": True, "task_id": task_id, "msg": "生图任务已启动,请通过 task_id 轮询结果"})
except Exception as e:
return jsonify({"ok": False, "msg": str(e)}), 500
@app.route("/assets/generate-rpg-background/poll", methods=["GET"])
def assets_generate_rpg_background_poll():
"""Poll async generation task status."""
guard = _require_asset_editor_auth()
if guard:
return guard
task_id = (request.args.get("task_id") or "").strip()
if not task_id:
return jsonify({"ok": False, "msg": "缺少 task_id"}), 400
with _bg_tasks_lock:
task = _bg_tasks.get(task_id)
if not task:
return jsonify({"ok": False, "msg": "任务不存在"}), 404
status = task.get("status", "pending")
if status == "pending":
return jsonify({"ok": True, "status": "pending", "msg": "生图进行中..."})
elif status == "done":
# Clean up task after delivering result
with _bg_tasks_lock:
_bg_tasks.pop(task_id, None)
return jsonify({"ok": True, "status": "done", **task.get("result", {})})
else:
with _bg_tasks_lock:
_bg_tasks.pop(task_id, None)
result = task.get("result", {})
code = 400 if result.get("code") else 500
return jsonify({"ok": False, "status": "error", **result}), code
@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/restore-last-generated-background", methods=["POST"])
def assets_restore_last_generated_background():
"""Restore office_bg_small.webp from latest bg-history snapshot."""
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.isdir(BG_HISTORY_DIR):
return jsonify({"ok": False, "msg": "暂无历史底图"}), 404
files = [
os.path.join(BG_HISTORY_DIR, x)
for x in os.listdir(BG_HISTORY_DIR)
if x.startswith("office_bg_small-") and x.endswith(".webp")
]
if not files:
return jsonify({"ok": False, "msg": "暂无历史底图"}), 404
latest = max(files, key=lambda p: os.path.getmtime(p))
bak = target.with_suffix(target.suffix + ".bak")
shutil.copy2(target, bak)
shutil.copy2(latest, target)
st = target.stat()
return jsonify({
"ok": True,
"path": "office_bg_small.webp",
"size": st.st_size,
"from": os.path.relpath(latest, ROOT_DIR),
"msg": "已回退到最近一次生成底图",
})
except Exception as e:
return jsonify({"ok": False, "msg": str(e)}), 500
@app.route("/assets/home-favorites/list", methods=["GET"])
def assets_home_favorites_list():
guard = _require_asset_editor_auth()
if guard:
return guard
try:
data = _load_home_favorites_index()
items = data.get("items") or []
out = []
for it in items:
rel = (it.get("path") or "").strip()
if not rel:
continue
abs_path = os.path.join(ROOT_DIR, rel)
if not os.path.exists(abs_path):
continue
fn = os.path.basename(rel)
out.append({
"id": it.get("id"),
"path": rel,
"url": f"/assets/home-favorites/file/{fn}",
"thumb_url": f"/assets/home-favorites/file/{fn}",
"created_at": it.get("created_at") or "",
})
out.sort(key=lambda x: x.get("created_at") or "", reverse=True)
return jsonify({"ok": True, "items": out})
except Exception as e:
return jsonify({"ok": False, "msg": str(e)}), 500
@app.route("/assets/home-favorites/file/<path:filename>", methods=["GET"])
def assets_home_favorites_file(filename):
guard = _require_asset_editor_auth()
if guard:
return guard
return send_from_directory(HOME_FAVORITES_DIR, filename)
@app.route("/assets/home-favorites/save-current", methods=["POST"])
def assets_home_favorites_save_current():
guard = _require_asset_editor_auth()
if guard:
return guard
try:
src = FRONTEND_PATH / "office_bg_small.webp"
if not src.exists():
return jsonify({"ok": False, "msg": "office_bg_small.webp 不存在"}), 404
_ensure_home_favorites_index()
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
item_id = f"home-{ts}"
fn = f"{item_id}.webp"
dst = os.path.join(HOME_FAVORITES_DIR, fn)
shutil.copy2(str(src), dst)
idx = _load_home_favorites_index()
items = idx.get("items") or []
items.insert(0, {
"id": item_id,
"path": os.path.relpath(dst, ROOT_DIR),
"created_at": datetime.now().isoformat(timespec="seconds"),
})
# 控制收藏数量上限,清理最旧项
if len(items) > HOME_FAVORITES_MAX:
extra = items[HOME_FAVORITES_MAX:]
items = items[:HOME_FAVORITES_MAX]
for it in extra:
try:
p = os.path.join(ROOT_DIR, it.get("path") or "")
if os.path.exists(p):
os.remove(p)
except Exception:
pass
idx["items"] = items
_save_home_favorites_index(idx)
return jsonify({"ok": True, "id": item_id, "path": os.path.relpath(dst, ROOT_DIR), "msg": "已收藏当前地图"})
except Exception as e:
return jsonify({"ok": False, "msg": str(e)}), 500
@app.route("/assets/home-favorites/delete", methods=["POST"])
def assets_home_favorites_delete():
guard = _require_asset_editor_auth()
if guard:
return guard
try:
data = request.get_json(silent=True) or {}
item_id = (data.get("id") or "").strip()
if not item_id:
return jsonify({"ok": False, "msg": "缺少 id"}), 400
idx = _load_home_favorites_index()
items = idx.get("items") or []
hit = next((x for x in items if (x.get("id") or "") == item_id), None)
if not hit:
return jsonify({"ok": False, "msg": "收藏项不存在"}), 404
rel = hit.get("path") or ""
abs_path = os.path.join(ROOT_DIR, rel)
if os.path.exists(abs_path):
try:
os.remove(abs_path)
except Exception:
pass
idx["items"] = [x for x in items if (x.get("id") or "") != item_id]
_save_home_favorites_index(idx)
return jsonify({"ok": True, "id": item_id, "msg": "已删除收藏"})
except Exception as e:
return jsonify({"ok": False, "msg": str(e)}), 500
@app.route("/assets/home-favorites/apply", methods=["POST"])
def assets_home_favorites_apply():
guard = _require_asset_editor_auth()
if guard:
return guard
try:
data = request.get_json(silent=True) or {}
item_id = (data.get("id") or "").strip()
if not item_id:
return jsonify({"ok": False, "msg": "缺少 id"}), 400
idx = _load_home_favorites_index()
items = idx.get("items") or []
hit = next((x for x in items if (x.get("id") or "") == item_id), None)
if not hit:
return jsonify({"ok": False, "msg": "收藏项不存在"}), 404
src = os.path.join(ROOT_DIR, hit.get("path") or "")
if not os.path.exists(src):
return jsonify({"ok": False, "msg": "收藏文件不存在"}), 404
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(str(target), str(bak))
shutil.copy2(src, str(target))
st = target.stat()
return jsonify({"ok": True, "path": "office_bg_small.webp", "size": st.st_size, "from": hit.get("path"), "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(),
"drawer_default_pass": ASSET_DRAWER_PASS_DEFAULT == "1234",
})
@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": _normalize_user_model(cfg.get("gemini_model") or "nanobanana-pro"),
})
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 = _normalize_user_model((data.get("model") or "").strip() or "nanobanana-pro")
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/restore-default", methods=["POST"])
def assets_restore_default():
guard = _require_asset_editor_auth()
if guard:
return guard
try:
data = request.get_json(silent=True) or {}
rel_path = (data.get("path") or "").strip().lstrip("/")
if not rel_path:
return jsonify({"ok": False, "msg": "缺少 path"}), 400
target = (FRONTEND_PATH / rel_path).resolve()
try:
target.relative_to(FRONTEND_PATH.resolve())
except Exception:
return jsonify({"ok": False, "msg": "非法 path"}), 400
if not target.exists():
return jsonify({"ok": False, "msg": "目标文件不存在"}), 404
root, ext = os.path.splitext(str(target))
default_path = root + ext + ".default"
if not os.path.exists(default_path):
return jsonify({"ok": False, "msg": "未找到默认资产快照"}), 404
# 回滚前保留上一版
bak = str(target) + ".bak"
if os.path.exists(str(target)):
shutil.copy2(str(target), bak)
shutil.copy2(default_path, str(target))
st = os.stat(str(target))
return jsonify({"ok": True, "path": rel_path, "size": st.st_size, "msg": "已重置为默认资产"})
except Exception as e:
return jsonify({"ok": False, "msg": str(e)}), 500
@app.route("/assets/restore-prev", methods=["POST"])
def assets_restore_prev():
guard = _require_asset_editor_auth()
if guard:
return guard
try:
data = request.get_json(silent=True) or {}
rel_path = (data.get("path") or "").strip().lstrip("/")
if not rel_path:
return jsonify({"ok": False, "msg": "缺少 path"}), 400
target = (FRONTEND_PATH / rel_path).resolve()
try:
target.relative_to(FRONTEND_PATH.resolve())
except Exception:
return jsonify({"ok": False, "msg": "非法 path"}), 400
bak = str(target) + ".bak"
if not os.path.exists(bak):
return jsonify({"ok": False, "msg": "未找到上一版备份"}), 404
shutil.copy2(str(target), bak + ".tmp") if os.path.exists(str(target)) else None
shutil.copy2(bak, str(target))
st = os.stat(str(target))
return jsonify({"ok": True, "path": rel_path, "size": st.st_size, "msg": "已回退到上一版"})
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)
# 首次上传前固化默认资产快照,供“重置为默认资产”使用
default_snap = Path(str(target) + ".default")
if not default_snap.exists():
try:
shutil.copy2(target, default_snap)
except Exception:
pass
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__":
raw_port = os.environ.get("STAR_BACKEND_PORT", "19000")
try:
backend_port = int(raw_port)
except ValueError:
backend_port = 19000
if backend_port <= 0:
backend_port = 19000
print("=" * 50)
print("Star Office UI - Backend State Service")
print("=" * 50)
print(f"State file: {STATE_FILE}")
print(f"Listening on: http://0.0.0.0:{backend_port}")
if backend_port != 19000:
print(f"(Port override: set STAR_BACKEND_PORT to change; current: {raw_port})")
else:
print("(Set STAR_BACKEND_PORT to use a different port, e.g. 3009)")
mode = "production" if is_production_mode() else "development"
print(f"Mode: {mode}")
if is_production_mode():
print("Security hardening: ENABLED (strict checks)")
else:
weak_flags = []
if not is_strong_secret(str(app.secret_key)):
weak_flags.append("weak FLASK_SECRET_KEY/STAR_OFFICE_SECRET")
if not is_strong_drawer_pass(ASSET_DRAWER_PASS_DEFAULT):
weak_flags.append("weak ASSET_DRAWER_PASS")
if weak_flags:
print("Security hardening: WARNING (dev mode) -> " + ", ".join(weak_flags))
else:
print("Security hardening: OK")
print("=" * 50)
app.run(host="0.0.0.0", port=backend_port, debug=False)
================================================
FILE: backend/memo_utils.py
================================================
#!/usr/bin/env python3
"""Memo extraction helpers for Star Office backend.
Reads and sanitizes daily memo content from memory/*.md for the yesterday-memo API.
"""
from __future__ import annotations
from datetime import datetime, timedelta
import random
import re
def get_yesterday_date_str() -> str:
"""Return yesterday's date as YYYY-MM-DD."""
yesterday = datetime.now() - timedelta(days=1)
return yesterday.strftime("%Y-%m-%d")
def sanitize_content(text: str) -> str:
"""Redact PII and sensitive patterns (OpenID, paths, IPs, email, phone) for safe display."""
text = re.sub(r'ou_[a-f0-9]+', '[用户]', text)
text = re.sub(r'user_id="[^"]+"', 'user_id="[隐藏]"', text)
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: str) -> str:
"""Extract display-safe memo text from a memory markdown file; sanitizes and truncates with a short fallback."""
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 = [
"「工欲善其事,必先利其器。」",
"「不积跬步,无以至千里;不积小流,无以成江海。」",
"「知行合一,方可致远。」",
"「业精于勤,荒于嬉;行成于思,毁于随。」",
"「路漫漫其修远兮,吾将上下而求索。」",
"「昨夜西风凋碧树,独上高楼,望尽天涯路。」",
"「衣带渐宽终不悔,为伊消得人憔悴。」",
"「众里寻他千百度,蓦然回首,那人却在,灯火阑珊处。」",
"「世事洞明皆学问,人情练达即文章。」",
"「纸上得来终觉浅,绝知此事要躬行。」"
]
quote = random.choice(wisdom_quotes)
# 组合内容
result = []
# 添加核心内容
if selected_points:
for point in 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"extract_memo_from_file failed: {e}")
return "「昨日记录加载失败」\n\n「往者不可谏,来者犹可追。」"
================================================
FILE: backend/requirements.txt
================================================
flask==3.0.2
pillow==10.4.0
================================================
FILE: backend/run.sh
================================================
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Auto-load project env file when present.
if [[ -f "$ROOT_DIR/.env" ]]; then
set -a
# shellcheck disable=SC1091
source "$ROOT_DIR/.env"
set +a
fi
exec "$ROOT_DIR/.venv/bin/python" "$ROOT_DIR/backend/app.py"
================================================
FILE: backend/security_utils.py
================================================
#!/usr/bin/env python3
"""Security helper utilities for Star Office backend.
Production detection and validation for Flask secret and asset drawer password.
"""
from __future__ import annotations
import os
def is_production_mode() -> bool:
"""Return True if STAR_OFFICE_ENV or FLASK_ENV is prod/production."""
env = (os.getenv("STAR_OFFICE_ENV") or os.getenv("FLASK_ENV") or "").strip().lower()
return env in {"prod", "production"}
def is_strong_secret(secret: str) -> bool:
"""Return True if secret is at least 24 chars and does not contain weak markers (e.g. change-me, dev)."""
if not secret:
return False
secret = secret.strip()
if len(secret) < 24:
return False
weak_markers = {"change-me", "dev", "example", "test", "default"}
low = secret.lower()
return not any(m in low for m in weak_markers)
def is_strong_drawer_pass(pwd: str) -> bool:
"""Return True if password is not default 1234 and has at least 8 characters."""
if not pwd:
return False
pwd = pwd.strip()
if pwd == "1234":
return False
return len(pwd) >= 8
================================================
FILE: backend/store_utils.py
================================================
#!/usr/bin/env python3
"""Storage helper utilities for Star Office backend.
JSON load/save for agents state, asset positions/defaults, runtime config, and join keys.
"""
from __future__ import annotations
import json
import os
def _load_json(path: str):
"""Load JSON from a file; caller handles missing file or parse errors."""
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def _save_json(path: str, data):
"""Write data as JSON with UTF-8 and indent=2."""
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def load_agents_state(path: str, default_agents: list) -> list:
"""Load agents list from path; return default_agents if file missing or invalid."""
if os.path.exists(path):
try:
data = _load_json(path)
if isinstance(data, list):
return data
except Exception:
pass
return list(default_agents)
def save_agents_state(path: str, agents: list):
"""Persist agents list to path."""
_save_json(path, agents)
def load_asset_positions(path: str) -> dict:
"""Load asset positions map from path; return {} if missing or invalid."""
if os.path.exists(path):
try:
data = _load_json(path)
if isinstance(data, dict):
return data
except Exception:
pass
return {}
def save_asset_positions(path: str, data: dict):
"""Persist asset positions to path."""
_save_json(path, data)
def load_asset_defaults(path: str) -> dict:
"""Load asset defaults map from path; return {} if missing or invalid."""
if os.path.exists(path):
try:
data = _load_json(path)
if isinstance(data, dict):
return data
except Exception:
pass
return {}
def save_asset_defaults(path: str, data: dict):
"""Persist asset defaults to path."""
_save_json(path, data)
def _normalize_user_model(model_name: str) -> str:
"""Map provider model names to canonical user-facing options (nanobanana-pro / nanobanana-2)."""
m = (model_name or "").strip().lower()
if m in {"nanobanana-pro", "nanobanana-2"}:
return m
if m in {"nano-banana-pro-preview", "gemini-3-pro-image-preview"}:
return "nanobanana-pro"
if m in {"gemini-2.5-flash-image", "gemini-2.0-flash-exp-image-generation"}:
return "nanobanana-2"
return "nanobanana-pro"
def load_runtime_config(path: str) -> dict:
"""Load runtime config (gemini_api_key, gemini_model) from env and optional JSON file."""
base = {
"gemini_api_key": os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY") or "",
"gemini_model": _normalize_user_model(os.getenv("GEMINI_MODEL") or "nanobanana-pro"),
}
if os.path.exists(path):
try:
data = _load_json(path)
if isinstance(data, dict):
base.update({k: data.get(k, base.get(k)) for k in ["gemini_api_key", "gemini_model"]})
base["gemini_model"] = _normalize_user_model(base.get("gemini_model") or "nanobanana-pro")
except Exception:
pass
return base
def save_runtime_config(path: str, data: dict):
"""Merge data into current runtime config and save to path; chmod 0o600 on path."""
cfg = load_runtime_config(path)
cfg.update(data or {})
_save_json(path, cfg)
try:
os.chmod(path, 0o600)
except Exception:
pass
def load_join_keys(path: str) -> dict:
"""Load join keys structure from path; return {'keys': []} if missing or invalid."""
if os.path.exists(path):
try:
data = _load_json(path)
if isinstance(data, dict) and isinstance(data.get("keys"), list):
return data
except Exception:
pass
return {"keys": []}
def save_join_keys(path: str, data: dict):
"""Persist join keys to path."""
_save_json(path, data)
================================================
FILE: convert_to_webp.py
================================================
#!/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()
================================================
FILE: desktop-pet/README.md
================================================
# Star Office Tauri Desktop Shell
这个目录用于把 `Star-Office-UI` 包成桌面应用(透明窗口),并在启动时自动拉起后端进程。
## 开发运行
先在仓库根目录准备 Python 环境:
```bash
cd /Users/wangzhaohan/Documents/GitHub/Star-Office-UI
uv venv .venv
uv pip install -r backend/requirements.txt --python .venv/bin/python
```
再启动 Tauri:
```bash
cd /Users/wangzhaohan/Documents/GitHub/Star-Office-UI/desktop-pet
npm install
npm run dev
```
## 自动拉起后端逻辑
- 优先使用:`../.venv/bin/python backend/app.py`
- 回退到:`python3 backend/app.py`
- 再回退到:`python backend/app.py`
窗口默认会跳转到:
- `http://127.0.0.1:19000/?desktop=1`
## 可选环境变量
- `STAR_PROJECT_ROOT`:项目根目录(默认会自动探测)
- `STAR_BACKEND_PYTHON`:自定义 Python 可执行路径
- `STAR_BACKEND_URL`:自定义桌面窗口打开的 URL
================================================
FILE: desktop-pet/STATE_API.md
================================================
# 桌宠状态对接说明(openclaw 用)
桌宠通过读取 **state.json** 获取当前状态并刷新表现(头顶图标/emoji、气泡文案、角色动画、寻路目标)。openclaw 需要**写入或更新**该文件以驱动桌宠。
---
## 1. 文件位置
- **路径**:与桌宠工作目录下的 `state.json`(桌宠启动时会解析项目根目录,即包含 `state.json` 和 `layers/` 的目录)。
- **格式**:UTF-8 JSON。
---
## 2. state.json 结构
```json
{
"state": "idle",
"detail": "可选,状态说明,目前仅用于展示/调试",
"progress": 0.0,
"updated_at": "2025-02-27T12:00:00Z"
}
```
| 字段 | 类型 | 必填 | 说明 |
|--------------|---------|------|------|
| `state` | string | 是 | 当前状态,见下表。桌宠每 ~2s 轮询读取。 |
| `detail` | string | 否 | 可选描述,可被后续扩展用于气泡或调试。 |
| `progress` | number | 否 | 0~1,可选进度,可被后续扩展。 |
| `updated_at` | string | 否 | ISO8601 时间,可选。 |
**只有 `state` 会影响桌宠行为**;其余字段可留空或省略。
---
## 3. 状态取值(openclaw 应写入的 `state`)
桌宠只认下面这些**标准状态名**(小写)。写别的值会被当成 `idle` 或按别名映射。
| state 值 | 含义 | 桌宠表现概要 |
|----------------|----------------|--------------|
| `idle` | 摸鱼/无任务 | 💤 呼吸动画,随机闲逛 |
| `writing` | 写作/记笔记 | Word 图标,走到 writing POI |
| `receiving` | 收消息 | Hangouts 图标,走到 receiving POI |
| `replying` | 回复消息 | Glovo 图标,走到 replying POI |
| `researching` | 调研/查资料 | Google 图标,走到 researching POI |
| `executing` | 执行任务/跑任务 | ⚡ emoji,走到 executing POI |
| `syncing` | 同步/备份 | ☁️ emoji,走到 syncing POI |
| `error` | 出错 | ❗ emoji,走到 error POI |
POI 在 `layers/map.json` 的 `pois` 里配置;状态变化时桌宠会寻路到对应格子。
---
## 4. 别名映射(可选)
若 openclaw 侧用不同名字,桌宠前端会先做一次**别名 → 标准状态**的映射,再按上表表现:
| openclaw 可写的 state | 映射为 |
|------------------------|--------|
| `working` | `writing` |
| `run` | `executing` |
| `running` | `executing` |
| `sync` | `syncing` |
| `research` | `researching` |
未在上述列表中的 `state` 会视为 `idle`。
---
## 5. openclaw 需要“跳”什么
- **写 state.json**:在约定目录下创建/覆盖 `state.json`,保证 `state` 为上面 8 个标准状态之一(或 5 个别名之一)。
- **何时写**:状态变化时写一次即可;桌宠轮询间隔约 2 秒,无需高频写入。
- **示例**
- 开始写文档:`{ "state": "writing" }`
- 收到消息:`{ "state": "receiving" }`
- 正在回复:`{ "state": "replying" }`
- 查资料:`{ "state": "researching" }`
- 执行任务:`{ "state": "executing" }`
- 同步中:`{ "state": "syncing" }`
- 出错:`{ "state": "error" }`
- 摸鱼/无任务:`{ "state": "idle" }`
按上述方式更新 `state.json`,即可与当前桌宠状态和 POI 行为一致。
================================================
FILE: desktop-pet/package.json
================================================
{
"name": "star-desktop-pet",
"private": true,
"version": "0.1.0",
"scripts": {
"dev": "STAR_PROJECT_ROOT=.. tauri dev",
"build": "STAR_PROJECT_ROOT=.. tauri build",
"tauri": "tauri"
},
"devDependencies": {
"@tauri-apps/cli": "^2"
}
}
================================================
FILE: desktop-pet/src/index.html
================================================
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<style>
@font-face {
font-family: 'ipix';
src: url('ipix.ttf') format('truetype');
}
* { margin: 0; padding: 0; }
html, body {
background: transparent;
overflow: hidden;
width: 100%;
height: 100%;
user-select: none;
-webkit-user-select: none;
font-family: 'ipix', monospace;
}
canvas { background: transparent !important; position: relative; }
#context-menu {
display: none;
position: fixed;
background: rgba(26, 26, 46, 0.95);
border: 2px solid #e94560;
border-radius: 6px;
padding: 4px 0;
z-index: 9999;
font-family: 'ipix', monospace;
font-size: 13px;
min-width: 110px;
backdrop-filter: blur(8px);
}
.menu-item { padding: 8px 16px; color: #eee; cursor: pointer; white-space: nowrap; }
.menu-item:hover { background: #e94560; }
.menu-sep { height: 1px; background: rgba(233,69,96,0.3); margin: 4px 8px; }
#bubble-layer {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
pointer-events: none;
overflow: visible;
z-index: 100;
}
.speech-bubble {
position: absolute;
background: rgba(255,255,255,0.95);
border: 2px solid #888;
border-radius: 8px;
padding: 6px 14px;
font-family: 'ipix', monospace;
font-size: 16px;
line-height: 1.3;
color: #333;
white-space: nowrap;
opacity: 0;
transition: opacity 0.2s;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.18));
}
.speech-bubble::after {
content: '';
position: absolute;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
width: 0; height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 8px solid #888;
}
.speech-bubble::before {
content: '';
position: absolute;
bottom: -6px;
left: 50%;
transform: translateX(-50%);
width: 0; height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 7px solid rgba(255,255,255,0.95);
z-index: 1;
}
</style>
</head>
<body>
<div id="bubble-layer"></div>
<div id="context-menu">
<div class="menu-item" data-action="info">🏷️ Star 桌宠</div>
<div class="menu-sep"></div>
<div class="menu-item" data-action="quit">❌ 退出</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/phaser@3.80.1/dist/phaser.min.js"></script>
<script>
(async function () {
/* ================================================================
§1 Tauri API
================================================================ */
const isTauri = !!window.__TAURI__;
const core = isTauri ? window.__TAURI__.core : null;
const winApi = isTauri ? window.__TAURI__.window : null;
const dpiApi = isTauri ? window.__TAURI__.dpi : null;
const appWindow = winApi ? winApi.getCurrentWindow() : null;
/* ================================================================
§2 Context menu & drag
================================================================ */
const ctxMenu = document.getElementById('context-menu');
document.addEventListener('contextmenu', e => {
e.preventDefault();
ctxMenu.style.display = 'block';
ctxMenu.style.left = Math.min(e.clientX, innerWidth - 120) + 'px';
ctxMenu.style.top = Math.min(e.clientY, innerHeight - 80) + 'px';
});
document.addEventListener('click', e => {
if (!e.target.closest('#context-menu')) ctxMenu.style.display = 'none';
});
document.querySelectorAll('.menu-item').forEach(el => {
el.addEventListener('click', () => {
ctxMenu.style.display = 'none';
if (el.dataset.action === 'quit' && appWindow) appWindow.close();
});
});
document.addEventListener('mousedown', e => {
if (e.button === 0 && !e.target.closest('#context-menu') && appWindow)
appWindow.startDragging();
});
/* ================================================================
§3 Load map config from Rust
================================================================ */
let map = null;
if (core) {
try { map = await core.invoke('load_map'); }
catch (e) { console.warn('load_map:', e); }
}
if (!map) {
document.body.innerHTML = '<p style="color:#fff;padding:20px">map.json not found</p>';
return;
}
const T = map.tile_size;
const COLS = map.cols;
const ROWS = map.rows;
const ZOOM = map.zoom;
const GW = COLS * T;
const GH = ROWS * T;
const BUBBLE_PAD = 40;
if (appWindow && dpiApi) {
try { await appWindow.setSize(new dpiApi.LogicalSize(GW * ZOOM, GH * ZOOM + BUBBLE_PAD)); }
catch (_) {}
}
/* ================================================================
§4 State definitions
================================================================ */
const SPECIAL = new Set([
'writing','receiving','replying','researching','executing','syncing','error'
]);
const NORM_MAP = {
working:'writing', run:'executing', running:'executing',
sync:'syncing', research:'researching'
};
const BUBBLE = {
idle: ['摸鱼中…','有没有新任务?','咖啡真好喝☕','伸个懒腰~'],
writing: ['这个要记下来','写得手酸','再检查一遍✍️'],
receiving: ['有人找我!','看看是什么','来消息了📨'],
replying: ['让我想想…','打字中…','这样回好了💬'],
researching: ['让我搜一下🔍','找到线索了','再深挖一点'],
executing: ['冲鸭!🦆','加油加油','马上搞定⚡'],
syncing: ['备份备份☁️','安全第一','同步中…'],
error: ['啊哦…','出问题了❗','马上修好🔧']
};
const EMOJI = {
idle:'💤', writing:'✏️', receiving:'📨', replying:'💬',
researching:'🔍', executing:'⚡', syncing:'☁️', error:'❗'
};
/* ================================================================
§5 A* pathfinding
================================================================ */
function astar(start, goal) {
const grid = map.collision;
const key = (r, c) => r * COLS + c;
const sk = key(start.row, start.col);
const gk = key(goal.row, goal.col);
if (grid[goal.row]?.[goal.col] !== 0) return null;
if (sk === gk) return [{ row: goal.row, col: goal.col }];
const open = new Set([sk]);
const from = new Map();
const gScore = new Map([[sk, 0]]);
const fScore = new Map([[sk, h(start, goal)]]);
const dirs = [[-1,0],[1,0],[0,-1],[0,1]];
while (open.size) {
let cur = -1, best = Infinity;
for (const k of open) {
const f = fScore.get(k) ?? Infinity;
if (f < best) { best = f; cur = k; }
}
if (cur === gk) {
const path = [];
let c = cur;
while (c !== undefined) {
path.unshift({ row: Math.floor(c / COLS), col: c % COLS });
c = from.get(c);
}
return path;
}
open.delete(cur);
const cr = Math.floor(cur / COLS), cc = cur % COLS;
for (const [dr, dc] of dirs) {
const nr = cr + dr, nc = cc + dc;
if (nr < 0 || nr >= ROWS || nc < 0 || nc >= COLS) continue;
if (grid[nr][nc] !== 0) continue;
const nk = key(nr, nc);
const tg = (gScore.get(cur) ?? Infinity) + 1;
if (tg < (gScore.get(nk) ?? Infinity)) {
from.set(nk, cur);
gScore.set(nk, tg);
fScore.set(nk, tg + h({ row: nr, col: nc }, goal));
open.add(nk);
}
}
}
return null;
}
function h(a, b) {
return Math.abs(a.row - b.row) + Math.abs(a.col - b.col);
}
/* ================================================================
§6 Game globals
================================================================ */
let game, star, stateEmoji, stateIcon, shadow;
let serverState = 'idle';
let charAnim = 'idle';
let charGridR, charGridC;
let path = null;
let pathIdx = 0;
let nextBubbleAt = 5000;
let lastFetch = 0;
const startPoi = map.pois.idle || { row: 5, col: 6 };
charGridR = startPoi.row;
charGridC = startPoi.col;
const SPEED = map.character_speed * T;
function tileX(c) { return c * T + T / 2; }
function tileY(r) { return r * T + T / 2; }
/* ================================================================
§7 Phaser game
================================================================ */
const phaserGame = new Phaser.Game({
type: Phaser.AUTO,
width: GW, height: GH,
zoom: ZOOM,
transparent: true,
pixelArt: true,
scene: { preload: preloadScene, create: createScene, update: updateScene }
});
phaserGame.events.on('ready', () => {
phaserGame.canvas.style.marginTop = BUBBLE_PAD + 'px';
});
/* ────────── preload ────────── */
function preloadScene() {
this.load.spritesheet('tiles', map.tileset_url, {
frameWidth: T, frameHeight: T
});
if (map.state_icons && typeof map.state_icons === 'object') {
for (const [state, dataUrl] of Object.entries(map.state_icons)) {
if (dataUrl) this.load.image('icon_' + state, dataUrl);
}
}
}
/* ────────── create ────────── */
function createScene() {
game = this;
/* ground layer (depth -100) */
for (let r = 0; r < ROWS; r++)
for (let c = 0; c < COLS; c++) {
const id = map.ground[r]?.[c] ?? -1;
if (id < 0) continue;
game.add.sprite(tileX(c), tileY(r), 'tiles', id).setDepth(-100);
}
/* border layer (topmost, depth 8000) */
if (map.border) {
for (let r = 0; r < ROWS; r++)
for (let c = 0; c < COLS; c++) {
const id = map.border[r]?.[c] ?? -1;
if (id < 0) continue;
game.add.sprite(tileX(c), tileY(r), 'tiles', id).setDepth(8000);
}
}
/* rug layer (depth -50, above ground/border, below objects & character) */
if (map.rug) {
for (let r = 0; r < ROWS; r++)
for (let c = 0; c < COLS; c++) {
const id = map.rug[r]?.[c] ?? -1;
if (id < 0) continue;
game.add.sprite(tileX(c), tileY(r), 'tiles', id).setDepth(-50);
}
}
/* objects layer (depth = row for Y-sorting) */
for (let r = 0; r < ROWS; r++)
for (let c = 0; c < COLS; c++) {
const id = map.objects[r]?.[c] ?? -1;
if (id < 0) continue;
game.add.sprite(tileX(c), tileY(r), 'tiles', id).setDepth(r * 10);
}
/* fallback character textures */
buildCharTextures();
buildCharAnims();
/* shadow */
shadow = game.add.ellipse(0, 0, T * 0.8, T * 0.3, 0x000000, 0.2).setDepth(-1);
/* character sprite */
const sx = tileX(charGridC), sy = tileY(charGridR);
star = game.add.sprite(sx, sy, 'cf0').setDepth(charGridR * 10 + 1);
star.play('idle');
/* state indicator: icon (from state_icons) or emoji fallback */
stateEmoji = game.add.text(sx + T * 0.6, sy - T * 0.7, '💤', {
font: `${Math.round(T * 0.55)}px sans-serif`
}).setOrigin(0.5).setDepth(9000);
const iconScale = (T * 1.1) / 24;
const firstIconKey = map.state_icons && Object.keys(map.state_icons)[0];
if (firstIconKey && game.textures.exists('icon_' + firstIconKey)) {
stateIcon = game.add.sprite(sx + T * 0.6, sy - T * 0.7, 'icon_' + firstIconKey)
.setOrigin(0.5).setDepth(9000).setScale(iconScale);
stateIcon.setData('baseScale', iconScale);
stateIcon.setVisible(false);
} else {
stateIcon = null;
}
fetchState();
}
/* ────────── update ────────── */
function updateScene(time, dt) {
if (time - lastFetch > 2000) { fetchState(); lastFetch = time; }
/* follow path */
if (path && pathIdx < path.length) {
const wp = path[pathIdx];
const tx = tileX(wp.col), ty = tileY(wp.row);
const dx = tx - star.x, dy = ty - star.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const step = SPEED * dt / 1000;
if (dist <= step + 0.5) {
star.x = tx;
star.y = ty;
charGridR = wp.row;
charGridC = wp.col;
pathIdx++;
} else {
star.x += (dx / dist) * step;
star.y += (dy / dist) * step;
}
/* pick move animation based on direction */
const anim = pickMoveAnim(dx, dy);
if (anim !== charAnim) { charAnim = anim; star.play(anim, true); }
} else {
/* arrived or no path — play state animation */
path = null;
const anim = SPECIAL.has(serverState) ? serverState : 'idle';
if (anim !== charAnim) { charAnim = anim; star.play(anim, true); }
/* idle wander */
if (serverState === 'idle' && Math.random() < 0.003) {
const nb = walkableNeighbor(charGridR, charGridC, 3);
if (nb) navigateTo(nb.row, nb.col);
}
}
/* Y-sort depth */
star.setDepth(Math.round(star.y / T) * 10 + 1);
/* walk wobble */
const wobble = path ? Math.sin(time / 120) * 0.4 : 0;
/* track shadow, state icon / emoji (follow + pulse), bubble */
shadow.setPosition(star.x, star.y + T * 0.55 + wobble * 0.3);
const stateX = star.x + T * 0.6;
const stateY = star.y - T * 0.7 + (path ? wobble * 0.25 : 0);
const pulse = 1 + 0.12 * Math.sin(time / 180);
stateEmoji.setPosition(stateX, stateY);
stateEmoji.setScale(pulse);
if (stateIcon) {
stateIcon.setPosition(stateX, stateY);
const baseScale = stateIcon.getData('baseScale') || (T * 1.1) / 24;
stateIcon.setScale(baseScale * pulse);
}
updateBubblePos();
/* bubble */
if (time > nextBubbleAt) {
showBubble();
nextBubbleAt = time + 6000 + Math.random() * 4000;
}
}
function pickMoveAnim(dx, dy) {
if (Math.abs(dx) >= Math.abs(dy))
return dx > 0 ? 'move_right' : 'move_left';
return dy > 0 ? 'move_down' : 'move_up';
}
function walkableNeighbor(r, c, radius) {
const tries = 10;
for (let i = 0; i < tries; i++) {
const nr = r + Math.round((Math.random() - 0.5) * radius * 2);
const nc = c + Math.round((Math.random() - 0.5) * radius * 2);
if (nr >= 0 && nr < ROWS && nc >= 0 && nc < COLS
&& map.collision[nr][nc] === 0 && (nr !== r || nc !== c))
return { row: nr, col: nc };
}
return null;
}
function navigateTo(row, col) {
const p = astar({ row: charGridR, col: charGridC }, { row, col });
if (p && p.length > 1) {
path = p.slice(1);
pathIdx = 0;
}
}
/* ================================================================
§8 Server state polling + POI navigation
================================================================ */
let prevServerState = 'idle';
async function fetchState() {
if (!core) return;
try {
const data = await core.invoke('read_state');
const raw = NORM_MAP[data.state] || data.state || 'idle';
serverState = SPECIAL.has(raw) ? raw : 'idle';
if (serverState !== prevServerState) {
prevServerState = serverState;
const iconKey = 'icon_' + serverState;
if (stateIcon && game.textures.exists(iconKey)) {
stateIcon.setTexture(iconKey).setVisible(true);
stateEmoji.setVisible(false);
} else {
if (stateIcon) stateIcon.setVisible(false);
stateEmoji.setVisible(true);
stateEmoji.setText(EMOJI[serverState] || '💤');
}
const poi = map.pois[serverState];
if (poi) navigateTo(poi.row, poi.col);
}
} catch (_) {}
}
/* ================================================================
§9 Character textures (16×16 fallback, 4 directions)
================================================================ */
function tex(key, fn) {
const g = game.make.graphics();
fn(g);
g.generateTexture(key, T, T);
g.destroy();
}
function buildCharTextures() {
tex('cf0', g => body(g, 'front', true));
tex('cf1', g => body(g, 'front', false));
tex('cfw0', g => { body(g, 'front', true); feet(g, 'v', 0); });
tex('cfw1', g => { body(g, 'front', true); feet(g, 'v', 1); });
tex('cb0', g => body(g, 'back'));
tex('cbw0', g => { body(g, 'back'); feet(g, 'v', 0); });
tex('cbw1', g => { body(g, 'back'); feet(g, 'v', 1); });
tex('cl0', g => body(g, 'left', true));
tex('clw0', g => { body(g, 'left', true); feet(g, 'h', 0); });
tex('clw1', g => { body(g, 'left', true); feet(g, 'h', 1); });
tex('cr0', g => body(g, 'right', true));
tex('crw0', g => { body(g, 'right', true); feet(g, 'h', 0); });
tex('crw1', g => { body(g, 'right', true); feet(g, 'h', 1); });
}
function body(g, dir, eyesOpen) {
const S = T;
const bx = Math.round(S * 0.15), by = Math.round(S * 0.1);
const bw = S - bx * 2, bh = Math.round(S * 0.8);
g.fillStyle(0xff6b35);
g.fillRect(bx, by, bw, bh);
g.fillStyle(0xffb347);
g.fillRect(bx + 1, by + 1, bw - 2, 1);
const ew = Math.max(2, Math.round(S * 0.18));
const eh = ew;
const ey = by + Math.round(bh * 0.28);
const pw = Math.max(1, Math.round(ew * 0.6));
switch (dir) {
case 'front': {
const e1x = bx + Math.round(bw * 0.18);
const e2x = bx + Math.round(bw * 0.55);
if (eyesOpen) {
g.fillStyle(0xffffff);
g.fillRect(e1x, ey, ew, eh);
g.fillRect(e2x, ey, ew, eh);
g.fillStyle(0x222222);
g.fillRect(e1x + 1, ey + 1, pw, pw);
g.fillRect(e2x + 1, ey + 1, pw, pw);
} else {
g.fillStyle(0x222222);
g.fillRect(e1x, ey + Math.round(eh / 2), ew, 1);
g.fillRect(e2x, ey + Math.round(eh / 2), ew, 1);
}
g.fillStyle(0xff8c69);
const mw = Math.max(2, Math.round(bw * 0.3));
g.fillRect(bx + Math.round((bw - mw) / 2), by + Math.round(bh * 0.7), mw, Math.max(1, Math.round(S * 0.1)));
break;
}
case 'back':
g.fillStyle(0xcc5522);
g.fillRect(bx + Math.round(bw * 0.25), by + Math.round(bh * 0.2), Math.round(bw * 0.5), 2);
break;
case 'left': {
const ex = bx + Math.round(bw * 0.12);
if (eyesOpen) {
g.fillStyle(0xffffff);
g.fillRect(ex, ey, ew, eh);
g.fillStyle(0x222222);
g.fillRect(ex, ey + 1, pw, pw);
} else {
g.fillStyle(0x222222);
g.fillRect(ex, ey + Math.round(eh / 2), ew, 1);
}
g.fillStyle(0xff8c69);
g.fillRect(bx, by + Math.round(bh * 0.7), Math.round(bw * 0.3), Math.max(1, Math.round(S * 0.1)));
break;
}
case 'right': {
const ex = bx + Math.round(bw * 0.55);
if (eyesOpen) {
g.fillStyle(0xffffff);
g.fillRect(ex, ey, ew, eh);
g.fillStyle(0x222222);
g.fillRect(ex + ew - pw, ey + 1, pw, pw);
} else {
g.fillStyle(0x222222);
g.fillRect(ex, ey + Math.round(eh / 2), ew, 1);
}
g.fillStyle(0xff8c69);
const mw = Math.round(bw * 0.3);
g.fillRect(bx + bw - mw, by + Math.round(bh * 0.7), mw, Math.max(1, Math.round(S * 0.1)));
break;
}
}
}
function feet(g, axis, frame) {
const S = T;
const bx = Math.round(S * 0.15);
const bw = S - bx * 2;
const fy = Math.round(S * 0.85);
const fw = Math.max(2, Math.round(bw * 0.25));
const fh = Math.max(1, Math.round(S * 0.1));
g.fillStyle(0xcc5522);
if (frame === 0) {
g.fillRect(bx + 1, fy, fw, fh);
} else {
g.fillRect(bx + bw - fw - 1, fy, fw, fh);
}
}
/* ================================================================
§10 Character animations
================================================================ */
function buildCharAnims() {
const defs = {
idle: { f: ['cf0','cf0','cf0','cf0','cf0','cf1'], r: 2 },
move_down: { f: ['cfw0','cf0','cfw1','cf0'], r: 6 },
move_up: { f: ['cbw0','cb0','cbw1','cb0'], r: 6 },
move_left: { f: ['clw0','cl0','clw1','cl0'], r: 6 },
move_right: { f: ['crw0','cr0','crw1','cr0'], r: 6 },
writing: { f: ['cf0','cf0','cf1','cf0'], r: 2 },
receiving: { f: ['cf0','cf1','cf0','cf1'], r: 3 },
replying: { f: ['cf0','cf0','cf0','cf1'], r: 2 },
researching:{ f: ['cf0','cf0','cf1','cf0'], r: 1.5 },
executing: { f: ['cf0','cf1','cf0','cf1'], r: 4 },
syncing: { f: ['cf0','cf0','cf0','cf1'], r: 1 },
error: { f: ['cf1','cf0','cf1','cf1'], r: 2 },
};
Object.entries(defs).forEach(([k, d]) => {
game.anims.create({
key: k,
frames: d.f.map(f => ({ key: f })),
frameRate: d.r,
repeat: -1
});
});
}
/* ================================================================
§11 Speech bubble (DOM-based, never clipped by canvas)
================================================================ */
const bubbleLayer = document.getElementById('bubble-layer');
let bubbleEl = null;
let bubbleTimer = null;
function showBubble() {
removeBubble();
const pool = BUBBLE[serverState] || BUBBLE.idle;
const text = pool[Math.floor(Math.random() * pool.length)];
bubbleEl = document.createElement('div');
bubbleEl.className = 'speech-bubble';
bubbleEl.textContent = text;
bubbleLayer.appendChild(bubbleEl);
updateBubblePos();
requestAnimationFrame(() => { if (bubbleEl) bubbleEl.style.opacity = '1'; });
bubbleTimer = setTimeout(() => {
if (bubbleEl) bubbleEl.style.opacity = '0';
setTimeout(removeBubble, 300);
}, 3500);
}
function removeBubble() {
if (bubbleTimer) { clearTimeout(bubbleTimer); bubbleTimer = null; }
if (bubbleEl) { bubbleEl.remove(); bubbleEl = null; }
}
function updateBubblePos() {
if (!bubbleEl || !star) return;
const winW = window.innerWidth;
const bw = bubbleEl.offsetWidth || 80;
const bh = bubbleEl.offsetHeight || 30;
const cx = star.x * ZOOM;
const cy = star.y * ZOOM + BUBBLE_PAD;
let x = cx - bw / 2;
let y = cy - T * ZOOM * 0.8 - bh;
x = Math.max(4, Math.min(x, winW - bw - 4));
y = Math.max(2, y);
bubbleEl.style.left = x + 'px';
bubbleEl.style.top = y + 'px';
}
})();
</script>
</body>
</html>
================================================
FILE: desktop-pet/src/minimized.html
================================================
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Star Mini</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root { --mini-status-sprite-gap: 0px; }
html, body {
width: 100%;
height: 100%;
background: transparent;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
font-family: Arial, sans-serif;
}
body.electron-shell,
body.electron-shell #wrap,
body.electron-shell #pet-box {
-webkit-app-region: drag;
}
body.electron-shell #status-pill,
body.electron-shell #pet-canvas {
-webkit-app-region: no-drag;
}
#wrap {
width: 100%;
height: 100%;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding-top: 8px;
gap: var(--mini-status-sprite-gap);
}
#status-pill {
max-width: 95%;
padding: 6px 10px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.72);
color: #eee;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
#pet-box {
width: 180px;
height: 180px;
border-radius: 6px;
background: transparent;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 0.14s ease, filter 0.18s ease;
}
#pet-canvas {
width: 140px;
height: 140px;
image-rendering: pixelated;
pointer-events: none;
transform: scale(1);
filter: drop-shadow(0 0 0 rgba(250, 244, 207, 0));
transition: transform 0.16s ease, filter 0.2s ease;
}
#pet-box:hover {
transform: translateY(-2px);
filter: brightness(1.04);
}
#pet-box:hover #pet-canvas {
filter: drop-shadow(0 0 8px rgba(250, 244, 207, 0.2));
}
#pet-box:active {
transform: translateY(0);
filter: brightness(0.98);
}
#hint { display: none; }
</style>
</head>
<body>
<div id="wrap">
<div id="status-pill">加载中...</div>
<div id="pet-box" title="点击恢复主窗口">
<canvas id="pet-canvas" width="140" height="140" aria-label="Star"></canvas>
</div>
<div id="hint">点击形象恢复主窗口</div>
</div>
<script>
(async function () {
const isTauri = !!window.__TAURI__;
const isElectron = !!window.__ELECTRON__;
const core = isTauri ? window.__TAURI__.core : null;
const eventApi = isTauri ? window.__TAURI__.event : null;
const win = isTauri ? window.__TAURI__.window.getCurrentWindow() : null;
const status = document.getElementById('status-pill');
const petCanvas = document.getElementById('pet-canvas');
const petCtx = petCanvas && petCanvas.getContext ? petCanvas.getContext('2d') : null;
if (isElectron) document.body.classList.add('electron-shell');
const BASE_URL = 'http://127.0.0.1:19000';
const STATIC_URL = `${BASE_URL}/static/`;
let uiLang = 'en';
const I18N = {
zh: { stateIdle: '待命', stateWriting: '整理文档', stateResearching: '搜索信息', stateExecuting: '执行任务', stateSyncing: '同步备份', stateError: '出错了', fallbackIdleDetail: '待命', connecting: '连接中...' },
en: { stateIdle: 'Standby', stateWriting: 'Organizing Docs', stateResearching: 'Researching', stateExecuting: 'Executing Tasks', stateSyncing: 'Syncing Backup', stateError: 'Error', fallbackIdleDetail: 'Standby', connecting: 'Connecting...' },
ja: { stateIdle: '待機', stateWriting: '文書整理', stateResearching: '情報検索', stateExecuting: 'タスク実行', stateSyncing: '同期バックアップ', stateError: 'エラー発生', fallbackIdleDetail: '待機', connecting: '接続中...' }
};
const t = (key) => ((I18N[uiLang] && I18N[uiLang][key]) || key);
// Keep exactly aligned with main page asset names.
const PET_ASSET_PATHS = {
idle: 'star-idle-v5.png',
working: 'star-working-spritesheet-grid.webp',
syncing: 'sync-animation-v3-grid.webp',
error: 'error-bug-spritesheet-grid.webp'
};
const PET_FRAME_CONFIG = {
idle: { frameW: 256, frameH: 256, fps: 12 },
working: { frameW: 300, frameH: 300, fps: 12 },
syncing: { frameW: 256, frameH: 256, fps: 12 },
error: { frameW: 220, frameH: 220, fps: 12 }
};
// Align perceived sprite size with main page defaults.
const PET_SCALE = { idle: 1.2, working: 1.4, syncing: 1.2, error: 1.2 };
let assetRefreshTick = Date.now();
let lastAssetKey = null;
let currentSpriteSrc = '';
let spriteImage = null;
let spriteMeta = { frameW: 1, frameH: 1, fps: 1, start: 0, end: 0, frames: 1 };
let currentFrame = 0;
let lastFrameAt = 0;
let rafId = null;
window.__miniLastState = { state: 'idle' };
function normalizeLang(rawLang) {
const v = String(rawLang || '').toLowerCase();
if (v === 'zh' || v === 'en' || v === 'ja') return v;
return 'en';
}
function normalizeState(state) {
if (!state) return 'idle';
if (state === 'working' || state === 'writing' || state === 'researching' || state === 'executing') return 'working';
if (state === 'sync' || state === 'syncing') return 'syncing';
if (state === 'error') return 'error';
return 'idle';
}
function stateLabel(rawState) {
const s = String(rawState || '').toLowerCase();
if (s === 'writing' || s === 'working') return t('stateWriting');
if (s === 'researching') return t('stateResearching');
if (s === 'executing' || s === 'run' || s === 'running') return t('stateExecuting');
if (s === 'syncing' || s === 'sync') return t('stateSyncing');
if (s === 'error') return t('stateError');
return t('stateIdle');
}
function buildPetSrcByState(rawState) {
const key = normalizeState(rawState);
const rel = PET_ASSET_PATHS[key] || PET_ASSET_PATHS.idle;
return { key, src: `${STATIC_URL}${rel}?v=${assetRefreshTick}` };
}
function resolveFrameRangeByState(stateKey, totalFrames) {
const maxIdx = Math.max(0, totalFrames - 1);
if (stateKey === 'working') {
return { start: 0, end: Math.min(37, maxIdx) };
}
if (stateKey === 'error') {
return { start: 0, end: Math.min(71, maxIdx) };
}
if (stateKey === 'syncing') {
if (totalFrames >= 3) {
const start = 1;
const end = Math.max(start, totalFrames - 2);
return { start, end };
}
return { start: 0, end: 0 };
}
// idle: use full available frames
return { start: 0, end: maxIdx };
}
function drawFrame() {
if (!petCtx || !spriteImage || !spriteMeta.frames) return;
const cols = Math.max(1, Math.floor(spriteImage.naturalWidth / spriteMeta.frameW));
const frame = spriteMeta.start + (currentFrame % spriteMeta.frames);
const sx = (frame % cols) * spriteMeta.frameW;
const sy = Math.floor(frame / cols) * spriteMeta.frameH;
petCtx.clearRect(0, 0, petCanvas.width, petCanvas.height);
petCtx.imageSmoothingEnabled = false;
petCtx.drawImage(
spriteImage,
sx, sy, spriteMeta.frameW, spriteMeta.frameH,
0, 0, petCanvas.width, petCanvas.height
);
}
function stopSpriteLoop() {
if (rafId) cancelAnimationFrame(rafId);
rafId = null;
}
function startSpriteLoop() {
stopSpriteLoop();
const tick = (ts) => {
if (!spriteImage || !spriteMeta.frames) return;
const interval = 1000 / Math.max(1, spriteMeta.fps || 1);
if (!lastFrameAt || ts - lastFrameAt >= interval) {
currentFrame = (currentFrame + 1) % Math.max(1, spriteMeta.frames);
drawFrame();
lastFrameAt = ts;
}
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
}
function loadSpriteByState(rawState) {
const next = buildPetSrcByState(rawState);
const cfg = PET_FRAME_CONFIG[next.key] || PET_FRAME_CONFIG.idle;
if (currentSpriteSrc === next.src && lastAssetKey === next.key) return;
const img = new Image();
img.onload = () => {
spriteImage = img;
const cols = Math.max(1, Math.floor(img.naturalWidth / cfg.frameW));
const rows = Math.max(1, Math.floor(img.naturalHeight / cfg.frameH));
const totalFrames = Math.max(1, cols * rows);
const range = resolveFrameRangeByState(next.key, totalFrames);
const frames = Math.max(1, range.end - range.start + 1);
spriteMeta = {
frameW: cfg.frameW,
frameH: cfg.frameH,
fps: cfg.fps,
start: range.start,
end: range.end,
frames
};
currentFrame = 0;
lastFrameAt = 0;
drawFrame();
if (frames > 1) startSpriteLoop();
else stopSpriteLoop();
currentSpriteSrc = next.src;
lastAssetKey = next.key;
};
img.onerror = () => {
if (next.key !== 'idle') {
currentSpriteSrc = '';
lastAssetKey = null;
loadSpriteByState('idle');
}
};
img.src = next.src;
}
function applyState(data, instant = false) {
const payload = data || { state: 'idle' };
uiLang = normalizeLang(payload.ui_lang || uiLang);
window.__miniLastState = payload;
const state = payload.state || 'idle';
const detail = payload.detail || t('fallbackIdleDetail');
status.textContent = `[${stateLabel(state)}] ${detail}`;
loadSpriteByState(state);
const scale = PET_SCALE[normalizeState(state)] || 1;
if (instant) {
const prevTransition = petCanvas.style.transition;
petCanvas.style.transition = 'none';
petCanvas.style.transform = `scale(${scale})`;
requestAnimationFrame(() => {
petCanvas.style.transition = prevTransition || 'transform 0.16s ease, filter 0.2s ease';
});
} else {
petCanvas.style.transform = `scale(${scale})`;
}
}
async function fetchStatus() {
try {
if (core) {
const data = await core.invoke('read_state');
applyState(data);
return;
}
const resp = await fetch(`${BASE_URL}/status`, { cache: 'no-store' });
if (!resp.ok) throw new Error('bad status');
const data = await resp.json();
applyState(data);
} catch (_) {
status.textContent = t('connecting');
}
}
if (eventApi && eventApi.listen) {
try {
await eventApi.listen('mini-sync-state', (evt) => {
if (evt && evt.payload) applyState(evt.payload, true);
});
} catch (_) {}
}
let downAt = null;
let dragTriggered = false;
const DRAG_THRESHOLD = 6;
document.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
downAt = { x: e.clientX, y: e.clientY };
dragTriggered = false;
});
document.addEventListener('pointermove', async (e) => {
if (!downAt || dragTriggered || !win) return;
const moved = Math.hypot(e.clientX - downAt.x, e.clientY - downAt.y);
if (moved < DRAG_THRESHOLD) return;
dragTriggered = true;
try { await win.startDragging(); } catch (_) {}
});
document.addEventListener('pointerup', async () => {
if (!downAt) return;
const wasDrag = dragTriggered;
downAt = null;
dragTriggered = false;
if (!wasDrag && core) {
try { await core.invoke('restore_main_window'); } catch (_) {}
}
});
document.addEventListener('contextmenu', async (e) => {
e.preventDefault();
if (!core) return;
try { await core.invoke('close_app'); } catch (_) {}
});
await fetchStatus();
setInterval(fetchStatus, 2000);
})();
</script>
</body>
</html>
================================================
FILE: desktop-pet/src-tauri/Cargo.toml
================================================
[package]
name = "star-desktop-pet"
version = "0.1.0"
edition = "2021"
[lib]
name = "star_desktop_pet_lib"
crate-type = ["lib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["macos-private-api"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22"
================================================
FILE: desktop-pet/src-tauri/build.rs
================================================
fn main() {
tauri_build::build()
}
================================================
FILE: desktop-pet/src-tauri/capabilities/default.json
================================================
{
"identifier": "default",
"description": "Default capabilities for the desktop pet",
"windows": ["main", "mini"],
"remote": {
"urls": [
"http://127.0.0.1:*",
"http://localhost:*"
]
},
"permissions": [
"core:default",
"core:window:allow-start-dragging",
"core:window:allow-close",
"core:window:allow-set-size"
]
}
================================================
FILE: desktop-pet/src-tauri/gen/schemas/acl-manifests.json
================================================
{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-b
gitextract_u7eo87gg/ ├── .gitignore ├── LICENSE ├── README.en.md ├── README.ja.md ├── README.md ├── SKILL.md ├── agent-invite-template.txt ├── asset-defaults.json ├── asset-positions.json ├── backend/ │ ├── app.py │ ├── memo_utils.py │ ├── requirements.txt │ ├── run.sh │ ├── security_utils.py │ └── store_utils.py ├── convert_to_webp.py ├── desktop-pet/ │ ├── README.md │ ├── STATE_API.md │ ├── package.json │ ├── src/ │ │ ├── index.html │ │ └── minimized.html │ └── src-tauri/ │ ├── Cargo.toml │ ├── build.rs │ ├── capabilities/ │ │ └── default.json │ ├── gen/ │ │ └── schemas/ │ │ ├── acl-manifests.json │ │ ├── capabilities.json │ │ ├── desktop-schema.json │ │ └── macOS-schema.json │ ├── icons/ │ │ ├── android/ │ │ │ ├── mipmap-anydpi-v26/ │ │ │ │ └── ic_launcher.xml │ │ │ └── values/ │ │ │ └── ic_launcher_background.xml │ │ └── icon.icns │ ├── src/ │ │ ├── lib.rs │ │ └── main.rs │ └── tauri.conf.json ├── dist/ │ └── Star-Office-UI-release-20260302/ │ └── RELEASE_NOTES.md ├── docs/ │ ├── CHANGELOG_2026-03.md │ ├── FEATURES_NEW_2026-03-01.md │ ├── OPEN_SOURCE_RELEASE_CHECKLIST.md │ ├── PROJECT_MAINTENANCE_SOP.md │ ├── PROJECT_SUMMARY_2026-03-01.md │ ├── PR_DRAFT_2026-03-refresh.md │ ├── PR_FILELIST_2026-03-refresh.md │ ├── STAR_OFFICE_UI_OVERVIEW.md │ ├── UPDATE_REPORT_2026-03-04_P0_P1.md │ └── UPDATE_REPORT_2026-03-05.md ├── electron-shell/ │ ├── README.md │ ├── main.js │ ├── package.json │ ├── preload.js │ └── standalone-assets/ │ ├── game.js │ └── layout.js ├── frontend/ │ ├── electron-standalone.html │ ├── fonts/ │ │ └── OFL.txt │ ├── game.js │ ├── index.html │ ├── invite.html │ ├── join-office-skill.md │ ├── join.html │ ├── layout.js │ └── office-agent-push.py ├── gif_to_spritesheet.py ├── healthcheck.sh ├── join-keys.sample.json ├── office-agent-push.py ├── pyproject.toml ├── repack_star_working.py ├── resize_map.py ├── runtime-config.sample.json ├── scripts/ │ ├── gemini_image_generate.py │ ├── security_check.py │ └── smoke_test.py ├── set_state.py ├── state.sample.json └── webp_to_spritesheet.py
SYMBOL INDEX (248 symbols across 24 files)
FILE: backend/app.py
function _is_asset_editor_authed (line 117) | def _is_asset_editor_authed() -> bool:
function _require_asset_editor_auth (line 121) | def _require_asset_editor_auth():
function add_no_cache_headers (line 128) | def add_no_cache_headers(response):
function load_state (line 154) | def load_state():
function get_office_name_from_identity (line 204) | def get_office_name_from_identity():
function save_state (line 220) | def save_state(state: dict):
function ensure_electron_standalone_snapshot (line 226) | def ensure_electron_standalone_snapshot():
function index (line 252) | def index():
function electron_standalone_page (line 270) | def electron_standalone_page():
function join_page (line 288) | def join_page():
function invite_page (line 298) | def invite_page():
function load_agents_state (line 325) | def load_agents_state():
function save_agents_state (line 329) | def save_agents_state(agents):
function load_asset_positions (line 333) | def load_asset_positions():
function save_asset_positions (line 337) | def save_asset_positions(data):
function load_asset_defaults (line 341) | def load_asset_defaults():
function save_asset_defaults (line 345) | def save_asset_defaults(data):
function load_runtime_config (line 349) | def load_runtime_config():
function save_runtime_config (line 353) | def save_runtime_config(data):
function _ensure_home_favorites_index (line 357) | def _ensure_home_favorites_index():
function _load_home_favorites_index (line 364) | def _load_home_favorites_index():
function _save_home_favorites_index (line 376) | def _save_home_favorites_index(data):
function _maybe_apply_random_home_favorite (line 382) | def _maybe_apply_random_home_favorite():
function load_join_keys (line 420) | def load_join_keys():
function save_join_keys (line 424) | def save_join_keys(data):
function _ensure_magick_or_ffmpeg_available (line 428) | def _ensure_magick_or_ffmpeg_available():
function _probe_animated_frame_size (line 436) | def _probe_animated_frame_size(upload_path: str):
function _animated_to_spritesheet (line 464) | def _animated_to_spritesheet(
function normalize_agent_state (line 563) | def normalize_agent_state(s):
function _normalize_user_model (line 602) | def _normalize_user_model(model_name: str) -> str:
function _provider_model_candidates (line 614) | def _provider_model_candidates(user_model: str):
function _generate_rpg_background_to_webp (line 619) | def _generate_rpg_background_to_webp(out_webp_path: str, width: int = 12...
function state_to_area (line 811) | def state_to_area(state):
function get_agents (line 839) | def get_agents():
function agent_approve (line 892) | def agent_approve():
function agent_reject (line 916) | def agent_reject():
function join_agent (line 954) | def join_agent():
function leave_agent (line 1099) | def leave_agent():
function get_status (line 1147) | def get_status():
function agent_push (line 1157) | def agent_push():
function health (line 1235) | def health():
function get_yesterday_memo (line 1245) | def get_yesterday_memo():
function set_state_endpoint (line 1291) | def set_state_endpoint():
function assets_template_download (line 1312) | def assets_template_download():
function assets_list (line 1319) | def assets_list():
function _bg_generate_worker (line 1350) | def _bg_generate_worker(task_id: str, custom_prompt: str, speed_mode: str):
function assets_generate_rpg_background (line 1405) | def assets_generate_rpg_background():
function assets_generate_rpg_background_poll (line 1450) | def assets_generate_rpg_background_poll():
function assets_restore_reference_background (line 1479) | def assets_restore_reference_background():
function assets_restore_last_generated_background (line 1527) | def assets_restore_last_generated_background():
function assets_home_favorites_list (line 1567) | def assets_home_favorites_list():
function assets_home_favorites_file (line 1597) | def assets_home_favorites_file(filename):
function assets_home_favorites_save_current (line 1605) | def assets_home_favorites_save_current():
function assets_home_favorites_delete (line 1649) | def assets_home_favorites_delete():
function assets_home_favorites_apply (line 1681) | def assets_home_favorites_apply():
function assets_auth (line 1716) | def assets_auth():
function assets_auth_status (line 1729) | def assets_auth_status():
function assets_positions_get (line 1738) | def assets_positions_get():
function assets_positions_set (line 1749) | def assets_positions_set():
function assets_defaults_get (line 1778) | def assets_defaults_get():
function assets_defaults_set (line 1789) | def assets_defaults_set():
function gemini_config_get (line 1818) | def gemini_config_get():
function gemini_config_set (line 1837) | def gemini_config_set():
function assets_restore_default (line 1855) | def assets_restore_default():
function assets_restore_prev (line 1892) | def assets_restore_prev():
function assets_upload (line 1921) | def assets_upload():
FILE: backend/memo_utils.py
function get_yesterday_date_str (line 14) | def get_yesterday_date_str() -> str:
function sanitize_content (line 20) | def sanitize_content(text: str) -> str:
function extract_memo_from_file (line 33) | def extract_memo_from_file(file_path: str) -> str:
FILE: backend/security_utils.py
function is_production_mode (line 12) | def is_production_mode() -> bool:
function is_strong_secret (line 18) | def is_strong_secret(secret: str) -> bool:
function is_strong_drawer_pass (line 30) | def is_strong_drawer_pass(pwd: str) -> bool:
FILE: backend/store_utils.py
function _load_json (line 13) | def _load_json(path: str):
function _save_json (line 19) | def _save_json(path: str, data):
function load_agents_state (line 25) | def load_agents_state(path: str, default_agents: list) -> list:
function save_agents_state (line 37) | def save_agents_state(path: str, agents: list):
function load_asset_positions (line 42) | def load_asset_positions(path: str) -> dict:
function save_asset_positions (line 54) | def save_asset_positions(path: str, data: dict):
function load_asset_defaults (line 59) | def load_asset_defaults(path: str) -> dict:
function save_asset_defaults (line 71) | def save_asset_defaults(path: str, data: dict):
function _normalize_user_model (line 76) | def _normalize_user_model(model_name: str) -> str:
function load_runtime_config (line 88) | def load_runtime_config(path: str) -> dict:
function save_runtime_config (line 105) | def save_runtime_config(path: str, data: dict):
function load_join_keys (line 116) | def load_join_keys(path: str) -> dict:
function save_join_keys (line 128) | def save_join_keys(path: str, data: dict):
FILE: convert_to_webp.py
function convert_to_webp (line 36) | def convert_to_webp(input_path, output_path, lossless=True, quality=85):
function main (line 61) | def main():
FILE: desktop-pet/src-tauri/build.rs
function main (line 1) | fn main() {
FILE: desktop-pet/src-tauri/src/lib.rs
type PetState (line 16) | pub struct PetState {
type CfgFile (line 26) | struct CfgFile {
type CharCfg (line 35) | struct CharCfg {
type LayerCfg (line 44) | struct LayerCfg {
type SpritesCfg (line 54) | struct SpritesCfg {
type AnimCfg (line 61) | struct AnimCfg {
function neg_one (line 69) | fn neg_one() -> i32 {
type MapCfgFile (line 76) | struct MapCfgFile {
type PoiCfg (line 93) | struct PoiCfg {
type FullData (line 101) | struct FullData {
type CharData (line 110) | struct CharData {
type LayerItem (line 119) | struct LayerItem {
type SpritesData (line 129) | struct SpritesData {
type AnimItem (line 136) | struct AnimItem {
type MapData (line 145) | struct MapData {
type PoiOut (line 163) | struct PoiOut {
type AppPaths (line 170) | struct AppPaths {
type BackendProcess (line 175) | struct BackendProcess {
method drop (line 180) | fn drop(&mut self) {
function encode_image (line 188) | fn encode_image(path: &PathBuf) -> Result<String, String> {
function read_state_file (line 206) | fn read_state_file(state_path: &PathBuf) -> Result<PetState, String> {
function read_state_via_backend (line 212) | fn read_state_via_backend() -> Result<PetState, String> {
function read_state_with_fallback (line 235) | fn read_state_with_fallback(state_path: &PathBuf) -> Result<PetState, St...
function read_state (line 246) | fn read_state(paths: tauri::State<'_, Mutex<AppPaths>>) -> Result<PetSta...
function load_layers (line 252) | fn load_layers(paths: tauri::State<'_, Mutex<AppPaths>>) -> Result<FullD...
function load_map (line 334) | fn load_map(paths: tauri::State<'_, Mutex<AppPaths>>) -> Result<MapData,...
function png_width (line 393) | fn png_width(data: &[u8]) -> Option<u32> {
function find_project_root (line 402) | fn find_project_root() -> PathBuf {
function spawn_backend (line 443) | fn spawn_backend(root: &PathBuf) -> Option<Child> {
function wait_backend_ready (line 501) | fn wait_backend_ready() -> bool {
function enter_minimize_mode (line 513) | fn enter_minimize_mode(
function restore_main_window (line 545) | fn restore_main_window(app: tauri::AppHandle) -> Result<(), String> {
function close_app (line 560) | fn close_app(app: tauri::AppHandle) {
function open_external_url (line 565) | fn open_external_url(url: String) -> Result<(), String> {
function run (line 593) | pub fn run() {
FILE: desktop-pet/src-tauri/src/main.rs
function main (line 3) | fn main() {
FILE: electron-shell/main.js
constant APP_NAME (line 6) | const APP_NAME = "Star Office UI";
constant BACKEND_HOST (line 7) | const BACKEND_HOST = process.env.STAR_BACKEND_HOST || "127.0.0.1";
constant BACKEND_PORT (line 9) | const BACKEND_PORT = Number.isFinite(rawBackendPort) && rawBackendPort >...
constant BACKEND_BASE_URL (line 10) | const BACKEND_BASE_URL = `http://${BACKEND_HOST}:${BACKEND_PORT}`;
function sleep (line 20) | function sleep(ms) {
function tcpReachable (line 24) | function tcpReachable(host, port, timeoutMs = 500) {
function waitBackendReady (line 42) | async function waitBackendReady(timeoutMs = 20000) {
function findProjectRoot (line 52) | function findProjectRoot() {
function resolveAppIconPath (line 83) | function resolveAppIconPath(projectRoot) {
function applyAppIcon (line 96) | function applyAppIcon(projectRoot) {
function readStateFile (line 108) | function readStateFile(statePath) {
function readStateViaBackend (line 113) | function readStateViaBackend() {
function readStateWithFallback (line 144) | async function readStateWithFallback(projectRoot) {
function spawnBackend (line 153) | function spawnBackend(projectRoot) {
function ensureElectronStandaloneSnapshot (line 181) | function ensureElectronStandaloneSnapshot(projectRoot) {
function emitMini (line 194) | function emitMini(event, payload) {
function emitMain (line 199) | function emitMain(event, payload) {
function enterMiniMode (line 204) | async function enterMiniMode(projectRoot) {
function openFrontendAndQuit (line 221) | async function openFrontendAndQuit() {
function createAssetWindow (line 226) | function createAssetWindow(projectRoot) {
function createWindows (line 281) | function createWindows(projectRoot) {
function createTray (line 332) | function createTray(projectRoot) {
function registerIpc (line 380) | function registerIpc(projectRoot) {
function bootstrap (line 508) | async function bootstrap() {
FILE: electron-shell/preload.js
class LogicalSize (line 16) | class LogicalSize {
method constructor (line 17) | constructor(width, height) {
function ensureDragMoveHandlers (line 29) | function ensureDragMoveHandlers() {
FILE: electron-shell/standalone-assets/game.js
function checkWebPSupport (line 8) | function checkWebPSupport() {
function checkWebPSupportFallback (line 20) | function checkWebPSupportFallback() {
function getExt (line 30) | function getExt(pngFile) {
function loadMemo (line 57) | async function loadMemo() {
function updateLoadingProgress (line 78) | function updateLoadingProgress() {
function hideLoadingOverlay (line 90) | function hideLoadingOverlay() {
constant STATES (line 102) | const STATES = {
constant BUBBLE_TEXTS (line 111) | const BUBBLE_TEXTS = {
constant FETCH_INTERVAL (line 196) | const FETCH_INTERVAL = 2000;
constant BLINK_INTERVAL (line 197) | const BLINK_INTERVAL = 2500;
constant BUBBLE_INTERVAL (line 198) | const BUBBLE_INTERVAL = 8000;
constant CAT_BUBBLE_INTERVAL (line 199) | const CAT_BUBBLE_INTERVAL = 18000;
constant TYPEWRITER_DELAY (line 201) | const TYPEWRITER_DELAY = 50;
constant AGENTS_FETCH_INTERVAL (line 204) | const AGENTS_FETCH_INTERVAL = 2500;
constant AGENT_COLORS (line 207) | const AGENT_COLORS = {
constant NAME_TAG_COLORS (line 215) | const NAME_TAG_COLORS = {
constant AREA_POSITIONS (line 224) | const AREA_POSITIONS = {
function setState (line 246) | function setState(state, detail) {
function initGame (line 255) | async function initGame() {
function preload (line 270) | function preload() {
function create (line 312) | function create() {
function update (line 590) | function update(time) {
function normalizeState (line 663) | function normalizeState(s) {
function fetchStatus (line 672) | function fetchStatus() {
function moveStar (line 762) | function moveStar(time) {
function showBubble (line 840) | function showBubble() {
function showCatBubble (line 868) | function showCatBubble() {
function fetchAgents (line 883) | function fetchAgents() {
function getAreaPosition (line 910) | function getAreaPosition(area) {
function renderAgent (line 917) | function renderAgent(agent) {
FILE: electron-shell/standalone-assets/layout.js
constant LAYOUT (line 9) | const LAYOUT = {
FILE: frontend/game.js
function checkWebPSupport (line 8) | function checkWebPSupport() {
function checkWebPSupportFallback (line 20) | function checkWebPSupportFallback() {
function getExt (line 30) | function getExt(pngFile) {
function loadMemo (line 57) | async function loadMemo() {
function updateLoadingProgress (line 78) | function updateLoadingProgress() {
function hideLoadingOverlay (line 90) | function hideLoadingOverlay() {
constant STATES (line 102) | const STATES = {
constant BUBBLE_TEXTS (line 111) | const BUBBLE_TEXTS = {
constant FETCH_INTERVAL (line 196) | const FETCH_INTERVAL = 2000;
constant BLINK_INTERVAL (line 197) | const BLINK_INTERVAL = 2500;
constant BUBBLE_INTERVAL (line 198) | const BUBBLE_INTERVAL = 8000;
constant CAT_BUBBLE_INTERVAL (line 199) | const CAT_BUBBLE_INTERVAL = 18000;
constant TYPEWRITER_DELAY (line 201) | const TYPEWRITER_DELAY = 50;
constant AGENTS_FETCH_INTERVAL (line 204) | const AGENTS_FETCH_INTERVAL = 2500;
constant AGENT_COLORS (line 207) | const AGENT_COLORS = {
constant NAME_TAG_COLORS (line 215) | const NAME_TAG_COLORS = {
constant AREA_POSITIONS (line 224) | const AREA_POSITIONS = {
function setState (line 259) | function setState(state, detail) {
function initGame (line 268) | async function initGame() {
function preload (line 283) | function preload() {
function create (line 325) | function create() {
function update (line 621) | function update(time) {
function normalizeState (line 694) | function normalizeState(s) {
function fetchStatus (line 703) | function fetchStatus() {
function moveStar (line 793) | function moveStar(time) {
function showBubble (line 871) | function showBubble() {
function showCatBubble (line 899) | function showCatBubble() {
function fetchAgents (line 914) | function fetchAgents() {
function getAreaPosition (line 944) | function getAreaPosition(area, slotIndex) {
function renderAgent (line 950) | function renderAgent(agent) {
FILE: frontend/layout.js
constant LAYOUT (line 9) | const LAYOUT = {
FILE: frontend/office-agent-push.py
function load_local_state (line 53) | def load_local_state():
function save_local_state (line 68) | def save_local_state(data):
function normalize_state (line 73) | def normalize_state(s):
function map_detail_to_state (line 89) | def map_detail_to_state(detail, fallback_state="idle"):
function _state_age_seconds (line 105) | def _state_age_seconds(data):
function fetch_local_status (line 119) | def fetch_local_status():
function do_join (line 199) | def do_join(local):
function do_push (line 220) | def do_push(local, status_data):
function main (line 254) | def main():
FILE: gif_to_spritesheet.py
function gif_to_spritesheet (line 7) | def gif_to_spritesheet(gif_path, output_path, target_height=64):
FILE: office-agent-push.py
function load_local_state (line 61) | def load_local_state():
function save_local_state (line 76) | def save_local_state(data):
function normalize_state (line 81) | def normalize_state(s):
function map_detail_to_state (line 97) | def map_detail_to_state(detail, fallback_state="idle"):
function _state_age_seconds (line 113) | def _state_age_seconds(data):
function fetch_local_status (line 127) | def fetch_local_status():
function do_join (line 207) | def do_join(local):
function do_push (line 228) | def do_push(local, status_data):
function main (line 262) | def main():
FILE: repack_star_working.py
function main (line 36) | def main():
FILE: resize_map.py
function resize_map (line 6) | def resize_map(input_path, output_path, target_short_edge=600):
FILE: scripts/gemini_image_generate.py
function detect_mime (line 42) | def detect_mime(path: str) -> str:
function main (line 56) | def main():
FILE: scripts/security_check.py
function run (line 21) | def run(cmd: list[str]) -> tuple[int, str, str]:
function is_strong_secret (line 26) | def is_strong_secret(v: str) -> bool:
function is_strong_pass (line 39) | def is_strong_pass(v: str) -> bool:
function tracked_files (line 48) | def tracked_files() -> list[str]:
function file_has_secret_pattern (line 55) | def file_has_secret_pattern(path: Path) -> list[str]:
function main (line 73) | def main() -> int:
FILE: scripts/smoke_test.py
function req (line 30) | def req(method: str, url: str, body: dict | None = None, token: str = ""...
function main (line 51) | def main() -> int:
FILE: set_state.py
function load_state (line 31) | def load_state():
function save_state (line 42) | def save_state(state):
FILE: webp_to_spritesheet.py
function webp_to_spritesheet (line 14) | def webp_to_spritesheet(in_path: str, out_path: str, frame_w: int, frame...
function main (line 33) | def main():
Condensed preview — 74 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (1,198K chars).
[
{
"path": ".gitignore",
"chars": 558,
"preview": "# Python environment\n__pycache__/\n*.py[cod]\n*.pyo\n*.pyd\n*.egg-info/\n.venv/\nvenv/\n.env\n\n# OS/editor\n.DS_Store\n\n# Runtime "
},
{
"path": "LICENSE",
"chars": 2238,
"preview": "# Star Office UI — License & Usage Notice\n\nThis project is a co-created work by **Ring Hyacinth** and **Simon Lee**.\n\n##"
},
{
"path": "README.en.md",
"chars": 11391,
"preview": "# Star Office UI\n\n🌐 Language: [中文](./README.md) | **English** | [日本語](./README.ja.md)\n\n | [English](./README.en.md) | **日本語**\n\n | [日本語](./README.ja.md)\n\n:[请填入对方给你的 ocj_xxx]\n- 你想要在办公室里显示的名"
},
{
"path": "asset-defaults.json",
"chars": 133,
"preview": "{\n \"flowers-bloom-v2.webp\": {\n \"x\": 310.0,\n \"y\": 390.0,\n \"scale\": 0.8,\n \"updated_at\": \"2026-03-03T01:32:18."
},
{
"path": "asset-positions.json",
"chars": 107,
"preview": "{\n \"desk-v3.webp\": {\n \"x\": 218.0,\n \"y\": 417.0,\n \"updated_at\": \"2026-03-02T15:58:27.228023\"\n }\n}\n"
},
{
"path": "backend/app.py",
"chars": 78423,
"preview": "#!/usr/bin/env python3\n\"\"\"Star Office UI - Backend State Service\"\"\"\n\nfrom flask import Flask, jsonify, send_from_directo"
},
{
"path": "backend/memo_utils.py",
"chars": 3586,
"preview": "#!/usr/bin/env python3\n\"\"\"Memo extraction helpers for Star Office backend.\n\nReads and sanitizes daily memo content from "
},
{
"path": "backend/requirements.txt",
"chars": 28,
"preview": "flask==3.0.2\npillow==10.4.0\n"
},
{
"path": "backend/run.sh",
"chars": 316,
"preview": "#!/usr/bin/env bash\nset -euo pipefail\nROOT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")/..\" && pwd)\"\n\n# Auto-load project e"
},
{
"path": "backend/security_utils.py",
"chars": 1123,
"preview": "#!/usr/bin/env python3\n\"\"\"Security helper utilities for Star Office backend.\n\nProduction detection and validation for Fl"
},
{
"path": "backend/store_utils.py",
"chars": 4021,
"preview": "#!/usr/bin/env python3\n\"\"\"Storage helper utilities for Star Office backend.\n\nJSON load/save for agents state, asset posi"
},
{
"path": "convert_to_webp.py",
"chars": 3038,
"preview": "#!/usr/bin/env python3\n\"\"\"\n批量转换 PNG 资源为 WebP 格式\n- 精灵图使用无损转换\n- 背景图等使用有损转换(质量 85)\n\"\"\"\n\nimport os\nfrom PIL import Image\n\n# "
},
{
"path": "desktop-pet/README.md",
"chars": 680,
"preview": "# Star Office Tauri Desktop Shell\n\n这个目录用于把 `Star-Office-UI` 包成桌面应用(透明窗口),并在启动时自动拉起后端进程。\n\n## 开发运行\n\n先在仓库根目录准备 Python 环境:\n\n"
},
{
"path": "desktop-pet/STATE_API.md",
"chars": 2280,
"preview": "# 桌宠状态对接说明(openclaw 用)\n\n桌宠通过读取 **state.json** 获取当前状态并刷新表现(头顶图标/emoji、气泡文案、角色动画、寻路目标)。openclaw 需要**写入或更新**该文件以驱动桌宠。\n\n---\n"
},
{
"path": "desktop-pet/package.json",
"chars": 265,
"preview": "{\n \"name\": \"star-desktop-pet\",\n \"private\": true,\n \"version\": \"0.1.0\",\n \"scripts\": {\n \"dev\": \"STAR_PROJECT_ROOT=.."
},
{
"path": "desktop-pet/src/index.html",
"chars": 24973,
"preview": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n <meta charset=\"UTF-8\">\n <style>\n @font-face {\n font-"
},
{
"path": "desktop-pet/src/minimized.html",
"chars": 12413,
"preview": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-widt"
},
{
"path": "desktop-pet/src-tauri/Cargo.toml",
"chars": 356,
"preview": "[package]\nname = \"star-desktop-pet\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[lib]\nname = \"star_desktop_pet_lib\"\ncrate-type ="
},
{
"path": "desktop-pet/src-tauri/build.rs",
"chars": 39,
"preview": "fn main() {\n tauri_build::build()\n}\n"
},
{
"path": "desktop-pet/src-tauri/capabilities/default.json",
"chars": 364,
"preview": "{\n \"identifier\": \"default\",\n \"description\": \"Default capabilities for the desktop pet\",\n \"windows\": [\"main\", \"mini\"],"
},
{
"path": "desktop-pet/src-tauri/gen/schemas/acl-manifests.json",
"chars": 64079,
"preview": "{\"core\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"Default core plugins set.\",\"permissions\":[\"core:pat"
},
{
"path": "desktop-pet/src-tauri/gen/schemas/capabilities.json",
"chars": 315,
"preview": "{\"default\":{\"identifier\":\"default\",\"description\":\"Default capabilities for the desktop pet\",\"remote\":{\"urls\":[\"http://12"
},
{
"path": "desktop-pet/src-tauri/gen/schemas/desktop-schema.json",
"chars": 113239,
"preview": "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"title\": \"CapabilityFile\",\n \"description\": \"Capability form"
},
{
"path": "desktop-pet/src-tauri/gen/schemas/macOS-schema.json",
"chars": 113239,
"preview": "{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"title\": \"CapabilityFile\",\n \"description\": \"Capability form"
},
{
"path": "desktop-pet/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml",
"chars": 261,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<adaptive-icon xmlns:android=\"http://schemas.android.com/apk/res/android\">\n <for"
},
{
"path": "desktop-pet/src-tauri/icons/android/values/ic_launcher_background.xml",
"chars": 115,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n <color name=\"ic_launcher_background\">#fff</color>\n</resources>"
},
{
"path": "desktop-pet/src-tauri/src/lib.rs",
"chars": 17559,
"preview": "use base64::engine::general_purpose::STANDARD as B64;\nuse base64::Engine;\nuse serde::{Deserialize, Serialize};\nuse std::"
},
{
"path": "desktop-pet/src-tauri/src/main.rs",
"chars": 115,
"preview": "#![cfg_attr(not(debug_assertions), windows_subsystem = \"windows\")]\n\nfn main() {\n star_desktop_pet_lib::run();\n}\n"
},
{
"path": "desktop-pet/src-tauri/tauri.conf.json",
"chars": 757,
"preview": "{\n \"productName\": \"Star Desktop Pet\",\n \"version\": \"0.1.0\",\n \"identifier\": \"com.star.desktop-pet\",\n \"build\": { \"front"
},
{
"path": "dist/Star-Office-UI-release-20260302/RELEASE_NOTES.md",
"chars": 1240,
"preview": "# Star-Office-UI Release Notes (2026-03-02)\n\n## Summary\nThis package is a cleaned release snapshot for handoff/update.\nI"
},
{
"path": "docs/CHANGELOG_2026-03.md",
"chars": 1678,
"preview": "# CHANGELOG — 2026-03\n\n## 2026-03-06\n\n- 默认端口从 `18791` 调整为 `19000`,避开 OpenClaw Browser Control 端口冲突\n- 同步更新 `office-agent-"
},
{
"path": "docs/FEATURES_NEW_2026-03-01.md",
"chars": 790,
"preview": "# Star Office UI — 新增功能说明(本阶段)\n\n## 1. 多龙虾访客系统\n- 支持多个远端 OpenClaw 同时加入同一办公室。\n- 访客支持独立头像、名字、状态、区域、气泡。\n- 支持动态上下线与实时刷新。\n\n## 2"
},
{
"path": "docs/OPEN_SOURCE_RELEASE_CHECKLIST.md",
"chars": 1459,
"preview": "# Star Office UI — 开源发布准备清单(仅准备,不上传)\n\n## 0. 当前目标\n- 本文档用于“发布前准备”,不执行实际上传。\n- 所有 push 行为需海辛最终明确批准。\n\n## 1. 隐私与安全审查结果(当前仓库)\n\n"
},
{
"path": "docs/PROJECT_MAINTENANCE_SOP.md",
"chars": 3410,
"preview": "# Star-Office-UI 项目维护 SOP(轻量版)\n\n> 目标:让 Star-Office-UI 在继续增长的同时,保持仓库干净、回复友好、节奏稳定、社区感明确。\n\n---\n\n## 1. 总原则\n\n### 1.1 关闭 issue"
},
{
"path": "docs/PROJECT_SUMMARY_2026-03-01.md",
"chars": 1981,
"preview": "# Star Office UI — 项目阶段总结(2026-03-01)\n\n## 一、今日工作总结\n\n今天主要完成了两条主线:\n\n1. **多龙虾(多 OpenClaw)加入办公室能力稳定化**\n2. **手机版展示能力完善**\n\n并且围"
},
{
"path": "docs/PR_DRAFT_2026-03-refresh.md",
"chars": 3373,
"preview": "# PR Draft — Star Office UI March Refresh\n\n## Title\nfeat: asset editor + i18n + loading UX + sprite pipeline + security/"
},
{
"path": "docs/PR_FILELIST_2026-03-refresh.md",
"chars": 624,
"preview": "# PR File List — 2026-03 Refresh\n\n## Core code changes\n- `frontend/index.html`\n- `backend/app.py`\n- `frontend/vendor/pha"
},
{
"path": "docs/STAR_OFFICE_UI_OVERVIEW.md",
"chars": 1332,
"preview": "# Star Office UI — 功能说明(Overview)\n\nStar Office UI 是一个“像素办公室”可视化界面,用来把 AI 助手/多个 OpenClaw 访客的状态,渲染成可在网页(含手机)查看的小办公室场景。\n\n##"
},
{
"path": "docs/UPDATE_REPORT_2026-03-04_P0_P1.md",
"chars": 2071,
"preview": "# Star Office UI 更新文档(P0 / P1)\n\n更新时间:2026-03-04\n分支:`feat/office-art-rebuild`\n\n---\n\n## 1. 更新目标\n\n本轮更新目标分为两层:\n\n- **P0:安全与可发"
},
{
"path": "docs/UPDATE_REPORT_2026-03-05.md",
"chars": 3269,
"preview": "# 更新报告 — 2026-03-05\n\n> 本次更新覆盖 8 个 commit,聚焦「稳定性修复 + 移动端体验 + 安全收尾」。\n\n---\n\n## 变更概览\n\n| # | Commit | 分类 | 说明 |\n|---|--------"
},
{
"path": "electron-shell/README.md",
"chars": 656,
"preview": "# Star Desktop Pet (Electron Shell)\n\n这个目录是 Electron 版桌面壳,和现有 Tauri 版并行存在,方便逐步迁移。\n\n## 已接入能力\n\n- 复用原有前端:`http://127.0.0.1:1"
},
{
"path": "electron-shell/main.js",
"chars": 15874,
"preview": "const { app, BrowserWindow, Tray, Menu, ipcMain, shell, nativeImage } = require(\"electron\");\nconst { spawn } = require(\""
},
{
"path": "electron-shell/package.json",
"chars": 250,
"preview": "{\n \"name\": \"star-office-ui\",\n \"productName\": \"Star Office UI\",\n \"private\": true,\n \"version\": \"0.1.0\",\n \"main\": \"mai"
},
{
"path": "electron-shell/preload.js",
"chars": 2825,
"preview": "const { contextBridge, ipcRenderer } = require(\"electron\");\n\nconst listeners = new Map();\n\nipcRenderer.on(\"tauri:event\","
},
{
"path": "electron-shell/standalone-assets/game.js",
"chars": 32344,
"preview": "// Star Office UI - 游戏主逻辑\n// 依赖: layout.js(必须在这个之前加载)\n\n// 检测浏览器是否支持 WebP\nlet supportsWebP = false;\n\n// 方法 1: 使用 canvas 检"
},
{
"path": "electron-shell/standalone-assets/layout.js",
"chars": 2168,
"preview": "// Star Office UI - 布局与层级配置\n// 所有坐标、depth、资源路径统一管理在这里\n// 避免 magic numbers,降低改错风险\n\n// 核心规则:\n// - 透明资源(如办公桌)强制 .png,不透明优先 "
},
{
"path": "frontend/electron-standalone.html",
"chars": 267046,
"preview": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-wi"
},
{
"path": "frontend/fonts/OFL.txt",
"chars": 4485,
"preview": "Copyright (c) 2021, TakWolf (https://takwolf.com),\r\nwith Reserved Font Name \"Ark Pixel\".\r\n\r\nThis Font Software is licens"
},
{
"path": "frontend/game.js",
"chars": 33312,
"preview": "// Star Office UI - 游戏主逻辑\n// 依赖: layout.js(必须在这个之前加载)\n\n// 检测浏览器是否支持 WebP\nlet supportsWebP = false;\n\n// 方法 1: 使用 canvas 检"
},
{
"path": "frontend/index.html",
"chars": 227524,
"preview": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-wi"
},
{
"path": "frontend/invite.html",
"chars": 4494,
"preview": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-wi"
},
{
"path": "frontend/join-office-skill.md",
"chars": 1469,
"preview": "# Join Star Office - Visitor Agent Skill\n\n## Description\n接入海辛的像素办公室,让你的龙虾在看板上有一个工位,实时显示工作状态。\n\n## Prerequisites\n- 你需要一个接入"
},
{
"path": "frontend/join.html",
"chars": 6250,
"preview": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-wi"
},
{
"path": "frontend/layout.js",
"chars": 2168,
"preview": "// Star Office UI - 布局与层级配置\n// 所有坐标、depth、资源路径统一管理在这里\n// 避免 magic numbers,降低改错风险\n\n// 核心规则:\n// - 透明资源(如办公桌)强制 .png,不透明优先 "
},
{
"path": "frontend/office-agent-push.py",
"chars": 9333,
"preview": "#!/usr/bin/env python3\n\"\"\"\n海辛办公室 - Agent 状态主动推送脚本\n\n用法:\n1. 填入下面的 JOIN_KEY(你从海辛那里拿到的一次性 join key)\n2. 填入 AGENT_NAME(你想要在办公室"
},
{
"path": "gif_to_spritesheet.py",
"chars": 2373,
"preview": "#!/usr/bin/env python3\n\"\"\"Convert GIF animation to sprite sheet for Phaser\"\"\"\n\nfrom PIL import Image\nimport os\n\ndef gif_"
},
{
"path": "healthcheck.sh",
"chars": 673,
"preview": "#!/bin/bash\n# Star Office UI Health Check\n# Checks if backend is responding, restarts if not\n\nBACKEND_URL=\"http://127.0."
},
{
"path": "join-keys.sample.json",
"chars": 211,
"preview": "{\n \"keys\": [\n {\n \"key\": \"ocj_example_team_01\",\n \"used\": false,\n \"reusable\": true,\n \"maxConcurren"
},
{
"path": "office-agent-push.py",
"chars": 10372,
"preview": "#!/usr/bin/env python3\n\"\"\"\n海辛办公室 - Agent 状态主动推送脚本\n\n用法:\n1. 填入下面的 JOIN_KEY(你从海辛那里拿到的一次性 join key)\n2. 填入 AGENT_NAME(你想要在办公室"
},
{
"path": "pyproject.toml",
"chars": 194,
"preview": "[project]\nname = \"star-office-ui\"\nversion = \"0.1.0\"\ndescription = \"Star Office UI - Backend State Service\"\nreadme = \"REA"
},
{
"path": "repack_star_working.py",
"chars": 1851,
"preview": "#!/usr/bin/env python3\n\"\"\"Repack star-working spritesheet into a grid to fit GPU max texture sizes.\n\nProblem:\n- Current "
},
{
"path": "resize_map.py",
"chars": 1524,
"preview": "#!/usr/bin/env python3\n\"\"\"Resize office map by SHORT EDGE scaling (keep aspect ratio, no stretching/cropping)\"\"\"\n\nfrom P"
},
{
"path": "runtime-config.sample.json",
"chars": 82,
"preview": "{\n \"gemini_api_key\": \"YOUR_GEMINI_API_KEY\",\n \"gemini_model\": \"nanobanana-pro\"\n}\n"
},
{
"path": "scripts/gemini_image_generate.py",
"chars": 5530,
"preview": "#!/usr/bin/env python3\n\"\"\"Gemini Image Generate - CLI for Star Office UI background generation.\n\nCalls Google's Gemini A"
},
{
"path": "scripts/security_check.py",
"chars": 3701,
"preview": "#!/usr/bin/env python3\n\"\"\"Star Office UI security preflight checker (non-destructive).\n\nChecks:\n- weak/default secrets i"
},
{
"path": "scripts/smoke_test.py",
"chars": 2498,
"preview": "#!/usr/bin/env python3\n\"\"\"Star Office UI smoke test (non-destructive).\n\nUsage:\n python3 scripts/smoke_test.py --base-ur"
},
{
"path": "set_state.py",
"chars": 1996,
"preview": "#!/usr/bin/env python3\n\"\"\"Update Star Office UI state (for testing or agent-driven sync).\n\nFor automatic state sync from"
},
{
"path": "state.sample.json",
"chars": 104,
"preview": "{\n \"state\": \"idle\",\n \"detail\": \"Waiting...\",\n \"progress\": 0,\n \"updated_at\": \"2026-02-26T00:00:00\"\n}\n"
},
{
"path": "webp_to_spritesheet.py",
"chars": 1319,
"preview": "#!/usr/bin/env python3\n\"\"\"Convert an animated WebP to a horizontal spritesheet PNG.\n\nNotes:\n- Phaser's built-in loader d"
}
]
// ... and 1 more files (download for full content)
About this extraction
This page contains the full source code of the ringhyacinth/Star-Office-UI GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 74 files (1.1 MB), approximately 291.1k tokens, and a symbol index with 248 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.