Compare commits
170 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f29c107e97 | ||
|
|
915d7355ef | ||
|
|
383e1c84b0 | ||
|
|
dffc868920 | ||
|
|
d2e7af1957 | ||
|
|
88160bd755 | ||
|
|
f321ee6f52 | ||
|
|
4c356dc376 | ||
|
|
fb67ead389 | ||
|
|
c0b13ab047 | ||
|
|
e281a1909b | ||
|
|
171051af4f | ||
|
|
83cd40533c | ||
|
|
7c73340bd5 | ||
|
|
171d4495d8 | ||
|
|
e9a538298d | ||
|
|
61a5d5f101 | ||
|
|
e7e08b54e4 | ||
|
|
bb02d21e88 | ||
|
|
0f6a224fc2 | ||
|
|
e5f3fc9247 | ||
|
|
6da1dc9689 | ||
|
|
8ac5b51c3f | ||
|
|
a139bb05b6 | ||
|
|
a062ae3efe | ||
|
|
6f9187ef7f | ||
|
|
9d074036f2 | ||
|
|
ce821f78be | ||
|
|
eee8f7dc48 | ||
|
|
8a9557f3ad | ||
|
|
fb5efd4815 | ||
|
|
51172ee202 | ||
|
|
8569245b29 | ||
|
|
a89008241f | ||
|
|
87e473e108 | ||
|
|
82807581ef | ||
|
|
0a93306958 | ||
|
|
de1fed43a3 | ||
|
|
8d36ad5267 | ||
|
|
e03082629a | ||
|
|
5338bad92d | ||
|
|
ad9b1fbc31 | ||
|
|
f8a06be7f7 | ||
|
|
d9fe21efd2 | ||
|
|
6dc73b91e7 | ||
|
|
02a731e8af | ||
|
|
5aef4302b9 | ||
|
|
899f27e19f | ||
|
|
83e61ff8bf | ||
|
|
ee141de7cc | ||
|
|
103f944cf0 | ||
|
|
cc22403046 | ||
|
|
878793d74b | ||
|
|
53d33d6cdf | ||
|
|
038f9d1647 | ||
|
|
56b0abe367 | ||
|
|
40235a32aa | ||
|
|
b000b9bb08 | ||
|
|
7bd44d8119 | ||
|
|
2795c77796 | ||
|
|
bae7bd3408 | ||
|
|
b5ede6c939 | ||
|
|
4aa8ad420e | ||
|
|
e657fb9a62 | ||
|
|
dcb65f3f5d | ||
|
|
edab08dfbb | ||
|
|
9f44e4e0e9 | ||
|
|
cdb3eb544c | ||
|
|
f230ade809 | ||
|
|
19e82bb405 | ||
|
|
e888dc07b8 | ||
|
|
5ea06a2d06 | ||
|
|
627b29025a | ||
|
|
06c1dad038 | ||
|
|
9957e81c13 | ||
|
|
21387da895 | ||
|
|
823646cb26 | ||
|
|
a2e32e292a | ||
|
|
8a3cc03ef8 | ||
|
|
fbe71a3fdc | ||
|
|
c44d6a4547 | ||
|
|
be95dab467 | ||
|
|
4fcfc677aa | ||
|
|
112fbcf9e4 | ||
|
|
98ac66cd16 | ||
|
|
2ef00ce8c7 | ||
|
|
0b322aa4e1 | ||
|
|
35067c5b8b | ||
|
|
9f8c909c9e | ||
|
|
f8e8d1072f | ||
|
|
4994d22285 | ||
|
|
f6a30821ab | ||
|
|
fd25e8009a | ||
|
|
91ecba23d8 | ||
|
|
feac8ce057 | ||
|
|
ac9af695e3 | ||
|
|
d0f5880558 | ||
|
|
1f824c18a2 | ||
|
|
3369530b50 | ||
|
|
91aad3fa2a | ||
|
|
b4d0a1d0cc | ||
|
|
8a84151643 | ||
|
|
8aed9ab7d8 | ||
|
|
b3312def80 | ||
|
|
35014fb6da | ||
|
|
b89bccb775 | ||
|
|
c2e772c45c | ||
|
|
868e9ee0f8 | ||
|
|
78ea4c5692 | ||
|
|
d4daba8cef | ||
|
|
14ed0fc7a5 | ||
|
|
0fe0f159c7 | ||
|
|
258da2104c | ||
|
|
e3600ba2f8 | ||
|
|
81cd7c679b | ||
|
|
45179e66cb | ||
|
|
985631aaba | ||
|
|
6d08cb1390 | ||
|
|
87508b5797 | ||
|
|
886f2e60ac | ||
|
|
2a1f53d45e | ||
|
|
21402e7ce6 | ||
|
|
6605d1d9a2 | ||
|
|
87edb73027 | ||
|
|
4fe21ff515 | ||
|
|
df5bd3f631 | ||
|
|
450b6a8356 | ||
|
|
749e4ee6e1 | ||
|
|
1289a5d5ef | ||
|
|
ab95ae8028 | ||
|
|
d0981fec27 | ||
|
|
952638d879 | ||
|
|
c19d4acafa | ||
|
|
5c27c66287 | ||
|
|
6cb77af51a | ||
|
|
df39c93816 | ||
|
|
29edc8a076 | ||
|
|
22e9bcc91f | ||
|
|
388380e008 | ||
|
|
9fc5389486 | ||
|
|
971ab702f4 | ||
|
|
c604e80a3d | ||
|
|
999b3445c2 | ||
|
|
c0fefa0e6a | ||
|
|
dcf1876a37 | ||
|
|
9068c42e0b | ||
|
|
3a7d357253 | ||
|
|
1996365dc8 | ||
|
|
efb432409f | ||
|
|
01e6c3fa7e | ||
|
|
cf76f8556a | ||
|
|
474811083b | ||
|
|
24d3b6d45e | ||
|
|
63d7b9c9fb | ||
|
|
3f2671f760 | ||
|
|
b2091041d4 | ||
|
|
e6e31e841b | ||
|
|
084bf4dfaa | ||
|
|
04733eaed3 | ||
|
|
d7dcc64f8b | ||
|
|
4bcdd305e7 | ||
|
|
6a5c959206 | ||
|
|
7ad3323d40 | ||
|
|
a68d87af8f | ||
|
|
e3006ca9b3 | ||
|
|
b1e9ed912a | ||
|
|
03b5c5b030 | ||
|
|
5b3b7d4861 | ||
|
|
8a113a1447 | ||
|
|
26a03a4ee8 |
18
.env.example
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Star Office UI - production environment example
|
||||
# Copy to .env (or your systemd/pm2 env file), then fill values.
|
||||
|
||||
# Mark production mode to enable startup hardening checks
|
||||
STAR_OFFICE_ENV=production
|
||||
|
||||
# Flask/session secret (REQUIRED in production)
|
||||
# Must be long/random (>=24 chars)
|
||||
FLASK_SECRET_KEY=replace_with_a_long_random_secret
|
||||
|
||||
# Asset drawer password (REQUIRED in production)
|
||||
# Do NOT use 1234 in production. Recommend >=8 chars.
|
||||
ASSET_DRAWER_PASS=replace_with_strong_drawer_password
|
||||
|
||||
# Optional Gemini runtime defaults
|
||||
# You can also set these in runtime-config.json via UI
|
||||
GEMINI_API_KEY=
|
||||
GEMINI_MODEL=nanobanana-pro
|
||||
27
.gitignore
vendored
|
|
@ -1,4 +1,4 @@
|
|||
# Python
|
||||
# Python environment
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
|
|
@ -8,11 +8,32 @@ __pycache__/
|
|||
venv/
|
||||
.env
|
||||
|
||||
# OS
|
||||
# OS/editor
|
||||
.DS_Store
|
||||
|
||||
# Runtime / local files
|
||||
# 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
|
||||
|
|
|
|||
35
LICENSE
|
|
@ -1,6 +1,14 @@
|
|||
# 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
|
||||
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
|
||||
|
|
@ -19,3 +27,28 @@ 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.
|
||||
|
|
|
|||
294
README.en.md
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
# 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)
|
||||
294
README.ja.md
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
# 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)
|
||||
311
README.md
|
|
@ -1,88 +1,295 @@
|
|||
# Star Office UI
|
||||
|
||||
A tiny “pixel office” status UI for your AI assistant.
|
||||
🌐 Language: **中文** | [English](./README.en.md) | [日本語](./README.ja.md)
|
||||
|
||||
- Pixel office background (top-down)
|
||||
- A little character that moves between areas based on `state`
|
||||
- Optional speech bubble / typing effect
|
||||
- Mobile-friendly access via Cloudflare Tunnel quick tunnel
|
||||

|
||||
|
||||
> Language: the demo code/docs are currently mainly in Chinese (中文). PRs welcome.
|
||||
**一个像素风格的 AI 办公室看板** —— 把 AI 助手的工作状态实时可视化,让你直观看到"谁在做什么、昨天做了什么、现在是否在线"。
|
||||
|
||||
## What it looks like
|
||||
支持多 Agent 协作、中英日三语、AI 生图装修、桌面宠物模式。
|
||||
与 [OpenClaw](https://github.com/openclaw/openclaw) 深度集成时体验最佳,也可以独立部署作为状态看板使用。
|
||||
|
||||
- `idle / syncing / error` → breakroom area
|
||||
- `writing / researching / executing` → desk area
|
||||
> 本项目由 **[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,也感谢每一位贡献者的支持。
|
||||
|
||||
The UI polls `/status` and renders the assistant avatar accordingly.
|
||||
---
|
||||
|
||||
## Folder structure
|
||||
## ✨ 快速体验
|
||||
|
||||
```
|
||||
star-office-ui/
|
||||
backend/ # Flask backend (serves index + status)
|
||||
frontend/ # Phaser frontend + office_bg.png
|
||||
state.json # runtime status file
|
||||
set_state.py # helper to update state.json
|
||||
### 方式一:让龙虾帮你部署(推荐给 OpenClaw 用户)
|
||||
|
||||
如果你正在使用 [OpenClaw](https://github.com/openclaw/openclaw),直接把下面这句话发给你的龙虾:
|
||||
|
||||
```text
|
||||
请按照这个 SKILL.md 帮我完成 Star Office UI 的部署:
|
||||
https://github.com/ringhyacinth/Star-Office-UI/blob/master/SKILL.md
|
||||
```
|
||||
|
||||
## Requirements
|
||||
龙虾会自动完成 clone、安装依赖、启动后端、配置状态同步,并把访问地址发给你。
|
||||
|
||||
- Python 3.9+
|
||||
- Flask
|
||||
### 方式二:30 秒手动部署
|
||||
|
||||
## Quick start (local)
|
||||
|
||||
### 1) Install dependencies
|
||||
> **环境要求:Python 3.10+**(代码使用了 `X | Y` union type 语法,不支持 3.9 及更低版本)
|
||||
|
||||
```bash
|
||||
pip install flask
|
||||
# 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
|
||||
```
|
||||
|
||||
### 2) Put your background image
|
||||
|
||||
Put a **800×600 PNG** at:
|
||||
|
||||
```
|
||||
star-office-ui/frontend/office_bg.png
|
||||
```
|
||||
|
||||
### 3) Start backend
|
||||
打开 **http://127.0.0.1:19000** 然后试试切状态:
|
||||
|
||||
```bash
|
||||
cd star-office-ui/backend
|
||||
python app.py
|
||||
python3 set_state.py writing "正在整理文档"
|
||||
python3 set_state.py error "发现问题,排查中"
|
||||
python3 set_state.py idle "待命中"
|
||||
```
|
||||
|
||||
Then open:
|
||||

|
||||
|
||||
- http://127.0.0.1:18791
|
||||
---
|
||||
|
||||
### 4) Update status
|
||||
## 🤔 适合谁用?
|
||||
|
||||
From the project root:
|
||||
### 有 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
|
||||
python3 star-office-ui/set_state.py writing "Working on a task..."
|
||||
python3 star-office-ui/set_state.py idle "Standing by"
|
||||
cd Star-Office-UI
|
||||
python3 -m pip install -r backend/requirements.txt
|
||||
```
|
||||
|
||||
## Public access (Cloudflare quick tunnel)
|
||||
|
||||
Install `cloudflared`, then:
|
||||
### 2) 初始化状态文件
|
||||
|
||||
```bash
|
||||
cloudflared tunnel --url http://127.0.0.1:18791
|
||||
cp state.sample.json state.json
|
||||
```
|
||||
|
||||
You’ll get a `https://xxx.trycloudflare.com` URL.
|
||||
### 3) 启动后端
|
||||
|
||||
## Security notes
|
||||
```bash
|
||||
cd backend
|
||||
python3 app.py
|
||||
```
|
||||
|
||||
- Anyone with the tunnel URL can read `/status`.
|
||||
- Don’t put sensitive info in `detail`.
|
||||
- If needed, add a token check for `/status` (or only return coarse states).
|
||||
打开 `http://127.0.0.1:19000`
|
||||
|
||||
## License
|
||||
> ✅ 首次部署可以先保留默认配置;在生产环境中,请复制 `.env.example` 为 `.env` 并设置强随机的 `FLASK_SECRET_KEY` 与 `ASSET_DRAWER_PASS`,避免弱密码和会话泄露。
|
||||
|
||||
MIT
|
||||
### 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)
|
||||
|
|
|
|||
413
SKILL.md
|
|
@ -1,164 +1,295 @@
|
|||
---
|
||||
name: star-office-ui
|
||||
description: 为你的 AI 助手创建一个“像素办公室”可视化界面,手机可通过 Cloudflare Tunnel 公网访问!
|
||||
metadata:
|
||||
{
|
||||
"openclaw": { "emoji": "🏢", "title": "Star 像素办公室", "color": "#ff6b35" }
|
||||
}
|
||||
description: Star Office UI 一键化 Skill:帮主人快速部署像素办公室看板,支持多 Agent 加入、状态可视化、移动端查看与公网访问。
|
||||
---
|
||||
|
||||
# Star Office UI Skill
|
||||
|
||||
## 效果预览
|
||||
- 俯视像素办公室背景(可自己画/AI 生成/找素材)
|
||||
- 像素小人代表助手:会根据 `state` 在不同区域移动,并带眨眼/气泡/打字机等动态
|
||||
- 手机可通过 Cloudflare Tunnel quick tunnel 公网访问
|
||||
本 Skill 面向想快速帮主人搭建"像素办公室看板"的 OpenClaw(龙虾):
|
||||
- 目标:主人只需"看到效果",尽量少问问题
|
||||
- 你可以按下面的步骤,一步一步帮主人跑起来并对外访问
|
||||
|
||||
## 前置条件
|
||||
- 有一台能跑 Python 的服务器(或本地电脑)
|
||||
- 一张 800×600 的 PNG 办公室背景图(俯视像素风最佳)
|
||||
- 有 Python 3 + Flask
|
||||
- 有 Phaser CDN(前端直接用,无需安装)
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
## 0. 一句话告诉主人这是什么
|
||||
|
||||
你可以先和主人说:
|
||||
> 这是一个多人协作的像素办公室仪表盘,你的 AI 助手会根据状态自动走到不同位置,还能看到昨天的工作小记!
|
||||
|
||||
---
|
||||
|
||||
## 1. 30 秒一键启动(推荐你先做一遍)
|
||||
|
||||
在你这台机器执行(按顺序):
|
||||
|
||||
### 1. 准备目录
|
||||
```bash
|
||||
mkdir -p star-office-ui/backend star-office-ui/frontend
|
||||
# 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
|
||||
```
|
||||
|
||||
### 2. 准备背景图
|
||||
把你的办公室背景图放到 `star-office-ui/frontend/office_bg.png`
|
||||
然后告诉主人:
|
||||
> 好了,你现在打开 http://127.0.0.1:19000 就能看到像素办公室了!
|
||||
|
||||
### 3. 写后端 Flask app
|
||||
创建 `star-office-ui/backend/app.py`:
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
from flask import Flask, jsonify, send_from_directory
|
||||
from datetime import datetime
|
||||
import json
|
||||
import os
|
||||
---
|
||||
|
||||
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
FRONTEND_DIR = os.path.join(ROOT_DIR, "frontend")
|
||||
STATE_FILE = os.path.join(ROOT_DIR, "state.json")
|
||||
app = Flask(__name__, static_folder=FRONTEND_DIR, static_url_path="/static")
|
||||
## 2. 帮主人切状态体验一下
|
||||
|
||||
DEFAULT_STATE = {
|
||||
"state": "idle",
|
||||
"detail": "等待任务中...",
|
||||
"progress": 0,
|
||||
"updated_at": datetime.now().isoformat()
|
||||
}
|
||||
在项目根目录执行:
|
||||
|
||||
def load_state():
|
||||
if os.path.exists(STATE_FILE):
|
||||
try:
|
||||
with open(STATE_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return dict(DEFAULT_STATE)
|
||||
|
||||
def save_state(state):
|
||||
with open(STATE_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(state, f, ensure_ascii=False, indent=2)
|
||||
|
||||
if not os.path.exists(STATE_FILE):
|
||||
save_state(DEFAULT_STATE)
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return send_from_directory(FRONTEND_DIR, "index.html")
|
||||
|
||||
@app.route("/status")
|
||||
def get_status():
|
||||
return jsonify(load_state())
|
||||
|
||||
@app.route("/health")
|
||||
def health():
|
||||
return jsonify({"status": "ok", "timestamp": datetime.now().isoformat()})
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Listening on http://0.0.0.0:18791")
|
||||
app.run(host="0.0.0.0", port=18791, debug=False)
|
||||
```
|
||||
|
||||
### 4. 写前端 Phaser UI
|
||||
创建 `star-office-ui/frontend/index.html`(参考完整示例):
|
||||
- 用 `this.load.image('office_bg', '/static/office_bg.png')` 加载背景图
|
||||
- 用 `this.add.image(400, 300, 'office_bg')` 放背景
|
||||
- 状态区域映射:自己定义 workdesk/breakroom 的坐标
|
||||
- 加动态效果:眨眼/气泡/打字机/小踱步等
|
||||
|
||||
### 5. 写状态更新脚本
|
||||
创建 `star-office-ui/set_state.py`:
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
import json, os, sys
|
||||
from datetime import datetime
|
||||
STATE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "state.json")
|
||||
VALID_STATES = ["idle", "writing", "researching", "executing", "syncing", "error"]
|
||||
|
||||
def load_state():
|
||||
if os.path.exists(STATE_FILE):
|
||||
try:
|
||||
with open(STATE_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return {"state": "idle", "detail": "等待任务中...", "progress": 0, "updated_at": datetime.now().isoformat()}
|
||||
|
||||
def save_state(state):
|
||||
with open(STATE_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(state, f, ensure_ascii=False, indent=2)
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: python set_state.py <state> [detail]")
|
||||
sys.exit(1)
|
||||
s = sys.argv[1]
|
||||
if s not in VALID_STATES:
|
||||
print(f"有效状态: {', '.join(VALID_STATES)}")
|
||||
sys.exit(1)
|
||||
state = load_state()
|
||||
state["state"] = s
|
||||
state["detail"] = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||
state["updated_at"] = datetime.now().isoformat()
|
||||
save_state(state)
|
||||
print(f"状态已更新: {s} - {state['detail']}")
|
||||
```
|
||||
|
||||
### 6. 启动后端
|
||||
```bash
|
||||
cd star-office-ui/backend
|
||||
pip install flask
|
||||
python app.py
|
||||
# 工作中 → 去办公桌
|
||||
python3 set_state.py writing "正在帮你整理文档"
|
||||
|
||||
# 同步中
|
||||
python3 set_state.py syncing "同步进度中"
|
||||
|
||||
# 报错中 → 去 bug 区
|
||||
python3 set_state.py error "发现问题,正在排查"
|
||||
|
||||
# 待命 → 回休息区
|
||||
python3 set_state.py idle "待命中,随时准备为你服务"
|
||||
```
|
||||
|
||||
### 7. 开通 Cloudflare Tunnel(公网访问)
|
||||
- 下载 cloudflared:https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/tunnel-guide/local/#1-download-and-install-cloudflared
|
||||
- 启动 quick tunnel:
|
||||
```bash
|
||||
cloudflared tunnel --url http://127.0.0.1:18791
|
||||
```
|
||||
- 它会给你一个 `https://xxx.trycloudflare.com` 地址,手机就能打开了
|
||||
---
|
||||
|
||||
## 状态约定(可按需调整)
|
||||
- `idle / syncing / error` → 休息区(breakroom)
|
||||
- `writing / researching / executing` → 办公桌(workdesk)
|
||||
## 3. 侧边栏验证码设置(必须教会新龙虾)
|
||||
|
||||
## 安全注意事项
|
||||
- quick tunnel URL 可能会变,不保证 uptime(适合 demo)
|
||||
- 对外分享时:任何访问者都能看到 `state/detail`(detail 里不要写隐私)
|
||||
- 如需更强隐私:给 `/status` 加 token / 只返回模糊状态 / 不写 detail
|
||||
当前默认验证码是:`1234`。
|
||||
|
||||
## 动态效果(好实现版,开箱即用)
|
||||
- 同区域随机小踱步
|
||||
- 偶尔眨眼
|
||||
- 偶尔冒气泡(按状态随机短句)
|
||||
- 状态栏打字机效果
|
||||
- 走路轻微上下颠
|
||||
你需要这样引导主人:
|
||||
|
||||
## 完整示例仓库(可选)
|
||||
可直接复制这个项目的完整文件:`/root/.openclaw/workspace/star-office-ui/`(包含完整的前端 + 后端 + 状态脚本)
|
||||
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`
|
||||
|
|
|
|||
55
agent-invite-template.txt
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# 海辛办公室 · 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,过期后重新申请
|
||||
- 只推送状态,不推送任何具体内容/隐私
|
||||
8
asset-defaults.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"flowers-bloom-v2.webp": {
|
||||
"x": 310.0,
|
||||
"y": 390.0,
|
||||
"scale": 0.8,
|
||||
"updated_at": "2026-03-03T01:32:18.211712"
|
||||
}
|
||||
}
|
||||
7
asset-positions.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"desk-v3.webp": {
|
||||
"x": 218.0,
|
||||
"y": 417.0,
|
||||
"updated_at": "2026-03-02T15:58:27.228023"
|
||||
}
|
||||
}
|
||||
BIN
assets/room-reference.png
Normal file
|
After Width: | Height: | Size: 742 KiB |
BIN
assets/room-reference.webp
Normal file
|
After Width: | Height: | Size: 103 KiB |
2017
backend/app.py
116
backend/memo_utils.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
#!/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「往者不可谏,来者犹可追。」"
|
||||
2
backend/requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
flask==3.0.2
|
||||
pillow==10.4.0
|
||||
13
backend/run.sh
Executable file
|
|
@ -0,0 +1,13 @@
|
|||
#!/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"
|
||||
37
backend/security_utils.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
#!/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
|
||||
130
backend/store_utils.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
#!/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)
|
||||
115
convert_to_webp.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
批量转换 PNG 资源为 WebP 格式
|
||||
- 精灵图使用无损转换
|
||||
- 背景图等使用有损转换(质量 85)
|
||||
"""
|
||||
|
||||
import os
|
||||
from PIL import Image
|
||||
|
||||
# 路径
|
||||
FRONTEND_DIR = "/root/.openclaw/workspace/star-office-ui/frontend"
|
||||
STATIC_DIR = os.path.join(FRONTEND_DIR, "")
|
||||
|
||||
# 文件分类配置
|
||||
# 无损转换:精灵图、需要保持透明精度的
|
||||
LOSSLESS_FILES = [
|
||||
"star-idle-spritesheet.png",
|
||||
"star-researching-spritesheet.png",
|
||||
"star-working-spritesheet.png",
|
||||
"sofa-busy-spritesheet.png",
|
||||
"plants-spritesheet.png",
|
||||
"posters-spritesheet.png",
|
||||
"coffee-machine-spritesheet.png",
|
||||
"serverroom-spritesheet.png"
|
||||
]
|
||||
|
||||
# 有损转换:背景图等,质量 85
|
||||
LOSSY_FILES = [
|
||||
"office_bg.png",
|
||||
"sofa-idle.png",
|
||||
"desk.png"
|
||||
]
|
||||
|
||||
|
||||
def convert_to_webp(input_path, output_path, lossless=True, quality=85):
|
||||
"""转换单个文件为 WebP"""
|
||||
try:
|
||||
img = Image.open(input_path)
|
||||
|
||||
# 保存为 WebP
|
||||
if lossless:
|
||||
img.save(output_path, 'WebP', lossless=True, method=6)
|
||||
else:
|
||||
img.save(output_path, 'WebP', quality=quality, method=6)
|
||||
|
||||
# 计算文件大小
|
||||
orig_size = os.path.getsize(input_path)
|
||||
new_size = os.path.getsize(output_path)
|
||||
savings = (1 - new_size / orig_size) * 100
|
||||
|
||||
print(f"✅ {os.path.basename(input_path)} -> {os.path.basename(output_path)}")
|
||||
print(f" 原大小: {orig_size/1024:.1f}KB -> 新大小: {new_size/1024:.1f}KB (-{savings:.1f}%)")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ {os.path.basename(input_path)} 转换失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("PNG → WebP 批量转换工具")
|
||||
print("=" * 60)
|
||||
|
||||
# 检查目录
|
||||
if not os.path.exists(STATIC_DIR):
|
||||
print(f"❌ 目录不存在: {STATIC_DIR}")
|
||||
return
|
||||
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
|
||||
print("\n📁 开始转换...\n")
|
||||
|
||||
# 转换无损文件
|
||||
print("--- 无损转换(精灵图)---")
|
||||
for filename in LOSSLESS_FILES:
|
||||
input_path = os.path.join(STATIC_DIR, filename)
|
||||
if not os.path.exists(input_path):
|
||||
print(f"⚠️ 文件不存在,跳过: {filename}")
|
||||
continue
|
||||
|
||||
output_path = os.path.join(STATIC_DIR, filename.replace(".png", ".webp"))
|
||||
if convert_to_webp(input_path, output_path, lossless=True):
|
||||
success_count += 1
|
||||
else:
|
||||
fail_count += 1
|
||||
|
||||
# 转换有损文件
|
||||
print("\n--- 有损转换(背景图,质量 85)---")
|
||||
for filename in LOSSY_FILES:
|
||||
input_path = os.path.join(STATIC_DIR, filename)
|
||||
if not os.path.exists(input_path):
|
||||
print(f"⚠️ 文件不存在,跳过: {filename}")
|
||||
continue
|
||||
|
||||
output_path = os.path.join(STATIC_DIR, filename.replace(".png", ".webp"))
|
||||
if convert_to_webp(input_path, output_path, lossless=False, quality=85):
|
||||
success_count += 1
|
||||
else:
|
||||
fail_count += 1
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(f"转换完成!成功: {success_count}, 失败: {fail_count}")
|
||||
print("=" * 60)
|
||||
print("\n📝 注意:")
|
||||
print(" - PNG 原文件已保留,不会删除")
|
||||
print(" - 需要修改前端代码引用 .webp 文件")
|
||||
print(" - 如需回滚,只需把代码改回引用 .png 即可")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
37
desktop-pet/README.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# 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
|
||||
85
desktop-pet/STATE_API.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# 桌宠状态对接说明(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 行为一致。
|
||||
13
desktop-pet/package.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
4787
desktop-pet/src-tauri/Cargo.lock
generated
Normal file
17
desktop-pet/src-tauri/Cargo.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[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"
|
||||
3
desktop-pet/src-tauri/build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
17
desktop-pet/src-tauri/capabilities/default.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
1
desktop-pet/src-tauri/gen/schemas/acl-manifests.json
Normal file
1
desktop-pet/src-tauri/gen/schemas/capabilities.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"default":{"identifier":"default","description":"Default capabilities for the desktop pet","remote":{"urls":["http://127.0.0.1:*","http://localhost:*"]},"local":true,"windows":["main","mini"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-close","core:window:allow-set-size"]}}
|
||||
2244
desktop-pet/src-tauri/gen/schemas/desktop-schema.json
Normal file
2244
desktop-pet/src-tauri/gen/schemas/macOS-schema.json
Normal file
BIN
desktop-pet/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
desktop-pet/src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
desktop-pet/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
desktop-pet/src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
BIN
desktop-pet/src-tauri/icons/icon.icns
Normal file
BIN
desktop-pet/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
desktop-pet/src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 839 KiB |
641
desktop-pet/src-tauri/src/lib.rs
Normal file
|
|
@ -0,0 +1,641 @@
|
|||
use base64::engine::general_purpose::STANDARD as B64;
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
|
||||
|
||||
// ── state.json ──
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PetState {
|
||||
pub state: String,
|
||||
pub detail: Option<String>,
|
||||
pub progress: Option<f64>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
// ── layers.json input ──
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CfgFile {
|
||||
width: Option<u32>,
|
||||
height: Option<u32>,
|
||||
character: Option<CharCfg>,
|
||||
layers: Option<Vec<LayerCfg>>,
|
||||
sprites: Option<SpritesCfg>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CharCfg {
|
||||
x: Option<f64>,
|
||||
y: Option<f64>,
|
||||
scale: Option<f64>,
|
||||
depth: Option<i32>,
|
||||
wander: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LayerCfg {
|
||||
image: String,
|
||||
x: Option<f64>,
|
||||
y: Option<f64>,
|
||||
depth: Option<i32>,
|
||||
scale: Option<f64>,
|
||||
alpha: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SpritesCfg {
|
||||
frame_width: Option<u32>,
|
||||
frame_height: Option<u32>,
|
||||
anims: Option<HashMap<String, AnimCfg>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AnimCfg {
|
||||
file: String,
|
||||
frames: Option<u32>,
|
||||
rate: Option<u32>,
|
||||
#[serde(default = "neg_one")]
|
||||
repeat: i32,
|
||||
}
|
||||
|
||||
fn neg_one() -> i32 {
|
||||
-1
|
||||
}
|
||||
|
||||
// ── map.json input ──
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct MapCfgFile {
|
||||
tile_size: Option<u32>,
|
||||
cols: Option<u32>,
|
||||
rows: Option<u32>,
|
||||
zoom: Option<u32>,
|
||||
tileset: String,
|
||||
character_speed: Option<f64>,
|
||||
ground: Vec<Vec<i32>>,
|
||||
border: Option<Vec<Vec<i32>>>,
|
||||
rug: Option<Vec<Vec<i32>>>,
|
||||
objects: Vec<Vec<i32>>,
|
||||
collision: Vec<Vec<u8>>,
|
||||
pois: Option<HashMap<String, PoiCfg>>,
|
||||
state_icons: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PoiCfg {
|
||||
col: u32,
|
||||
row: u32,
|
||||
}
|
||||
|
||||
// ── IPC responses ──
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct FullData {
|
||||
width: u32,
|
||||
height: u32,
|
||||
character: CharData,
|
||||
layers: Vec<LayerItem>,
|
||||
sprites: Option<SpritesData>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CharData {
|
||||
x: f64,
|
||||
y: f64,
|
||||
scale: f64,
|
||||
depth: i32,
|
||||
wander: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct LayerItem {
|
||||
data_url: String,
|
||||
x: f64,
|
||||
y: f64,
|
||||
depth: i32,
|
||||
scale: f64,
|
||||
alpha: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SpritesData {
|
||||
frame_width: u32,
|
||||
frame_height: u32,
|
||||
anims: Vec<AnimItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AnimItem {
|
||||
key: String,
|
||||
data_url: String,
|
||||
frames: u32,
|
||||
rate: u32,
|
||||
repeat: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct MapData {
|
||||
tile_size: u32,
|
||||
cols: u32,
|
||||
rows: u32,
|
||||
zoom: u32,
|
||||
tileset_url: String,
|
||||
tileset_cols: u32,
|
||||
character_speed: f64,
|
||||
ground: Vec<Vec<i32>>,
|
||||
border: Vec<Vec<i32>>,
|
||||
rug: Vec<Vec<i32>>,
|
||||
objects: Vec<Vec<i32>>,
|
||||
collision: Vec<Vec<u8>>,
|
||||
pois: HashMap<String, PoiOut>,
|
||||
state_icons: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct PoiOut {
|
||||
col: u32,
|
||||
row: u32,
|
||||
}
|
||||
|
||||
// ── shared ──
|
||||
|
||||
struct AppPaths {
|
||||
state_path: PathBuf,
|
||||
layers_dir: PathBuf,
|
||||
}
|
||||
|
||||
struct BackendProcess {
|
||||
child: Option<Child>,
|
||||
}
|
||||
|
||||
impl Drop for BackendProcess {
|
||||
fn drop(&mut self) {
|
||||
if let Some(child) = &mut self.child {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_image(path: &PathBuf) -> Result<String, String> {
|
||||
let bytes = fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("png");
|
||||
let mime = match ext {
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
_ => "image/png",
|
||||
};
|
||||
Ok(format!("data:{mime};base64,{}", B64.encode(&bytes)))
|
||||
}
|
||||
|
||||
// ── commands ──
|
||||
|
||||
fn read_state_file(state_path: &PathBuf) -> Result<PetState, String> {
|
||||
let raw = fs::read_to_string(state_path)
|
||||
.map_err(|e| format!("{}: {e}", state_path.display()))?;
|
||||
serde_json::from_str(&raw).map_err(|e| format!("parse: {e}"))
|
||||
}
|
||||
|
||||
fn read_state_via_backend() -> Result<PetState, String> {
|
||||
let mut stream = std::net::TcpStream::connect("127.0.0.1:19000")
|
||||
.map_err(|e| format!("backend connect: {e}"))?;
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_millis(1200)));
|
||||
let _ = stream.set_write_timeout(Some(Duration::from_millis(1200)));
|
||||
|
||||
let request = b"GET /status HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n";
|
||||
stream
|
||||
.write_all(request)
|
||||
.map_err(|e| format!("backend write: {e}"))?;
|
||||
|
||||
let mut raw = String::new();
|
||||
stream
|
||||
.read_to_string(&mut raw)
|
||||
.map_err(|e| format!("backend read: {e}"))?;
|
||||
|
||||
let body = raw
|
||||
.split_once("\r\n\r\n")
|
||||
.map(|(_, b)| b)
|
||||
.ok_or_else(|| "backend response parse failed".to_string())?;
|
||||
serde_json::from_str(body).map_err(|e| format!("backend json parse: {e}"))
|
||||
}
|
||||
|
||||
fn read_state_with_fallback(state_path: &PathBuf) -> Result<PetState, String> {
|
||||
match read_state_file(state_path) {
|
||||
Ok(state) => Ok(state),
|
||||
Err(file_err) => {
|
||||
eprintln!("⚠️ read state file failed, fallback to backend: {file_err}");
|
||||
read_state_via_backend()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn read_state(paths: tauri::State<'_, Mutex<AppPaths>>) -> Result<PetState, String> {
|
||||
let p = paths.lock().map_err(|e| e.to_string())?;
|
||||
read_state_with_fallback(&p.state_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn load_layers(paths: tauri::State<'_, Mutex<AppPaths>>) -> Result<FullData, String> {
|
||||
let p = paths.lock().map_err(|e| e.to_string())?;
|
||||
let cfg_path = p.layers_dir.join("layers.json");
|
||||
|
||||
let cfg: CfgFile = if cfg_path.exists() {
|
||||
let raw = fs::read_to_string(&cfg_path).map_err(|e| format!("layers.json: {e}"))?;
|
||||
serde_json::from_str(&raw).map_err(|e| format!("layers.json: {e}"))?
|
||||
} else {
|
||||
CfgFile {
|
||||
width: None,
|
||||
height: None,
|
||||
character: None,
|
||||
layers: None,
|
||||
sprites: None,
|
||||
}
|
||||
};
|
||||
|
||||
let w = cfg.width.unwrap_or(200);
|
||||
let h = cfg.height.unwrap_or(250);
|
||||
let cc = cfg.character.unwrap_or(CharCfg {
|
||||
x: None, y: None, scale: None, depth: None, wander: None,
|
||||
});
|
||||
let character = CharData {
|
||||
x: cc.x.unwrap_or(w as f64 / 2.0),
|
||||
y: cc.y.unwrap_or(h as f64 * 0.66),
|
||||
scale: cc.scale.unwrap_or(2.5),
|
||||
depth: cc.depth.unwrap_or(0),
|
||||
wander: cc.wander.unwrap_or(18.0),
|
||||
};
|
||||
|
||||
let mut items = Vec::new();
|
||||
for entry in cfg.layers.unwrap_or_default() {
|
||||
let img_path = p.layers_dir.join(&entry.image);
|
||||
if !img_path.exists() {
|
||||
continue;
|
||||
}
|
||||
items.push(LayerItem {
|
||||
data_url: encode_image(&img_path)?,
|
||||
x: entry.x.unwrap_or(w as f64 / 2.0),
|
||||
y: entry.y.unwrap_or(h as f64 / 2.0),
|
||||
depth: entry.depth.unwrap_or(-1),
|
||||
scale: entry.scale.unwrap_or(1.0),
|
||||
alpha: entry.alpha.unwrap_or(1.0),
|
||||
});
|
||||
}
|
||||
|
||||
let sprites_data = if let Some(scfg) = cfg.sprites {
|
||||
let fw = scfg.frame_width.unwrap_or(32);
|
||||
let fh = scfg.frame_height.unwrap_or(32);
|
||||
let mut anims = Vec::new();
|
||||
for (key, acfg) in scfg.anims.unwrap_or_default() {
|
||||
let img_path = p.layers_dir.join(&acfg.file);
|
||||
if !img_path.exists() {
|
||||
continue;
|
||||
}
|
||||
anims.push(AnimItem {
|
||||
key,
|
||||
data_url: encode_image(&img_path)?,
|
||||
frames: acfg.frames.unwrap_or(1),
|
||||
rate: acfg.rate.unwrap_or(4),
|
||||
repeat: acfg.repeat,
|
||||
});
|
||||
}
|
||||
Some(SpritesData {
|
||||
frame_width: fw,
|
||||
frame_height: fh,
|
||||
anims,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(FullData {
|
||||
width: w,
|
||||
height: h,
|
||||
character,
|
||||
layers: items,
|
||||
sprites: sprites_data,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn load_map(paths: tauri::State<'_, Mutex<AppPaths>>) -> Result<MapData, String> {
|
||||
let p = paths.lock().map_err(|e| e.to_string())?;
|
||||
let map_path = p.layers_dir.join("map.json");
|
||||
|
||||
if !map_path.exists() {
|
||||
return Err("map.json not found".into());
|
||||
}
|
||||
|
||||
let raw = fs::read_to_string(&map_path).map_err(|e| format!("map.json: {e}"))?;
|
||||
let cfg: MapCfgFile = serde_json::from_str(&raw).map_err(|e| format!("map.json: {e}"))?;
|
||||
|
||||
let ts = cfg.tile_size.unwrap_or(16);
|
||||
let cols = cfg.cols.unwrap_or(cfg.ground.first().map_or(12, |r| r.len() as u32));
|
||||
let rows = cfg.rows.unwrap_or(cfg.ground.len() as u32);
|
||||
|
||||
let tileset_path = p.layers_dir.join(&cfg.tileset);
|
||||
if !tileset_path.exists() {
|
||||
return Err(format!("tileset not found: {}", cfg.tileset));
|
||||
}
|
||||
let tileset_url = encode_image(&tileset_path)?;
|
||||
|
||||
// figure out tileset column count from image width
|
||||
let img_bytes = fs::read(&tileset_path).map_err(|e| e.to_string())?;
|
||||
let tileset_cols = png_width(&img_bytes).unwrap_or(160) / ts;
|
||||
|
||||
let mut pois = HashMap::new();
|
||||
for (k, v) in cfg.pois.unwrap_or_default() {
|
||||
pois.insert(k, PoiOut { col: v.col, row: v.row });
|
||||
}
|
||||
|
||||
let icons_dir = p.layers_dir.join("Small (24x24) PNG");
|
||||
let mut state_icons = HashMap::new();
|
||||
for (state, filename) in cfg.state_icons.unwrap_or_default() {
|
||||
let path = icons_dir.join(&filename);
|
||||
if path.exists() {
|
||||
if let Ok(url) = encode_image(&path) {
|
||||
state_icons.insert(state, url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(MapData {
|
||||
tile_size: ts,
|
||||
cols,
|
||||
rows,
|
||||
zoom: cfg.zoom.unwrap_or(2),
|
||||
tileset_url,
|
||||
tileset_cols,
|
||||
character_speed: cfg.character_speed.unwrap_or(2.5),
|
||||
ground: cfg.ground,
|
||||
border: cfg.border.unwrap_or_default(),
|
||||
rug: cfg.rug.unwrap_or_default(),
|
||||
objects: cfg.objects,
|
||||
collision: cfg.collision,
|
||||
pois,
|
||||
state_icons,
|
||||
})
|
||||
}
|
||||
|
||||
fn png_width(data: &[u8]) -> Option<u32> {
|
||||
if data.len() < 24 || &data[0..4] != b"\x89PNG" {
|
||||
return None;
|
||||
}
|
||||
Some(u32::from_be_bytes([data[16], data[17], data[18], data[19]]))
|
||||
}
|
||||
|
||||
// ── bootstrap ──
|
||||
|
||||
fn find_project_root() -> PathBuf {
|
||||
if let Ok(p) = std::env::var("STAR_PROJECT_ROOT") {
|
||||
let candidate = PathBuf::from(&p);
|
||||
let abs = if candidate.is_absolute() {
|
||||
candidate
|
||||
} else {
|
||||
std::env::current_dir().unwrap_or_default().join(candidate)
|
||||
};
|
||||
if abs.join("backend").join("app.py").exists() {
|
||||
return abs;
|
||||
}
|
||||
}
|
||||
let mut dir = std::env::current_dir().unwrap_or_default();
|
||||
for _ in 0..8 {
|
||||
if dir.join("backend").join("app.py").exists()
|
||||
|| dir.join("state.json").exists()
|
||||
|| dir.join("state.sample.json").exists()
|
||||
{
|
||||
return dir;
|
||||
}
|
||||
if !dir.pop() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
let home = PathBuf::from(home);
|
||||
let candidates = [
|
||||
home.join("Documents").join("GitHub").join("Star-Office-UI"),
|
||||
home.join("GitHub").join("Star-Office-UI"),
|
||||
home.join("Documents").join("Star-Office-UI"),
|
||||
home.join("Star-Office-UI"),
|
||||
];
|
||||
for candidate in candidates {
|
||||
if candidate.join("backend").join("app.py").exists() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
std::env::current_dir().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn spawn_backend(root: &PathBuf) -> Option<Child> {
|
||||
if std::net::TcpStream::connect("127.0.0.1:19000").is_ok() {
|
||||
eprintln!("ℹ️ backend already running on 127.0.0.1:19000");
|
||||
return None;
|
||||
}
|
||||
|
||||
let script = root.join("backend").join("app.py");
|
||||
if !script.exists() {
|
||||
eprintln!("⚠️ backend/app.py not found: {}", script.display());
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut candidates: Vec<(PathBuf, Vec<String>)> = vec![
|
||||
(
|
||||
root.join(".venv").join("bin").join("python"),
|
||||
vec![script.to_string_lossy().to_string()],
|
||||
),
|
||||
(
|
||||
PathBuf::from("python3"),
|
||||
vec![script.to_string_lossy().to_string()],
|
||||
),
|
||||
(
|
||||
PathBuf::from("python"),
|
||||
vec![script.to_string_lossy().to_string()],
|
||||
),
|
||||
];
|
||||
|
||||
if let Ok(custom_python) = std::env::var("STAR_BACKEND_PYTHON") {
|
||||
candidates.insert(
|
||||
0,
|
||||
(
|
||||
PathBuf::from(custom_python),
|
||||
vec![script.to_string_lossy().to_string()],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (bin, args) in candidates {
|
||||
let mut cmd = Command::new(&bin);
|
||||
cmd.current_dir(root)
|
||||
.args(&args)
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit());
|
||||
|
||||
match cmd.spawn() {
|
||||
Ok(child) => {
|
||||
eprintln!("🚀 backend started with {}", bin.display());
|
||||
return Some(child);
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("⚠️ failed to spawn {}: {}", bin.display(), err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn wait_backend_ready() -> bool {
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
while Instant::now() < deadline {
|
||||
if std::net::TcpStream::connect("127.0.0.1:19000").is_ok() {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn enter_minimize_mode(
|
||||
app: tauri::AppHandle,
|
||||
paths: tauri::State<'_, Mutex<AppPaths>>,
|
||||
) -> Result<(), String> {
|
||||
let main = app
|
||||
.get_webview_window("main")
|
||||
.ok_or_else(|| "main window not found".to_string())?;
|
||||
let mini = app
|
||||
.get_webview_window("mini")
|
||||
.ok_or_else(|| "mini window not found".to_string())?;
|
||||
|
||||
let state_path = {
|
||||
let p = paths.lock().map_err(|e| e.to_string())?;
|
||||
p.state_path.clone()
|
||||
};
|
||||
if let Ok(snapshot) = read_state_with_fallback(&state_path) {
|
||||
// Sync mini immediately before showing it, avoiding stale one-shot transition.
|
||||
let _ = mini.emit("mini-sync-state", snapshot);
|
||||
}
|
||||
|
||||
// Keep mini near the main window top-left for continuity.
|
||||
if let Ok(main_pos) = main.outer_position() {
|
||||
let _ = mini.set_position(main_pos);
|
||||
}
|
||||
|
||||
let _ = main.hide();
|
||||
let _ = mini.show();
|
||||
let _ = mini.set_focus();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn restore_main_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||
let main = app
|
||||
.get_webview_window("main")
|
||||
.ok_or_else(|| "main window not found".to_string())?;
|
||||
let mini = app
|
||||
.get_webview_window("mini")
|
||||
.ok_or_else(|| "mini window not found".to_string())?;
|
||||
|
||||
let _ = mini.hide();
|
||||
let _ = main.show();
|
||||
let _ = main.set_focus();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn close_app(app: tauri::AppHandle) {
|
||||
app.exit(0);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_external_url(url: String) -> Result<(), String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
let mut cmd = {
|
||||
let mut c = Command::new("open");
|
||||
c.arg(&url);
|
||||
c
|
||||
};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let mut cmd = {
|
||||
let mut c = Command::new("cmd");
|
||||
c.args(["/C", "start", "", &url]);
|
||||
c
|
||||
};
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let mut cmd = {
|
||||
let mut c = Command::new("xdg-open");
|
||||
c.arg(&url);
|
||||
c
|
||||
};
|
||||
|
||||
cmd.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("failed to open browser: {e}"))
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let root = find_project_root();
|
||||
eprintln!("📦 State : {}", root.join("state.json").display());
|
||||
eprintln!("🎨 Layers: {}", root.join("layers").display());
|
||||
let backend_child = spawn_backend(&root);
|
||||
let backend_ready = wait_backend_ready();
|
||||
if !backend_ready {
|
||||
eprintln!("⚠️ backend not ready within 10s");
|
||||
}
|
||||
|
||||
tauri::Builder::default()
|
||||
.manage(Mutex::new(BackendProcess { child: backend_child }))
|
||||
.manage(Mutex::new(AppPaths {
|
||||
state_path: root.join("state.json"),
|
||||
layers_dir: root.join("layers"),
|
||||
}))
|
||||
.setup(|app| {
|
||||
// Hidden mini window: transparent square with only avatar + status.
|
||||
let mini = WebviewWindowBuilder::new(
|
||||
app,
|
||||
"mini",
|
||||
WebviewUrl::App("minimized.html".into()),
|
||||
)
|
||||
.title("Star Mini")
|
||||
.inner_size(220.0, 240.0)
|
||||
.min_inner_size(180.0, 200.0)
|
||||
.resizable(false)
|
||||
.decorations(false)
|
||||
.transparent(true)
|
||||
.always_on_top(true)
|
||||
.shadow(false)
|
||||
.visible(false)
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let _ = mini.hide();
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
read_state,
|
||||
load_layers,
|
||||
load_map,
|
||||
enter_minimize_mode,
|
||||
restore_main_window,
|
||||
close_app,
|
||||
open_external_url
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
5
desktop-pet/src-tauri/src/main.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
star_desktop_pet_lib::run();
|
||||
}
|
||||
35
desktop-pet/src-tauri/tauri.conf.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"productName": "Star Desktop Pet",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.star.desktop-pet",
|
||||
"build": { "frontendDist": "../src" },
|
||||
"app": {
|
||||
"macOSPrivateApi": true,
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Star Desktop Pet",
|
||||
"x": 80,
|
||||
"y": 60,
|
||||
"width": 700,
|
||||
"height": 500,
|
||||
"url": "http://127.0.0.1:19000/?desktop=1",
|
||||
"decorations": false,
|
||||
"transparent": true,
|
||||
"alwaysOnTop": true,
|
||||
"resizable": true,
|
||||
"shadow": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"bundle": {
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
677
desktop-pet/src/index.html
Normal file
|
|
@ -0,0 +1,677 @@
|
|||
<!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>
|
||||
BIN
desktop-pet/src/ipix.ttf
Executable file
340
desktop-pet/src/minimized.html
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
<!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>
|
||||
37
dist/Star-Office-UI-release-20260302/RELEASE_NOTES.md
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Star-Office-UI Release Notes (2026-03-02)
|
||||
|
||||
## Summary
|
||||
This package is a cleaned release snapshot for handoff/update.
|
||||
It excludes runtime files, logs, and local backup artifacts.
|
||||
|
||||
## Included
|
||||
- backend/
|
||||
- frontend/
|
||||
- docs/
|
||||
- assets/room-reference.png
|
||||
- core scripts and docs (README.md, SKILL.md, LICENSE, set_state.py, etc.)
|
||||
- asset-defaults.json / asset-positions.json
|
||||
|
||||
## Excluded on purpose
|
||||
- .git/
|
||||
- .venv/
|
||||
- __pycache__/
|
||||
- *.log / *.out / *.pid
|
||||
- *.bak (frontend image backups)
|
||||
- assets/bg-history/ (historical generated backgrounds)
|
||||
- runtime state files: state.json / agents-state.json / join-keys.json
|
||||
|
||||
## Artifact
|
||||
- File: `dist/Star-Office-UI-release-20260302.tgz`
|
||||
- SHA256: `bf52147b7664adc3c457eadd3748f969b1ad5ee7e8d3059ce9c8da4c6030f6ae`
|
||||
|
||||
## Pre-publish checklist
|
||||
1. Confirm whether `asset-defaults.json` and `asset-positions.json` should be shipped as current defaults.
|
||||
2. Confirm whether `assets/bg-history/` should remain local-only (currently excluded).
|
||||
3. On target machine, create fresh `state.json` and `join-keys.json` if needed.
|
||||
4. Start backend and validate:
|
||||
- `/health`
|
||||
- `/status`
|
||||
- language switches (EN/JP/CN)
|
||||
- loading overlay + sidebar layering
|
||||
- asset drawer selection / upload panel behavior
|
||||
42
docs/CHANGELOG_2026-03.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# CHANGELOG — 2026-03
|
||||
|
||||
## 2026-03-06
|
||||
|
||||
- 默认端口从 `18791` 调整为 `19000`,避开 OpenClaw Browser Control 端口冲突
|
||||
- 同步更新 `office-agent-push.py`、`healthcheck.sh`、`scripts/smoke_test.py` 的默认地址
|
||||
- 同步更新 Tauri / Electron 桌面壳默认连接地址
|
||||
- 同步更新 README / SKILL / join-office 文档中的本地访问与 tunnel 示例
|
||||
|
||||
# Changelog — 2026-03 Refresh
|
||||
|
||||
## Highlights
|
||||
|
||||
- Added robust asset editing workflow in drawer (select/deselect, highlight sync, default/override split)
|
||||
- Added EN/JP/CN language buttons with real-time UI + loading + bubble text switching
|
||||
- Added room loading overlay with emoji rotation and localized copy
|
||||
- Fixed layering/layout issues (drawer overlap, detail overflow, canvas border fit)
|
||||
- Completed multi-round state sprite replacement pipeline (Writing/Idle/Syncing/Error) with auto frame sync
|
||||
- Updated syncing behavior: non-sync shows frame 0, syncing starts from frame 1
|
||||
- Disabled error movement path (error anim stays in place)
|
||||
- Removed GIF legacy assets and stale references
|
||||
- Restored `assets/room-reference.png` for reference background restore
|
||||
- Added configurable asset drawer password via env (`ASSET_DRAWER_PASS`, default `1234`)
|
||||
- Improved startup performance:
|
||||
- static assets use long cache headers
|
||||
- local phaser vendor restored
|
||||
|
||||
## Security / Config
|
||||
|
||||
- Asset drawer default pass changed to `1234`
|
||||
- Recommend deployment override:
|
||||
- `ASSET_DRAWER_PASS=<your-strong-pass>`
|
||||
- Rationale: prevent unauthorized layout/asset modifications from shared links
|
||||
|
||||
## AI model recommendation for room generation
|
||||
|
||||
For best style-transfer quality (while preserving room structure), recommend:
|
||||
|
||||
1. gemini nanobanana pro
|
||||
2. gemini nanobanana 2
|
||||
|
||||
Other models may produce unstable structure consistency.
|
||||
38
docs/FEATURES_NEW_2026-03-01.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Star Office UI — 新增功能说明(本阶段)
|
||||
|
||||
## 1. 多龙虾访客系统
|
||||
- 支持多个远端 OpenClaw 同时加入同一办公室。
|
||||
- 访客支持独立头像、名字、状态、区域、气泡。
|
||||
- 支持动态上下线与实时刷新。
|
||||
|
||||
## 2. Join Key 机制升级
|
||||
- 从“一次性 key”升级为“固定可复用 key”。
|
||||
- 默认 key:`ocj_starteam01` ~ `ocj_starteam08`。
|
||||
- 保留安全控制:每个 key 的并发上限 `maxConcurrent`(默认 3)。
|
||||
|
||||
## 3. 并发控制(已修复竞态)
|
||||
- 修复并发 join 的竞态问题(race condition)。
|
||||
- 同 key 第 4 个并发 join 会被正确拒绝(HTTP 429)。
|
||||
|
||||
## 4. 访客状态映射与区域渲染
|
||||
- `idle -> breakroom`
|
||||
- `writing/researching/executing/syncing -> writing`
|
||||
- `error -> error`
|
||||
- 访客气泡文案与状态同步,不再错位。
|
||||
|
||||
## 5. 访客动画与资源优化
|
||||
- 访客由静态图升级为动画精灵(像素风)。
|
||||
- `guest_anim_1~6` 已提供 webp 版本,减少加载体积。
|
||||
|
||||
## 6. 名字与气泡显示优化
|
||||
- 非 demo 访客名字与气泡位置上移,避免角色遮挡。
|
||||
- 气泡锚点改为基于名字定位,保障“气泡在名字上方”。
|
||||
|
||||
## 7. 移动端展示
|
||||
- 页面可在手机端直接访问与展示。
|
||||
- 布局已进行基础移动端适配,满足演示场景。
|
||||
|
||||
## 8. 远端推送脚本联调改进
|
||||
- 支持从状态文件读取并推送状态到 office。
|
||||
- 增加状态来源诊断日志(用于定位“为何一直 idle”)。
|
||||
- 修复 AGENT_NAME 环境变量覆盖时序问题。
|
||||
91
docs/OPEN_SOURCE_RELEASE_CHECKLIST.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# Star Office UI — 开源发布准备清单(仅准备,不上传)
|
||||
|
||||
## 0. 当前目标
|
||||
- 本文档用于“发布前准备”,不执行实际上传。
|
||||
- 所有 push 行为需海辛最终明确批准。
|
||||
|
||||
## 1. 隐私与安全审查结果(当前仓库)
|
||||
|
||||
### 发现高风险文件(必须排除)
|
||||
- 运行日志:
|
||||
- `cloudflared.out`
|
||||
- `cloudflared-named.out`
|
||||
- `cloudflared-quick.out`
|
||||
- `healthcheck.log`
|
||||
- `backend.log`
|
||||
- `backend/backend.out`
|
||||
- 运行状态:
|
||||
- `state.json`
|
||||
- `agents-state.json`
|
||||
- `backend/backend.pid`
|
||||
- 备份/历史文件:
|
||||
- `index.html.backup.*`
|
||||
- `index.html.original`
|
||||
- `*.backup*` 目录与文件
|
||||
- 本地虚拟环境与缓存:
|
||||
- `.venv/`
|
||||
- `__pycache__/`
|
||||
|
||||
### 发现潜在敏感内容
|
||||
- 代码内含绝对路径 `/root/...`(建议改为相对路径或环境变量)
|
||||
- 文档与脚本含私有域名 `office.example.com`(可保留为示例,但建议改成占位域名)
|
||||
|
||||
## 2. 必改项(提交前)
|
||||
|
||||
### A. .gitignore(需补齐)
|
||||
建议新增:
|
||||
```
|
||||
*.log
|
||||
*.out
|
||||
*.pid
|
||||
state.json
|
||||
agents-state.json
|
||||
join-keys.json
|
||||
*.backup*
|
||||
*.original
|
||||
__pycache__/
|
||||
.venv/
|
||||
venv/
|
||||
```
|
||||
|
||||
### B. README 版权声明(必须新增)
|
||||
新增“美术资产版权与使用限制”章节:
|
||||
- 代码按开源协议(如 MIT)
|
||||
- 美术素材归原作者/工作室所有
|
||||
- 素材仅供学习/演示,**禁止商用**
|
||||
|
||||
### C. 发布目录瘦身
|
||||
- 清理运行日志、运行态文件、备份文件
|
||||
- 仅保留“可运行最小集 + 必要素材 + 文档”
|
||||
|
||||
## 3. 准备中的发布包建议结构
|
||||
```
|
||||
star-office-ui/
|
||||
backend/
|
||||
app.py
|
||||
requirements.txt
|
||||
run.sh
|
||||
frontend/
|
||||
index.html
|
||||
game.js (若仍需要)
|
||||
layout.js
|
||||
assets/* (仅可公开素材)
|
||||
office-agent-push.py
|
||||
set_state.py
|
||||
state.sample.json
|
||||
README.md
|
||||
LICENSE
|
||||
SKILL.md
|
||||
docs/
|
||||
```
|
||||
|
||||
## 4. 发布前最终核对(给海辛确认)
|
||||
- [ ] 是否保留私有域名示例(`office.example.com`)
|
||||
- [ ] 哪些美术资源允许公开(逐项确认)
|
||||
- [ ] README 非商用声明是否满足你的预期措辞
|
||||
- [ ] 是否需要将“阿文龙虾联调脚本”单独放 examples 目录
|
||||
|
||||
## 5. 当前状态
|
||||
- ✅ 文档准备完成(总结、功能说明、Skill v2、发布检查清单)
|
||||
- ⏳ 等待海辛确认“公开素材范围 + 声明文案 + 是否开始执行打包清理脚本”
|
||||
- ⛔ 尚未执行 GitHub 上传
|
||||
232
docs/PROJECT_MAINTENANCE_SOP.md
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
# Star-Office-UI 项目维护 SOP(轻量版)
|
||||
|
||||
> 目标:让 Star-Office-UI 在继续增长的同时,保持仓库干净、回复友好、节奏稳定、社区感明确。
|
||||
|
||||
---
|
||||
|
||||
## 1. 总原则
|
||||
|
||||
### 1.1 关闭 issue / PR 时,一定留一句 closure reason
|
||||
|
||||
无论是:
|
||||
- 已修复
|
||||
- 重复
|
||||
- 超出当前范围
|
||||
- 提问者自行取消
|
||||
- 已被其他 PR / issue 吸收
|
||||
|
||||
都尽量留一句话说明原因。
|
||||
|
||||
**最小模板:**
|
||||
- Fixed in `commit/PR #xxx`, thanks for the report!
|
||||
- Closing as duplicate of #xxx, thank you!
|
||||
- Out of current scope for now, but welcome a focused PR.
|
||||
- Canceled by requester / resolved in latest master.
|
||||
|
||||
目标不是“正式”,而是让后来人一眼看懂为什么被关。
|
||||
|
||||
---
|
||||
|
||||
## 2. Issue 处理规则
|
||||
|
||||
### 2.1 先判断 issue 类型
|
||||
|
||||
收到 issue 后,先分到四类之一:
|
||||
|
||||
#### A. Bug report
|
||||
特征:报错、页面打不开、功能异常、状态不对
|
||||
|
||||
处理方式:
|
||||
1. 复现 / 判断是否已知问题
|
||||
2. 如果已修:回复 + 给 commit / PR 号
|
||||
3. 如果未修:标记为待处理,必要时自己修 / 等 PR
|
||||
4. close 时一定写清楚“修在哪了”
|
||||
|
||||
**推荐回复模板:**
|
||||
> 感谢反馈!这个问题已在 `PR #xx` / `commit xxx` 中修复。请拉取最新 master 后再试一下,如果还有问题欢迎继续反馈。
|
||||
|
||||
---
|
||||
|
||||
#### B. Support / setup question
|
||||
特征:怎么部署、为什么 Unauthorized、如何自动同步等
|
||||
|
||||
处理方式:
|
||||
1. 先回答问题
|
||||
2. 给最短路径(README / SKILL / 命令)
|
||||
3. 如果文档能优化,顺手记成后续动作
|
||||
4. close 时说明“问题已答复,如仍有问题欢迎 reopen”
|
||||
|
||||
**推荐回复模板:**
|
||||
> 这个问题大概率和 xxx 有关。最新版已经做了相关修复 / 文档补充。你可以先试试最新 master;如果还有问题,欢迎重新打开 issue。
|
||||
|
||||
---
|
||||
|
||||
#### C. Feature request
|
||||
特征:希望支持某个新能力、新方向、新体验
|
||||
|
||||
处理方式:
|
||||
1. 明确是否感兴趣
|
||||
2. 不要误关成“已修复”
|
||||
3. 如果暂不做,也要说清楚“当前不做,但欢迎 PR / 后续讨论”
|
||||
|
||||
**推荐回复模板:**
|
||||
> 这是个很好的方向,我们对这个想法感兴趣。不过它还不是当前阶段的既定工作项。如果你愿意推进,欢迎提一个更聚焦的 PR,我们可以一起讨论实现方式。
|
||||
|
||||
---
|
||||
|
||||
#### D. Duplicate / canceled / absorbed
|
||||
特征:重复提问、提问者自己放弃、已被其他 issue 吸收
|
||||
|
||||
处理方式:
|
||||
1. 链接到对应 issue / PR
|
||||
2. 简短说明关闭原因
|
||||
3. 保持礼貌
|
||||
|
||||
---
|
||||
|
||||
## 3. PR 处理规则
|
||||
|
||||
### 3.1 Merge 前检查四件事
|
||||
|
||||
#### 1) 这个 PR 是不是解决了真实问题?
|
||||
- 是 bug fix 还是只是作者个人偏好?
|
||||
- 是否对应某个 issue / 用户痛点?
|
||||
|
||||
#### 2) 改动范围是否可控?
|
||||
- 小而聚焦 → 倾向合并
|
||||
- 大而混杂 → 要求拆分 / 暂缓
|
||||
|
||||
#### 3) 是否引入额外维护负担?
|
||||
- 新依赖
|
||||
- 新配置
|
||||
- 新架构
|
||||
- 新文档成本
|
||||
|
||||
#### 4) 是否需要同步 README / changelog / release notes?
|
||||
如果影响用户使用路径,必须同步文档。
|
||||
|
||||
---
|
||||
|
||||
### 3.2 PR 结果分三类
|
||||
|
||||
#### A. 直接合并
|
||||
适合:
|
||||
- 小 bug fix
|
||||
- 文档修正
|
||||
- 明确提升 onboarding / 稳定性
|
||||
|
||||
#### B. 关闭但感谢
|
||||
适合:
|
||||
- 已被 master 提前修复
|
||||
- 重复 PR
|
||||
- 方向不错但当前不合适
|
||||
|
||||
**原则:不合并 ≠ 否定贡献者**
|
||||
|
||||
#### C. 请求作者调整后再看
|
||||
适合:
|
||||
- 思路对,但改动太大
|
||||
- 混入不相关内容
|
||||
- 需要拆小
|
||||
|
||||
---
|
||||
|
||||
### 3.3 关闭 PR 时,尽量做到三件事
|
||||
|
||||
1. **先感谢**
|
||||
2. **再说明原因**
|
||||
3. **如果 possible,指出未来更容易被接受的方向**
|
||||
|
||||
**推荐模板:**
|
||||
> Thanks for the PR — this is a thoughtful direction. We’re not merging it right now because xxx. If you’d like, a smaller / more focused PR around yyy would be much easier for us to review and land.
|
||||
|
||||
---
|
||||
|
||||
## 4. Release / 大版本收口流程
|
||||
|
||||
适用于:
|
||||
- 一轮 bug fix 完成
|
||||
- 一次文档重构完成
|
||||
- 一次功能包发布(如 v1.0)
|
||||
|
||||
### 发布前 checklist
|
||||
- [ ] 关键功能本机验证一次(至少 health / status / agents / set_state)
|
||||
- [ ] smoke test 跑通
|
||||
- [ ] README / SKILL / relevant docs 已同步
|
||||
- [ ] CHANGELOG 已更新
|
||||
- [ ] 相关 issue 已回复 / 关闭
|
||||
- [ ] 如果有贡献者,考虑在 README / release note 致谢
|
||||
- [ ] 确认仓库 worktree 干净,没有误提交文件
|
||||
|
||||
### Release note 结构建议
|
||||
1. 这次版本是什么
|
||||
2. 核心变化 3-5 条
|
||||
3. 对用户有什么实际影响
|
||||
4. 快速体验方式
|
||||
5. 感谢贡献者
|
||||
|
||||
---
|
||||
|
||||
## 5. README / 文档维护规则
|
||||
|
||||
### 5.1 README 优先回答四个问题
|
||||
1. 这是什么?
|
||||
2. 适合谁?
|
||||
3. 最快怎么用?
|
||||
4. 如果我是 OpenClaw 用户,最短路径是什么?
|
||||
|
||||
### 5.2 文档更新触发条件
|
||||
以下情况发生时,要同步 README / docs:
|
||||
- 默认端口改变
|
||||
- 默认安装方式改变
|
||||
- 核心依赖或路径改变
|
||||
- onboarding 流程改变
|
||||
- 修复了高频 issue(尤其是部署 / 401 / loading 这类)
|
||||
|
||||
---
|
||||
|
||||
## 6. 社区关系维护
|
||||
|
||||
### 6.1 要主动做的三件事
|
||||
- 在 README 或 release note 感谢明显贡献者
|
||||
- 对早期贡献者保持尊重,即使 PR 没合并
|
||||
- 对误解 / 错判及时补充说明
|
||||
|
||||
### 6.2 哪些 contributor 值得重点维护
|
||||
优先维护这些人:
|
||||
- 连续提多个高质量 PR 的
|
||||
- 会主动补文档 / onboarding 的
|
||||
- 不只是修自己问题,而是在帮项目补完整性的
|
||||
|
||||
---
|
||||
|
||||
## 7. 当前阶段最适合 Star-Office-UI 的维护策略
|
||||
|
||||
### 适合优先接收
|
||||
- bug fix
|
||||
- onboarding 改进
|
||||
- 文档优化
|
||||
- 小而明确的稳定性修复
|
||||
- 与 OpenClaw / agent 体验强相关的增强
|
||||
|
||||
### 暂时谨慎对待
|
||||
- 大规模重构
|
||||
- 引入重依赖
|
||||
- 强绑定某个个人工作流的改动
|
||||
- 边界不清的大 feature
|
||||
|
||||
---
|
||||
|
||||
## 8. Star 自己要记住的维护原则
|
||||
|
||||
- 不要为了“显得热情”而模糊关闭原因
|
||||
- 不要把 feature request 当成 bug fix 关掉
|
||||
- 不要 merge 之后忘了补文档
|
||||
- 不要忽略早期贡献者
|
||||
- 仓库看起来干净,本身就是产品体验的一部分
|
||||
|
||||
---
|
||||
|
||||
## 一句话版本
|
||||
|
||||
> **小问题及时收口,大问题说清边界;每次关闭都留痕,每次发布都成阶段。**
|
||||
99
docs/PROJECT_SUMMARY_2026-03-01.md
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# Star Office UI — 项目阶段总结(2026-03-01)
|
||||
|
||||
## 一、今日工作总结
|
||||
|
||||
今天主要完成了两条主线:
|
||||
|
||||
1. **多龙虾(多 OpenClaw)加入办公室能力稳定化**
|
||||
2. **手机版展示能力完善**
|
||||
|
||||
并且围绕“阿文龙虾状态同步不稳定”做了多轮排查,明确了链路问题与当前未完全闭环点。
|
||||
|
||||
---
|
||||
|
||||
## 二、已完成能力(可对外描述)
|
||||
|
||||
### 1) 多 Agent 加入与显示
|
||||
- 支持多个远端 OpenClaw 通过 `join-agent` 加入办公室。
|
||||
- 每个访客有独立 `agentId`、名字、状态、区域与动画。
|
||||
- 场景会基于 `/agents` 动态创建、更新、移除访客。
|
||||
|
||||
### 2) 固定可复用 Join Key 机制
|
||||
- 一次性 key 改为固定可复用 key:`ocj_starteam01` ~ `ocj_starteam08`。
|
||||
- 去掉了“used 即不可再用”的阻断逻辑,支持长期复用。
|
||||
- 加入了并发上限配置(`maxConcurrent`),默认每个 key 限 3 并发在线。
|
||||
|
||||
### 3) 并发限制修复(关键)
|
||||
- 发现 4 并发仍能通过的根因是后端竞争条件(race condition)。
|
||||
- 在 `join-agent` 临界区增加锁 + 锁内重读状态,修复后压测通过:
|
||||
- 前 3 个 200
|
||||
- 第 4 个 429
|
||||
|
||||
### 4) 访客动画与性能优化
|
||||
- 访客动画改为像素动画精灵,不再是静态星星。
|
||||
- `guest_anim_1~6` 已转为 `.webp`,显著降低加载体积。
|
||||
- 前端预加载与渲染资源已切换到 webp 优先。
|
||||
|
||||
### 5) 状态 → 区域映射统一
|
||||
- 规则统一:
|
||||
- `idle -> breakroom`
|
||||
- `writing/researching/executing/syncing -> writing`
|
||||
- `error -> error`
|
||||
- 访客 bubble 文案已按状态做映射,不再与区域脱节。
|
||||
|
||||
### 6) 名字与气泡层级/位置优化
|
||||
- 非 demo 访客名字、气泡位置上移,减少遮挡。
|
||||
- 访客气泡锚点改为相对名字计算,确保“气泡在名字上方”。
|
||||
- demo 与真实访客路径已区分,互不干扰。
|
||||
|
||||
### 7) 手机版展示
|
||||
- 现有 UI 在手机端可访问与展示,适合演示与外部查看。
|
||||
- 关键控件布局做过整理,移动端基本可用。
|
||||
|
||||
---
|
||||
|
||||
## 三、当前未完全闭环点(诚实披露)
|
||||
|
||||
### 阿文龙虾“真实状态稳定同步”仍存在偶发不一致
|
||||
虽然链路已多次验证打通(writing 能进工作区、idle 能回休息区),但线上实测仍出现过:
|
||||
- 本地脚本持续推 idle(旧版本脚本 / 读错状态源)
|
||||
- 403 未授权(离线状态恢复/旧 agentId 缓存问题)
|
||||
- 前台退出触发 leave-agent 后角色消失
|
||||
|
||||
> 结论:
|
||||
> - “机制可行、链路可通”已经验证;
|
||||
> - “端到端持续稳定”还需要继续收口(尤其阿文侧运行脚本版本统一、状态源统一、常驻策略统一)。
|
||||
|
||||
---
|
||||
|
||||
## 四、今天新增/调整文件(核心)
|
||||
|
||||
- `backend/app.py`
|
||||
- join 并发限制加锁修复
|
||||
- offline/approved 授权流逻辑调整(便于恢复)
|
||||
- `join-keys.json`
|
||||
- 固定 key + `maxConcurrent: 3`
|
||||
- `frontend/index.html`(及相关渲染逻辑)
|
||||
- 访客动画、名字与气泡定位优化
|
||||
- 状态文案映射调整
|
||||
- `office-agent-push.py`(多版本并行调试)
|
||||
- 增加状态源诊断日志
|
||||
- 增加环境变量覆盖逻辑
|
||||
- 修复 AGENT_NAME 读取时机问题
|
||||
|
||||
---
|
||||
|
||||
## 五、对外开源前建议描述(建议文案)
|
||||
|
||||
> Star Office UI 是一个可视化多 Agent 像素办公室:
|
||||
> 支持多个 OpenClaw 远端接入、状态驱动位置渲染、访客动画与移动端访问。
|
||||
> 项目当前已完成多 Agent 主链路与 UI 能力;状态同步稳定性仍在持续优化中。
|
||||
|
||||
---
|
||||
|
||||
## 六、下一步(建议)
|
||||
1. 统一阿文侧运行脚本“唯一来源”,避免旧版本混跑。
|
||||
2. 增加 `/agent-push` 与前端渲染诊断日志(可开关)。
|
||||
3. 增加“状态过期自动 idle”兜底(脚本侧 + 服务端侧双保险)。
|
||||
4. 补一份可复现联调流程(10 分钟 smoke test)。
|
||||
5. 完成开源前隐私清理与发布清单(见 `docs/OPEN_SOURCE_RELEASE_CHECKLIST.md`)。
|
||||
87
docs/PR_DRAFT_2026-03-refresh.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# PR Draft — Star Office UI March Refresh
|
||||
|
||||
## Title
|
||||
feat: asset editor + i18n + loading UX + sprite pipeline + security/perf refinements
|
||||
|
||||
## Summary
|
||||
This PR delivers a full refresh of Star Office UI across UX, asset pipeline, localization, stability, and deployment security.
|
||||
|
||||
### What changed
|
||||
|
||||
#### 1) Asset editor / room decoration
|
||||
- Improved drawer selection UX (select/deselect + dual highlight sync)
|
||||
- Upload panel now appears only when an asset is selected
|
||||
- Added defaults/overrides split:
|
||||
- `GET/POST /assets/defaults`
|
||||
- `GET/POST /assets/positions`
|
||||
- Added “Set Default” flow for persistent base placement
|
||||
|
||||
#### 2) Localization (CN/EN/JP)
|
||||
- Replaced language toggles with EN / JP / CN buttons
|
||||
- Active language button highlighted in green
|
||||
- Real-time language switching for:
|
||||
- UI labels
|
||||
- loading texts
|
||||
- role/cat/guest bubbles
|
||||
- initial boot loading sentence
|
||||
|
||||
#### 3) Loading overlay and UX polish
|
||||
- Added room-bound loading overlay with emoji rotation
|
||||
- Updated copy to voyage-themed localized sets
|
||||
- Trigger timing fixed: overlay shows immediately on click
|
||||
- Overlay and detail placement bound to canvas rect for consistency
|
||||
|
||||
#### 4) Layout and layering fixes
|
||||
- Fixed canvas border fit and theme color unification (#64477d)
|
||||
- Ensured status/detail stays inside canvas and single-line clipped
|
||||
- Drawer open now shifts main stage to avoid overlap and large gaps
|
||||
- Drawer kept above room loading overlay
|
||||
|
||||
#### 5) Sprite replacement pipeline hardening
|
||||
- Reworked replacement flow to detect frame size/count from incoming animated webp
|
||||
- Synced loader + animation frame ranges to avoid flicker
|
||||
- Applied across Writing / Idle / Syncing / Error replacements
|
||||
- Syncing behavior adjusted:
|
||||
- non-sync state shows frame 0
|
||||
- syncing animation starts from frame 1
|
||||
- Error animation movement path removed (fixed in place)
|
||||
|
||||
#### 6) Cleanup / reliability / perf
|
||||
- Removed legacy GIF assets
|
||||
- Removed stale asset references (zero missing static refs)
|
||||
- Restored `assets/room-reference.png` for restore-reference endpoint
|
||||
- Added configurable drawer pass via env:
|
||||
- `ASSET_DRAWER_PASS` (default `1234`)
|
||||
- Performance improvements:
|
||||
- static assets served with long cache headers
|
||||
- local phaser vendor restored to reduce cold-load latency
|
||||
|
||||
## Documentation updates included
|
||||
- README rewritten for latest behavior/config
|
||||
- SKILL updated with deployment + safety + replacement SOP
|
||||
- LICENSE updated to remove old third-party character disclaimer and keep:
|
||||
- code MIT
|
||||
- art assets non-commercial
|
||||
- Added `docs/CHANGELOG_2026-03.md`
|
||||
|
||||
## Deployment notes
|
||||
- Recommended model for room generation:
|
||||
1. gemini nanobanana pro
|
||||
2. gemini nanobanana 2
|
||||
- Security recommendation:
|
||||
- always override `ASSET_DRAWER_PASS` in production/public deployments
|
||||
|
||||
## Test checklist
|
||||
- [ ] Open page cold + warm load
|
||||
- [ ] Switch CN/EN/JP at any state
|
||||
- [ ] Trigger Move Home/Broker and observe local status text + loading overlay
|
||||
- [ ] Replace one animated asset and verify frame sync/no flicker
|
||||
- [ ] Verify Error is fixed in place
|
||||
- [ ] Verify `/assets/restore-reference-background` works with `assets/room-reference.png`
|
||||
- [ ] Verify no missing `/static/*` refs in runtime logs
|
||||
|
||||
## How to create PR
|
||||
1. `git checkout -b feat/march-refresh`
|
||||
2. `git push -u origin feat/march-refresh`
|
||||
3. Open PR to `ringhyacinth/Star-Office-UI:main`
|
||||
4. Paste this document as PR description
|
||||
25
docs/PR_FILELIST_2026-03-refresh.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# PR File List — 2026-03 Refresh
|
||||
|
||||
## Core code changes
|
||||
- `frontend/index.html`
|
||||
- `backend/app.py`
|
||||
- `frontend/vendor/phaser-3.80.1.min.js`
|
||||
- `assets/room-reference.webp`
|
||||
|
||||
## Configuration / templates
|
||||
- `.gitignore`
|
||||
- `runtime-config.sample.json`
|
||||
|
||||
## Documentation
|
||||
- `README.md`
|
||||
- `SKILL.md`
|
||||
- `LICENSE`
|
||||
- `docs/CHANGELOG_2026-03.md`
|
||||
- `docs/PR_DRAFT_2026-03-refresh.md`
|
||||
- `docs/PR_FILELIST_2026-03-refresh.md`
|
||||
|
||||
## Notes (excluded from PR)
|
||||
- `state.json`, `agents-state.json`, `runtime-config.json` (local runtime)
|
||||
- `assets/bg-history/` (local generated history)
|
||||
- `frontend/*.bak` (local backups)
|
||||
- temporary dist packages
|
||||
61
docs/STAR_OFFICE_UI_OVERVIEW.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Star Office UI — 功能说明(Overview)
|
||||
|
||||
Star Office UI 是一个“像素办公室”可视化界面,用来把 AI 助手/多个 OpenClaw 访客的状态,渲染成可在网页(含手机)查看的小办公室场景。
|
||||
|
||||
## 你能看到什么
|
||||
- 像素办公室背景(俯视图)
|
||||
- 角色(Star + 访客)会根据状态在不同区域移动
|
||||
- 名字与气泡(bubble)展示当前状态/想法(可自定义映射)
|
||||
- 手机端打开也能展示(适合作品展示/直播/对外演示)
|
||||
|
||||
## 核心能力
|
||||
|
||||
### 1) 单 Agent(本地 Star)状态渲染
|
||||
- 后端读取 `state.json` 提供 `GET /status`
|
||||
- 前端轮询 `/status`,根据 `state` 渲染 Star 所在区域
|
||||
- 提供 `set_state.py` 快速切换状态
|
||||
|
||||
### 2) 多访客(多龙虾)加入办公室
|
||||
- 访客通过 `POST /join-agent` 加入,获得 `agentId`
|
||||
- 访客通过 `POST /agent-push` 持续推送自己的状态
|
||||
- 前端通过 `GET /agents` 拉取访客列表并渲染
|
||||
|
||||
### 3) Join Key(接入密钥)机制
|
||||
- 支持固定可复用 join key(如 `ocj_starteam01~08`)
|
||||
- 支持每个 key 的并发在线上限(默认 3)
|
||||
- 便于控制“谁能进办公室”和“同一个 key 同时可进几只龙虾”
|
||||
|
||||
### 4) 状态 → 区域映射(统一逻辑)
|
||||
- idle → breakroom(休息区)
|
||||
- writing / researching / executing / syncing → writing(工作区)
|
||||
- error → error(故障区)
|
||||
|
||||
### 5) 访客动画与性能优化
|
||||
- 访客角色使用动画精灵
|
||||
- 支持 WebP 资源(体积更小、加载更快)
|
||||
|
||||
### 6) 名字/气泡不遮挡的布局
|
||||
- 真实访客与 demo 访客分离逻辑
|
||||
- 非 demo 访客名字与气泡整体上移
|
||||
- bubble 锚定在名字上方,避免压住名字
|
||||
|
||||
### 7) Demo 模式(可选)
|
||||
- `?demo=1` 才显示 demo 访客(默认不显示)
|
||||
- demo 与真实访客互不影响
|
||||
|
||||
## 主要接口(Backend)
|
||||
- `GET /`:前端页面
|
||||
- `GET /status`:单 agent 状态(兼容旧版)
|
||||
- `GET /agents`:多 agent 列表(访客渲染用)
|
||||
- `POST /join-agent`:访客加入
|
||||
- `POST /agent-push`:访客推送状态
|
||||
- `POST /leave-agent`:访客离开
|
||||
- `GET /health`:健康检查
|
||||
|
||||
## 安全与隐私注意
|
||||
- 不要把隐私信息写进 `detail`(因为会被渲染/可被拉取)
|
||||
- 开源前必须清理:日志、运行态文件、join keys、隧道输出等
|
||||
|
||||
## 美术资产使用声明(必须)
|
||||
- 代码可开源,但美术素材(背景、角色、动画等)版权归原作者/工作室所有。
|
||||
- 美术资产仅供学习与演示,**禁止商用**。
|
||||
141
docs/UPDATE_REPORT_2026-03-04_P0_P1.md
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
# Star Office UI 更新文档(P0 / P1)
|
||||
|
||||
更新时间:2026-03-04
|
||||
分支:`feat/office-art-rebuild`
|
||||
|
||||
---
|
||||
|
||||
## 1. 更新目标
|
||||
|
||||
本轮更新目标分为两层:
|
||||
|
||||
- **P0:安全与可发布性**(防泄漏、防弱配置、上线前可自检)
|
||||
- **P1:结构与稳定性优化**(不减功能、提升状态同步与加载体验)
|
||||
|
||||
同时处理了线上关键问题:
|
||||
|
||||
- 服务偶发 502(进程/服务启动方式不稳定)
|
||||
- 角色状态与真实工作状态不一致(尤其“回复结束仍在工位”)
|
||||
|
||||
---
|
||||
|
||||
## 2. P0 已完成项
|
||||
|
||||
### 2.1 后端安全基线加固
|
||||
|
||||
- 增加生产模式安全校验(弱密钥/弱口令阻止启动)
|
||||
- Session Cookie 安全参数加固(HttpOnly / SameSite / Secure)
|
||||
- `runtime-config.json` 写入后自动尝试收紧文件权限(`600`)
|
||||
|
||||
### 2.2 敏感文件治理
|
||||
|
||||
- `.gitignore` 补充运行态文件与高风险文件
|
||||
- 引入样例文件替代运行态文件:
|
||||
- `join-keys.sample.json`
|
||||
- `.env.example`
|
||||
- `join-keys.json` 改为运行时初始化,不再作为仓库内固定配置
|
||||
|
||||
### 2.3 上线前安全自检能力
|
||||
|
||||
- 新增 `scripts/security_check.py`
|
||||
- 可检查:
|
||||
- 弱 secret / 弱口令
|
||||
- 风险文件是否被 git 跟踪
|
||||
- 常见敏感 token 模式
|
||||
|
||||
---
|
||||
|
||||
## 3. P1 已完成项(不改业务能力)
|
||||
|
||||
### 3.1 后端结构拆分
|
||||
|
||||
在不改变现有 API 行为前提下,把 `backend/app.py` 拆出:
|
||||
|
||||
- `backend/security_utils.py`
|
||||
- `backend/memo_utils.py`
|
||||
- `backend/store_utils.py`
|
||||
|
||||
收益:
|
||||
- 降低单文件复杂度
|
||||
- 降低后续功能改动时的回归风险
|
||||
- 提升可读性与维护效率
|
||||
|
||||
### 3.2 状态同步修复(核心)
|
||||
|
||||
- 修复状态源路径优先级(避免读取错误状态文件)
|
||||
- 增加 stale 状态自动回 `idle` 机制(避免假工作中)
|
||||
- 前端状态轮询改为更快节奏并强制视觉对齐,避免动画卡旧状态
|
||||
|
||||
### 3.3 生图模型策略收敛
|
||||
|
||||
按需求收敛为两种用户模型语义:
|
||||
|
||||
- `nanobanana-pro`
|
||||
- `nanobanana-2`
|
||||
|
||||
并补充 provider 映射与错误细节透出,提升可诊断性。
|
||||
|
||||
### 3.4 首屏性能与体感优化
|
||||
|
||||
- 首页 HTML 缓存(后端进程内缓存)
|
||||
- 非关键初始化延后(先出画面)
|
||||
- 加入画布骨架屏,减少“黑屏 + 长时间加载中”体感
|
||||
- 加速 loading overlay 淡出
|
||||
|
||||
---
|
||||
|
||||
## 4. 线上稳定性修复(本轮重点)
|
||||
|
||||
### 4.1 502 根因
|
||||
|
||||
Cloudflare 正常,但 `18888` 源站进程存在不稳定/启动方式不一致,导致偶发 connection refused。
|
||||
|
||||
### 4.2 已处理
|
||||
|
||||
- 修复并统一 `star-office-ui.service` 启动方式(systemd 常驻)
|
||||
- 清理手工临时启动造成的端口抢占
|
||||
- 重启并验证:
|
||||
- `star-office-ui.service` 运行正常
|
||||
- `star-office-push.service` 运行正常
|
||||
|
||||
---
|
||||
|
||||
## 5. 当前已知风险 / 待跟进
|
||||
|
||||
1. **状态策略仍需完全事件化**
|
||||
- 目前已大幅收敛误判,但建议后续做单一状态控制器(显式事件优先,彻底禁用隐式推断)
|
||||
|
||||
2. **进程模型仍是 Flask 开发服务器**
|
||||
- 当前可用但不理想,后续建议迁移为 gunicorn/uvicorn 等生产进程模型
|
||||
|
||||
3. **动画状态同步仍建议增加端到端回归脚本**
|
||||
- 尤其 writing / syncing / error / idle 切换链路
|
||||
|
||||
---
|
||||
|
||||
## 6. 验收建议(人工)
|
||||
|
||||
验收地址:`https://simonoffice.hyacinth.im/`
|
||||
|
||||
建议至少覆盖:
|
||||
|
||||
1. 首页进入速度与骨架屏体验
|
||||
2. 状态切换(writing / syncing / error / idle)
|
||||
3. 回复结束后是否回到待命区
|
||||
4. 生图两入口(搬新家 / 找中介)
|
||||
5. 断网或服务短时波动后是否自动恢复
|
||||
|
||||
---
|
||||
|
||||
## 7. 提交范围(摘要)
|
||||
|
||||
本轮主要覆盖:
|
||||
|
||||
- 安全与配置:P0
|
||||
- 后端重构:P1
|
||||
- 状态同步与动画一致性修复
|
||||
- 生图模型策略与错误诊断
|
||||
- 加载性能与体验优化
|
||||
- systemd 常驻与稳定性修复
|
||||
|
||||
如需 PR 附件,可直接将本文件作为“更新说明 / Release Notes”。
|
||||
100
docs/UPDATE_REPORT_2026-03-05.md
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
# 更新报告 — 2026-03-05
|
||||
|
||||
> 本次更新覆盖 8 个 commit,聚焦「稳定性修复 + 移动端体验 + 安全收尾」。
|
||||
|
||||
---
|
||||
|
||||
## 变更概览
|
||||
|
||||
| # | Commit | 分类 | 说明 |
|
||||
|---|--------|------|------|
|
||||
| 1 | `878793d` | 🐛 fix | 修复 CDN 缓存 404 导致页面无法加载 |
|
||||
| 2 | `cc22403` | 🐛 fix | 修复 `fetchStatus()` 中多余的 `else` 块导致 JS 语法错误 |
|
||||
| 3 | `103f944` | 🐛 fix | 生图接口改为异步任务模式,避免 Cloudflare 524 超时 |
|
||||
| 4 | `ee141de` | 🧹 chore | 清理本地测试时意外提交的文件 |
|
||||
| 5 | `83e61ff` | 🧹 chore | 将 `join-keys.json` 加入 `.gitignore`(运行时数据不入库) |
|
||||
| 6 | `899f27e` | 🐛 fix | 移动端/iPad 侧边栏修复(遮罩层 + body 滚动锁定 + `100dvh`) |
|
||||
| 7 | `5aef430` | 🐛 fix | 移动端 drawer 关闭时完全移出屏幕(`right: -100vw`) |
|
||||
| 8 | `02a731e` | ✨ feat | 新增 join key 级别过期时间 + 并发上限支持 |
|
||||
|
||||
---
|
||||
|
||||
## 详细说明
|
||||
|
||||
### 1. 修复 CDN 缓存 404(`878793d`)
|
||||
|
||||
**问题**:`/static/` 路径下的所有响应(含 404)都被设置了一年长缓存头。Cloudflare 缓存了 `phaser.js` 的 404 响应长达 2.7 天,导致 `office.hyacinth.im` 完全无法加载。
|
||||
|
||||
**修复**:
|
||||
- `add_no_cache_headers` 仅对 2xx 响应设置长缓存,非 2xx 响应设为 no-cache
|
||||
- 给 `phaser.js` 的 `<script>` 标签添加 `?v={{VERSION_TIMESTAMP}}` 缓存破坏参数
|
||||
|
||||
### 2. 修复 fetchStatus JS 语法错误(`cc22403`)
|
||||
|
||||
**问题**:`fetchStatus()` 函数内 `try/catch` 之间存在一个孤立的 `} else { ... }` 块,破坏了 JS 语法结构,导致浏览器报 `Missing catch or finally after try`,整个页面卡在 loading。
|
||||
|
||||
**修复**:移除多余的 `else` 块(其中的打字机逻辑已被前面的 `if/else` 分支覆盖)。
|
||||
|
||||
> ⚠️ 此 bug 是 GitHub 上 PR #49、#51、#52 同时在修的问题,三个 PR 现在可以关闭。
|
||||
|
||||
### 3. 生图接口异步化(`103f944`)
|
||||
|
||||
**问题**:`POST /assets/generate-rpg-background` 是同步的,生图通常需要 30~120 秒。Cloudflare 的代理超时限制为 100 秒(HTTP 524),导致公网用户频繁触发超时。
|
||||
|
||||
**修复**:
|
||||
- 后端:拆分为 `_bg_generate_worker`(后台线程)+ `POST /assets/generate-rpg-background`(返回 `task_id`)+ `GET /assets/generate-rpg-background/poll`(轮询结果)
|
||||
- 前端:新增 `_startAndPollGeneration()` 函数,提交任务后每 3 秒轮询,显示实时等待进度
|
||||
- 同时抽取了 `_handleGenError()` 统一错误处理(DRY 优化)
|
||||
- 防重入:如果已有生图任务在跑,直接返回已有 `task_id`
|
||||
|
||||
### 4-5. 清理与 gitignore(`ee141de` + `83e61ff`)
|
||||
|
||||
- 清理了测试时意外提交的文件
|
||||
- 将 `join-keys.json` 加入 `.gitignore`(包含密钥数据,不应入库)
|
||||
|
||||
### 6-7. 移动端侧边栏修复(`899f27e` + `5aef430`)
|
||||
|
||||
**问题**:移动端/iPad 打开资产侧边栏时,背后的页面仍可滚动;关闭侧边栏后 drawer 只偏移 -320px,在宽屏移动设备上仍可见。
|
||||
|
||||
**修复**:
|
||||
- 新增 `#asset-drawer-backdrop` 遮罩层,点击即关闭 drawer
|
||||
- 打开 drawer 时 `body` 加 `drawer-open` class(`overflow:hidden; position:fixed; touch-action:none`)
|
||||
- 关闭时恢复 `scrollY` 位置(避免跳顶部)
|
||||
- Drawer 关闭状态改为 `right: -100vw`(完全移出视口)
|
||||
- 使用 `100dvh` 适配移动端 dynamic viewport
|
||||
- 添加 `overscroll-behavior: contain` 防止 drawer 内滚动穿透
|
||||
|
||||
### 8. Join Key 级别过期时间(`02a731e`)
|
||||
|
||||
**新增功能**:
|
||||
- `join-keys.json` 中每个 key 支持 `expiresAt` 字段(ISO 8601 时间戳)
|
||||
- `join-agent` 和 `agent-push` 两个端点在执行前都会检查 key 是否过期
|
||||
- 过期后返回友好提示:"该接入密钥已过期,活动已结束 🎉"
|
||||
- 支持 `maxConcurrent` 字段控制同一个 key 的并发在线数
|
||||
|
||||
---
|
||||
|
||||
## 潜在风险评估
|
||||
|
||||
| 风险点 | 等级 | 说明 |
|
||||
|--------|------|------|
|
||||
| 异步任务内存泄漏 | 🟡 低 | `_bg_tasks` 在任务完成并被 poll 消费后会清理;但如果前端从未 poll(如用户关闭页面),任务对象会残留。当前风险极低(生图频率低),后续可加定期清理。 |
|
||||
| `join-keys.json` 历史泄露 | 🟢 已解决 | 已加入 `.gitignore`,但如果之前有 commit 包含此文件,历史中仍存在。建议确认远端历史是否干净。 |
|
||||
| 前端 `fetchStatus` 修复 | 🟢 已验证 | 修复后的 `try/catch` 结构完整,本地运行正常。 |
|
||||
| 移动端 drawer `position:fixed` | 🟢 低 | iOS Safari 下 `position:fixed` + `100dvh` 的组合偶有兼容问题,但已是业界最佳实践。 |
|
||||
|
||||
**结论:无新增 bug 风险,可以安全推送。**
|
||||
|
||||
---
|
||||
|
||||
## 文件变更统计
|
||||
|
||||
```
|
||||
.gitignore | 1 +
|
||||
backend/app.py | 166 ++++++++++++++++++------
|
||||
frontend/index.html | 162 ++++++++++++++++--------
|
||||
frontend/join-office-skill.md | 102 +++++++++------
|
||||
frontend/office-agent-push.py | 286 ++++++++++++++++++++++++++++++++++++++++++
|
||||
office-agent-push.py | 2 +-
|
||||
共 6 个文件,+589 行,-130 行
|
||||
```
|
||||
BIN
docs/screenshots/office-preview-20260301.jpg
Normal file
|
After Width: | Height: | Size: 756 KiB |
BIN
docs/screenshots/readme-cover-1.jpg
Normal file
|
After Width: | Height: | Size: 428 KiB |
BIN
docs/screenshots/readme-cover-2.jpg
Normal file
|
After Width: | Height: | Size: 850 KiB |
32
electron-shell/README.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Star Desktop Pet (Electron Shell)
|
||||
|
||||
这个目录是 Electron 版桌面壳,和现有 Tauri 版并行存在,方便逐步迁移。
|
||||
|
||||
## 已接入能力
|
||||
|
||||
- 复用原有前端:`http://127.0.0.1:19000/?desktop=1`
|
||||
- 复用 mini 页面:`desktop-pet/src/minimized.html`
|
||||
- 启动时自动拉起 Python backend(若未运行)
|
||||
- 主窗口 / mini 窗口切换
|
||||
- 托盘(menu bar)常驻菜单
|
||||
- 通过 preload 注入 `window.__TAURI__` 兼容层,尽量少改现有前端逻辑
|
||||
|
||||
## 启动方式
|
||||
|
||||
```bash
|
||||
cd "/Users/wangzhaohan/Documents/GitHub/Star-Office-UI/electron-shell"
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 可选环境变量
|
||||
|
||||
- `STAR_PROJECT_ROOT`:项目根目录(默认自动探测)
|
||||
- `STAR_BACKEND_PYTHON`:后端 Python 可执行路径
|
||||
- `STAR_BACKEND_HOST`:后端主机(默认 `127.0.0.1`)
|
||||
- `STAR_BACKEND_PORT`:后端端口(默认 `19000`)
|
||||
|
||||
## 说明
|
||||
|
||||
- 当前阶段是“可运行迁移骨架”,目的是先替换桌面容器层。
|
||||
- 现有 Tauri 目录不受影响,可随时回滚或并行对比。
|
||||
543
electron-shell/main.js
Normal file
|
|
@ -0,0 +1,543 @@
|
|||
const { app, BrowserWindow, Tray, Menu, ipcMain, shell, nativeImage } = require("electron");
|
||||
const { spawn } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const net = require("net");
|
||||
const APP_NAME = "Star Office UI";
|
||||
const BACKEND_HOST = process.env.STAR_BACKEND_HOST || "127.0.0.1";
|
||||
const rawBackendPort = Number(process.env.STAR_BACKEND_PORT || 19000);
|
||||
const BACKEND_PORT = Number.isFinite(rawBackendPort) && rawBackendPort > 0 ? rawBackendPort : 19000;
|
||||
const BACKEND_BASE_URL = `http://${BACKEND_HOST}:${BACKEND_PORT}`;
|
||||
|
||||
let mainWindow = null;
|
||||
let miniWindow = null;
|
||||
let assetWindow = null;
|
||||
let tray = null;
|
||||
let backendChild = null;
|
||||
let isQuitting = false;
|
||||
let currentUiLang = "en";
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function tcpReachable(host, port, timeoutMs = 500) {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
let settled = false;
|
||||
const done = (ok) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
socket.destroy();
|
||||
resolve(ok);
|
||||
};
|
||||
socket.setTimeout(timeoutMs);
|
||||
socket.once("connect", () => done(true));
|
||||
socket.once("timeout", () => done(false));
|
||||
socket.once("error", () => done(false));
|
||||
socket.connect(port, host);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitBackendReady(timeoutMs = 20000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await tcpReachable(BACKEND_HOST, BACKEND_PORT, 400)) return true;
|
||||
await sleep(200);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
function findProjectRoot() {
|
||||
if (process.env.STAR_PROJECT_ROOT) {
|
||||
const custom = path.isAbsolute(process.env.STAR_PROJECT_ROOT)
|
||||
? process.env.STAR_PROJECT_ROOT
|
||||
: path.resolve(process.cwd(), process.env.STAR_PROJECT_ROOT);
|
||||
if (fs.existsSync(path.join(custom, "backend", "app.py"))) return custom;
|
||||
}
|
||||
|
||||
const fromDir = __dirname;
|
||||
let cursor = fromDir;
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
if (fs.existsSync(path.join(cursor, "backend", "app.py"))) return cursor;
|
||||
const parent = path.dirname(cursor);
|
||||
if (parent === cursor) break;
|
||||
cursor = parent;
|
||||
}
|
||||
|
||||
const home = process.env.HOME || "";
|
||||
const candidates = [
|
||||
path.join(home, "Documents", "GitHub", "Star-Office-UI"),
|
||||
path.join(home, "GitHub", "Star-Office-UI"),
|
||||
path.join(home, "Documents", "Star-Office-UI"),
|
||||
path.join(home, "Star-Office-UI"),
|
||||
];
|
||||
for (const c of candidates) {
|
||||
if (fs.existsSync(path.join(c, "backend", "app.py"))) return c;
|
||||
}
|
||||
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
function resolveAppIconPath(projectRoot) {
|
||||
const candidates = [
|
||||
path.join(projectRoot, "desktop-pet", "src-tauri", "icons", "icon.png"),
|
||||
path.join(projectRoot, "desktop-pet", "src-tauri", "icons", "128x128@2x.png"),
|
||||
path.join(projectRoot, "desktop-pet", "src-tauri", "icons", "128x128.png"),
|
||||
path.join(projectRoot, "desktop-pet", "src-tauri", "icons", "32x32.png"),
|
||||
];
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyAppIcon(projectRoot) {
|
||||
const iconPath = resolveAppIconPath(projectRoot);
|
||||
if (!iconPath) return null;
|
||||
const iconImg = nativeImage.createFromPath(iconPath);
|
||||
if (iconImg.isEmpty()) return null;
|
||||
|
||||
if (process.platform === "darwin" && app.dock && app.dock.setIcon) {
|
||||
app.dock.setIcon(iconImg);
|
||||
}
|
||||
return iconPath;
|
||||
}
|
||||
|
||||
function readStateFile(statePath) {
|
||||
const raw = fs.readFileSync(statePath, "utf-8");
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
function readStateViaBackend() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = `GET /status HTTP/1.1\r\nHost: ${BACKEND_HOST}\r\nConnection: close\r\n\r\n`;
|
||||
const socket = net.createConnection({ host: BACKEND_HOST, port: BACKEND_PORT });
|
||||
let buf = "";
|
||||
socket.setTimeout(1200);
|
||||
socket.on("connect", () => socket.write(req));
|
||||
socket.on("data", (chunk) => {
|
||||
buf += chunk.toString("utf-8");
|
||||
});
|
||||
socket.on("timeout", () => {
|
||||
socket.destroy();
|
||||
reject(new Error("backend timeout"));
|
||||
});
|
||||
socket.on("error", reject);
|
||||
socket.on("end", () => {
|
||||
const sep = "\r\n\r\n";
|
||||
const idx = buf.indexOf(sep);
|
||||
if (idx === -1) {
|
||||
reject(new Error("invalid backend response"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(buf.slice(idx + sep.length)));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function readStateWithFallback(projectRoot) {
|
||||
const statePath = path.join(projectRoot, "state.json");
|
||||
try {
|
||||
return readStateFile(statePath);
|
||||
} catch (_) {
|
||||
return readStateViaBackend();
|
||||
}
|
||||
}
|
||||
|
||||
function spawnBackend(projectRoot) {
|
||||
const script = path.join(projectRoot, "backend", "app.py");
|
||||
if (!fs.existsSync(script)) {
|
||||
console.warn(`backend/app.py not found: ${script}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = [];
|
||||
if (process.env.STAR_BACKEND_PYTHON) candidates.push(process.env.STAR_BACKEND_PYTHON);
|
||||
candidates.push(path.join(projectRoot, ".venv", "bin", "python"));
|
||||
candidates.push("python3");
|
||||
candidates.push("python");
|
||||
|
||||
for (const bin of candidates) {
|
||||
try {
|
||||
const child = spawn(bin, [script], {
|
||||
cwd: projectRoot,
|
||||
stdio: "inherit",
|
||||
});
|
||||
console.log(`backend started with ${bin}`);
|
||||
return child;
|
||||
} catch (e) {
|
||||
console.warn(`failed to spawn ${bin}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ensureElectronStandaloneSnapshot(projectRoot) {
|
||||
const src = path.join(projectRoot, "frontend", "index.html");
|
||||
const dst = path.join(projectRoot, "frontend", "electron-standalone.html");
|
||||
if (!fs.existsSync(src)) return;
|
||||
if (fs.existsSync(dst)) return;
|
||||
try {
|
||||
fs.copyFileSync(src, dst);
|
||||
console.log(`created standalone snapshot: ${dst}`);
|
||||
} catch (e) {
|
||||
console.warn(`failed to create standalone snapshot: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function emitMini(event, payload) {
|
||||
if (!miniWindow || miniWindow.isDestroyed()) return;
|
||||
miniWindow.webContents.send("tauri:event", { event, payload });
|
||||
}
|
||||
|
||||
function emitMain(event, payload) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
mainWindow.webContents.send("tauri:event", { event, payload });
|
||||
}
|
||||
|
||||
async function enterMiniMode(projectRoot) {
|
||||
const snapshot = await readStateWithFallback(projectRoot).catch(() => null);
|
||||
if (snapshot) emitMini("mini-sync-state", { ...snapshot, ui_lang: currentUiLang });
|
||||
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
const bounds = mainWindow.getBounds();
|
||||
if (miniWindow && !miniWindow.isDestroyed()) {
|
||||
miniWindow.setBounds({ ...miniWindow.getBounds(), x: bounds.x, y: bounds.y });
|
||||
}
|
||||
mainWindow.hide();
|
||||
}
|
||||
if (miniWindow && !miniWindow.isDestroyed()) {
|
||||
miniWindow.show();
|
||||
miniWindow.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function openFrontendAndQuit() {
|
||||
await shell.openExternal(`${BACKEND_BASE_URL}/`);
|
||||
app.quit();
|
||||
}
|
||||
|
||||
function createAssetWindow(projectRoot) {
|
||||
if (assetWindow && !assetWindow.isDestroyed()) {
|
||||
assetWindow.show();
|
||||
assetWindow.focus();
|
||||
assetWindow.moveTop();
|
||||
return assetWindow;
|
||||
}
|
||||
|
||||
const preloadPath = path.join(__dirname, "preload.js");
|
||||
const appIconPath = resolveAppIconPath(projectRoot);
|
||||
const mainBounds = mainWindow && !mainWindow.isDestroyed() ? mainWindow.getBounds() : null;
|
||||
const x = mainBounds ? mainBounds.x + 32 : 160;
|
||||
const y = mainBounds ? mainBounds.y + 32 : 120;
|
||||
const assetUrl = `${BACKEND_BASE_URL}/electron-standalone?desktop=1&assetWindow=1`;
|
||||
|
||||
assetWindow = new BrowserWindow({
|
||||
width: 300,
|
||||
height: 580,
|
||||
minWidth: 300,
|
||||
maxWidth: 300,
|
||||
minHeight: 580,
|
||||
x,
|
||||
y,
|
||||
title: "Star Decorate Room",
|
||||
frame: false,
|
||||
transparent: true,
|
||||
hasShadow: false,
|
||||
alwaysOnTop: true,
|
||||
resizable: true,
|
||||
maximizable: true,
|
||||
fullscreenable: false,
|
||||
backgroundColor: "#00000000",
|
||||
icon: appIconPath || undefined,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
preload: preloadPath,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
assetWindow.once("ready-to-show", () => {
|
||||
if (!assetWindow || assetWindow.isDestroyed()) return;
|
||||
assetWindow.setAlwaysOnTop(true, "floating");
|
||||
assetWindow.moveTop();
|
||||
assetWindow.show();
|
||||
assetWindow.focus();
|
||||
});
|
||||
assetWindow.on("closed", () => {
|
||||
assetWindow = null;
|
||||
});
|
||||
assetWindow.loadURL(assetUrl);
|
||||
return assetWindow;
|
||||
}
|
||||
|
||||
function createWindows(projectRoot) {
|
||||
const preloadPath = path.join(__dirname, "preload.js");
|
||||
const appIconPath = resolveAppIconPath(projectRoot);
|
||||
ensureElectronStandaloneSnapshot(projectRoot);
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 700,
|
||||
height: 460,
|
||||
x: 80,
|
||||
y: 60,
|
||||
transparent: true,
|
||||
frame: false,
|
||||
alwaysOnTop: true,
|
||||
resizable: false,
|
||||
maximizable: false,
|
||||
fullscreenable: false,
|
||||
hasShadow: false,
|
||||
icon: appIconPath || undefined,
|
||||
webPreferences: {
|
||||
preload: preloadPath,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
mainWindow.setTitle(APP_NAME);
|
||||
|
||||
miniWindow = new BrowserWindow({
|
||||
width: 220,
|
||||
height: 240,
|
||||
minWidth: 180,
|
||||
minHeight: 200,
|
||||
transparent: true,
|
||||
frame: false,
|
||||
alwaysOnTop: true,
|
||||
resizable: false,
|
||||
hasShadow: false,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
preload: preloadPath,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
miniWindow.setTitle("Star Office UI Mini");
|
||||
|
||||
const v = Date.now();
|
||||
const mainUrl = `${BACKEND_BASE_URL}/electron-standalone?desktop=1&v=${v}`;
|
||||
mainWindow.loadURL(mainUrl);
|
||||
miniWindow.loadFile(path.join(projectRoot, "desktop-pet", "src", "minimized.html"));
|
||||
}
|
||||
|
||||
function createTray(projectRoot) {
|
||||
const tray32 = path.join(projectRoot, "desktop-pet", "src-tauri", "icons", "32x32.png");
|
||||
const iconPath = fs.existsSync(tray32) ? tray32 : resolveAppIconPath(projectRoot);
|
||||
if (!iconPath) return;
|
||||
const trayImage = nativeImage.createFromPath(iconPath);
|
||||
tray = new Tray(trayImage);
|
||||
tray.setToolTip(APP_NAME);
|
||||
|
||||
const menu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: "显示主窗口",
|
||||
click: () => {
|
||||
if (miniWindow && !miniWindow.isDestroyed()) miniWindow.hide();
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "显示 Mini 窗口",
|
||||
click: () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.hide();
|
||||
if (miniWindow && !miniWindow.isDestroyed()) {
|
||||
miniWindow.show();
|
||||
miniWindow.focus();
|
||||
}
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "退出",
|
||||
click: () => app.quit(),
|
||||
},
|
||||
]);
|
||||
|
||||
tray.setContextMenu(menu);
|
||||
tray.on("click", () => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
if (mainWindow.isVisible()) mainWindow.hide();
|
||||
else {
|
||||
if (miniWindow && !miniWindow.isDestroyed()) miniWindow.hide();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function registerIpc(projectRoot) {
|
||||
const applyMainWindowMode = (expanded) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
const bounds = mainWindow.getBounds();
|
||||
const targetHeight = expanded ? 620 : 460;
|
||||
const targetWidth = bounds.width || 700;
|
||||
mainWindow.setSize(targetWidth, targetHeight, true);
|
||||
mainWindow.setContentSize(targetWidth, targetHeight, true);
|
||||
};
|
||||
|
||||
ipcMain.handle("tauri:invoke", async (_event, payload) => {
|
||||
const cmd = payload && payload.command;
|
||||
const args = (payload && payload.args) || {};
|
||||
|
||||
if (cmd === "read_state") {
|
||||
const state = await readStateWithFallback(projectRoot);
|
||||
return { ...state, ui_lang: currentUiLang };
|
||||
}
|
||||
|
||||
if (cmd === "set_ui_lang") {
|
||||
const lang = String(args && args.lang ? args.lang : "").toLowerCase();
|
||||
if (lang === "zh" || lang === "en" || lang === "ja") {
|
||||
currentUiLang = lang;
|
||||
}
|
||||
return { ok: true, lang: currentUiLang };
|
||||
}
|
||||
|
||||
if (cmd === "enter_minimize_mode") {
|
||||
await enterMiniMode(projectRoot);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cmd === "restore_main_window") {
|
||||
if (miniWindow && !miniWindow.isDestroyed()) miniWindow.hide();
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cmd === "close_app") {
|
||||
app.quit();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cmd === "open_external_url") {
|
||||
if (args && args.url) {
|
||||
await shell.openExternal(args.url);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cmd === "set_main_window_mode") {
|
||||
const senderWin = BrowserWindow.fromWebContents(_event.sender);
|
||||
// Only main window is allowed to control main window height.
|
||||
if (!senderWin || !mainWindow || senderWin.id !== mainWindow.id) {
|
||||
return null;
|
||||
}
|
||||
const expanded = !!(args && args.expanded);
|
||||
applyMainWindowMode(expanded);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cmd === "open_asset_window") {
|
||||
createAssetWindow(projectRoot);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cmd === "close_asset_window") {
|
||||
if (assetWindow && !assetWindow.isDestroyed()) {
|
||||
assetWindow.close();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cmd === "notify_main_window_asset_refresh") {
|
||||
const payloadData = {
|
||||
...(args && typeof args === "object" ? args : {}),
|
||||
};
|
||||
const kind = String(payloadData.kind ? payloadData.kind : "asset");
|
||||
const path = String(payloadData.path ? payloadData.path : "");
|
||||
emitMain("main-window-asset-refresh", {
|
||||
...payloadData,
|
||||
kind,
|
||||
path,
|
||||
at: Date.now(),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported invoke command: ${cmd}`);
|
||||
});
|
||||
|
||||
ipcMain.handle("window:set-size", (event, payload) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (!win) return null;
|
||||
const width = Number(payload && payload.width);
|
||||
const height = Number(payload && payload.height);
|
||||
if (Number.isFinite(width) && Number.isFinite(height)) {
|
||||
const w = Math.round(width);
|
||||
const h = Math.round(height);
|
||||
// Dual strategy: outer-size and content-size together for transparent frameless windows.
|
||||
win.setSize(w, h, true);
|
||||
win.setContentSize(w, h, true);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
ipcMain.handle("window:get-position", (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (!win) return { x: 0, y: 0 };
|
||||
const [x, y] = win.getPosition();
|
||||
return { x, y };
|
||||
});
|
||||
|
||||
ipcMain.handle("window:set-position", (event, payload) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (!win) return null;
|
||||
const x = Number(payload && payload.x);
|
||||
const y = Number(payload && payload.y);
|
||||
if (Number.isFinite(x) && Number.isFinite(y)) {
|
||||
win.setPosition(Math.round(x), Math.round(y), false);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const projectRoot = findProjectRoot();
|
||||
console.log(`project root: ${projectRoot}`);
|
||||
console.log(`state path: ${path.join(projectRoot, "state.json")}`);
|
||||
const iconPath = applyAppIcon(projectRoot);
|
||||
if (iconPath) console.log(`app icon: ${iconPath}`);
|
||||
|
||||
if (!(await tcpReachable(BACKEND_HOST, BACKEND_PORT, 400))) {
|
||||
backendChild = spawnBackend(projectRoot);
|
||||
const ready = await waitBackendReady(20000);
|
||||
if (!ready) console.warn("backend not ready within 20s");
|
||||
} else {
|
||||
console.log(`backend already running on ${BACKEND_HOST}:${BACKEND_PORT}`);
|
||||
}
|
||||
|
||||
registerIpc(projectRoot);
|
||||
createWindows(projectRoot);
|
||||
createTray(projectRoot);
|
||||
}
|
||||
|
||||
app.on("window-all-closed", (e) => {
|
||||
// Keep tray app resident by default (unless quitting).
|
||||
if (!isQuitting) e.preventDefault();
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
isQuitting = true;
|
||||
if (backendChild) {
|
||||
try {
|
||||
backendChild.kill();
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
|
||||
if (app.setName) app.setName(APP_NAME);
|
||||
app.whenReady().then(bootstrap);
|
||||
801
electron-shell/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,801 @@
|
|||
{
|
||||
"name": "star-desktop-pet-electron",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "star-desktop-pet-electron",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"electron": "^40.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/get": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz",
|
||||
"integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1",
|
||||
"env-paths": "^2.2.0",
|
||||
"fs-extra": "^8.1.0",
|
||||
"got": "^11.8.5",
|
||||
"progress": "^2.0.3",
|
||||
"semver": "^6.2.0",
|
||||
"sumchecker": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"global-agent": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sindresorhus/is": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
|
||||
"integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/is?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@szmarczak/http-timer": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
|
||||
"integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"defer-to-connect": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cacheable-request": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
|
||||
"integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-cache-semantics": "*",
|
||||
"@types/keyv": "^3.1.4",
|
||||
"@types/node": "*",
|
||||
"@types/responselike": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/http-cache-semantics": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||
"integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/keyv": {
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz",
|
||||
"integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz",
|
||||
"integrity": "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/responselike": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz",
|
||||
"integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/yauzl": {
|
||||
"version": "2.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
||||
"integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/boolean": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
|
||||
"integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
|
||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/buffer-crc32": {
|
||||
"version": "0.2.13",
|
||||
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
||||
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/cacheable-lookup": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz",
|
||||
"integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cacheable-request": {
|
||||
"version": "7.0.4",
|
||||
"resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz",
|
||||
"integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clone-response": "^1.0.2",
|
||||
"get-stream": "^5.1.0",
|
||||
"http-cache-semantics": "^4.0.0",
|
||||
"keyv": "^4.0.0",
|
||||
"lowercase-keys": "^2.0.0",
|
||||
"normalize-url": "^6.0.1",
|
||||
"responselike": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/clone-response": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz",
|
||||
"integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-response": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-response": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response/node_modules/mimic-response": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/defer-to-connect": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
|
||||
"integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/define-data-property": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/define-properties": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
|
||||
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"define-data-property": "^1.0.1",
|
||||
"has-property-descriptors": "^1.0.0",
|
||||
"object-keys": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-node": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
|
||||
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/electron": {
|
||||
"version": "40.6.1",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-40.6.1.tgz",
|
||||
"integrity": "sha512-u9YfoixttdauciHV9Ut9Zf3YipJoU093kR1GSYTTXTAXqhiXI0G1A0NnL/f0O2m2UULCXaXMf2W71PloR6V9pQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@electron/get": "^2.0.0",
|
||||
"@types/node": "^24.9.0",
|
||||
"extract-zip": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"electron": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.20.55"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/env-paths": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es6-error": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
|
||||
"integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/extract-zip": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
|
||||
"integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1",
|
||||
"get-stream": "^5.1.0",
|
||||
"yauzl": "^2.10.0"
|
||||
},
|
||||
"bin": {
|
||||
"extract-zip": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.17.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@types/yauzl": "^2.9.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fd-slicer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
||||
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pend": "~1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
|
||||
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^4.0.0",
|
||||
"universalify": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6 <7 || >=8"
|
||||
}
|
||||
},
|
||||
"node_modules/get-stream": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
|
||||
"integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pump": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/global-agent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
|
||||
"integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"boolean": "^3.0.1",
|
||||
"es6-error": "^4.1.1",
|
||||
"matcher": "^3.0.0",
|
||||
"roarr": "^2.15.3",
|
||||
"semver": "^7.3.2",
|
||||
"serialize-error": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/global-agent/node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/globalthis": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
|
||||
"integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"define-properties": "^1.2.1",
|
||||
"gopd": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/got": {
|
||||
"version": "11.8.6",
|
||||
"resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz",
|
||||
"integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sindresorhus/is": "^4.0.0",
|
||||
"@szmarczak/http-timer": "^4.0.5",
|
||||
"@types/cacheable-request": "^6.0.1",
|
||||
"@types/responselike": "^1.0.0",
|
||||
"cacheable-lookup": "^5.0.3",
|
||||
"cacheable-request": "^7.0.2",
|
||||
"decompress-response": "^6.0.0",
|
||||
"http2-wrapper": "^1.0.0-beta.5.2",
|
||||
"lowercase-keys": "^2.0.0",
|
||||
"p-cancelable": "^2.0.0",
|
||||
"responselike": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/got?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/has-property-descriptors": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/http-cache-semantics": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||
"integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/http2-wrapper": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
|
||||
"integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"quick-lru": "^5.1.1",
|
||||
"resolve-alpn": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-buffer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-stringify-safe": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
|
||||
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/jsonfile": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
|
||||
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-buffer": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lowercase-keys": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
|
||||
"integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/matcher": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
|
||||
"integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"escape-string-regexp": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
|
||||
"integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/normalize-url": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
|
||||
"integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/object-keys": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/p-cancelable": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
|
||||
"integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pend": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/progress": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/quick-lru": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
|
||||
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-alpn": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
|
||||
"integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/responselike": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz",
|
||||
"integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lowercase-keys": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/roarr": {
|
||||
"version": "2.15.4",
|
||||
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
|
||||
"integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"boolean": "^3.0.1",
|
||||
"detect-node": "^2.0.4",
|
||||
"globalthis": "^1.0.1",
|
||||
"json-stringify-safe": "^5.0.1",
|
||||
"semver-compare": "^1.0.0",
|
||||
"sprintf-js": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/semver-compare": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
|
||||
"integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/serialize-error": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
|
||||
"integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"type-fest": "^0.13.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
||||
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/sumchecker": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
|
||||
"integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "0.13.1",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
|
||||
"integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
|
||||
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yauzl": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
|
||||
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-crc32": "~0.2.3",
|
||||
"fd-slicer": "~1.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
electron-shell/package.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "star-office-ui",
|
||||
"productName": "Star Office UI",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "electron .",
|
||||
"start": "electron ."
|
||||
},
|
||||
"dependencies": {
|
||||
"electron": "^40.6.1"
|
||||
}
|
||||
}
|
||||
107
electron-shell/preload.js
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
const listeners = new Map();
|
||||
|
||||
ipcRenderer.on("tauri:event", (_event, data) => {
|
||||
const eventName = data && data.event;
|
||||
if (!eventName) return;
|
||||
const subs = listeners.get(eventName) || [];
|
||||
for (const cb of subs) {
|
||||
try {
|
||||
cb({ payload: data.payload });
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
|
||||
class LogicalSize {
|
||||
constructor(width, height) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
}
|
||||
}
|
||||
|
||||
let dragging = false;
|
||||
let dragStartPointer = null;
|
||||
let dragStartWindow = null;
|
||||
let dragMoveBound = false;
|
||||
let lastMouseScreen = { x: 0, y: 0 };
|
||||
|
||||
function ensureDragMoveHandlers() {
|
||||
if (dragMoveBound) return;
|
||||
dragMoveBound = true;
|
||||
|
||||
window.addEventListener("mousemove", async (e) => {
|
||||
lastMouseScreen = { x: e.screenX, y: e.screenY };
|
||||
if (!dragging || !dragStartPointer || !dragStartWindow) return;
|
||||
const dx = e.screenX - dragStartPointer.x;
|
||||
const dy = e.screenY - dragStartPointer.y;
|
||||
await ipcRenderer.invoke("window:set-position", {
|
||||
x: dragStartWindow.x + dx,
|
||||
y: dragStartWindow.y + dy,
|
||||
});
|
||||
});
|
||||
|
||||
const stopDrag = () => {
|
||||
dragging = false;
|
||||
dragStartPointer = null;
|
||||
dragStartWindow = null;
|
||||
};
|
||||
window.addEventListener("mouseup", stopDrag);
|
||||
window.addEventListener("blur", stopDrag);
|
||||
}
|
||||
|
||||
const tauriCompat = {
|
||||
core: {
|
||||
invoke: (command, args = {}) =>
|
||||
ipcRenderer.invoke("tauri:invoke", { command, args }),
|
||||
},
|
||||
event: {
|
||||
listen: async (eventName, callback) => {
|
||||
const subs = listeners.get(eventName) || [];
|
||||
subs.push(callback);
|
||||
listeners.set(eventName, subs);
|
||||
return () => {
|
||||
const cur = listeners.get(eventName) || [];
|
||||
listeners.set(
|
||||
eventName,
|
||||
cur.filter((x) => x !== callback),
|
||||
);
|
||||
};
|
||||
},
|
||||
},
|
||||
window: {
|
||||
getCurrentWindow: () => ({
|
||||
startDragging: async () => {
|
||||
ensureDragMoveHandlers();
|
||||
const pos = await ipcRenderer.invoke("window:get-position");
|
||||
dragStartWindow = {
|
||||
x: Number(pos && pos.x) || 0,
|
||||
y: Number(pos && pos.y) || 0,
|
||||
};
|
||||
dragStartPointer = {
|
||||
x: lastMouseScreen.x,
|
||||
y: lastMouseScreen.y,
|
||||
};
|
||||
dragging = true;
|
||||
return null;
|
||||
},
|
||||
setSize: async (logicalSize) =>
|
||||
ipcRenderer.invoke("window:set-size", {
|
||||
width: logicalSize && logicalSize.width,
|
||||
height: logicalSize && logicalSize.height,
|
||||
}),
|
||||
close: async () => tauriCompat.core.invoke("close_app"),
|
||||
hide: async () => null,
|
||||
show: async () => null,
|
||||
setFocus: async () => null,
|
||||
}),
|
||||
},
|
||||
dpi: {
|
||||
LogicalSize,
|
||||
},
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld("__TAURI__", tauriCompat);
|
||||
contextBridge.exposeInMainWorld("__ELECTRON__", {
|
||||
invoke: tauriCompat.core.invoke,
|
||||
});
|
||||
1001
electron-shell/standalone-assets/game.js
Normal file
133
electron-shell/standalone-assets/layout.js
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
// Star Office UI - 布局与层级配置
|
||||
// 所有坐标、depth、资源路径统一管理在这里
|
||||
// 避免 magic numbers,降低改错风险
|
||||
|
||||
// 核心规则:
|
||||
// - 透明资源(如办公桌)强制 .png,不透明优先 .webp
|
||||
// - 层级:低 → sofa(10) → starWorking(900) → desk(1000) → flower(1100)
|
||||
|
||||
const LAYOUT = {
|
||||
// === 游戏画布 ===
|
||||
game: {
|
||||
width: 1280,
|
||||
height: 720
|
||||
},
|
||||
|
||||
// === 各区域坐标 ===
|
||||
areas: {
|
||||
door: { x: 640, y: 550 },
|
||||
writing: { x: 320, y: 360 },
|
||||
researching: { x: 320, y: 360 },
|
||||
error: { x: 1066, y: 180 },
|
||||
breakroom: { x: 640, y: 360 }
|
||||
},
|
||||
|
||||
// === 装饰与家具:坐标 + 原点 + depth ===
|
||||
furniture: {
|
||||
// 沙发
|
||||
sofa: {
|
||||
x: 670,
|
||||
y: 144,
|
||||
origin: { x: 0, y: 0 },
|
||||
depth: 10
|
||||
},
|
||||
|
||||
// 新办公桌(透明 PNG 强制)
|
||||
desk: {
|
||||
x: 218,
|
||||
y: 417,
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
depth: 1000
|
||||
},
|
||||
|
||||
// 桌上花盆
|
||||
flower: {
|
||||
x: 310,
|
||||
y: 390,
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
depth: 1100,
|
||||
scale: 0.8
|
||||
},
|
||||
|
||||
// Star 在桌前工作(在 desk 下面)
|
||||
starWorking: {
|
||||
x: 217,
|
||||
y: 333,
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
depth: 900,
|
||||
scale: 1.32
|
||||
},
|
||||
|
||||
// 植物们
|
||||
plants: [
|
||||
{ x: 565, y: 178, depth: 5 },
|
||||
{ x: 230, y: 185, depth: 5 },
|
||||
{ x: 977, y: 496, depth: 5 }
|
||||
],
|
||||
|
||||
// 海报
|
||||
poster: {
|
||||
x: 252,
|
||||
y: 66,
|
||||
depth: 4
|
||||
},
|
||||
|
||||
// 咖啡机
|
||||
coffeeMachine: {
|
||||
x: 659,
|
||||
y: 397,
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
depth: 99
|
||||
},
|
||||
|
||||
// 服务器区
|
||||
serverroom: {
|
||||
x: 1021,
|
||||
y: 142,
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
depth: 2
|
||||
},
|
||||
|
||||
// 错误 bug
|
||||
errorBug: {
|
||||
x: 1007,
|
||||
y: 221,
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
depth: 50,
|
||||
scale: 0.9,
|
||||
pingPong: { leftX: 1007, rightX: 1111, speed: 0.6 }
|
||||
},
|
||||
|
||||
// 同步动画
|
||||
syncAnim: {
|
||||
x: 1157,
|
||||
y: 592,
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
depth: 40
|
||||
},
|
||||
|
||||
// 小猫
|
||||
cat: {
|
||||
x: 94,
|
||||
y: 557,
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
depth: 2000
|
||||
}
|
||||
},
|
||||
|
||||
// === 牌匾 ===
|
||||
plaque: {
|
||||
x: 640,
|
||||
y: 720 - 36,
|
||||
width: 420,
|
||||
height: 44
|
||||
},
|
||||
|
||||
// === 资源加载规则:哪些强制用 PNG(透明资源) ===
|
||||
forcePng: {
|
||||
desk_v2: true // 新办公桌必须透明,强制 PNG
|
||||
},
|
||||
|
||||
// === 总资源数量(用于加载进度条) ===
|
||||
totalAssets: 15
|
||||
};
|
||||
BIN
frontend/btn-back-home-sprite.png
Normal file
|
After Width: | Height: | Size: 599 B |
BIN
frontend/btn-broker-sprite.png
Normal file
|
After Width: | Height: | Size: 589 B |
BIN
frontend/btn-diy-sprite.png
Normal file
|
After Width: | Height: | Size: 726 B |
BIN
frontend/btn-move-house-sprite.png
Normal file
|
After Width: | Height: | Size: 503 B |
BIN
frontend/btn-open-drawer-sprite.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/btn-state-sprite.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
frontend/cats-spritesheet.webp
Normal file
|
After Width: | Height: | Size: 568 KiB |
BIN
frontend/coffee-machine-shadow-v1.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
frontend/coffee-machine-v3-grid.webp
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
frontend/desk-v3.webp
Normal file
|
After Width: | Height: | Size: 53 KiB |
5772
frontend/electron-standalone.html
Normal file
BIN
frontend/error-bug-spritesheet-grid.webp
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
frontend/flowers-bloom-v2.webp
Normal file
|
After Width: | Height: | Size: 341 KiB |
94
frontend/fonts/OFL.txt
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
Copyright (c) 2021, TakWolf (https://takwolf.com),
|
||||
with Reserved Font Name "Ark Pixel".
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
frontend/fonts/ark-pixel-12px-proportional-ja.ttf.woff2
Normal file
BIN
frontend/fonts/ark-pixel-12px-proportional-ko.ttf.woff2
Normal file
BIN
frontend/fonts/ark-pixel-12px-proportional-latin.ttf.woff2
Normal file
BIN
frontend/fonts/ark-pixel-12px-proportional-zh_cn.ttf.woff2
Normal file
BIN
frontend/fonts/ark-pixel-12px-proportional-zh_hk.ttf.woff2
Normal file
BIN
frontend/fonts/ark-pixel-12px-proportional-zh_tr.ttf.woff2
Normal file
BIN
frontend/fonts/ark-pixel-12px-proportional-zh_tw.ttf.woff2
Normal file
1034
frontend/game.js
Normal file
BIN
frontend/guest_anim_1.webp
Normal file
|
After Width: | Height: | Size: 468 B |
BIN
frontend/guest_anim_2.webp
Normal file
|
After Width: | Height: | Size: 464 B |
BIN
frontend/guest_anim_3.webp
Normal file
|
After Width: | Height: | Size: 306 B |
BIN
frontend/guest_anim_4.webp
Normal file
|
After Width: | Height: | Size: 322 B |
BIN
frontend/guest_anim_5.webp
Normal file
|
After Width: | Height: | Size: 364 B |
BIN
frontend/guest_anim_6.webp
Normal file
|
After Width: | Height: | Size: 364 B |
BIN
frontend/guest_role_1.png
Normal file
|
After Width: | Height: | Size: 781 B |
BIN
frontend/guest_role_2.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
frontend/guest_role_3.png
Normal file
|
After Width: | Height: | Size: 944 B |
BIN
frontend/guest_role_4.png
Normal file
|
After Width: | Height: | Size: 838 B |
BIN
frontend/guest_role_5.png
Normal file
|
After Width: | Height: | Size: 914 B |