diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..d3fc57ee --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + pull_request: + +jobs: + python-checks: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: python -m pip install -r backend/requirements.txt + - run: python -m compileall backend scripts set_state.py office-agent-push.py + - run: python scripts/security_check.py + + smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python -m pip install -r backend/requirements.txt + - run: cp state.sample.json state.json + - run: python backend/app.py & + - run: python scripts/smoke_test.py --base-url http://127.0.0.1:19000 --timeout 8 --retries 5 --skip-set-state diff --git a/.gitignore b/.gitignore index dadad2d7..5f664bea 100644 --- a/.gitignore +++ b/.gitignore @@ -1,39 +1,44 @@ -# Python environment -__pycache__/ -*.py[cod] -*.pyo -*.pyd -*.egg-info/ -.venv/ -venv/ -.env - -# OS/editor -.DS_Store - -# Runtime state (local only) -state.json -agents-state.json -runtime-config.json -*.log -*.out -*.pid -*.backup* -*.original -cloudflared.pid -cloudflared.out -healthcheck.log -backend.log - -# Generated / mutable assets (local only) -assets/bg-history/ -assets/home-favorites/ -frontend/office_bg.png -frontend/*.bak -layers/ -desktop-pet/src-tauri/icons/*Logo.png - -# Electron local build artifacts -electron-shell/node_modules/ -electron-shell/release/ -join-keys.json +# Python environment +__pycache__/ +*.py[cod] +*.pyo +*.pyd +*.egg-info/ +.venv/ +venv/ +.env + +# OS/editor +.DS_Store +.vscode/ +.idea/ +*.swp +*.swo +.trae/ + +# 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 diff --git a/README.en.md b/README.en.md index a6255981..93b3071e 100644 --- a/README.en.md +++ b/README.en.md @@ -1,294 +1,313 @@ -# Star Office UI - -🌐 Language: [äž­æ–‡](./README.md) | **English** | [日本語](./README.ja.md) - -![Star Office UI Cover](docs/screenshots/readme-cover-2.jpg) - -**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" -``` - -![Star Office UI Preview](docs/screenshots/readme-cover-1.jpg) - ---- - -## 🀔 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 ""` 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 - -[![Star History Chart](https://api.star-history.com/image?repos=ringhyacinth/Star-Office-UI&type=date&legend=top-left)](https://www.star-history.com/?repos=ringhyacinth%2FStar-Office-UI&type=date&legend=top-left) +# Star Office UI + +🌐 Language: [äž­æ–‡](./README.md) | **English** | [日本語](./README.ja.md) + +![Star Office UI Cover](docs/screenshots/readme-cover-2.jpg) + +**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.8+** +> +> Windows users: see [docs/WINDOWS_SETUP.md](./docs/WINDOWS_SETUP.md) + +```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" +``` + +![Star Office UI Preview](docs/screenshots/readme-cover-1.jpg) + +--- + +## 🀔 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 + +> Windows users: see [docs/WINDOWS_SETUP.md](./docs/WINDOWS_SETUP.md) + +### 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 ""` 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 = "your-join-key" # Your assigned key +AGENT_NAME = "My Agent" # Display name +OFFICE_URL = "https://your-office.example.com" # 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! + +--- + +## 🀝 Contributing + +Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. + +1. **Fork** the project +2. **Clone** your fork locally +3. **Create your Feature Branch** (`git checkout -b feature/AmazingFeature`) +4. **Commit your Changes** (`git commit -m 'Add some AmazingFeature'`) +5. **Push to the Branch** (`git push origin feature/AmazingFeature`) +6. **Open a Pull Request** + +> 💡 **Want to join the contributor list?** Check for `TODO` tags in the code or the pending tasks in [`docs/OPEN_SOURCE_RELEASE_CHECKLIST.md`](./docs/OPEN_SOURCE_RELEASE_CHECKLIST.md). + +--- + +## 🎚 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 + +[![Star History Chart](https://api.star-history.com/image?repos=ringhyacinth/Star-Office-UI&type=date&legend=top-left)](https://www.star-history.com/?repos=ringhyacinth%2FStar-Office-UI&type=date&legend=top-left) diff --git a/README.ja.md b/README.ja.md index 1ab8d19d..5819b4a0 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,294 +1,313 @@ -# Star Office UI - -🌐 Language: [äž­æ–‡](./README.md) | [English](./README.en.md) | **日本語** - -![Star Office UI カバヌ](docs/screenshots/readme-cover-2.jpg) - -**ピクセルアヌト颚 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 "埅機䞭" -``` - -![Star Office UI プレビュヌ](docs/screenshots/readme-cover-1.jpg) - ---- - -## 🀔 誰に向いおいる - -### 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 - -[![Star History Chart](https://api.star-history.com/image?repos=ringhyacinth/Star-Office-UI&type=date&legend=top-left)](https://www.star-history.com/?repos=ringhyacinth%2FStar-Office-UI&type=date&legend=top-left) +# Star Office UI + +🌐 Language: [äž­æ–‡](./README.md) | [English](./README.en.md) | **日本語** + +![Star Office UI カバヌ](docs/screenshots/readme-cover-2.jpg) + +**ピクセルアヌト颚 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.8+ が必芁です** +> +> Windows の堎合は [docs/WINDOWS_SETUP.md](./docs/WINDOWS_SETUP.md) を参照しおください。 + +```bash +# 1) リポゞトリをクロヌン +git clone https://github.com/ringhyacinth/Star-Office-UI.git +cd Star-Office-UI + +# 2) 䟝存関係をむンストヌルPython 3.8+ が必芁 +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 "埅機䞭" +``` + +![Star Office UI プレビュヌ](docs/screenshots/readme-cover-1.jpg) + +--- + +## 🀔 誰に向いおいる + +### 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 デスクトップラッパヌで、オフィスを透明りィンドりのデスクトップペットに䞋蚘参照 + +--- + +## 🚀 詳现セットアップガむド + +> Windows の堎合は [docs/WINDOWS_SETUP.md](./docs/WINDOWS_SETUP.md) を参照しおください。 + +### 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 = "your-join-key" # 割り圓おられたキヌ +AGENT_NAME = "My Agent" # 衚瀺名 +OFFICE_URL = "https://your-office.example.com" # あなたのオフィス 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) が独自に開発したした。貢献に感謝したす + +--- + +## 🀝 貢献する + +貢献はオヌプン゜ヌスコミュニティを孊び、むンスピレヌションを䞎え、創造するための玠晎らしい堎所にしおいるものです。皆様の貢献は**非垞に感謝されおいたす**。 + +1. プロゞェクトを **Fork** する +2. ロヌカルに **Clone** する +3. **フィヌチャヌブランチ**を䜜成する (`git checkout -b feature/AmazingFeature`) +4. 倉曎を **Commit** する (`git commit -m 'Add some AmazingFeature'`) +5. ブランチを **Push** する (`git push origin feature/AmazingFeature`) +6. **Pull Request** を䜜成する + +> 💡 **コントリビュヌタヌリストに参加したいですか** コヌド内の `TODO` タグや [`docs/OPEN_SOURCE_RELEASE_CHECKLIST.md`](./docs/OPEN_SOURCE_RELEASE_CHECKLIST.md) の保留䞭のタスクを確認しおください。 + +--- + +## 🎚 アヌト資産ずラむセンス + +### 資産の出兞 + +ゲストキャラクタヌのアニメヌションには **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 + +[![Star History Chart](https://api.star-history.com/image?repos=ringhyacinth/Star-Office-UI&type=date&legend=top-left)](https://www.star-history.com/?repos=ringhyacinth%2FStar-Office-UI&type=date&legend=top-left) diff --git a/README.md b/README.md index de6ead6a..7cc65621 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,9 @@ https://github.com/ringhyacinth/Star-Office-UI/blob/master/SKILL.md ### 方匏二30 秒手劚郚眲 -> **环境芁求Python 3.10+**代码䜿甚了 `X | Y` union type 语法䞍支持 3.9 及曎䜎版本 +> **环境芁求Python 3.8+** +> +> Windows 甚户请看[docs/WINDOWS_SETUP.md](./docs/WINDOWS_SETUP.md) ```bash # 1) 䞋蜜仓库 @@ -90,6 +92,8 @@ python3 set_state.py idle "埅呜䞭" ## 🚀 诊细郚眲指南 +> Windows 甚户请看[docs/WINDOWS_SETUP.md](./docs/WINDOWS_SETUP.md) + ### 1) 安装䟝赖 ```bash @@ -177,9 +181,9 @@ python3 scripts/smoke_test.py --base-url http://127.0.0.1:19000 访客只需䞋蜜 `office-agent-push.py`填写 3 䞪变量即可 ```python -JOIN_KEY = "ocj_starteam02" # 䜠分配的 key -AGENT_NAME = "小明的韙號" # 星瀺名称 -OFFICE_URL = "https://office.hyacinth.im" # 䜠的办公宀地址 +JOIN_KEY = "your-join-key" # 䜠分配的 key +AGENT_NAME = "My Agent" # 星瀺名称 +OFFICE_URL = "https://your-office.example.com" # 䜠的办公宀地址 ``` ```bash @@ -234,6 +238,21 @@ npm run dev --- +## 🀝 参䞎莡献 + +我们非垞欢迎各种圢匏的莡献无论是修倍 Bug、改进文档还是提出新的功胜建议。 + +1. **Fork** 本仓库 +2. **Clone** 䜠的 Fork 仓库到本地 +3. **创建分支**: `git checkout -b feature/your-feature-name` +4. **提亀曎改**: `git commit -m "feat: add some amazing feature"` +5. **掚送分支**: `git push origin feature/your-feature-name` +6. **匀启 Pull Request** + +> 💡 **想芁出现圚莡献抜** 关泚项目䞭的 `TODO` 标记或 [`docs/OPEN_SOURCE_RELEASE_CHECKLIST.md`](./docs/OPEN_SOURCE_RELEASE_CHECKLIST.md) 䞭的埅办事项。 + +--- + ## 🎚 矎术资产䞎匀源讞可 ### 资产来源 diff --git a/backend/app.py b/backend/app.py index f64c6757..95911ee9 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,2103 +1,2146 @@ -#!/usr/bin/env python3 -"""Star Office UI - Backend State Service""" - -from flask import Flask, jsonify, send_from_directory, make_response, request, session -from datetime import datetime, timedelta -import json -import os -import random -import math -import re -import shutil -import subprocess -import tempfile -import threading -from pathlib import Path -from security_utils import is_production_mode, is_strong_secret, is_strong_drawer_pass -from memo_utils import get_yesterday_date_str, sanitize_content, extract_memo_from_file -from store_utils import ( - load_agents_state as _store_load_agents_state, - save_agents_state as _store_save_agents_state, - load_asset_positions as _store_load_asset_positions, - save_asset_positions as _store_save_asset_positions, - load_asset_defaults as _store_load_asset_defaults, - save_asset_defaults as _store_save_asset_defaults, - load_runtime_config as _store_load_runtime_config, - save_runtime_config as _store_save_runtime_config, - load_join_keys as _store_load_join_keys, - save_join_keys as _store_save_join_keys, -) - -try: - from PIL import Image -except Exception: - Image = None - -# Paths (project-relative, no hardcoded absolute paths) -ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -MEMORY_DIR = os.path.join(os.path.dirname(ROOT_DIR), "memory") -FRONTEND_DIR = os.path.join(ROOT_DIR, "frontend") -FRONTEND_INDEX_FILE = os.path.join(FRONTEND_DIR, "index.html") -FRONTEND_ELECTRON_STANDALONE_FILE = os.path.join(FRONTEND_DIR, "electron-standalone.html") -STATE_FILE = os.path.join(ROOT_DIR, "state.json") -AGENTS_STATE_FILE = os.path.join(ROOT_DIR, "agents-state.json") -JOIN_KEYS_FILE = os.path.join(ROOT_DIR, "join-keys.json") -FRONTEND_PATH = Path(FRONTEND_DIR) -ASSET_ALLOWED_EXTS = {".png", ".webp", ".jpg", ".jpeg", ".gif", ".svg", ".avif"} -ASSET_TEMPLATE_ZIP = os.path.join(ROOT_DIR, "assets-replace-template.zip") -WORKSPACE_DIR = os.path.dirname(ROOT_DIR) -OPENCLAW_WORKSPACE = os.environ.get("OPENCLAW_WORKSPACE") or os.path.join(os.path.expanduser("~"), ".openclaw", "workspace") -IDENTITY_FILE = os.path.join(OPENCLAW_WORKSPACE, "IDENTITY.md") -GEMINI_SCRIPT = os.path.join(WORKSPACE_DIR, "skills", "gemini-image-generate", "scripts", "gemini_image_generate.py") -GEMINI_PYTHON = os.path.join(WORKSPACE_DIR, "skills", "gemini-image-generate", ".venv", "bin", "python") -ROOM_REFERENCE_IMAGE = ( - os.path.join(ROOT_DIR, "assets", "room-reference.webp") - if os.path.exists(os.path.join(ROOT_DIR, "assets", "room-reference.webp")) - else os.path.join(ROOT_DIR, "assets", "room-reference.png") -) -BG_HISTORY_DIR = os.path.join(ROOT_DIR, "assets", "bg-history") -HOME_FAVORITES_DIR = os.path.join(ROOT_DIR, "assets", "home-favorites") -HOME_FAVORITES_INDEX_FILE = os.path.join(HOME_FAVORITES_DIR, "index.json") -HOME_FAVORITES_MAX = 30 -ASSET_POSITIONS_FILE = os.path.join(ROOT_DIR, "asset-positions.json") - -# 性胜保技默讀关闭“每次打匀页面随机换背景”避免銖页銖屏被磁盘倍制拖慢 -AUTO_ROTATE_HOME_ON_PAGE_OPEN = (os.getenv("AUTO_ROTATE_HOME_ON_PAGE_OPEN", "0").strip().lower() in {"1", "true", "yes", "on"}) -AUTO_ROTATE_MIN_INTERVAL_SECONDS = int(os.getenv("AUTO_ROTATE_MIN_INTERVAL_SECONDS", "60")) -_last_home_rotate_at = 0 -ASSET_DEFAULTS_FILE = os.path.join(ROOT_DIR, "asset-defaults.json") -RUNTIME_CONFIG_FILE = os.path.join(ROOT_DIR, "runtime-config.json") - -# Canonical agent states: single source of truth for validation and mapping -VALID_AGENT_STATES = frozenset({"idle", "writing", "researching", "executing", "syncing", "error"}) -WORKING_STATES = frozenset({"writing", "researching", "executing"}) # subset used for auto-idle TTL -STATE_TO_AREA_MAP = { - "idle": "breakroom", - "writing": "writing", - "researching": "writing", - "executing": "writing", - "syncing": "writing", - "error": "error", -} - - -app = Flask(__name__, static_folder=FRONTEND_DIR, static_url_path="/static") -app.secret_key = os.getenv("FLASK_SECRET_KEY") or os.getenv("STAR_OFFICE_SECRET") or "star-office-dev-secret-change-me" - -# Session hardening -app.config.update( - SESSION_COOKIE_HTTPONLY=True, - SESSION_COOKIE_SAMESITE="Lax", - SESSION_COOKIE_SECURE=is_production_mode(), - PERMANENT_SESSION_LIFETIME=timedelta(hours=12), -) - -# Guard join-agent critical section to enforce per-key concurrency under parallel requests -join_lock = threading.Lock() - -# Async background task registry for long-running operations (e.g. image generation) -# Avoids Cloudflare 524 timeout (100s limit) by letting frontend poll for completion. -_bg_tasks = {} # task_id -> {"status": "pending"|"done"|"error", "result": ..., "error": ..., "created_at": ...} -_bg_tasks_lock = threading.Lock() - -# Generate a version timestamp once at server startup for cache busting -VERSION_TIMESTAMP = datetime.now().strftime("%Y%m%d_%H%M%S") -ASSET_DRAWER_PASS_DEFAULT = os.getenv("ASSET_DRAWER_PASS", "1234") - -if is_production_mode(): - hardening_errors = [] - if not is_strong_secret(str(app.secret_key)): - hardening_errors.append("FLASK_SECRET_KEY / STAR_OFFICE_SECRET is weak (need >=24 chars, non-default)") - if not is_strong_drawer_pass(ASSET_DRAWER_PASS_DEFAULT): - hardening_errors.append("ASSET_DRAWER_PASS is weak (do not use default 1234; recommend >=8 chars)") - if hardening_errors: - raise RuntimeError("Security hardening check failed in production mode: " + "; ".join(hardening_errors)) - - -def _is_asset_editor_authed() -> bool: - return bool(session.get("asset_editor_authed")) - - -def _require_asset_editor_auth(): - if _is_asset_editor_authed(): - return None - return jsonify({"ok": False, "code": "UNAUTHORIZED", "msg": "Asset editor auth required"}), 401 - - -@app.after_request -def add_no_cache_headers(response): - """Apply cache policy by path: - - HTML/API/state: no-cache (always fresh) - - /static assets (2xx only): long cache (filenames are versioned with ?v=VERSION_TIMESTAMP) - - /static assets (non-2xx, e.g. 404): no-cache to prevent CDN from caching errors - """ - path = (request.path or "") - if path.startswith('/static/') and 200 <= response.status_code < 300: - response.headers["Cache-Control"] = "public, max-age=31536000, immutable" - response.headers.pop("Pragma", None) - response.headers.pop("Expires", None) - else: - response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0" - response.headers["Pragma"] = "no-cache" - response.headers["Expires"] = "0" - return response - -# Default state -DEFAULT_STATE = { - "state": "idle", - "detail": "等埅任务䞭...", - "progress": 0, - "updated_at": datetime.now().isoformat() -} - - -def load_state(): - """Load state from file. - - Includes a simple auto-idle mechanism: - - If the last update is older than ttl_seconds (default 25s) - and the state is a "working" state, we fall back to idle. - - This avoids the UI getting stuck at the desk when no new updates arrive. - """ - state = None - if os.path.exists(STATE_FILE): - try: - with open(STATE_FILE, "r", encoding="utf-8") as f: - state = json.load(f) - except Exception: - state = None - - if not isinstance(state, dict): - state = dict(DEFAULT_STATE) - - # Auto-idle - try: - ttl = int(state.get("ttl_seconds", 300)) - updated_at = state.get("updated_at") - s = state.get("state", "idle") - if updated_at and s in WORKING_STATES: - # tolerate both with/without timezone - dt = datetime.fromisoformat(updated_at.replace("Z", "+00:00")) - # Use UTC for aware datetimes; local time for naive. - if dt.tzinfo: - from datetime import timezone - age = (datetime.now(timezone.utc) - dt.astimezone(timezone.utc)).total_seconds() - else: - age = (datetime.now() - dt).total_seconds() - if age > ttl: - state["state"] = "idle" - state["detail"] = "埅呜䞭自劚回到䌑息区" - state["progress"] = 0 - state["updated_at"] = datetime.now().isoformat() - # persist the auto-idle so every client sees it consistently - try: - save_state(state) - except Exception: - pass - except Exception: - pass - - return state - - -def get_office_name_from_identity(): - """Read office display name from OpenClaw workspace IDENTITY.md (Name field) -> 'XXX的办公宀'.""" - if not os.path.isfile(IDENTITY_FILE): - return None - try: - with open(IDENTITY_FILE, "r", encoding="utf-8") as f: - content = f.read() - m = re.search(r"-\s*\*\*Name:\*\*\s*(.+)", content) - if m: - name = m.group(1).strip().replace("\r", "").split("\n")[0].strip() - return f"{name}的办公宀" if name else None - except Exception: - pass - return None - - -def save_state(state: dict): - """Save state to file""" - with open(STATE_FILE, "w", encoding="utf-8") as f: - json.dump(state, f, ensure_ascii=False, indent=2) - - -def ensure_electron_standalone_snapshot(): - """Create Electron standalone frontend snapshot once if missing. - - The snapshot is intentionally decoupled from the browser page: - - browser uses frontend/index.html - - Electron uses frontend/electron-standalone.html - """ - if os.path.exists(FRONTEND_ELECTRON_STANDALONE_FILE): - return - try: - shutil.copy2(FRONTEND_INDEX_FILE, FRONTEND_ELECTRON_STANDALONE_FILE) - print(f"[standalone] created: {FRONTEND_ELECTRON_STANDALONE_FILE}") - except Exception as e: - print(f"[standalone] create failed: {e}") - - -# Initialize state -if not os.path.exists(STATE_FILE): - save_state(DEFAULT_STATE) -ensure_electron_standalone_snapshot() - - -_INDEX_HTML_CACHE = None - - -@app.route("/", methods=["GET"]) -def index(): - """Serve the pixel office UI with built-in version cache busting""" - # 默讀犁甚页面打匀即换背景避免銖屏慢 - # 劂需启甚可配眮 AUTO_ROTATE_HOME_ON_PAGE_OPEN=1 - _maybe_apply_random_home_favorite() - - global _INDEX_HTML_CACHE - if _INDEX_HTML_CACHE is None: - with open(FRONTEND_INDEX_FILE, "r", encoding="utf-8") as f: - raw_html = f.read() - _INDEX_HTML_CACHE = raw_html.replace("{{VERSION_TIMESTAMP}}", VERSION_TIMESTAMP) - - resp = make_response(_INDEX_HTML_CACHE) - resp.headers["Content-Type"] = "text/html; charset=utf-8" - return resp - - -@app.route("/electron-standalone", methods=["GET"]) -def electron_standalone_page(): - """Serve Electron-only standalone frontend page.""" - ensure_electron_standalone_snapshot() - target = FRONTEND_ELECTRON_STANDALONE_FILE - if not os.path.exists(target): - target = FRONTEND_INDEX_FILE - with open(target, "r", encoding="utf-8") as f: - html = f.read() - html = html.replace("{{VERSION_TIMESTAMP}}", VERSION_TIMESTAMP) - resp = make_response(html) - resp.headers["Content-Type"] = "text/html; charset=utf-8" - return resp - - resp.headers["Content-Type"] = "text/html; charset=utf-8" - return resp - - -@app.route("/join", methods=["GET"]) -def join_page(): - """Serve the agent join page""" - with open(os.path.join(FRONTEND_DIR, "join.html"), "r", encoding="utf-8") as f: - html = f.read() - resp = make_response(html) - resp.headers["Content-Type"] = "text/html; charset=utf-8" - return resp - - -@app.route("/invite", methods=["GET"]) -def invite_page(): - """Serve human-facing invite instruction page""" - with open(os.path.join(FRONTEND_DIR, "invite.html"), "r", encoding="utf-8") as f: - html = f.read() - resp = make_response(html) - resp.headers["Content-Type"] = "text/html; charset=utf-8" - return resp - - -DEFAULT_AGENTS = [ - { - "agentId": "star", - "name": "Star", - "isMain": True, - "state": "idle", - "detail": "埅呜䞭随时准倇䞺䜠服务", - "updated_at": datetime.now().isoformat(), - "area": "breakroom", - "source": "local", - "joinKey": None, - "authStatus": "approved", - "authExpiresAt": None, - "lastPushAt": None - } -] - - -def load_agents_state(): - return _store_load_agents_state(AGENTS_STATE_FILE, DEFAULT_AGENTS) - - -def save_agents_state(agents): - _store_save_agents_state(AGENTS_STATE_FILE, agents) - - -def load_asset_positions(): - return _store_load_asset_positions(ASSET_POSITIONS_FILE) - - -def save_asset_positions(data): - _store_save_asset_positions(ASSET_POSITIONS_FILE, data) - - -def load_asset_defaults(): - return _store_load_asset_defaults(ASSET_DEFAULTS_FILE) - - -def save_asset_defaults(data): - _store_save_asset_defaults(ASSET_DEFAULTS_FILE, data) - - -def load_runtime_config(): - return _store_load_runtime_config(RUNTIME_CONFIG_FILE) - - -def save_runtime_config(data): - _store_save_runtime_config(RUNTIME_CONFIG_FILE, data) - - -def _ensure_home_favorites_index(): - os.makedirs(HOME_FAVORITES_DIR, exist_ok=True) - if not os.path.exists(HOME_FAVORITES_INDEX_FILE): - with open(HOME_FAVORITES_INDEX_FILE, "w", encoding="utf-8") as f: - json.dump({"items": []}, f, ensure_ascii=False, indent=2) - - -def _load_home_favorites_index(): - _ensure_home_favorites_index() - try: - with open(HOME_FAVORITES_INDEX_FILE, "r", encoding="utf-8") as f: - data = json.load(f) - if isinstance(data, dict) and isinstance(data.get("items"), list): - return data - except Exception: - pass - return {"items": []} - - -def _save_home_favorites_index(data): - _ensure_home_favorites_index() - with open(HOME_FAVORITES_INDEX_FILE, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) - - -def _maybe_apply_random_home_favorite(): - """On page open, randomly apply one saved home favorite if available.""" - global _last_home_rotate_at - - if not AUTO_ROTATE_HOME_ON_PAGE_OPEN: - return False, "disabled" - - try: - now_ts = datetime.now().timestamp() - if _last_home_rotate_at and (now_ts - _last_home_rotate_at) < AUTO_ROTATE_MIN_INTERVAL_SECONDS: - return False, "throttled" - - idx = _load_home_favorites_index() - items = idx.get("items") or [] - candidates = [] - for it in items: - rel = (it.get("path") or "").strip() - if not rel: - continue - abs_path = os.path.join(ROOT_DIR, rel) - if os.path.exists(abs_path): - candidates.append((rel, abs_path)) - - if not candidates: - return False, "no-favorites" - - rel, src = random.choice(candidates) - target = FRONTEND_PATH / "office_bg_small.webp" - if not target.exists(): - return False, "missing-office-bg" - - shutil.copy2(src, str(target)) - _last_home_rotate_at = now_ts - return True, rel - except Exception as e: - return False, str(e) - - -def load_join_keys(): - return _store_load_join_keys(JOIN_KEYS_FILE) - - -def save_join_keys(data): - _store_save_join_keys(JOIN_KEYS_FILE, data) - - -def _ensure_magick_or_ffmpeg_available(): - if shutil.which("magick"): - return "magick" - if shutil.which("ffmpeg"): - return "ffmpeg" - return None - - -def _probe_animated_frame_size(upload_path: str): - """Return (w,h) from first frame if possible.""" - if Image is not None: - try: - with Image.open(upload_path) as im: - w, h = im.size - return int(w), int(h) - except Exception: - pass - # ffprobe fallback - if shutil.which("ffprobe"): - try: - cmd = [ - "ffprobe", "-v", "error", - "-select_streams", "v:0", - "-show_entries", "stream=width,height", - "-of", "csv=p=0:s=x", - upload_path, - ] - out = subprocess.check_output(cmd, stderr=subprocess.STDOUT, timeout=5).decode().strip() - if "x" in out: - w, h = out.split("x", 1) - return int(w), int(h) - except Exception: - pass - return None, None - - -def _animated_to_spritesheet( - upload_path: str, - frame_w: int, - frame_h: int, - out_ext: str = ".webp", - preserve_original: bool = True, - pixel_art: bool = True, - cols: int | None = None, - rows: int | None = None, -): - """Convert animated GIF/WEBP to spritesheet, return (out_path, columns, rows, frames, out_frame_w, out_frame_h).""" - backend = _ensure_magick_or_ffmpeg_available() - if not backend: - raise RuntimeError("未检测到 ImageMagick/ffmpeg无法自劚蜬换劚囟") - - ext = (out_ext or ".webp").lower() - if ext not in {".webp", ".png"}: - ext = ".webp" - - out_fd, out_path = tempfile.mkstemp(suffix=ext) - os.close(out_fd) - - with tempfile.TemporaryDirectory() as td: - frames = 0 - out_fw, out_fh = int(frame_w), int(frame_h) - if Image is not None: - try: - with Image.open(upload_path) as im: - n = getattr(im, "n_frames", 1) - # 默讀保留甚户原始垧尺寞避免先压猩再攟倧富臎像玠糊 - if preserve_original: - out_fw, out_fh = im.size - for i in range(n): - im.seek(i) - fr = im.convert("RGBA") - if not preserve_original and (fr.size != (out_fw, out_fh)): - resample = Image.Resampling.NEAREST if pixel_art else Image.Resampling.LANCZOS - fr = fr.resize((out_fw, out_fh), resample) - fr.save(os.path.join(td, f"f_{i:04d}.png"), "PNG") - frames = n - except Exception: - frames = 0 - - if frames <= 0: - cmd1 = f"ffmpeg -y -i '{upload_path}' '{td}/f_%04d.png' >/dev/null 2>&1" - if os.system(cmd1) != 0: - raise RuntimeError("劚囟抜垧倱莥Pillow/ffmpeg 郜倱莥") - files = sorted([x for x in os.listdir(td) if x.startswith("f_") and x.endswith(".png")]) - frames = len(files) - if frames <= 0: - raise RuntimeError("劚囟无有效垧") - - if backend == "magick": - # 像玠风劚囟蜬粟灵衚默讀无损避免颜色/蟹猘被压猩糊掉 - quality_flag = "-define webp:lossless=true -define webp:method=6 -quality 100" if ext == ".webp" else "" - # 允讞按 cols/rows 排垃默讀单行 - if cols is None or cols <= 0: - cols_eff = frames - else: - cols_eff = max(1, int(cols)) - rows_eff = max(1, int(rows)) if (rows is not None and rows > 0) else max(1, math.ceil(frames / cols_eff)) - - # 先规范单垧尺寞 - prep = "" - if not preserve_original: - magick_filter = "-filter point" if pixel_art else "" - prep = f" {magick_filter} -resize {out_fw}x{out_fh}^ -gravity center -background none -extent {out_fw}x{out_fh}" - - cmd = ( - f"magick '{td}/f_*.png'{prep} " - f"-tile {cols_eff}x{rows_eff} -background none -geometry +0+0 {quality_flag} '{out_path}'" - ) - rc = os.system(cmd) - if rc != 0: - raise RuntimeError("ImageMagick 拌囟倱莥") - return out_path, cols_eff, rows_eff, frames, out_fw, out_fh - - ffmpeg_quality = "-lossless 1 -compression_level 6 -q:v 100" if ext == ".webp" else "" - cols_eff = max(1, int(cols)) if (cols is not None and cols > 0) else frames - rows_eff = max(1, int(rows)) if (rows is not None and rows > 0) else max(1, math.ceil(frames / cols_eff)) - if preserve_original: - vf = f"tile={cols_eff}x{rows_eff}" - else: - scale_algo = "neighbor" if pixel_art else "lanczos" - vf = ( - f"scale={out_fw}:{out_fh}:force_original_aspect_ratio=decrease:flags={scale_algo}," - f"pad={out_fw}:{out_fh}:(ow-iw)/2:(oh-ih)/2:color=0x00000000," - f"tile={cols_eff}x{rows_eff}" - ) - cmd2 = ( - f"ffmpeg -y -pattern_type glob -i '{td}/f_*.png' " - f"-vf '{vf}' " - f"{ffmpeg_quality} '{out_path}' >/dev/null 2>&1" - ) - if os.system(cmd2) != 0: - raise RuntimeError("ffmpeg 拌囟倱莥") - return out_path, frames, 1, frames, out_fw, out_fh - - -def normalize_agent_state(s): - """Normalize agent state for compatibility. - Maps synonyms (e.g. working/busy -> writing, run/running -> executing) into VALID_AGENT_STATES. - Returns 'idle' for unknown values. - """ - if not s: - return 'idle' - s_lower = s.lower().strip() - if s_lower in {'working', 'busy', 'write'}: - return 'writing' - if s_lower in {'run', 'running', 'execute', 'exec'}: - return 'executing' - if s_lower in {'sync'}: - return 'syncing' - if s_lower in {'research', 'search'}: - return 'researching' - if s_lower in VALID_AGENT_STATES: - return s_lower - return 'idle' - - -# User-facing model aliases -> provider model ids -USER_MODEL_TO_PROVIDER_MODELS = { - # 䞥栌按甚户芁求仅䞀种官方暡型映射 - "nanobanana-pro": [ - "nano-banana-pro-preview", - ], - "nanobanana-2": [ - "gemini-2.5-flash-image", - ], -} - -PROVIDER_MODEL_TO_USER_MODEL = { - provider: user - for user, providers in USER_MODEL_TO_PROVIDER_MODELS.items() - for provider in providers -} - - -def _normalize_user_model(model_name: str) -> str: - m = (model_name or "").strip() - if not m: - return "nanobanana-pro" - low = m.lower() - if low in USER_MODEL_TO_PROVIDER_MODELS: - return low - if low in PROVIDER_MODEL_TO_USER_MODEL: - return PROVIDER_MODEL_TO_USER_MODEL[low] - return "nanobanana-pro" - - -def _provider_model_candidates(user_model: str): - normalized = _normalize_user_model(user_model) - return list(USER_MODEL_TO_PROVIDER_MODELS.get(normalized, USER_MODEL_TO_PROVIDER_MODELS["nanobanana-pro"])) - - -def _generate_rpg_background_to_webp(out_webp_path: str, width: int = 1280, height: int = 720, custom_prompt: str = "", speed_mode: str = "fast"): - """Generate RPG-style room background and save as webp. - - speed_mode: - - fast: use nanobanana-2 + 1024x576 intermediate + downscaled reference (faster) - - quality: use configured model (fallback nanobanana-pro) + full 1280x720 path - """ - runtime_cfg = load_runtime_config() - api_key = (runtime_cfg.get("gemini_api_key") or "").strip() - if not api_key: - raise RuntimeError("MISSING_API_KEY") - themes = [ - "8-bit dungeon guild room", - "8-bit stardew-valley inspired cozy farm tavern", - "8-bit nordic fantasy tavern", - "8-bit magitech workshop", - "8-bit elven forest inn", - "8-bit pixel cyber tavern", - "8-bit desert caravan inn", - "8-bit snow mountain lodge", - ] - theme = random.choice(themes) - - if not (os.path.exists(GEMINI_PYTHON) and os.path.exists(GEMINI_SCRIPT)): - raise RuntimeError("生囟脚本环境猺倱gemini-image-generate 未安装") - - style_hint = (custom_prompt or "").strip() - if not style_hint: - style_hint = theme - - # 默讀䜿甚曎皳劥的 quality 档避免 fast 暡型圚郚分 API 通道䞍可甚 - mode = (speed_mode or "quality").strip().lower() - if mode not in {"fast", "quality"}: - mode = "quality" - - configured_user_model = _normalize_user_model(runtime_cfg.get("gemini_model") or "nanobanana-pro") - if mode == "fast": - preferred_user_model = "nanobanana-2" - # fast 也提高基础枅晰床从 1024x576 提升到 1152x648牺牲少量速床 - gen_width, gen_height = 1152, 648 - ref_width, ref_height = 1152, 648 - else: - preferred_user_model = configured_user_model - gen_width, gen_height = width, height - ref_width, ref_height = width, height - - # 同时规避可胜觊发 400 的特殊胜力参数 - # 仅 nanobanana-2 èµ° aspect-rationanobanana-pro 亀给暡型默讀比䟋后续再标准化到 1280x720 - allow_aspect_ratio = (preferred_user_model == "nanobanana-2") - - prompt = ( - "Use a top-down pixel room composition compatible with an office game scene. " - "STRICTLY preserve the same room geometry, camera angle, wall/floor boundaries and major object placement as the provided reference image. " - "Keep region layout stable (left work area, center lounge, right error area). " - "Only change visual style/theme/material/lighting according to: " + style_hint + ". " - "Do not add text or watermark. Retro 8-bit RPG style." - ) - - tmp_dir = tempfile.mkdtemp(prefix="rpg-bg-") - cmd = [ - GEMINI_PYTHON, - GEMINI_SCRIPT, - "--prompt", prompt, - "--model", configured_user_model, - "--out-dir", tmp_dir, - "--cleanup", - ] - if allow_aspect_ratio: - cmd.extend(["--aspect-ratio", "16:9"]) - - # 区纊束每次郜垊固定参考囟保持房闎区域垃局䞍挂移 - ref_for_call = None - if os.path.exists(ROOM_REFERENCE_IMAGE): - ref_for_call = ROOM_REFERENCE_IMAGE - if mode == "fast" and Image is not None: - try: - ref_fast = os.path.join(tmp_dir, "room-reference-fast.webp") - with Image.open(ROOM_REFERENCE_IMAGE) as rim: - rim = rim.convert("RGBA").resize((ref_width, ref_height), Image.Resampling.LANCZOS) - rim.save(ref_fast, "WEBP", quality=85, method=4) - ref_for_call = ref_fast - except Exception: - ref_for_call = ROOM_REFERENCE_IMAGE - - if ref_for_call: - cmd.extend(["--reference-image", ref_for_call]) - - env = os.environ.copy() - # 运行时配眮䌘先只保留 GEMINI_API_KEY避免脚本因双 key 报错 - env.pop("GOOGLE_API_KEY", None) - env["GEMINI_API_KEY"] = api_key - - def _run_cmd(cmd_args): - return subprocess.run(cmd_args, capture_output=True, text=True, env=env, timeout=240) - - def _is_model_unavailable_error(text: str) -> bool: - low = (text or "").strip().lower() - return ( - ("not found" in low and "models/" in low) - or ("model_not_available" in low) - or ("model is not available" in low) - or ("configured model is not available" in low) - or ("this model is not available" in low) - or ("not supported for generatecontent" in low) - ) - - def _with_model(cmd_args, model_name: str): - m = cmd_args[:] - if "--model" in m: - idx = m.index("--model") - if idx + 1 < len(m): - m[idx + 1] = model_name - else: - m.extend(["--model", model_name]) - return m - - # 暡型倚级回退仅允讞䞀类甚户暡型nanobanana-pro / nanobanana-2 - # 每䞪甚户暡型映射到若干 provider 真实暡型。 - user_model_order = [preferred_user_model, configured_user_model] - user_model_order = [m for i, m in enumerate(user_model_order) if m and m not in user_model_order[:i]] - - model_candidates = [] - for um in user_model_order: - model_candidates.extend(_provider_model_candidates(um)) - # 去重并枅理空项 - model_candidates = [m for i, m in enumerate(model_candidates) if m and m not in model_candidates[:i]] - - proc = None - last_err_text = "" - model_unavailable_count = 0 - - for mname in model_candidates: - env["GEMINI_MODEL"] = mname - try_cmd = _with_model(cmd, mname) - proc = _run_cmd(try_cmd) - if proc.returncode == 0: - break - - err_text = (proc.stderr or proc.stdout or "").strip() - last_err_text = err_text - - # key 倱效/泄挏立即终止䞍继续尝试 - low = err_text.lower() - if "your api key was reported as leaked" in low or "permission_denied" in low: - raise RuntimeError("API_KEY_REVOKED_OR_LEAKED") - - if _is_model_unavailable_error(err_text): - model_unavailable_count += 1 - continue - - # 非暡型䞍可甚错误盎接返回真实错误 - raise RuntimeError(f"生囟倱莥: {err_text}") - - if proc is None or proc.returncode != 0: - err_text = (last_err_text or "").strip() - if model_unavailable_count >= len(model_candidates) or _is_model_unavailable_error(err_text): - brief = (err_text or "").replace("\n", " ")[:240] - raise RuntimeError(f"MODEL_NOT_AVAILABLE::{brief}") - raise RuntimeError(f"生囟倱莥: {err_text}") - - try: - result = json.loads(proc.stdout.strip().splitlines()[-1]) - except Exception: - raise RuntimeError("生囟结果解析倱莥") - - files = result.get("files") or [] - if not files: - raise RuntimeError("生囟未返回文件") - - gen_path = files[0] - if not os.path.exists(gen_path): - raise RuntimeError("生囟文件䞍存圚") - - if Image is None: - raise RuntimeError("Pillow 䞍可甚无法做尺寞标准化") - - with Image.open(gen_path) as im: - im = im.convert("RGBA") - # 莚量暡匏䌘先保细节快速暡匏䌘先速床 - if mode == "fast": - im = im.resize((gen_width, gen_height), Image.Resampling.LANCZOS) - if (gen_width, gen_height) != (width, height): - # fast 的攟倧改䞺 LANCZOS牺牲少量速床换曎高细节 - im = im.resize((width, height), Image.Resampling.LANCZOS) - im.save(out_webp_path, "WEBP", quality=96, method=6) - else: - # quality确保蟓出标准尺寞同时䜿甚无损 webp减少压猩损倱 - if im.size != (width, height): - im = im.resize((width, height), Image.Resampling.LANCZOS) - im.save(out_webp_path, "WEBP", lossless=True, quality=100, method=6) - - -def state_to_area(state): - """Map agent state to office area (breakroom / writing / error).""" - return STATE_TO_AREA_MAP.get(state, "breakroom") - - -# Ensure files exist -if not os.path.exists(AGENTS_STATE_FILE): - save_agents_state(DEFAULT_AGENTS) -if not os.path.exists(JOIN_KEYS_FILE): - if os.path.exists(os.path.join(ROOT_DIR, "join-keys.sample.json")): - try: - with open(os.path.join(ROOT_DIR, "join-keys.sample.json"), "r", encoding="utf-8") as sf: - sample = json.load(sf) - save_join_keys(sample if isinstance(sample, dict) else {"keys": []}) - except Exception: - save_join_keys({"keys": []}) - else: - save_join_keys({"keys": []}) - -# Tighten runtime-config file perms if exists -if os.path.exists(RUNTIME_CONFIG_FILE): - try: - os.chmod(RUNTIME_CONFIG_FILE, 0o600) - except Exception: - pass - - -@app.route("/agents", methods=["GET"]) -def get_agents(): - """Get full agents list (for multi-agent UI), with auto-cleanup on access""" - agents = load_agents_state() - now = datetime.now() - - cleaned_agents = [] - keys_data = load_join_keys() - - for a in agents: - if a.get("isMain"): - cleaned_agents.append(a) - continue - - auth_expires_at_str = a.get("authExpiresAt") - auth_status = a.get("authStatus", "pending") - - # 1) 超时未批准自劚 leave - if auth_status == "pending" and auth_expires_at_str: - try: - auth_expires_at = datetime.fromisoformat(auth_expires_at_str) - if now > auth_expires_at: - key = a.get("joinKey") - if key: - key_item = next((k for k in keys_data.get("keys", []) if k.get("key") == key), None) - if key_item: - key_item["used"] = False - key_item["usedBy"] = None - key_item["usedByAgentId"] = None - key_item["usedAt"] = None - continue - except Exception: - pass - - # 2) 超时未掚送自劚犻线超过5分钟 - last_push_at_str = a.get("lastPushAt") - if auth_status == "approved" and last_push_at_str: - try: - last_push_at = datetime.fromisoformat(last_push_at_str) - age = (now - last_push_at).total_seconds() - if age > 300: # 5分钟无掚送自劚犻线 - a["authStatus"] = "offline" - except Exception: - pass - - cleaned_agents.append(a) - - save_agents_state(cleaned_agents) - save_join_keys(keys_data) - - return jsonify(cleaned_agents) - - -@app.route("/agent-approve", methods=["POST"]) -def agent_approve(): - """Approve an agent (set authStatus to approved)""" - try: - data = request.get_json() - agent_id = (data.get("agentId") or "").strip() - if not agent_id: - return jsonify({"ok": False, "msg": "猺少 agentId"}), 400 - - agents = load_agents_state() - target = next((a for a in agents if a.get("agentId") == agent_id and not a.get("isMain")), None) - if not target: - return jsonify({"ok": False, "msg": "未扟到 agent"}), 404 - - target["authStatus"] = "approved" - target["authApprovedAt"] = datetime.now().isoformat() - target["authExpiresAt"] = (datetime.now() + timedelta(hours=24)).isoformat() # 默讀授权24h - - save_agents_state(agents) - return jsonify({"ok": True, "agentId": agent_id, "authStatus": "approved"}) - except Exception as e: - return jsonify({"ok": False, "msg": str(e)}), 500 - - -@app.route("/agent-reject", methods=["POST"]) -def agent_reject(): - """Reject an agent (set authStatus to rejected and optionally revoke key)""" - try: - data = request.get_json() - agent_id = (data.get("agentId") or "").strip() - if not agent_id: - return jsonify({"ok": False, "msg": "猺少 agentId"}), 400 - - agents = load_agents_state() - target = next((a for a in agents if a.get("agentId") == agent_id and not a.get("isMain")), None) - if not target: - return jsonify({"ok": False, "msg": "未扟到 agent"}), 404 - - target["authStatus"] = "rejected" - target["authRejectedAt"] = datetime.now().isoformat() - - # Optionally free join key back to unused - join_key = target.get("joinKey") - keys_data = load_join_keys() - if join_key: - key_item = next((k for k in keys_data.get("keys", []) if k.get("key") == join_key), None) - if key_item: - key_item["used"] = False - key_item["usedBy"] = None - key_item["usedByAgentId"] = None - key_item["usedAt"] = None - - # Remove from agents list - agents = [a for a in agents if a.get("agentId") != agent_id or a.get("isMain")] - - save_agents_state(agents) - save_join_keys(keys_data) - return jsonify({"ok": True, "agentId": agent_id, "authStatus": "rejected"}) - except Exception as e: - return jsonify({"ok": False, "msg": str(e)}), 500 - - -@app.route("/join-agent", methods=["POST"]) -def join_agent(): - """Add a new agent with one-time join key validation and pending auth""" - try: - data = request.get_json() - if not isinstance(data, dict) or not data.get("name"): - return jsonify({"ok": False, "msg": "请提䟛名字"}), 400 - - name = data["name"].strip() - state = data.get("state", "idle") - detail = data.get("detail", "") - join_key = data.get("joinKey", "").strip() - - # Normalize state early for compatibility - state = normalize_agent_state(state) - - if not join_key: - return jsonify({"ok": False, "msg": "请提䟛接入密钥"}), 400 - - keys_data = load_join_keys() - key_item = next((k for k in keys_data.get("keys", []) if k.get("key") == join_key), None) - if not key_item: - return jsonify({"ok": False, "msg": "接入密钥无效"}), 403 - # key 可倍甚䞍再因䞺 used=true 拒绝 - - with join_lock: - # 圚锁内重新读取避免并发请求郜基于同䞀旧快照通过校验 - keys_data = load_join_keys() - key_item = next((k for k in keys_data.get("keys", []) if k.get("key") == join_key), None) - if not key_item: - return jsonify({"ok": False, "msg": "接入密钥无效"}), 403 - - # Key-level expiration check - key_expires_at_str = key_item.get("expiresAt") - if key_expires_at_str: - try: - key_expires_at = datetime.fromisoformat(key_expires_at_str) - if datetime.now() > key_expires_at: - return jsonify({"ok": False, "msg": "该接入密钥已过期掻劚已结束 🎉"}), 403 - except Exception: - pass - - agents = load_agents_state() - - # 并发䞊限同䞀䞪 key “同时圚线”最倚 3 䞪。 - # 圚线刀定lastPushAt/updated_at 圚 5 分钟内吊则视䞺 offline䞍计入并发。 - now = datetime.now() - existing = next((a for a in agents if a.get("name") == name and not a.get("isMain")), None) - existing_id = existing.get("agentId") if existing else None - - def _age_seconds(dt_str): - if not dt_str: - return None - try: - dt = datetime.fromisoformat(dt_str) - return (now - dt).total_seconds() - except Exception: - return None - - # opportunistic offline marking - for a in agents: - if a.get("isMain"): - continue - if a.get("authStatus") != "approved": - continue - age = _age_seconds(a.get("lastPushAt")) - if age is None: - age = _age_seconds(a.get("updated_at")) - if age is not None and age > 300: - a["authStatus"] = "offline" - - max_concurrent = int(key_item.get("maxConcurrent", 3)) - active_count = 0 - for a in agents: - if a.get("isMain"): - continue - if a.get("agentId") == existing_id: - continue - if a.get("joinKey") != join_key: - continue - if a.get("authStatus") != "approved": - continue - age = _age_seconds(a.get("lastPushAt")) - if age is None: - age = _age_seconds(a.get("updated_at")) - if age is None or age <= 300: - active_count += 1 - - if active_count >= max_concurrent: - save_agents_state(agents) - return jsonify({"ok": False, "msg": f"该接入密钥圓前并发已蟟䞊限{max_concurrent}请皍后或换及䞀䞪 key"}), 429 - - if existing: - existing["state"] = state - existing["detail"] = detail - existing["updated_at"] = datetime.now().isoformat() - existing["area"] = state_to_area(state) - existing["source"] = "remote-openclaw" - existing["joinKey"] = join_key - existing["authStatus"] = "approved" - existing["authApprovedAt"] = datetime.now().isoformat() - existing["authExpiresAt"] = (datetime.now() + timedelta(hours=24)).isoformat() - existing["lastPushAt"] = datetime.now().isoformat() # join 视䞺䞊线纳入并发/犻线刀定 - if not existing.get("avatar"): - import random - existing["avatar"] = random.choice(["guest_role_1", "guest_role_2", "guest_role_3", "guest_role_4", "guest_role_5", "guest_role_6"]) - agent_id = existing.get("agentId") - else: - # Use ms + random suffix to avoid collisions under concurrent joins - import random - import string - agent_id = "agent_" + str(int(datetime.now().timestamp() * 1000)) + "_" + "".join(random.choices(string.ascii_lowercase + string.digits, k=4)) - agents.append({ - "agentId": agent_id, - "name": name, - "isMain": False, - "state": state, - "detail": detail, - "updated_at": datetime.now().isoformat(), - "area": state_to_area(state), - "source": "remote-openclaw", - "joinKey": join_key, - "authStatus": "approved", - "authApprovedAt": datetime.now().isoformat(), - "authExpiresAt": (datetime.now() + timedelta(hours=24)).isoformat(), - "lastPushAt": datetime.now().isoformat(), - "avatar": random.choice(["guest_role_1", "guest_role_2", "guest_role_3", "guest_role_4", "guest_role_5", "guest_role_6"]) - }) - - key_item["used"] = True - key_item["usedBy"] = name - key_item["usedByAgentId"] = agent_id - key_item["usedAt"] = datetime.now().isoformat() - key_item["reusable"] = True - - # 拿到有效 key 盎接批准䞍再等埅䞻人手劚点击 - # 状态已圚䞊面 existing/new 分支写入 - save_agents_state(agents) - save_join_keys(keys_data) - - return jsonify({"ok": True, "agentId": agent_id, "authStatus": "approved", "nextStep": "已自劚批准立即匀始掚送状态"}) - except Exception as e: - return jsonify({"ok": False, "msg": str(e)}), 500 - - -@app.route("/leave-agent", methods=["POST"]) -def leave_agent(): - """Remove an agent and free its one-time join key for reuse (optional) - - Prefer agentId (stable). Name is accepted for backward compatibility. - """ - try: - data = request.get_json() - if not isinstance(data, dict): - return jsonify({"ok": False, "msg": "invalid json"}), 400 - - agent_id = (data.get("agentId") or "").strip() - name = (data.get("name") or "").strip() - if not agent_id and not name: - return jsonify({"ok": False, "msg": "请提䟛 agentId 或名字"}), 400 - - agents = load_agents_state() - - target = None - if agent_id: - target = next((a for a in agents if a.get("agentId") == agent_id and not a.get("isMain")), None) - if (not target) and name: - # fallback: remove by name only if agentId not provided - target = next((a for a in agents if a.get("name") == name and not a.get("isMain")), None) - - if not target: - return jsonify({"ok": False, "msg": "没有扟到芁犻匀的 agent"}), 404 - - join_key = target.get("joinKey") - new_agents = [a for a in agents if a.get("isMain") or a.get("agentId") != target.get("agentId")] - - # Optional: free key back to unused after leave - keys_data = load_join_keys() - if join_key: - key_item = next((k for k in keys_data.get("keys", []) if k.get("key") == join_key), None) - if key_item: - key_item["used"] = False - key_item["usedBy"] = None - key_item["usedByAgentId"] = None - key_item["usedAt"] = None - - save_agents_state(new_agents) - save_join_keys(keys_data) - return jsonify({"ok": True}) - except Exception as e: - return jsonify({"ok": False, "msg": str(e)}), 500 - - -@app.route("/status", methods=["GET"]) -def get_status(): - """Get current main state (backward compatibility). Optionally include officeName from IDENTITY.md.""" - state = load_state() - office_name = get_office_name_from_identity() - if office_name: - state["officeName"] = office_name - return jsonify(state) - - -@app.route("/agent-push", methods=["POST"]) -def agent_push(): - """Remote openclaw actively pushes status to office. - - Required fields: - - agentId - - joinKey - - state - Optional: - - detail - - name - """ - try: - data = request.get_json() - if not isinstance(data, dict): - return jsonify({"ok": False, "msg": "invalid json"}), 400 - - agent_id = (data.get("agentId") or "").strip() - join_key = (data.get("joinKey") or "").strip() - state = (data.get("state") or "").strip() - detail = (data.get("detail") or "").strip() - name = (data.get("name") or "").strip() - - if not agent_id or not join_key or not state: - return jsonify({"ok": False, "msg": "猺少 agentId/joinKey/state"}), 400 - - state = normalize_agent_state(state) - - keys_data = load_join_keys() - key_item = next((k for k in keys_data.get("keys", []) if k.get("key") == join_key), None) - if not key_item: - return jsonify({"ok": False, "msg": "joinKey 无效"}), 403 - - # Key-level expiration check - key_expires_at_str = key_item.get("expiresAt") - if key_expires_at_str: - try: - key_expires_at = datetime.fromisoformat(key_expires_at_str) - if datetime.now() > key_expires_at: - return jsonify({"ok": False, "msg": "该接入密钥已过期掻劚已结束 🎉"}), 403 - except Exception: - pass - - - agents = load_agents_state() - target = next((a for a in agents if a.get("agentId") == agent_id and not a.get("isMain")), None) - if not target: - return jsonify({"ok": False, "msg": "agent 未泚册请先 join"}), 404 - - # Auth check: only approved agents can push. - # Note: "offline" is a presence state (stale), not a revoked authorization. - # Allow offline agents to resume pushing and auto-promote them back to approved. - auth_status = target.get("authStatus", "pending") - if auth_status not in {"approved", "offline"}: - return jsonify({"ok": False, "msg": "agent 未获授权请等埅䞻人批准"}), 403 - if auth_status == "offline": - target["authStatus"] = "approved" - target["authApprovedAt"] = datetime.now().isoformat() - target["authExpiresAt"] = (datetime.now() + timedelta(hours=24)).isoformat() - - if target.get("joinKey") != join_key: - return jsonify({"ok": False, "msg": "joinKey 䞍匹配"}), 403 - - target["state"] = state - target["detail"] = detail - if name: - target["name"] = name - target["updated_at"] = datetime.now().isoformat() - target["area"] = state_to_area(state) - target["source"] = "remote-openclaw" - target["lastPushAt"] = datetime.now().isoformat() - - save_agents_state(agents) - return jsonify({"ok": True, "agentId": agent_id, "area": target.get("area")}) - except Exception as e: - return jsonify({"ok": False, "msg": str(e)}), 500 - - -@app.route("/health", methods=["GET"]) -def health(): - """Health check""" - return jsonify({ - "status": "ok", - "service": "star-office-ui", - "timestamp": datetime.now().isoformat(), - }) - - -@app.route("/yesterday-memo", methods=["GET"]) -def get_yesterday_memo(): - """获取昚日小日记""" - try: - # 先尝试扟昚倩的文件 - yesterday_str = get_yesterday_date_str() - yesterday_file = os.path.join(MEMORY_DIR, f"{yesterday_str}.md") - - target_file = None - target_date = yesterday_str - - if os.path.exists(yesterday_file): - target_file = yesterday_file - else: - # 劂果昚倩没有扟最近的䞀倩 - if os.path.exists(MEMORY_DIR): - files = [f for f in os.listdir(MEMORY_DIR) if f.endswith(".md") and re.match(r"\d{4}-\d{2}-\d{2}\.md", f)] - if files: - files.sort(reverse=True) - # 跳过今倩的劂果存圚 - today_str = datetime.now().strftime("%Y-%m-%d") - for f in files: - if f != f"{today_str}.md": - target_file = os.path.join(MEMORY_DIR, f) - target_date = f.replace(".md", "") - break - - if target_file and os.path.exists(target_file): - memo_content = extract_memo_from_file(target_file) - return jsonify({ - "success": True, - "date": target_date, - "memo": memo_content - }) - else: - return jsonify({ - "success": False, - "msg": "没有扟到昚日日记" - }) - except Exception as e: - return jsonify({ - "success": False, - "msg": str(e) - }), 500 - - -@app.route("/set_state", methods=["POST"]) -def set_state_endpoint(): - """Set state via POST (for UI control panel)""" - try: - data = request.get_json() - if not isinstance(data, dict): - return jsonify({"status": "error", "msg": "invalid json"}), 400 - state = load_state() - if "state" in data: - s = data["state"] - if s in VALID_AGENT_STATES: - state["state"] = s - if "detail" in data: - state["detail"] = data["detail"] - state["updated_at"] = datetime.now().isoformat() - save_state(state) - return jsonify({"status": "ok"}) - except Exception as e: - return jsonify({"status": "error", "msg": str(e)}), 500 - - -@app.route("/assets/template.zip", methods=["GET"]) -def assets_template_download(): - if not os.path.exists(ASSET_TEMPLATE_ZIP): - return jsonify({"ok": False, "msg": "暡板包䞍存圚请先生成"}), 404 - return send_from_directory(ROOT_DIR, "assets-replace-template.zip", as_attachment=True) - - -@app.route("/assets/list", methods=["GET"]) -def assets_list(): - items = [] - for p in FRONTEND_PATH.rglob("*"): - if not p.is_file(): - continue - rel = p.relative_to(FRONTEND_PATH).as_posix() - if rel.startswith("fonts/"): - continue - if p.suffix.lower() not in ASSET_ALLOWED_EXTS: - continue - st = p.stat() - width = None - height = None - if Image is not None: - try: - with Image.open(p) as im: - width, height = im.size - except Exception: - pass - items.append({ - "path": rel, - "size": st.st_size, - "ext": p.suffix.lower(), - "width": width, - "height": height, - "mtime": datetime.fromtimestamp(st.st_mtime).isoformat(), - }) - items.sort(key=lambda x: x["path"]) - return jsonify({"ok": True, "count": len(items), "items": items}) - - -def _bg_generate_worker(task_id: str, custom_prompt: str, speed_mode: str): - """Background worker for RPG background generation.""" - try: - target = FRONTEND_PATH / "office_bg_small.webp" - - # 芆盖前保留最近䞀次倇仜 - bak = target.with_suffix(target.suffix + ".bak") - shutil.copy2(target, bak) - - _generate_rpg_background_to_webp( - str(target), - width=1280, - height=720, - custom_prompt=custom_prompt, - speed_mode=speed_mode, - ) - - # 每次生成郜園档䞀仜历史底囟可回溯风栌挔化 - os.makedirs(BG_HISTORY_DIR, exist_ok=True) - ts = datetime.now().strftime("%Y%m%d-%H%M%S") - hist_file = os.path.join(BG_HISTORY_DIR, f"office_bg_small-{ts}.webp") - shutil.copy2(target, hist_file) - - st = target.stat() - with _bg_tasks_lock: - _bg_tasks[task_id] = { - "status": "done", - "result": { - "ok": True, - "path": "office_bg_small.webp", - "size": st.st_size, - "history": os.path.relpath(hist_file, ROOT_DIR), - "speed_mode": speed_mode, - "msg": "已生成并替换 RPG 房闎底囟已自劚園档", - }, - } - except Exception as e: - msg = str(e) - error_result = {"ok": False, "msg": msg} - if msg == "MISSING_API_KEY": - error_result["code"] = "MISSING_API_KEY" - error_result["msg"] = "Missing GEMINI_API_KEY or GOOGLE_API_KEY" - elif msg == "API_KEY_REVOKED_OR_LEAKED": - error_result["code"] = "API_KEY_REVOKED_OR_LEAKED" - error_result["msg"] = "API key is revoked or flagged as leaked. Please rotate to a new key." - elif msg.startswith("MODEL_NOT_AVAILABLE"): - error_result["code"] = "MODEL_NOT_AVAILABLE" - error_result["msg"] = "Configured model is not available for this API key/channel." - if "::" in msg: - error_result["detail"] = msg.split("::", 1)[1] - with _bg_tasks_lock: - _bg_tasks[task_id] = {"status": "error", "result": error_result} - - -@app.route("/assets/generate-rpg-background", methods=["POST"]) -def assets_generate_rpg_background(): - """Start async RPG background generation. Returns a task_id for polling.""" - guard = _require_asset_editor_auth() - if guard: - return guard - try: - req = request.get_json(silent=True) or {} - custom_prompt = (req.get("prompt") or "").strip() if isinstance(req, dict) else "" - speed_mode = (req.get("speed_mode") or "quality").strip().lower() if isinstance(req, dict) else "quality" - if speed_mode not in {"fast", "quality"}: - speed_mode = "fast" - - target = FRONTEND_PATH / "office_bg_small.webp" - if not target.exists(): - return jsonify({"ok": False, "msg": "office_bg_small.webp 䞍存圚"}), 404 - - # Pre-flight checks that can fail fast (before spawning thread) - runtime_cfg = load_runtime_config() - api_key = (runtime_cfg.get("gemini_api_key") or "").strip() - if not api_key: - return jsonify({"ok": False, "code": "MISSING_API_KEY", "msg": "Missing GEMINI_API_KEY or GOOGLE_API_KEY"}), 400 - if not (os.path.exists(GEMINI_PYTHON) and os.path.exists(GEMINI_SCRIPT)): - return jsonify({"ok": False, "msg": "生囟脚本环境猺倱gemini-image-generate 未安装"}), 500 - - # Check if another generation is already running - with _bg_tasks_lock: - for tid, task in _bg_tasks.items(): - if task.get("status") == "pending": - return jsonify({"ok": True, "async": True, "task_id": tid, "msg": "已有生囟任务进行䞭请等埅完成"}), 200 - - # Create async task - import string as _string - task_id = "gen_" + str(int(datetime.now().timestamp() * 1000)) + "_" + "".join(random.choices(_string.ascii_lowercase + _string.digits, k=4)) - with _bg_tasks_lock: - _bg_tasks[task_id] = {"status": "pending", "created_at": datetime.now().isoformat()} - - t = threading.Thread(target=_bg_generate_worker, args=(task_id, custom_prompt, speed_mode), daemon=True) - t.start() - - return jsonify({"ok": True, "async": True, "task_id": task_id, "msg": "生囟任务已启劚请通过 task_id 蜮询结果"}) - except Exception as e: - return jsonify({"ok": False, "msg": str(e)}), 500 - - -@app.route("/assets/generate-rpg-background/poll", methods=["GET"]) -def assets_generate_rpg_background_poll(): - """Poll async generation task status.""" - guard = _require_asset_editor_auth() - if guard: - return guard - task_id = (request.args.get("task_id") or "").strip() - if not task_id: - return jsonify({"ok": False, "msg": "猺少 task_id"}), 400 - with _bg_tasks_lock: - task = _bg_tasks.get(task_id) - if not task: - return jsonify({"ok": False, "msg": "任务䞍存圚"}), 404 - status = task.get("status", "pending") - if status == "pending": - return jsonify({"ok": True, "status": "pending", "msg": "生囟进行䞭..."}) - elif status == "done": - # Clean up task after delivering result - with _bg_tasks_lock: - _bg_tasks.pop(task_id, None) - return jsonify({"ok": True, "status": "done", **task.get("result", {})}) - else: - with _bg_tasks_lock: - _bg_tasks.pop(task_id, None) - result = task.get("result", {}) - code = 400 if result.get("code") else 500 - return jsonify({"ok": False, "status": "error", **result}), code - - -@app.route("/assets/restore-reference-background", methods=["POST"]) -def assets_restore_reference_background(): - """Restore office_bg_small.webp from fixed reference image.""" - guard = _require_asset_editor_auth() - if guard: - return guard - try: - target = FRONTEND_PATH / "office_bg_small.webp" - if not target.exists(): - return jsonify({"ok": False, "msg": "office_bg_small.webp 䞍存圚"}), 404 - if not os.path.exists(ROOM_REFERENCE_IMAGE): - return jsonify({"ok": False, "msg": "参考囟䞍存圚"}), 404 - - # 倇仜圓前底囟 - bak = target.with_suffix(target.suffix + ".bak") - shutil.copy2(target, bak) - - # 快速路埄若参考囟已是 1280x720 的 webp盎接拷莝秒级 - ref_ext = os.path.splitext(ROOM_REFERENCE_IMAGE)[1].lower() - fast_copied = False - if ref_ext == '.webp': - try: - with Image.open(ROOM_REFERENCE_IMAGE) as rim: - if rim.size == (1280, 720): - shutil.copy2(ROOM_REFERENCE_IMAGE, target) - fast_copied = True - except Exception: - fast_copied = False - - # 慢路埄仅圚必芁时重猖码 - if not fast_copied: - if Image is None: - return jsonify({"ok": False, "msg": "Pillow 䞍可甚"}), 500 - with Image.open(ROOM_REFERENCE_IMAGE) as im: - im = im.convert("RGBA").resize((1280, 720), Image.Resampling.LANCZOS) - im.save(target, "WEBP", quality=92, method=6) - - st = target.stat() - return jsonify({ - "ok": True, - "path": "office_bg_small.webp", - "size": st.st_size, - "msg": "已恢倍初始底囟", - }) - except Exception as e: - return jsonify({"ok": False, "msg": str(e)}), 500 - - -@app.route("/assets/restore-last-generated-background", methods=["POST"]) -def assets_restore_last_generated_background(): - """Restore office_bg_small.webp from latest bg-history snapshot.""" - guard = _require_asset_editor_auth() - if guard: - return guard - try: - target = FRONTEND_PATH / "office_bg_small.webp" - if not target.exists(): - return jsonify({"ok": False, "msg": "office_bg_small.webp 䞍存圚"}), 404 - - if not os.path.isdir(BG_HISTORY_DIR): - return jsonify({"ok": False, "msg": "暂无历史底囟"}), 404 - - files = [ - os.path.join(BG_HISTORY_DIR, x) - for x in os.listdir(BG_HISTORY_DIR) - if x.startswith("office_bg_small-") and x.endswith(".webp") - ] - if not files: - return jsonify({"ok": False, "msg": "暂无历史底囟"}), 404 - - latest = max(files, key=lambda p: os.path.getmtime(p)) - - bak = target.with_suffix(target.suffix + ".bak") - shutil.copy2(target, bak) - shutil.copy2(latest, target) - - st = target.stat() - return jsonify({ - "ok": True, - "path": "office_bg_small.webp", - "size": st.st_size, - "from": os.path.relpath(latest, ROOT_DIR), - "msg": "已回退到最近䞀次生成底囟", - }) - except Exception as e: - return jsonify({"ok": False, "msg": str(e)}), 500 - - -@app.route("/assets/home-favorites/list", methods=["GET"]) -def assets_home_favorites_list(): - guard = _require_asset_editor_auth() - if guard: - return guard - try: - data = _load_home_favorites_index() - items = data.get("items") or [] - out = [] - for it in items: - rel = (it.get("path") or "").strip() - if not rel: - continue - abs_path = os.path.join(ROOT_DIR, rel) - if not os.path.exists(abs_path): - continue - fn = os.path.basename(rel) - out.append({ - "id": it.get("id"), - "path": rel, - "url": f"/assets/home-favorites/file/{fn}", - "thumb_url": f"/assets/home-favorites/file/{fn}", - "created_at": it.get("created_at") or "", - }) - out.sort(key=lambda x: x.get("created_at") or "", reverse=True) - return jsonify({"ok": True, "items": out}) - except Exception as e: - return jsonify({"ok": False, "msg": str(e)}), 500 - - -@app.route("/assets/home-favorites/file/", methods=["GET"]) -def assets_home_favorites_file(filename): - guard = _require_asset_editor_auth() - if guard: - return guard - return send_from_directory(HOME_FAVORITES_DIR, filename) - - -@app.route("/assets/home-favorites/save-current", methods=["POST"]) -def assets_home_favorites_save_current(): - guard = _require_asset_editor_auth() - if guard: - return guard - try: - src = FRONTEND_PATH / "office_bg_small.webp" - if not src.exists(): - return jsonify({"ok": False, "msg": "office_bg_small.webp 䞍存圚"}), 404 - - _ensure_home_favorites_index() - ts = datetime.now().strftime("%Y%m%d-%H%M%S") - item_id = f"home-{ts}" - fn = f"{item_id}.webp" - dst = os.path.join(HOME_FAVORITES_DIR, fn) - shutil.copy2(str(src), dst) - - idx = _load_home_favorites_index() - items = idx.get("items") or [] - items.insert(0, { - "id": item_id, - "path": os.path.relpath(dst, ROOT_DIR), - "created_at": datetime.now().isoformat(timespec="seconds"), - }) - - # 控制收藏数量䞊限枅理最旧项 - if len(items) > HOME_FAVORITES_MAX: - extra = items[HOME_FAVORITES_MAX:] - items = items[:HOME_FAVORITES_MAX] - for it in extra: - try: - p = os.path.join(ROOT_DIR, it.get("path") or "") - if os.path.exists(p): - os.remove(p) - except Exception: - pass - - idx["items"] = items - _save_home_favorites_index(idx) - return jsonify({"ok": True, "id": item_id, "path": os.path.relpath(dst, ROOT_DIR), "msg": "已收藏圓前地囟"}) - except Exception as e: - return jsonify({"ok": False, "msg": str(e)}), 500 - - -@app.route("/assets/home-favorites/delete", methods=["POST"]) -def assets_home_favorites_delete(): - guard = _require_asset_editor_auth() - if guard: - return guard - try: - data = request.get_json(silent=True) or {} - item_id = (data.get("id") or "").strip() - if not item_id: - return jsonify({"ok": False, "msg": "猺少 id"}), 400 - - idx = _load_home_favorites_index() - items = idx.get("items") or [] - hit = next((x for x in items if (x.get("id") or "") == item_id), None) - if not hit: - return jsonify({"ok": False, "msg": "收藏项䞍存圚"}), 404 - - rel = hit.get("path") or "" - abs_path = os.path.join(ROOT_DIR, rel) - if os.path.exists(abs_path): - try: - os.remove(abs_path) - except Exception: - pass - - idx["items"] = [x for x in items if (x.get("id") or "") != item_id] - _save_home_favorites_index(idx) - return jsonify({"ok": True, "id": item_id, "msg": "已删陀收藏"}) - except Exception as e: - return jsonify({"ok": False, "msg": str(e)}), 500 - - -@app.route("/assets/home-favorites/apply", methods=["POST"]) -def assets_home_favorites_apply(): - guard = _require_asset_editor_auth() - if guard: - return guard - try: - data = request.get_json(silent=True) or {} - item_id = (data.get("id") or "").strip() - if not item_id: - return jsonify({"ok": False, "msg": "猺少 id"}), 400 - - idx = _load_home_favorites_index() - items = idx.get("items") or [] - hit = next((x for x in items if (x.get("id") or "") == item_id), None) - if not hit: - return jsonify({"ok": False, "msg": "收藏项䞍存圚"}), 404 - - src = os.path.join(ROOT_DIR, hit.get("path") or "") - if not os.path.exists(src): - return jsonify({"ok": False, "msg": "收藏文件䞍存圚"}), 404 - - target = FRONTEND_PATH / "office_bg_small.webp" - if not target.exists(): - return jsonify({"ok": False, "msg": "office_bg_small.webp 䞍存圚"}), 404 - - bak = target.with_suffix(target.suffix + ".bak") - shutil.copy2(str(target), str(bak)) - shutil.copy2(src, str(target)) - - st = target.stat() - return jsonify({"ok": True, "path": "office_bg_small.webp", "size": st.st_size, "from": hit.get("path"), "msg": "已应甚收藏地囟"}) - except Exception as e: - return jsonify({"ok": False, "msg": str(e)}), 500 - - -@app.route("/assets/auth", methods=["POST"]) -def assets_auth(): - try: - data = request.get_json(silent=True) or {} - pwd = (data.get("password") or "").strip() - if pwd and pwd == ASSET_DRAWER_PASS_DEFAULT: - session["asset_editor_authed"] = True - return jsonify({"ok": True, "msg": "讀证成功"}) - return jsonify({"ok": False, "msg": "验证码错误"}), 401 - except Exception as e: - return jsonify({"ok": False, "msg": str(e)}), 500 - - -@app.route("/assets/auth/status", methods=["GET"]) -def assets_auth_status(): - return jsonify({ - "ok": True, - "authed": _is_asset_editor_authed(), - "drawer_default_pass": ASSET_DRAWER_PASS_DEFAULT == "1234", - }) - - -@app.route("/assets/positions", methods=["GET"]) -def assets_positions_get(): - guard = _require_asset_editor_auth() - if guard: - return guard - try: - return jsonify({"ok": True, "items": load_asset_positions()}) - except Exception as e: - return jsonify({"ok": False, "msg": str(e)}), 500 - - -@app.route("/assets/positions", methods=["POST"]) -def assets_positions_set(): - guard = _require_asset_editor_auth() - if guard: - return guard - try: - data = request.get_json(silent=True) or {} - key = (data.get("key") or "").strip() - x = data.get("x") - y = data.get("y") - scale = data.get("scale") - if not key: - return jsonify({"ok": False, "msg": "猺少 key"}), 400 - if x is None or y is None: - return jsonify({"ok": False, "msg": "猺少 x/y"}), 400 - x = float(x) - y = float(y) - if scale is None: - scale = 1.0 - scale = float(scale) - - all_pos = load_asset_positions() - all_pos[key] = {"x": x, "y": y, "scale": scale, "updated_at": datetime.now().isoformat()} - save_asset_positions(all_pos) - return jsonify({"ok": True, "key": key, "x": x, "y": y, "scale": scale}) - except Exception as e: - return jsonify({"ok": False, "msg": str(e)}), 500 - - -@app.route("/assets/defaults", methods=["GET"]) -def assets_defaults_get(): - guard = _require_asset_editor_auth() - if guard: - return guard - try: - return jsonify({"ok": True, "items": load_asset_defaults()}) - except Exception as e: - return jsonify({"ok": False, "msg": str(e)}), 500 - - -@app.route("/assets/defaults", methods=["POST"]) -def assets_defaults_set(): - guard = _require_asset_editor_auth() - if guard: - return guard - try: - data = request.get_json(silent=True) or {} - key = (data.get("key") or "").strip() - x = data.get("x") - y = data.get("y") - scale = data.get("scale") - if not key: - return jsonify({"ok": False, "msg": "猺少 key"}), 400 - if x is None or y is None: - return jsonify({"ok": False, "msg": "猺少 x/y"}), 400 - x = float(x) - y = float(y) - if scale is None: - scale = 1.0 - scale = float(scale) - - all_defaults = load_asset_defaults() - all_defaults[key] = {"x": x, "y": y, "scale": scale, "updated_at": datetime.now().isoformat()} - save_asset_defaults(all_defaults) - return jsonify({"ok": True, "key": key, "x": x, "y": y, "scale": scale}) - except Exception as e: - return jsonify({"ok": False, "msg": str(e)}), 500 - - -@app.route("/config/gemini", methods=["GET"]) -def gemini_config_get(): - guard = _require_asset_editor_auth() - if guard: - return guard - try: - cfg = load_runtime_config() - key = (cfg.get("gemini_api_key") or "").strip() - masked = ("*" * max(0, len(key) - 4)) + key[-4:] if key else "" - return jsonify({ - "ok": True, - "has_api_key": bool(key), - "api_key_masked": masked, - "gemini_model": _normalize_user_model(cfg.get("gemini_model") or "nanobanana-pro"), - }) - except Exception as e: - return jsonify({"ok": False, "msg": str(e)}), 500 - - -@app.route("/config/gemini", methods=["POST"]) -def gemini_config_set(): - guard = _require_asset_editor_auth() - if guard: - return guard - try: - data = request.get_json(silent=True) or {} - api_key = (data.get("api_key") or "").strip() - model = _normalize_user_model((data.get("model") or "").strip() or "nanobanana-pro") - payload = {"gemini_model": model} - if api_key: - payload["gemini_api_key"] = api_key - save_runtime_config(payload) - return jsonify({"ok": True, "msg": "Gemini 配眮已保存"}) - except Exception as e: - return jsonify({"ok": False, "msg": str(e)}), 500 - - -@app.route("/assets/restore-default", methods=["POST"]) -def assets_restore_default(): - guard = _require_asset_editor_auth() - if guard: - return guard - try: - data = request.get_json(silent=True) or {} - rel_path = (data.get("path") or "").strip().lstrip("/") - if not rel_path: - return jsonify({"ok": False, "msg": "猺少 path"}), 400 - - target = (FRONTEND_PATH / rel_path).resolve() - try: - target.relative_to(FRONTEND_PATH.resolve()) - except Exception: - return jsonify({"ok": False, "msg": "非法 path"}), 400 - - if not target.exists(): - return jsonify({"ok": False, "msg": "目标文件䞍存圚"}), 404 - - root, ext = os.path.splitext(str(target)) - default_path = root + ext + ".default" - if not os.path.exists(default_path): - return jsonify({"ok": False, "msg": "未扟到默讀资产快照"}), 404 - - # 回滚前保留䞊䞀版 - bak = str(target) + ".bak" - if os.path.exists(str(target)): - shutil.copy2(str(target), bak) - - shutil.copy2(default_path, str(target)) - st = os.stat(str(target)) - return jsonify({"ok": True, "path": rel_path, "size": st.st_size, "msg": "已重眮䞺默讀资产"}) - except Exception as e: - return jsonify({"ok": False, "msg": str(e)}), 500 - - -@app.route("/assets/restore-prev", methods=["POST"]) -def assets_restore_prev(): - guard = _require_asset_editor_auth() - if guard: - return guard - try: - data = request.get_json(silent=True) or {} - rel_path = (data.get("path") or "").strip().lstrip("/") - if not rel_path: - return jsonify({"ok": False, "msg": "猺少 path"}), 400 - - target = (FRONTEND_PATH / rel_path).resolve() - try: - target.relative_to(FRONTEND_PATH.resolve()) - except Exception: - return jsonify({"ok": False, "msg": "非法 path"}), 400 - - bak = str(target) + ".bak" - if not os.path.exists(bak): - return jsonify({"ok": False, "msg": "未扟到䞊䞀版倇仜"}), 404 - - shutil.copy2(str(target), bak + ".tmp") if os.path.exists(str(target)) else None - shutil.copy2(bak, str(target)) - st = os.stat(str(target)) - return jsonify({"ok": True, "path": rel_path, "size": st.st_size, "msg": "已回退到䞊䞀版"}) - except Exception as e: - return jsonify({"ok": False, "msg": str(e)}), 500 - - -@app.route("/assets/upload", methods=["POST"]) -def assets_upload(): - guard = _require_asset_editor_auth() - if guard: - return guard - try: - rel_path = (request.form.get("path") or "").strip().lstrip("/") - backup = (request.form.get("backup") or "1").strip() != "0" - f = request.files.get("file") - - if not rel_path or f is None: - return jsonify({"ok": False, "msg": "猺少 path 或 file"}), 400 - - target = (FRONTEND_PATH / rel_path).resolve() - try: - target.relative_to(FRONTEND_PATH.resolve()) - except Exception: - return jsonify({"ok": False, "msg": "非法 path"}), 400 - - if target.suffix.lower() not in ASSET_ALLOWED_EXTS: - return jsonify({"ok": False, "msg": "仅允讞䞊䌠囟片/矎术资源类型"}), 400 - - if not target.exists(): - return jsonify({"ok": False, "msg": "目标文件䞍存圚请先从 /assets/list 选择 path"}), 404 - - target.parent.mkdir(parents=True, exist_ok=True) - - # 銖次䞊䌠前固化默讀资产快照䟛“重眮䞺默讀资产”䜿甚 - default_snap = Path(str(target) + ".default") - if not default_snap.exists(): - try: - shutil.copy2(target, default_snap) - except Exception: - pass - - if backup: - bak = target.with_suffix(target.suffix + ".bak") - shutil.copy2(target, bak) - - auto_sheet = (request.form.get("auto_spritesheet") or "0").strip() == "1" - ext_name = (f.filename or "").lower() - - if auto_sheet and target.suffix.lower() in {".webp", ".png"}: - with tempfile.NamedTemporaryFile(suffix=os.path.splitext(ext_name)[1] or ".gif", delete=False) as tf: - src_path = tf.name - f.save(src_path) - try: - in_w, in_h = _probe_animated_frame_size(src_path) - frame_w = int(request.form.get("frame_w") or (in_w or 64)) - frame_h = int(request.form.get("frame_h") or (in_h or 64)) - - # 劂果是静态囟䞊䌠到粟灵衚目标按眑栌切片而䞍是敎囟芆盖 - if not (ext_name.endswith(".gif") or ext_name.endswith(".webp")) and Image is not None: - try: - with Image.open(src_path) as sim: - sim = sim.convert("RGBA") - sw, sh = sim.size - if frame_w <= 0 or frame_h <= 0: - frame_w, frame_h = sw, sh - cols = max(1, sw // frame_w) - rows = max(1, sh // frame_h) - sheet_w = cols * frame_w - sheet_h = rows * frame_h - if sheet_w <= 0 or sheet_h <= 0: - raise RuntimeError("静态囟尺寞䞎垧规栌䞍匹配") - - cropped = sim.crop((0, 0, sheet_w, sheet_h)) - # 目标是 webp 仍按无损保存避免像玠损倱 - if target.suffix.lower() == ".webp": - cropped.save(str(target), "WEBP", lossless=True, quality=100, method=6) - else: - cropped.save(str(target), "PNG") - - st = target.stat() - return jsonify({ - "ok": True, - "path": rel_path, - "size": st.st_size, - "backup": backup, - "converted": { - "from": ext_name.split(".")[-1] if "." in ext_name else "image", - "to": "webp_spritesheet" if target.suffix.lower() == ".webp" else "png_spritesheet", - "frame_w": frame_w, - "frame_h": frame_h, - "columns": cols, - "rows": rows, - "frames": cols * rows, - "preserve_original": False, - "pixel_art": True, - } - }) - finally: - pass - - # 默讀䌘先保留蟓入垧尺寞若前端䌠了区制倌则按前端。 - preserve_original_val = request.form.get("preserve_original") - if preserve_original_val is None: - preserve_original = True - else: - preserve_original = preserve_original_val.strip() == "1" - - pixel_art = (request.form.get("pixel_art") or "1").strip() == "1" - req_cols = int(request.form.get("cols") or 0) - req_rows = int(request.form.get("rows") or 0) - sheet_path, cols, rows, frames, out_fw, out_fh = _animated_to_spritesheet( - src_path, - frame_w, - frame_h, - out_ext=target.suffix.lower(), - preserve_original=preserve_original, - pixel_art=pixel_art, - cols=(req_cols if req_cols > 0 else None), - rows=(req_rows if req_rows > 0 else None), - ) - shutil.move(sheet_path, str(target)) - st = target.stat() - from_type = "gif" if ext_name.endswith(".gif") else "webp" - to_type = "webp_spritesheet" if target.suffix.lower() == ".webp" else "png_spritesheet" - return jsonify({ - "ok": True, - "path": rel_path, - "size": st.st_size, - "backup": backup, - "converted": { - "from": from_type, - "to": to_type, - "frame_w": out_fw, - "frame_h": out_fh, - "columns": cols, - "rows": rows, - "frames": frames, - "preserve_original": preserve_original, - "pixel_art": pixel_art, - } - }) - finally: - try: - os.remove(src_path) - except Exception: - pass - - f.save(str(target)) - st = target.stat() - return jsonify({"ok": True, "path": rel_path, "size": st.st_size, "backup": backup}) - except Exception as e: - return jsonify({"ok": False, "msg": str(e)}), 500 - - -if __name__ == "__main__": - raw_port = os.environ.get("STAR_BACKEND_PORT", "19000") - try: - backend_port = int(raw_port) - except ValueError: - backend_port = 19000 - if backend_port <= 0: - backend_port = 19000 - - print("=" * 50) - print("Star Office UI - Backend State Service") - print("=" * 50) - print(f"State file: {STATE_FILE}") - print(f"Listening on: http://0.0.0.0:{backend_port}") - if backend_port != 19000: - print(f"(Port override: set STAR_BACKEND_PORT to change; current: {raw_port})") - else: - print("(Set STAR_BACKEND_PORT to use a different port, e.g. 3009)") - mode = "production" if is_production_mode() else "development" - print(f"Mode: {mode}") - if is_production_mode(): - print("Security hardening: ENABLED (strict checks)") - else: - weak_flags = [] - if not is_strong_secret(str(app.secret_key)): - weak_flags.append("weak FLASK_SECRET_KEY/STAR_OFFICE_SECRET") - if not is_strong_drawer_pass(ASSET_DRAWER_PASS_DEFAULT): - weak_flags.append("weak ASSET_DRAWER_PASS") - if weak_flags: - print("Security hardening: WARNING (dev mode) -> " + ", ".join(weak_flags)) - else: - print("Security hardening: OK") - print("=" * 50) - - app.run(host="0.0.0.0", port=backend_port, debug=False) - +#!/usr/bin/env python3 +"""Star Office UI - Backend State Service""" + +from flask import Flask, jsonify, send_from_directory, make_response, request, session +from datetime import datetime, timedelta +from typing import Optional, Union, Dict, List, Any +import json +import os +import random +import math +import re +import shutil +import subprocess +import tempfile +import threading +from pathlib import Path +from security_utils import is_production_mode, is_strong_secret, is_strong_drawer_pass +from memo_utils import get_yesterday_date_str, sanitize_content, extract_memo_from_file +from store_utils import ( + load_agents_state as _store_load_agents_state, + save_agents_state as _store_save_agents_state, + load_asset_positions as _store_load_asset_positions, + save_asset_positions as _store_save_asset_positions, + load_asset_defaults as _store_load_asset_defaults, + save_asset_defaults as _store_save_asset_defaults, + load_runtime_config as _store_load_runtime_config, + save_runtime_config as _store_save_runtime_config, + load_join_keys as _store_load_join_keys, + save_join_keys as _store_save_join_keys, +) + +try: + from PIL import Image +except Exception: + Image = None + +# Paths (project-relative, no hardcoded absolute paths) +ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +MEMORY_DIR = os.path.join(os.path.dirname(ROOT_DIR), "memory") +FRONTEND_DIR = os.path.join(ROOT_DIR, "frontend") +FRONTEND_INDEX_FILE = os.path.join(FRONTEND_DIR, "index.html") +FRONTEND_ELECTRON_STANDALONE_FILE = os.path.join(FRONTEND_DIR, "electron-standalone.html") +STATE_FILE = os.path.join(ROOT_DIR, "state.json") +AGENTS_STATE_FILE = os.path.join(ROOT_DIR, "agents-state.json") +JOIN_KEYS_FILE = os.path.join(ROOT_DIR, "join-keys.json") +FRONTEND_PATH = Path(FRONTEND_DIR) +ASSET_ALLOWED_EXTS = {".png", ".webp", ".jpg", ".jpeg", ".gif", ".svg", ".avif"} +ASSET_TEMPLATE_ZIP = os.path.join(ROOT_DIR, "assets-replace-template.zip") +WORKSPACE_DIR = os.path.dirname(ROOT_DIR) +OPENCLAW_WORKSPACE = os.environ.get("OPENCLAW_WORKSPACE") or os.path.join(os.path.expanduser("~"), ".openclaw", "workspace") +IDENTITY_FILE = os.path.join(OPENCLAW_WORKSPACE, "IDENTITY.md") +GEMINI_SCRIPT = os.path.join(WORKSPACE_DIR, "skills", "gemini-image-generate", "scripts", "gemini_image_generate.py") +GEMINI_PYTHON = os.path.join(WORKSPACE_DIR, "skills", "gemini-image-generate", ".venv", "bin", "python") +ROOM_REFERENCE_IMAGE = ( + os.path.join(ROOT_DIR, "assets", "room-reference.webp") + if os.path.exists(os.path.join(ROOT_DIR, "assets", "room-reference.webp")) + else os.path.join(ROOT_DIR, "assets", "room-reference.png") +) +BG_HISTORY_DIR = os.path.join(ROOT_DIR, "assets", "bg-history") +HOME_FAVORITES_DIR = os.path.join(ROOT_DIR, "assets", "home-favorites") +HOME_FAVORITES_INDEX_FILE = os.path.join(HOME_FAVORITES_DIR, "index.json") +HOME_FAVORITES_MAX = 30 +ASSET_POSITIONS_FILE = os.path.join(ROOT_DIR, "asset-positions.json") + +# 性胜保技默讀关闭“每次打匀页面随机换背景”避免銖页銖屏被磁盘倍制拖慢 +AUTO_ROTATE_HOME_ON_PAGE_OPEN = (os.getenv("AUTO_ROTATE_HOME_ON_PAGE_OPEN", "0").strip().lower() in {"1", "true", "yes", "on"}) +AUTO_ROTATE_MIN_INTERVAL_SECONDS = int(os.getenv("AUTO_ROTATE_MIN_INTERVAL_SECONDS", "60")) +_last_home_rotate_at = 0 +ASSET_DEFAULTS_FILE = os.path.join(ROOT_DIR, "asset-defaults.json") +RUNTIME_CONFIG_FILE = os.path.join(ROOT_DIR, "runtime-config.json") + +# Canonical agent states: single source of truth for validation and mapping +VALID_AGENT_STATES = frozenset({"idle", "writing", "researching", "executing", "syncing", "error"}) +WORKING_STATES = frozenset({"writing", "researching", "executing"}) # subset used for auto-idle TTL +STATE_TO_AREA_MAP = { + "idle": "breakroom", + "writing": "writing", + "researching": "writing", + "executing": "writing", + "syncing": "writing", + "error": "error", +} + + +app = Flask(__name__, static_folder=FRONTEND_DIR, static_url_path="/static") +app.secret_key = os.getenv("FLASK_SECRET_KEY") or os.getenv("STAR_OFFICE_SECRET") or "star-office-dev-secret-change-me" + +# Session hardening +app.config.update( + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE="Lax", + SESSION_COOKIE_SECURE=is_production_mode(), + PERMANENT_SESSION_LIFETIME=timedelta(hours=12), +) + +# Guard join-agent critical section to enforce per-key concurrency under parallel requests +join_lock = threading.Lock() + +# Async background task registry for long-running operations (e.g. image generation) +# Avoids Cloudflare 524 timeout (100s limit) by letting frontend poll for completion. +_bg_tasks = {} # task_id -> {"status": "pending"|"done"|"error", "result": ..., "error": ..., "created_at": ...} +_bg_tasks_lock = threading.Lock() + + +def _cleanup_bg_tasks(): + """Remove tasks older than 1 hour to prevent memory leak. + Called periodically during task creation or polling. + """ + now = datetime.now() + with _bg_tasks_lock: + to_remove = [] + for tid, task in _bg_tasks.items(): + # If status is done or error, it should have been popped by poll, + # but we clean it anyway if it's too old (client never polled). + created_at_str = task.get("created_at") + if not created_at_str: + to_remove.append(tid) + continue + try: + # Tolerate both with/without timezone + dt = datetime.fromisoformat(created_at_str.replace("Z", "+00:00")) + if dt.tzinfo: + from datetime import timezone + age = (datetime.now(timezone.utc) - dt.astimezone(timezone.utc)).total_seconds() + else: + age = (datetime.now() - dt).total_seconds() + + if age > 3600: # 1 hour TTL for background tasks + to_remove.append(tid) + except Exception: + to_remove.append(tid) + + for tid in to_remove: + _bg_tasks.pop(tid, None) + if to_remove: + print(f"[bg_tasks] cleaned up {len(to_remove)} stale tasks") + +# Generate a version timestamp once at server startup for cache busting +VERSION_TIMESTAMP = datetime.now().strftime("%Y%m%d_%H%M%S") +ASSET_DRAWER_PASS_DEFAULT = os.getenv("ASSET_DRAWER_PASS", "1234") + +if is_production_mode(): + hardening_errors = [] + if not is_strong_secret(str(app.secret_key)): + hardening_errors.append("FLASK_SECRET_KEY / STAR_OFFICE_SECRET is weak (need >=24 chars, non-default)") + if not is_strong_drawer_pass(ASSET_DRAWER_PASS_DEFAULT): + hardening_errors.append("ASSET_DRAWER_PASS is weak (do not use default 1234; recommend >=8 chars)") + if hardening_errors: + raise RuntimeError("Security hardening check failed in production mode: " + "; ".join(hardening_errors)) + + +def _is_asset_editor_authed() -> bool: + return bool(session.get("asset_editor_authed")) + + +def _require_asset_editor_auth(): + if _is_asset_editor_authed(): + return None + return jsonify({"ok": False, "code": "UNAUTHORIZED", "msg": "Asset editor auth required"}), 401 + + +@app.after_request +def add_no_cache_headers(response): + """Apply cache policy by path: + - HTML/API/state: no-cache (always fresh) + - /static assets (2xx only): long cache (filenames are versioned with ?v=VERSION_TIMESTAMP) + - /static assets (non-2xx, e.g. 404): no-cache to prevent CDN from caching errors + """ + path = (request.path or "") + if path.startswith('/static/') and 200 <= response.status_code < 300: + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + response.headers.pop("Pragma", None) + response.headers.pop("Expires", None) + else: + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + return response + +# Default state +DEFAULT_STATE = { + "state": "idle", + "detail": "等埅任务䞭...", + "progress": 0, + "updated_at": datetime.now().isoformat() +} + + +def load_state(): + """Load state from file. + + Includes a simple auto-idle mechanism: + - If the last update is older than ttl_seconds (default 25s) + and the state is a "working" state, we fall back to idle. + + This avoids the UI getting stuck at the desk when no new updates arrive. + """ + state = None + if os.path.exists(STATE_FILE): + try: + with open(STATE_FILE, "r", encoding="utf-8") as f: + state = json.load(f) + except Exception: + state = None + + if not isinstance(state, dict): + state = dict(DEFAULT_STATE) + + # Auto-idle + try: + ttl = int(state.get("ttl_seconds", 300)) + updated_at = state.get("updated_at") + s = state.get("state", "idle") + if updated_at and s in WORKING_STATES: + # tolerate both with/without timezone + dt = datetime.fromisoformat(updated_at.replace("Z", "+00:00")) + # Use UTC for aware datetimes; local time for naive. + if dt.tzinfo: + from datetime import timezone + age = (datetime.now(timezone.utc) - dt.astimezone(timezone.utc)).total_seconds() + else: + age = (datetime.now() - dt).total_seconds() + if age > ttl: + state["state"] = "idle" + state["detail"] = "埅呜䞭自劚回到䌑息区" + state["progress"] = 0 + state["updated_at"] = datetime.now().isoformat() + # persist the auto-idle so every client sees it consistently + try: + save_state(state) + except Exception: + pass + except Exception: + pass + + return state + + +def get_office_name_from_identity(): + """Read office display name from OpenClaw workspace IDENTITY.md (Name field) -> 'XXX的办公宀'.""" + if not os.path.isfile(IDENTITY_FILE): + return None + try: + with open(IDENTITY_FILE, "r", encoding="utf-8") as f: + content = f.read() + m = re.search(r"-\s*\*\*Name:\*\*\s*(.+)", content) + if m: + name = m.group(1).strip().replace("\r", "").split("\n")[0].strip() + return f"{name}的办公宀" if name else None + except Exception: + pass + return None + + +def save_state(state: dict): + """Save state to file""" + with open(STATE_FILE, "w", encoding="utf-8") as f: + json.dump(state, f, ensure_ascii=False, indent=2) + + +def ensure_electron_standalone_snapshot(): + """Create Electron standalone frontend snapshot once if missing. + + The snapshot is intentionally decoupled from the browser page: + - browser uses frontend/index.html + - Electron uses frontend/electron-standalone.html + """ + if os.path.exists(FRONTEND_ELECTRON_STANDALONE_FILE): + return + try: + shutil.copy2(FRONTEND_INDEX_FILE, FRONTEND_ELECTRON_STANDALONE_FILE) + print(f"[standalone] created: {FRONTEND_ELECTRON_STANDALONE_FILE}") + except Exception as e: + print(f"[standalone] create failed: {e}") + + +# Initialize state +if not os.path.exists(STATE_FILE): + save_state(DEFAULT_STATE) +ensure_electron_standalone_snapshot() + + +_INDEX_HTML_CACHE = None + + +@app.route("/", methods=["GET"]) +def index(): + """Serve the pixel office UI with built-in version cache busting""" + # 默讀犁甚页面打匀即换背景避免銖屏慢 + # 劂需启甚可配眮 AUTO_ROTATE_HOME_ON_PAGE_OPEN=1 + _maybe_apply_random_home_favorite() + + global _INDEX_HTML_CACHE + if _INDEX_HTML_CACHE is None: + with open(FRONTEND_INDEX_FILE, "r", encoding="utf-8") as f: + raw_html = f.read() + _INDEX_HTML_CACHE = raw_html.replace("{{VERSION_TIMESTAMP}}", VERSION_TIMESTAMP) + + resp = make_response(_INDEX_HTML_CACHE) + resp.headers["Content-Type"] = "text/html; charset=utf-8" + return resp + + +@app.route("/electron-standalone", methods=["GET"]) +def electron_standalone_page(): + """Serve Electron-only standalone frontend page.""" + ensure_electron_standalone_snapshot() + target = FRONTEND_ELECTRON_STANDALONE_FILE + if not os.path.exists(target): + target = FRONTEND_INDEX_FILE + with open(target, "r", encoding="utf-8") as f: + html = f.read() + html = html.replace("{{VERSION_TIMESTAMP}}", VERSION_TIMESTAMP) + resp = make_response(html) + resp.headers["Content-Type"] = "text/html; charset=utf-8" + return resp + + resp.headers["Content-Type"] = "text/html; charset=utf-8" + return resp + + +@app.route("/join", methods=["GET"]) +def join_page(): + """Serve the agent join page""" + with open(os.path.join(FRONTEND_DIR, "join.html"), "r", encoding="utf-8") as f: + html = f.read() + resp = make_response(html) + resp.headers["Content-Type"] = "text/html; charset=utf-8" + return resp + + +@app.route("/invite", methods=["GET"]) +def invite_page(): + """Serve human-facing invite instruction page""" + with open(os.path.join(FRONTEND_DIR, "invite.html"), "r", encoding="utf-8") as f: + html = f.read() + resp = make_response(html) + resp.headers["Content-Type"] = "text/html; charset=utf-8" + return resp + + +DEFAULT_AGENTS = [ + { + "agentId": "star", + "name": "Star", + "isMain": True, + "state": "idle", + "detail": "埅呜䞭随时准倇䞺䜠服务", + "updated_at": datetime.now().isoformat(), + "area": "breakroom", + "source": "local", + "joinKey": None, + "authStatus": "approved", + "authExpiresAt": None, + "lastPushAt": None + } +] + + +def load_agents_state(): + return _store_load_agents_state(AGENTS_STATE_FILE, DEFAULT_AGENTS) + + +def save_agents_state(agents): + _store_save_agents_state(AGENTS_STATE_FILE, agents) + + +def load_asset_positions(): + return _store_load_asset_positions(ASSET_POSITIONS_FILE) + + +def save_asset_positions(data): + _store_save_asset_positions(ASSET_POSITIONS_FILE, data) + + +def load_asset_defaults(): + return _store_load_asset_defaults(ASSET_DEFAULTS_FILE) + + +def save_asset_defaults(data): + _store_save_asset_defaults(ASSET_DEFAULTS_FILE, data) + + +def load_runtime_config(): + return _store_load_runtime_config(RUNTIME_CONFIG_FILE) + + +def save_runtime_config(data): + _store_save_runtime_config(RUNTIME_CONFIG_FILE, data) + + +def _ensure_home_favorites_index(): + os.makedirs(HOME_FAVORITES_DIR, exist_ok=True) + if not os.path.exists(HOME_FAVORITES_INDEX_FILE): + with open(HOME_FAVORITES_INDEX_FILE, "w", encoding="utf-8") as f: + json.dump({"items": []}, f, ensure_ascii=False, indent=2) + + +def _load_home_favorites_index(): + _ensure_home_favorites_index() + try: + with open(HOME_FAVORITES_INDEX_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict) and isinstance(data.get("items"), list): + return data + except Exception: + pass + return {"items": []} + + +def _save_home_favorites_index(data): + _ensure_home_favorites_index() + with open(HOME_FAVORITES_INDEX_FILE, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +def _maybe_apply_random_home_favorite(): + """On page open, randomly apply one saved home favorite if available.""" + global _last_home_rotate_at + + if not AUTO_ROTATE_HOME_ON_PAGE_OPEN: + return False, "disabled" + + try: + now_ts = datetime.now().timestamp() + if _last_home_rotate_at and (now_ts - _last_home_rotate_at) < AUTO_ROTATE_MIN_INTERVAL_SECONDS: + return False, "throttled" + + idx = _load_home_favorites_index() + items = idx.get("items") or [] + candidates = [] + for it in items: + rel = (it.get("path") or "").strip() + if not rel: + continue + abs_path = os.path.join(ROOT_DIR, rel) + if os.path.exists(abs_path): + candidates.append((rel, abs_path)) + + if not candidates: + return False, "no-favorites" + + rel, src = random.choice(candidates) + target = FRONTEND_PATH / "office_bg_small.webp" + if not target.exists(): + return False, "missing-office-bg" + + shutil.copy2(src, str(target)) + _last_home_rotate_at = now_ts + return True, rel + except Exception as e: + return False, str(e) + + +def load_join_keys(): + return _store_load_join_keys(JOIN_KEYS_FILE) + + +def save_join_keys(data): + _store_save_join_keys(JOIN_KEYS_FILE, data) + + +def _ensure_magick_or_ffmpeg_available(): + if shutil.which("magick"): + return "magick" + if shutil.which("ffmpeg"): + return "ffmpeg" + return None + + +def _probe_animated_frame_size(upload_path: str): + """Return (w,h) from first frame if possible.""" + if Image is not None: + try: + with Image.open(upload_path) as im: + w, h = im.size + return int(w), int(h) + except Exception: + pass + # ffprobe fallback + if shutil.which("ffprobe"): + try: + cmd = [ + "ffprobe", "-v", "error", + "-select_streams", "v:0", + "-show_entries", "stream=width,height", + "-of", "csv=p=0:s=x", + upload_path, + ] + out = subprocess.check_output(cmd, stderr=subprocess.STDOUT, timeout=5).decode().strip() + if "x" in out: + w, h = out.split("x", 1) + return int(w), int(h) + except Exception: + pass + return None, None + + +def _animated_to_spritesheet( + upload_path: str, + frame_w: int, + frame_h: int, + out_ext: str = ".webp", + preserve_original: bool = True, + pixel_art: bool = True, + cols: Optional[int] = None, + rows: Optional[int] = None, +): + """Convert animated GIF/WEBP to spritesheet, return (out_path, columns, rows, frames, out_frame_w, out_frame_h).""" + backend = _ensure_magick_or_ffmpeg_available() + if not backend: + raise RuntimeError("未检测到 ImageMagick/ffmpeg无法自劚蜬换劚囟") + + ext = (out_ext or ".webp").lower() + if ext not in {".webp", ".png"}: + ext = ".webp" + + out_fd, out_path = tempfile.mkstemp(suffix=ext) + os.close(out_fd) + + with tempfile.TemporaryDirectory() as td: + frames = 0 + out_fw, out_fh = int(frame_w), int(frame_h) + if Image is not None: + try: + with Image.open(upload_path) as im: + n = getattr(im, "n_frames", 1) + # 默讀保留甚户原始垧尺寞避免先压猩再攟倧富臎像玠糊 + if preserve_original: + out_fw, out_fh = im.size + for i in range(n): + im.seek(i) + fr = im.convert("RGBA") + if not preserve_original and (fr.size != (out_fw, out_fh)): + resample = Image.Resampling.NEAREST if pixel_art else Image.Resampling.LANCZOS + fr = fr.resize((out_fw, out_fh), resample) + fr.save(os.path.join(td, f"f_{i:04d}.png"), "PNG") + frames = n + except Exception: + frames = 0 + + if frames <= 0: + cmd1 = f"ffmpeg -y -i '{upload_path}' '{td}/f_%04d.png' >/dev/null 2>&1" + if os.system(cmd1) != 0: + raise RuntimeError("劚囟抜垧倱莥Pillow/ffmpeg 郜倱莥") + files = sorted([x for x in os.listdir(td) if x.startswith("f_") and x.endswith(".png")]) + frames = len(files) + if frames <= 0: + raise RuntimeError("劚囟无有效垧") + + if backend == "magick": + # 像玠风劚囟蜬粟灵衚默讀无损避免颜色/蟹猘被压猩糊掉 + quality_flag = "-define webp:lossless=true -define webp:method=6 -quality 100" if ext == ".webp" else "" + # 允讞按 cols/rows 排垃默讀单行 + if cols is None or cols <= 0: + cols_eff = frames + else: + cols_eff = max(1, int(cols)) + rows_eff = max(1, int(rows)) if (rows is not None and rows > 0) else max(1, math.ceil(frames / cols_eff)) + + # 先规范单垧尺寞 + prep = "" + if not preserve_original: + magick_filter = "-filter point" if pixel_art else "" + prep = f" {magick_filter} -resize {out_fw}x{out_fh}^ -gravity center -background none -extent {out_fw}x{out_fh}" + + cmd = ( + f"magick '{td}/f_*.png'{prep} " + f"-tile {cols_eff}x{rows_eff} -background none -geometry +0+0 {quality_flag} '{out_path}'" + ) + rc = os.system(cmd) + if rc != 0: + raise RuntimeError("ImageMagick 拌囟倱莥") + return out_path, cols_eff, rows_eff, frames, out_fw, out_fh + + ffmpeg_quality = "-lossless 1 -compression_level 6 -q:v 100" if ext == ".webp" else "" + cols_eff = max(1, int(cols)) if (cols is not None and cols > 0) else frames + rows_eff = max(1, int(rows)) if (rows is not None and rows > 0) else max(1, math.ceil(frames / cols_eff)) + if preserve_original: + vf = f"tile={cols_eff}x{rows_eff}" + else: + scale_algo = "neighbor" if pixel_art else "lanczos" + vf = ( + f"scale={out_fw}:{out_fh}:force_original_aspect_ratio=decrease:flags={scale_algo}," + f"pad={out_fw}:{out_fh}:(ow-iw)/2:(oh-ih)/2:color=0x00000000," + f"tile={cols_eff}x{rows_eff}" + ) + cmd2 = ( + f"ffmpeg -y -pattern_type glob -i '{td}/f_*.png' " + f"-vf '{vf}' " + f"{ffmpeg_quality} '{out_path}' >/dev/null 2>&1" + ) + if os.system(cmd2) != 0: + raise RuntimeError("ffmpeg 拌囟倱莥") + return out_path, frames, 1, frames, out_fw, out_fh + + +def normalize_agent_state(s): + """Normalize agent state for compatibility. + Maps synonyms (e.g. working/busy -> writing, run/running -> executing) into VALID_AGENT_STATES. + Returns 'idle' for unknown values. + """ + if not s: + return 'idle' + s_lower = s.lower().strip() + if s_lower in {'working', 'busy', 'write'}: + return 'writing' + if s_lower in {'run', 'running', 'execute', 'exec'}: + return 'executing' + if s_lower in {'sync'}: + return 'syncing' + if s_lower in {'research', 'search'}: + return 'researching' + if s_lower in VALID_AGENT_STATES: + return s_lower + return 'idle' + + +# User-facing model aliases -> provider model ids +USER_MODEL_TO_PROVIDER_MODELS = { + # 䞥栌按甚户芁求仅䞀种官方暡型映射 + "nanobanana-pro": [ + "nano-banana-pro-preview", + ], + "nanobanana-2": [ + "gemini-2.5-flash-image", + ], +} + +PROVIDER_MODEL_TO_USER_MODEL = { + provider: user + for user, providers in USER_MODEL_TO_PROVIDER_MODELS.items() + for provider in providers +} + + +def _normalize_user_model(model_name: str) -> str: + m = (model_name or "").strip() + if not m: + return "nanobanana-pro" + low = m.lower() + if low in USER_MODEL_TO_PROVIDER_MODELS: + return low + if low in PROVIDER_MODEL_TO_USER_MODEL: + return PROVIDER_MODEL_TO_USER_MODEL[low] + return "nanobanana-pro" + + +def _provider_model_candidates(user_model: str): + normalized = _normalize_user_model(user_model) + return list(USER_MODEL_TO_PROVIDER_MODELS.get(normalized, USER_MODEL_TO_PROVIDER_MODELS["nanobanana-pro"])) + + +def _generate_rpg_background_to_webp(out_webp_path: str, width: int = 1280, height: int = 720, custom_prompt: str = "", speed_mode: str = "fast"): + """Generate RPG-style room background and save as webp. + + speed_mode: + - fast: use nanobanana-2 + 1024x576 intermediate + downscaled reference (faster) + - quality: use configured model (fallback nanobanana-pro) + full 1280x720 path + """ + runtime_cfg = load_runtime_config() + api_key = (runtime_cfg.get("gemini_api_key") or "").strip() + if not api_key: + raise RuntimeError("MISSING_API_KEY") + themes = [ + "8-bit dungeon guild room", + "8-bit stardew-valley inspired cozy farm tavern", + "8-bit nordic fantasy tavern", + "8-bit magitech workshop", + "8-bit elven forest inn", + "8-bit pixel cyber tavern", + "8-bit desert caravan inn", + "8-bit snow mountain lodge", + ] + theme = random.choice(themes) + + if not (os.path.exists(GEMINI_PYTHON) and os.path.exists(GEMINI_SCRIPT)): + raise RuntimeError("生囟脚本环境猺倱gemini-image-generate 未安装") + + style_hint = (custom_prompt or "").strip() + if not style_hint: + style_hint = theme + + # 默讀䜿甚曎皳劥的 quality 档避免 fast 暡型圚郚分 API 通道䞍可甚 + mode = (speed_mode or "quality").strip().lower() + if mode not in {"fast", "quality"}: + mode = "quality" + + configured_user_model = _normalize_user_model(runtime_cfg.get("gemini_model") or "nanobanana-pro") + if mode == "fast": + preferred_user_model = "nanobanana-2" + # fast 也提高基础枅晰床从 1024x576 提升到 1152x648牺牲少量速床 + gen_width, gen_height = 1152, 648 + ref_width, ref_height = 1152, 648 + else: + preferred_user_model = configured_user_model + gen_width, gen_height = width, height + ref_width, ref_height = width, height + + # 同时规避可胜觊发 400 的特殊胜力参数 + # 仅 nanobanana-2 èµ° aspect-rationanobanana-pro 亀给暡型默讀比䟋后续再标准化到 1280x720 + allow_aspect_ratio = (preferred_user_model == "nanobanana-2") + + prompt = ( + "Use a top-down pixel room composition compatible with an office game scene. " + "STRICTLY preserve the same room geometry, camera angle, wall/floor boundaries and major object placement as the provided reference image. " + "Keep region layout stable (left work area, center lounge, right error area). " + "Only change visual style/theme/material/lighting according to: " + style_hint + ". " + "Do not add text or watermark. Retro 8-bit RPG style." + ) + + tmp_dir = tempfile.mkdtemp(prefix="rpg-bg-") + cmd = [ + GEMINI_PYTHON, + GEMINI_SCRIPT, + "--prompt", prompt, + "--model", configured_user_model, + "--out-dir", tmp_dir, + "--cleanup", + ] + if allow_aspect_ratio: + cmd.extend(["--aspect-ratio", "16:9"]) + + # 区纊束每次郜垊固定参考囟保持房闎区域垃局䞍挂移 + ref_for_call = None + if os.path.exists(ROOM_REFERENCE_IMAGE): + ref_for_call = ROOM_REFERENCE_IMAGE + if mode == "fast" and Image is not None: + try: + ref_fast = os.path.join(tmp_dir, "room-reference-fast.webp") + with Image.open(ROOM_REFERENCE_IMAGE) as rim: + rim = rim.convert("RGBA").resize((ref_width, ref_height), Image.Resampling.LANCZOS) + rim.save(ref_fast, "WEBP", quality=85, method=4) + ref_for_call = ref_fast + except Exception: + ref_for_call = ROOM_REFERENCE_IMAGE + + if ref_for_call: + cmd.extend(["--reference-image", ref_for_call]) + + env = os.environ.copy() + # 运行时配眮䌘先只保留 GEMINI_API_KEY避免脚本因双 key 报错 + env.pop("GOOGLE_API_KEY", None) + env["GEMINI_API_KEY"] = api_key + + def _run_cmd(cmd_args): + return subprocess.run(cmd_args, capture_output=True, text=True, env=env, timeout=240) + + def _is_model_unavailable_error(text: str) -> bool: + low = (text or "").strip().lower() + return ( + ("not found" in low and "models/" in low) + or ("model_not_available" in low) + or ("model is not available" in low) + or ("configured model is not available" in low) + or ("this model is not available" in low) + or ("not supported for generatecontent" in low) + ) + + def _with_model(cmd_args, model_name: str): + m = cmd_args[:] + if "--model" in m: + idx = m.index("--model") + if idx + 1 < len(m): + m[idx + 1] = model_name + else: + m.extend(["--model", model_name]) + return m + + # 暡型倚级回退仅允讞䞀类甚户暡型nanobanana-pro / nanobanana-2 + # 每䞪甚户暡型映射到若干 provider 真实暡型。 + user_model_order = [preferred_user_model, configured_user_model] + user_model_order = [m for i, m in enumerate(user_model_order) if m and m not in user_model_order[:i]] + + model_candidates = [] + for um in user_model_order: + model_candidates.extend(_provider_model_candidates(um)) + # 去重并枅理空项 + model_candidates = [m for i, m in enumerate(model_candidates) if m and m not in model_candidates[:i]] + + proc = None + last_err_text = "" + model_unavailable_count = 0 + + for mname in model_candidates: + env["GEMINI_MODEL"] = mname + try_cmd = _with_model(cmd, mname) + proc = _run_cmd(try_cmd) + if proc.returncode == 0: + break + + err_text = (proc.stderr or proc.stdout or "").strip() + last_err_text = err_text + + # key 倱效/泄挏立即终止䞍继续尝试 + low = err_text.lower() + if "your api key was reported as leaked" in low or "permission_denied" in low: + raise RuntimeError("API_KEY_REVOKED_OR_LEAKED") + + if _is_model_unavailable_error(err_text): + model_unavailable_count += 1 + continue + + # 非暡型䞍可甚错误盎接返回真实错误 + raise RuntimeError(f"生囟倱莥: {err_text}") + + if proc is None or proc.returncode != 0: + err_text = (last_err_text or "").strip() + if model_unavailable_count >= len(model_candidates) or _is_model_unavailable_error(err_text): + brief = (err_text or "").replace("\n", " ")[:240] + raise RuntimeError(f"MODEL_NOT_AVAILABLE::{brief}") + raise RuntimeError(f"生囟倱莥: {err_text}") + + try: + result = json.loads(proc.stdout.strip().splitlines()[-1]) + except Exception: + raise RuntimeError("生囟结果解析倱莥") + + files = result.get("files") or [] + if not files: + raise RuntimeError("生囟未返回文件") + + gen_path = files[0] + if not os.path.exists(gen_path): + raise RuntimeError("生囟文件䞍存圚") + + if Image is None: + raise RuntimeError("Pillow 䞍可甚无法做尺寞标准化") + + with Image.open(gen_path) as im: + im = im.convert("RGBA") + # 莚量暡匏䌘先保细节快速暡匏䌘先速床 + if mode == "fast": + im = im.resize((gen_width, gen_height), Image.Resampling.LANCZOS) + if (gen_width, gen_height) != (width, height): + # fast 的攟倧改䞺 LANCZOS牺牲少量速床换曎高细节 + im = im.resize((width, height), Image.Resampling.LANCZOS) + im.save(out_webp_path, "WEBP", quality=96, method=6) + else: + # quality确保蟓出标准尺寞同时䜿甚无损 webp减少压猩损倱 + if im.size != (width, height): + im = im.resize((width, height), Image.Resampling.LANCZOS) + im.save(out_webp_path, "WEBP", lossless=True, quality=100, method=6) + + +def state_to_area(state): + """Map agent state to office area (breakroom / writing / error).""" + return STATE_TO_AREA_MAP.get(state, "breakroom") + + +# Ensure files exist +if not os.path.exists(AGENTS_STATE_FILE): + save_agents_state(DEFAULT_AGENTS) +if not os.path.exists(JOIN_KEYS_FILE): + if os.path.exists(os.path.join(ROOT_DIR, "join-keys.sample.json")): + try: + with open(os.path.join(ROOT_DIR, "join-keys.sample.json"), "r", encoding="utf-8") as sf: + sample = json.load(sf) + save_join_keys(sample if isinstance(sample, dict) else {"keys": []}) + except Exception: + save_join_keys({"keys": []}) + else: + save_join_keys({"keys": []}) + +# Tighten runtime-config file perms if exists +if os.path.exists(RUNTIME_CONFIG_FILE): + try: + os.chmod(RUNTIME_CONFIG_FILE, 0o600) + except Exception: + pass + + +@app.route("/agents", methods=["GET"]) +def get_agents(): + """Get full agents list (for multi-agent UI), with auto-cleanup on access""" + agents = load_agents_state() + now = datetime.now() + + cleaned_agents = [] + keys_data = load_join_keys() + + for a in agents: + if a.get("isMain"): + cleaned_agents.append(a) + continue + + auth_expires_at_str = a.get("authExpiresAt") + auth_status = a.get("authStatus", "pending") + + # 1) 超时未批准自劚 leave + if auth_status == "pending" and auth_expires_at_str: + try: + auth_expires_at = datetime.fromisoformat(auth_expires_at_str) + if now > auth_expires_at: + key = a.get("joinKey") + if key: + key_item = next((k for k in keys_data.get("keys", []) if k.get("key") == key), None) + if key_item: + key_item["used"] = False + key_item["usedBy"] = None + key_item["usedByAgentId"] = None + key_item["usedAt"] = None + continue + except Exception: + pass + + # 2) 超时未掚送自劚犻线超过5分钟 + last_push_at_str = a.get("lastPushAt") + if auth_status == "approved" and last_push_at_str: + try: + last_push_at = datetime.fromisoformat(last_push_at_str) + age = (now - last_push_at).total_seconds() + if age > 300: # 5分钟无掚送自劚犻线 + a["authStatus"] = "offline" + except Exception: + pass + + cleaned_agents.append(a) + + save_agents_state(cleaned_agents) + save_join_keys(keys_data) + + return jsonify(cleaned_agents) + + +@app.route("/agent-approve", methods=["POST"]) +def agent_approve(): + """Approve an agent (set authStatus to approved)""" + try: + data = request.get_json() + agent_id = (data.get("agentId") or "").strip() + if not agent_id: + return jsonify({"ok": False, "msg": "猺少 agentId"}), 400 + + agents = load_agents_state() + target = next((a for a in agents if a.get("agentId") == agent_id and not a.get("isMain")), None) + if not target: + return jsonify({"ok": False, "msg": "未扟到 agent"}), 404 + + target["authStatus"] = "approved" + target["authApprovedAt"] = datetime.now().isoformat() + target["authExpiresAt"] = (datetime.now() + timedelta(hours=24)).isoformat() # 默讀授权24h + + save_agents_state(agents) + return jsonify({"ok": True, "agentId": agent_id, "authStatus": "approved"}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/agent-reject", methods=["POST"]) +def agent_reject(): + """Reject an agent (set authStatus to rejected and optionally revoke key)""" + try: + data = request.get_json() + agent_id = (data.get("agentId") or "").strip() + if not agent_id: + return jsonify({"ok": False, "msg": "猺少 agentId"}), 400 + + agents = load_agents_state() + target = next((a for a in agents if a.get("agentId") == agent_id and not a.get("isMain")), None) + if not target: + return jsonify({"ok": False, "msg": "未扟到 agent"}), 404 + + target["authStatus"] = "rejected" + target["authRejectedAt"] = datetime.now().isoformat() + + # Optionally free join key back to unused + join_key = target.get("joinKey") + keys_data = load_join_keys() + if join_key: + key_item = next((k for k in keys_data.get("keys", []) if k.get("key") == join_key), None) + if key_item: + key_item["used"] = False + key_item["usedBy"] = None + key_item["usedByAgentId"] = None + key_item["usedAt"] = None + + # Remove from agents list + agents = [a for a in agents if a.get("agentId") != agent_id or a.get("isMain")] + + save_agents_state(agents) + save_join_keys(keys_data) + return jsonify({"ok": True, "agentId": agent_id, "authStatus": "rejected"}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/join-agent", methods=["POST"]) +def join_agent(): + """Add a new agent with one-time join key validation and pending auth""" + try: + data = request.get_json() + if not isinstance(data, dict) or not data.get("name"): + return jsonify({"ok": False, "msg": "请提䟛名字"}), 400 + + name = data["name"].strip() + state = data.get("state", "idle") + detail = data.get("detail", "") + join_key = data.get("joinKey", "").strip() + + # Normalize state early for compatibility + state = normalize_agent_state(state) + + if not join_key: + return jsonify({"ok": False, "msg": "请提䟛接入密钥"}), 400 + + keys_data = load_join_keys() + key_item = next((k for k in keys_data.get("keys", []) if k.get("key") == join_key), None) + if not key_item: + return jsonify({"ok": False, "msg": "接入密钥无效"}), 403 + # key 可倍甚䞍再因䞺 used=true 拒绝 + + with join_lock: + # 圚锁内重新读取避免并发请求郜基于同䞀旧快照通过校验 + keys_data = load_join_keys() + key_item = next((k for k in keys_data.get("keys", []) if k.get("key") == join_key), None) + if not key_item: + return jsonify({"ok": False, "msg": "接入密钥无效"}), 403 + + # Key-level expiration check + key_expires_at_str = key_item.get("expiresAt") + if key_expires_at_str: + try: + key_expires_at = datetime.fromisoformat(key_expires_at_str) + if datetime.now() > key_expires_at: + return jsonify({"ok": False, "msg": "该接入密钥已过期掻劚已结束 🎉"}), 403 + except Exception: + pass + + agents = load_agents_state() + + # 并发䞊限同䞀䞪 key “同时圚线”最倚 3 䞪。 + # 圚线刀定lastPushAt/updated_at 圚 5 分钟内吊则视䞺 offline䞍计入并发。 + now = datetime.now() + existing = next((a for a in agents if a.get("name") == name and not a.get("isMain")), None) + existing_id = existing.get("agentId") if existing else None + + def _age_seconds(dt_str): + if not dt_str: + return None + try: + dt = datetime.fromisoformat(dt_str) + return (now - dt).total_seconds() + except Exception: + return None + + # opportunistic offline marking + for a in agents: + if a.get("isMain"): + continue + if a.get("authStatus") != "approved": + continue + age = _age_seconds(a.get("lastPushAt")) + if age is None: + age = _age_seconds(a.get("updated_at")) + if age is not None and age > 300: + a["authStatus"] = "offline" + + max_concurrent = int(key_item.get("maxConcurrent", 3)) + active_count = 0 + for a in agents: + if a.get("isMain"): + continue + if a.get("agentId") == existing_id: + continue + if a.get("joinKey") != join_key: + continue + if a.get("authStatus") != "approved": + continue + age = _age_seconds(a.get("lastPushAt")) + if age is None: + age = _age_seconds(a.get("updated_at")) + if age is None or age <= 300: + active_count += 1 + + if active_count >= max_concurrent: + save_agents_state(agents) + return jsonify({"ok": False, "msg": f"该接入密钥圓前并发已蟟䞊限{max_concurrent}请皍后或换及䞀䞪 key"}), 429 + + if existing: + existing["state"] = state + existing["detail"] = detail + existing["updated_at"] = datetime.now().isoformat() + existing["area"] = state_to_area(state) + existing["source"] = "remote-openclaw" + existing["joinKey"] = join_key + existing["authStatus"] = "approved" + existing["authApprovedAt"] = datetime.now().isoformat() + existing["authExpiresAt"] = (datetime.now() + timedelta(hours=24)).isoformat() + existing["lastPushAt"] = datetime.now().isoformat() # join 视䞺䞊线纳入并发/犻线刀定 + if not existing.get("avatar"): + import random + existing["avatar"] = random.choice(["guest_role_1", "guest_role_2", "guest_role_3", "guest_role_4", "guest_role_5", "guest_role_6"]) + agent_id = existing.get("agentId") + else: + # Use ms + random suffix to avoid collisions under concurrent joins + import random + import string + agent_id = "agent_" + str(int(datetime.now().timestamp() * 1000)) + "_" + "".join(random.choices(string.ascii_lowercase + string.digits, k=4)) + agents.append({ + "agentId": agent_id, + "name": name, + "isMain": False, + "state": state, + "detail": detail, + "updated_at": datetime.now().isoformat(), + "area": state_to_area(state), + "source": "remote-openclaw", + "joinKey": join_key, + "authStatus": "approved", + "authApprovedAt": datetime.now().isoformat(), + "authExpiresAt": (datetime.now() + timedelta(hours=24)).isoformat(), + "lastPushAt": datetime.now().isoformat(), + "avatar": random.choice(["guest_role_1", "guest_role_2", "guest_role_3", "guest_role_4", "guest_role_5", "guest_role_6"]) + }) + + key_item["used"] = True + key_item["usedBy"] = name + key_item["usedByAgentId"] = agent_id + key_item["usedAt"] = datetime.now().isoformat() + key_item["reusable"] = True + + # 拿到有效 key 盎接批准䞍再等埅䞻人手劚点击 + # 状态已圚䞊面 existing/new 分支写入 + save_agents_state(agents) + save_join_keys(keys_data) + + return jsonify({"ok": True, "agentId": agent_id, "authStatus": "approved", "nextStep": "已自劚批准立即匀始掚送状态"}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/leave-agent", methods=["POST"]) +def leave_agent(): + """Remove an agent and free its one-time join key for reuse (optional) + + Prefer agentId (stable). Name is accepted for backward compatibility. + """ + try: + data = request.get_json() + if not isinstance(data, dict): + return jsonify({"ok": False, "msg": "invalid json"}), 400 + + agent_id = (data.get("agentId") or "").strip() + name = (data.get("name") or "").strip() + if not agent_id and not name: + return jsonify({"ok": False, "msg": "请提䟛 agentId 或名字"}), 400 + + agents = load_agents_state() + + target = None + if agent_id: + target = next((a for a in agents if a.get("agentId") == agent_id and not a.get("isMain")), None) + if (not target) and name: + # fallback: remove by name only if agentId not provided + target = next((a for a in agents if a.get("name") == name and not a.get("isMain")), None) + + if not target: + return jsonify({"ok": False, "msg": "没有扟到芁犻匀的 agent"}), 404 + + join_key = target.get("joinKey") + new_agents = [a for a in agents if a.get("isMain") or a.get("agentId") != target.get("agentId")] + + # Optional: free key back to unused after leave + keys_data = load_join_keys() + if join_key: + key_item = next((k for k in keys_data.get("keys", []) if k.get("key") == join_key), None) + if key_item: + key_item["used"] = False + key_item["usedBy"] = None + key_item["usedByAgentId"] = None + key_item["usedAt"] = None + + save_agents_state(new_agents) + save_join_keys(keys_data) + return jsonify({"ok": True}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/status", methods=["GET"]) +def get_status(): + """Get current main state (backward compatibility). Optionally include officeName from IDENTITY.md.""" + state = load_state() + office_name = get_office_name_from_identity() + if office_name: + state["officeName"] = office_name + return jsonify(state) + + +@app.route("/agent-push", methods=["POST"]) +def agent_push(): + """Remote openclaw actively pushes status to office. + + Required fields: + - agentId + - joinKey + - state + Optional: + - detail + - name + """ + try: + data = request.get_json() + if not isinstance(data, dict): + return jsonify({"ok": False, "msg": "invalid json"}), 400 + + agent_id = (data.get("agentId") or "").strip() + join_key = (data.get("joinKey") or "").strip() + state = (data.get("state") or "").strip() + detail = (data.get("detail") or "").strip() + name = (data.get("name") or "").strip() + + if not agent_id or not join_key or not state: + print(f"[agent-push] Missing required fields: agentId={agent_id}, joinKey={'***' if join_key else 'None'}, state={state}") + return jsonify({"ok": False, "msg": "猺少 agentId/joinKey/state"}), 400 + + state = normalize_agent_state(state) + + keys_data = load_join_keys() + key_item = next((k for k in keys_data.get("keys", []) if k.get("key") == join_key), None) + if not key_item: + print(f"[agent-push] Invalid joinKey: {join_key}") + return jsonify({"ok": False, "msg": "joinKey 无效"}), 403 + + # Key-level expiration check + key_expires_at_str = key_item.get("expiresAt") + if key_expires_at_str: + try: + key_expires_at = datetime.fromisoformat(key_expires_at_str) + if datetime.now() > key_expires_at: + return jsonify({"ok": False, "msg": "该接入密钥已过期掻劚已结束 🎉"}), 403 + except Exception: + pass + + + agents = load_agents_state() + target = next((a for a in agents if a.get("agentId") == agent_id and not a.get("isMain")), None) + if not target: + print(f"[agent-push] Agent not registered: {agent_id}") + return jsonify({"ok": False, "msg": "agent 未泚册请先 join"}), 404 + + # Auth check: only approved agents can push. + # Note: "offline" is a presence state (stale), not a revoked authorization. + # Allow offline agents to resume pushing and auto-promote them back to approved. + auth_status = target.get("authStatus", "pending") + if auth_status not in {"approved", "offline"}: + print(f"[agent-push] Agent not approved: {agent_id}, status: {auth_status}") + return jsonify({"ok": False, "msg": "agent 未获授权请等埅䞻人批准"}), 403 + if auth_status == "offline": + target["authStatus"] = "approved" + target["authApprovedAt"] = datetime.now().isoformat() + target["authExpiresAt"] = (datetime.now() + timedelta(hours=24)).isoformat() + print(f"[agent-push] Agent {agent_id} ({target.get('name')}) resumed from offline") + + if target.get("joinKey") != join_key: + print(f"[agent-push] joinKey mismatch for {agent_id}: provided={join_key}, stored={target.get('joinKey')}") + return jsonify({"ok": False, "msg": "joinKey 䞍匹配"}), 403 + + target["state"] = state + target["detail"] = detail + if name: + target["name"] = name + target["updated_at"] = datetime.now().isoformat() + target["area"] = state_to_area(state) + target["source"] = "remote-openclaw" + target["lastPushAt"] = datetime.now().isoformat() + + save_agents_state(agents) + return jsonify({"ok": True, "agentId": agent_id, "area": target.get("area")}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/health", methods=["GET"]) +def health(): + """Health check""" + return jsonify({ + "status": "ok", + "service": "star-office-ui", + "timestamp": datetime.now().isoformat(), + }) + + +@app.route("/yesterday-memo", methods=["GET"]) +def get_yesterday_memo(): + """获取昚日小日记""" + try: + # 先尝试扟昚倩的文件 + yesterday_str = get_yesterday_date_str() + yesterday_file = os.path.join(MEMORY_DIR, f"{yesterday_str}.md") + + target_file = None + target_date = yesterday_str + + if os.path.exists(yesterday_file): + target_file = yesterday_file + else: + # 劂果昚倩没有扟最近的䞀倩 + if os.path.exists(MEMORY_DIR): + files = [f for f in os.listdir(MEMORY_DIR) if f.endswith(".md") and re.match(r"\d{4}-\d{2}-\d{2}\.md", f)] + if files: + files.sort(reverse=True) + # 跳过今倩的劂果存圚 + today_str = datetime.now().strftime("%Y-%m-%d") + for f in files: + if f != f"{today_str}.md": + target_file = os.path.join(MEMORY_DIR, f) + target_date = f.replace(".md", "") + break + + if target_file and os.path.exists(target_file): + memo_content = extract_memo_from_file(target_file) + return jsonify({ + "success": True, + "date": target_date, + "memo": memo_content + }) + else: + return jsonify({ + "success": False, + "msg": "没有扟到昚日日记" + }) + except Exception as e: + return jsonify({ + "success": False, + "msg": str(e) + }), 500 + + +@app.route("/set_state", methods=["POST"]) +def set_state_endpoint(): + """Set state via POST (for UI control panel)""" + try: + data = request.get_json() + if not isinstance(data, dict): + return jsonify({"status": "error", "msg": "invalid json"}), 400 + state = load_state() + if "state" in data: + s = data["state"] + if s in VALID_AGENT_STATES: + state["state"] = s + if "detail" in data: + state["detail"] = data["detail"] + state["updated_at"] = datetime.now().isoformat() + save_state(state) + return jsonify({"status": "ok"}) + except Exception as e: + return jsonify({"status": "error", "msg": str(e)}), 500 + + +@app.route("/assets/template.zip", methods=["GET"]) +def assets_template_download(): + if not os.path.exists(ASSET_TEMPLATE_ZIP): + return jsonify({"ok": False, "msg": "暡板包䞍存圚请先生成"}), 404 + return send_from_directory(ROOT_DIR, "assets-replace-template.zip", as_attachment=True) + + +@app.route("/assets/list", methods=["GET"]) +def assets_list(): + items = [] + for p in FRONTEND_PATH.rglob("*"): + if not p.is_file(): + continue + rel = p.relative_to(FRONTEND_PATH).as_posix() + if rel.startswith("fonts/"): + continue + if p.suffix.lower() not in ASSET_ALLOWED_EXTS: + continue + st = p.stat() + width = None + height = None + if Image is not None: + try: + with Image.open(p) as im: + width, height = im.size + except Exception: + pass + items.append({ + "path": rel, + "size": st.st_size, + "ext": p.suffix.lower(), + "width": width, + "height": height, + "mtime": datetime.fromtimestamp(st.st_mtime).isoformat(), + }) + items.sort(key=lambda x: x["path"]) + return jsonify({"ok": True, "count": len(items), "items": items}) + + +def _bg_generate_worker(task_id: str, custom_prompt: str, speed_mode: str): + """Background worker for RPG background generation.""" + try: + target = FRONTEND_PATH / "office_bg_small.webp" + + # 芆盖前保留最近䞀次倇仜 + bak = target.with_suffix(target.suffix + ".bak") + shutil.copy2(target, bak) + + _generate_rpg_background_to_webp( + str(target), + width=1280, + height=720, + custom_prompt=custom_prompt, + speed_mode=speed_mode, + ) + + # 每次生成郜園档䞀仜历史底囟可回溯风栌挔化 + os.makedirs(BG_HISTORY_DIR, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + hist_file = os.path.join(BG_HISTORY_DIR, f"office_bg_small-{ts}.webp") + shutil.copy2(target, hist_file) + + st = target.stat() + with _bg_tasks_lock: + _bg_tasks[task_id] = { + "status": "done", + "result": { + "ok": True, + "path": "office_bg_small.webp", + "size": st.st_size, + "history": os.path.relpath(hist_file, ROOT_DIR), + "speed_mode": speed_mode, + "msg": "已生成并替换 RPG 房闎底囟已自劚園档", + }, + } + except Exception as e: + msg = str(e) + error_result = {"ok": False, "msg": msg} + if msg == "MISSING_API_KEY": + error_result["code"] = "MISSING_API_KEY" + error_result["msg"] = "Missing GEMINI_API_KEY or GOOGLE_API_KEY" + elif msg == "API_KEY_REVOKED_OR_LEAKED": + error_result["code"] = "API_KEY_REVOKED_OR_LEAKED" + error_result["msg"] = "API key is revoked or flagged as leaked. Please rotate to a new key." + elif msg.startswith("MODEL_NOT_AVAILABLE"): + error_result["code"] = "MODEL_NOT_AVAILABLE" + error_result["msg"] = "Configured model is not available for this API key/channel." + if "::" in msg: + error_result["detail"] = msg.split("::", 1)[1] + with _bg_tasks_lock: + _bg_tasks[task_id] = {"status": "error", "result": error_result} + + +@app.route("/assets/generate-rpg-background", methods=["POST"]) +def assets_generate_rpg_background(): + """Start async RPG background generation. Returns a task_id for polling.""" + _cleanup_bg_tasks() + guard = _require_asset_editor_auth() + if guard: + return guard + try: + req = request.get_json(silent=True) or {} + custom_prompt = (req.get("prompt") or "").strip() if isinstance(req, dict) else "" + speed_mode = (req.get("speed_mode") or "quality").strip().lower() if isinstance(req, dict) else "quality" + if speed_mode not in {"fast", "quality"}: + speed_mode = "fast" + + target = FRONTEND_PATH / "office_bg_small.webp" + if not target.exists(): + return jsonify({"ok": False, "msg": "office_bg_small.webp 䞍存圚"}), 404 + + # Pre-flight checks that can fail fast (before spawning thread) + runtime_cfg = load_runtime_config() + api_key = (runtime_cfg.get("gemini_api_key") or "").strip() + if not api_key: + return jsonify({"ok": False, "code": "MISSING_API_KEY", "msg": "Missing GEMINI_API_KEY or GOOGLE_API_KEY"}), 400 + if not (os.path.exists(GEMINI_PYTHON) and os.path.exists(GEMINI_SCRIPT)): + return jsonify({"ok": False, "msg": "生囟脚本环境猺倱gemini-image-generate 未安装"}), 500 + + # Check if another generation is already running + with _bg_tasks_lock: + for tid, task in _bg_tasks.items(): + if task.get("status") == "pending": + return jsonify({"ok": True, "async": True, "task_id": tid, "msg": "已有生囟任务进行䞭请等埅完成"}), 200 + + # Create async task + import string as _string + task_id = "gen_" + str(int(datetime.now().timestamp() * 1000)) + "_" + "".join(random.choices(_string.ascii_lowercase + _string.digits, k=4)) + with _bg_tasks_lock: + _bg_tasks[task_id] = {"status": "pending", "created_at": datetime.now().isoformat()} + + t = threading.Thread(target=_bg_generate_worker, args=(task_id, custom_prompt, speed_mode), daemon=True) + t.start() + + return jsonify({"ok": True, "async": True, "task_id": task_id, "msg": "生囟任务已启劚请通过 task_id 蜮询结果"}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/assets/generate-rpg-background/poll", methods=["GET"]) +def assets_generate_rpg_background_poll(): + """Poll async generation task status.""" + _cleanup_bg_tasks() + guard = _require_asset_editor_auth() + if guard: + return guard + task_id = (request.args.get("task_id") or "").strip() + if not task_id: + return jsonify({"ok": False, "msg": "猺少 task_id"}), 400 + with _bg_tasks_lock: + task = _bg_tasks.get(task_id) + if not task: + return jsonify({"ok": False, "msg": "任务䞍存圚"}), 404 + status = task.get("status", "pending") + if status == "pending": + return jsonify({"ok": True, "status": "pending", "msg": "生囟进行䞭..."}) + elif status == "done": + # Clean up task after delivering result + with _bg_tasks_lock: + _bg_tasks.pop(task_id, None) + return jsonify({"ok": True, "status": "done", **task.get("result", {})}) + else: + with _bg_tasks_lock: + _bg_tasks.pop(task_id, None) + result = task.get("result", {}) + code = 400 if result.get("code") else 500 + return jsonify({"ok": False, "status": "error", **result}), code + + +@app.route("/assets/restore-reference-background", methods=["POST"]) +def assets_restore_reference_background(): + """Restore office_bg_small.webp from fixed reference image.""" + guard = _require_asset_editor_auth() + if guard: + return guard + try: + target = FRONTEND_PATH / "office_bg_small.webp" + if not target.exists(): + return jsonify({"ok": False, "msg": "office_bg_small.webp 䞍存圚"}), 404 + if not os.path.exists(ROOM_REFERENCE_IMAGE): + return jsonify({"ok": False, "msg": "参考囟䞍存圚"}), 404 + + # 倇仜圓前底囟 + bak = target.with_suffix(target.suffix + ".bak") + shutil.copy2(target, bak) + + # 快速路埄若参考囟已是 1280x720 的 webp盎接拷莝秒级 + ref_ext = os.path.splitext(ROOM_REFERENCE_IMAGE)[1].lower() + fast_copied = False + if ref_ext == '.webp': + try: + with Image.open(ROOM_REFERENCE_IMAGE) as rim: + if rim.size == (1280, 720): + shutil.copy2(ROOM_REFERENCE_IMAGE, target) + fast_copied = True + except Exception: + fast_copied = False + + # 慢路埄仅圚必芁时重猖码 + if not fast_copied: + if Image is None: + return jsonify({"ok": False, "msg": "Pillow 䞍可甚"}), 500 + with Image.open(ROOM_REFERENCE_IMAGE) as im: + im = im.convert("RGBA").resize((1280, 720), Image.Resampling.LANCZOS) + im.save(target, "WEBP", quality=92, method=6) + + st = target.stat() + return jsonify({ + "ok": True, + "path": "office_bg_small.webp", + "size": st.st_size, + "msg": "已恢倍初始底囟", + }) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/assets/restore-last-generated-background", methods=["POST"]) +def assets_restore_last_generated_background(): + """Restore office_bg_small.webp from latest bg-history snapshot.""" + guard = _require_asset_editor_auth() + if guard: + return guard + try: + target = FRONTEND_PATH / "office_bg_small.webp" + if not target.exists(): + return jsonify({"ok": False, "msg": "office_bg_small.webp 䞍存圚"}), 404 + + if not os.path.isdir(BG_HISTORY_DIR): + return jsonify({"ok": False, "msg": "暂无历史底囟"}), 404 + + files = [ + os.path.join(BG_HISTORY_DIR, x) + for x in os.listdir(BG_HISTORY_DIR) + if x.startswith("office_bg_small-") and x.endswith(".webp") + ] + if not files: + return jsonify({"ok": False, "msg": "暂无历史底囟"}), 404 + + latest = max(files, key=lambda p: os.path.getmtime(p)) + + bak = target.with_suffix(target.suffix + ".bak") + shutil.copy2(target, bak) + shutil.copy2(latest, target) + + st = target.stat() + return jsonify({ + "ok": True, + "path": "office_bg_small.webp", + "size": st.st_size, + "from": os.path.relpath(latest, ROOT_DIR), + "msg": "已回退到最近䞀次生成底囟", + }) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/assets/home-favorites/list", methods=["GET"]) +def assets_home_favorites_list(): + guard = _require_asset_editor_auth() + if guard: + return guard + try: + data = _load_home_favorites_index() + items = data.get("items") or [] + out = [] + for it in items: + rel = (it.get("path") or "").strip() + if not rel: + continue + abs_path = os.path.join(ROOT_DIR, rel) + if not os.path.exists(abs_path): + continue + fn = os.path.basename(rel) + out.append({ + "id": it.get("id"), + "path": rel, + "url": f"/assets/home-favorites/file/{fn}", + "thumb_url": f"/assets/home-favorites/file/{fn}", + "created_at": it.get("created_at") or "", + }) + out.sort(key=lambda x: x.get("created_at") or "", reverse=True) + return jsonify({"ok": True, "items": out}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/assets/home-favorites/file/", methods=["GET"]) +def assets_home_favorites_file(filename): + guard = _require_asset_editor_auth() + if guard: + return guard + return send_from_directory(HOME_FAVORITES_DIR, filename) + + +@app.route("/assets/home-favorites/save-current", methods=["POST"]) +def assets_home_favorites_save_current(): + guard = _require_asset_editor_auth() + if guard: + return guard + try: + src = FRONTEND_PATH / "office_bg_small.webp" + if not src.exists(): + return jsonify({"ok": False, "msg": "office_bg_small.webp 䞍存圚"}), 404 + + _ensure_home_favorites_index() + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + item_id = f"home-{ts}" + fn = f"{item_id}.webp" + dst = os.path.join(HOME_FAVORITES_DIR, fn) + shutil.copy2(str(src), dst) + + idx = _load_home_favorites_index() + items = idx.get("items") or [] + items.insert(0, { + "id": item_id, + "path": os.path.relpath(dst, ROOT_DIR), + "created_at": datetime.now().isoformat(timespec="seconds"), + }) + + # 控制收藏数量䞊限枅理最旧项 + if len(items) > HOME_FAVORITES_MAX: + extra = items[HOME_FAVORITES_MAX:] + items = items[:HOME_FAVORITES_MAX] + for it in extra: + try: + p = os.path.join(ROOT_DIR, it.get("path") or "") + if os.path.exists(p): + os.remove(p) + except Exception: + pass + + idx["items"] = items + _save_home_favorites_index(idx) + return jsonify({"ok": True, "id": item_id, "path": os.path.relpath(dst, ROOT_DIR), "msg": "已收藏圓前地囟"}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/assets/home-favorites/delete", methods=["POST"]) +def assets_home_favorites_delete(): + guard = _require_asset_editor_auth() + if guard: + return guard + try: + data = request.get_json(silent=True) or {} + item_id = (data.get("id") or "").strip() + if not item_id: + return jsonify({"ok": False, "msg": "猺少 id"}), 400 + + idx = _load_home_favorites_index() + items = idx.get("items") or [] + hit = next((x for x in items if (x.get("id") or "") == item_id), None) + if not hit: + return jsonify({"ok": False, "msg": "收藏项䞍存圚"}), 404 + + rel = hit.get("path") or "" + abs_path = os.path.join(ROOT_DIR, rel) + if os.path.exists(abs_path): + try: + os.remove(abs_path) + except Exception: + pass + + idx["items"] = [x for x in items if (x.get("id") or "") != item_id] + _save_home_favorites_index(idx) + return jsonify({"ok": True, "id": item_id, "msg": "已删陀收藏"}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/assets/home-favorites/apply", methods=["POST"]) +def assets_home_favorites_apply(): + guard = _require_asset_editor_auth() + if guard: + return guard + try: + data = request.get_json(silent=True) or {} + item_id = (data.get("id") or "").strip() + if not item_id: + return jsonify({"ok": False, "msg": "猺少 id"}), 400 + + idx = _load_home_favorites_index() + items = idx.get("items") or [] + hit = next((x for x in items if (x.get("id") or "") == item_id), None) + if not hit: + return jsonify({"ok": False, "msg": "收藏项䞍存圚"}), 404 + + src = os.path.join(ROOT_DIR, hit.get("path") or "") + if not os.path.exists(src): + return jsonify({"ok": False, "msg": "收藏文件䞍存圚"}), 404 + + target = FRONTEND_PATH / "office_bg_small.webp" + if not target.exists(): + return jsonify({"ok": False, "msg": "office_bg_small.webp 䞍存圚"}), 404 + + bak = target.with_suffix(target.suffix + ".bak") + shutil.copy2(str(target), str(bak)) + shutil.copy2(src, str(target)) + + st = target.stat() + return jsonify({"ok": True, "path": "office_bg_small.webp", "size": st.st_size, "from": hit.get("path"), "msg": "已应甚收藏地囟"}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/assets/auth", methods=["POST"]) +def assets_auth(): + try: + data = request.get_json(silent=True) or {} + pwd = (data.get("password") or "").strip() + if pwd and pwd == ASSET_DRAWER_PASS_DEFAULT: + session["asset_editor_authed"] = True + return jsonify({"ok": True, "msg": "讀证成功"}) + return jsonify({"ok": False, "msg": "验证码错误"}), 401 + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/assets/auth/status", methods=["GET"]) +def assets_auth_status(): + return jsonify({ + "ok": True, + "authed": _is_asset_editor_authed(), + "drawer_default_pass": ASSET_DRAWER_PASS_DEFAULT == "1234", + }) + + +@app.route("/assets/positions", methods=["GET"]) +def assets_positions_get(): + guard = _require_asset_editor_auth() + if guard: + return guard + try: + return jsonify({"ok": True, "items": load_asset_positions()}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/assets/positions", methods=["POST"]) +def assets_positions_set(): + guard = _require_asset_editor_auth() + if guard: + return guard + try: + data = request.get_json(silent=True) or {} + key = (data.get("key") or "").strip() + x = data.get("x") + y = data.get("y") + scale = data.get("scale") + if not key: + return jsonify({"ok": False, "msg": "猺少 key"}), 400 + if x is None or y is None: + return jsonify({"ok": False, "msg": "猺少 x/y"}), 400 + x = float(x) + y = float(y) + if scale is None: + scale = 1.0 + scale = float(scale) + + all_pos = load_asset_positions() + all_pos[key] = {"x": x, "y": y, "scale": scale, "updated_at": datetime.now().isoformat()} + save_asset_positions(all_pos) + return jsonify({"ok": True, "key": key, "x": x, "y": y, "scale": scale}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/assets/defaults", methods=["GET"]) +def assets_defaults_get(): + guard = _require_asset_editor_auth() + if guard: + return guard + try: + return jsonify({"ok": True, "items": load_asset_defaults()}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/assets/defaults", methods=["POST"]) +def assets_defaults_set(): + guard = _require_asset_editor_auth() + if guard: + return guard + try: + data = request.get_json(silent=True) or {} + key = (data.get("key") or "").strip() + x = data.get("x") + y = data.get("y") + scale = data.get("scale") + if not key: + return jsonify({"ok": False, "msg": "猺少 key"}), 400 + if x is None or y is None: + return jsonify({"ok": False, "msg": "猺少 x/y"}), 400 + x = float(x) + y = float(y) + if scale is None: + scale = 1.0 + scale = float(scale) + + all_defaults = load_asset_defaults() + all_defaults[key] = {"x": x, "y": y, "scale": scale, "updated_at": datetime.now().isoformat()} + save_asset_defaults(all_defaults) + return jsonify({"ok": True, "key": key, "x": x, "y": y, "scale": scale}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/config/gemini", methods=["GET"]) +def gemini_config_get(): + guard = _require_asset_editor_auth() + if guard: + return guard + try: + cfg = load_runtime_config() + key = (cfg.get("gemini_api_key") or "").strip() + masked = ("*" * max(0, len(key) - 4)) + key[-4:] if key else "" + return jsonify({ + "ok": True, + "has_api_key": bool(key), + "api_key_masked": masked, + "gemini_model": _normalize_user_model(cfg.get("gemini_model") or "nanobanana-pro"), + }) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/config/gemini", methods=["POST"]) +def gemini_config_set(): + guard = _require_asset_editor_auth() + if guard: + return guard + try: + data = request.get_json(silent=True) or {} + api_key = (data.get("api_key") or "").strip() + model = _normalize_user_model((data.get("model") or "").strip() or "nanobanana-pro") + payload = {"gemini_model": model} + if api_key: + payload["gemini_api_key"] = api_key + save_runtime_config(payload) + return jsonify({"ok": True, "msg": "Gemini 配眮已保存"}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/assets/restore-default", methods=["POST"]) +def assets_restore_default(): + guard = _require_asset_editor_auth() + if guard: + return guard + try: + data = request.get_json(silent=True) or {} + rel_path = (data.get("path") or "").strip().lstrip("/") + if not rel_path: + return jsonify({"ok": False, "msg": "猺少 path"}), 400 + + target = (FRONTEND_PATH / rel_path).resolve() + try: + target.relative_to(FRONTEND_PATH.resolve()) + except Exception: + return jsonify({"ok": False, "msg": "非法 path"}), 400 + + if not target.exists(): + return jsonify({"ok": False, "msg": "目标文件䞍存圚"}), 404 + + root, ext = os.path.splitext(str(target)) + default_path = root + ext + ".default" + if not os.path.exists(default_path): + return jsonify({"ok": False, "msg": "未扟到默讀资产快照"}), 404 + + # 回滚前保留䞊䞀版 + bak = str(target) + ".bak" + if os.path.exists(str(target)): + shutil.copy2(str(target), bak) + + shutil.copy2(default_path, str(target)) + st = os.stat(str(target)) + return jsonify({"ok": True, "path": rel_path, "size": st.st_size, "msg": "已重眮䞺默讀资产"}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/assets/restore-prev", methods=["POST"]) +def assets_restore_prev(): + guard = _require_asset_editor_auth() + if guard: + return guard + try: + data = request.get_json(silent=True) or {} + rel_path = (data.get("path") or "").strip().lstrip("/") + if not rel_path: + return jsonify({"ok": False, "msg": "猺少 path"}), 400 + + target = (FRONTEND_PATH / rel_path).resolve() + try: + target.relative_to(FRONTEND_PATH.resolve()) + except Exception: + return jsonify({"ok": False, "msg": "非法 path"}), 400 + + bak = str(target) + ".bak" + if not os.path.exists(bak): + return jsonify({"ok": False, "msg": "未扟到䞊䞀版倇仜"}), 404 + + shutil.copy2(str(target), bak + ".tmp") if os.path.exists(str(target)) else None + shutil.copy2(bak, str(target)) + st = os.stat(str(target)) + return jsonify({"ok": True, "path": rel_path, "size": st.st_size, "msg": "已回退到䞊䞀版"}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +@app.route("/assets/upload", methods=["POST"]) +def assets_upload(): + guard = _require_asset_editor_auth() + if guard: + return guard + try: + rel_path = (request.form.get("path") or "").strip().lstrip("/") + backup = (request.form.get("backup") or "1").strip() != "0" + f = request.files.get("file") + + if not rel_path or f is None: + return jsonify({"ok": False, "msg": "猺少 path 或 file"}), 400 + + target = (FRONTEND_PATH / rel_path).resolve() + try: + target.relative_to(FRONTEND_PATH.resolve()) + except Exception: + return jsonify({"ok": False, "msg": "非法 path"}), 400 + + if target.suffix.lower() not in ASSET_ALLOWED_EXTS: + return jsonify({"ok": False, "msg": "仅允讞䞊䌠囟片/矎术资源类型"}), 400 + + if not target.exists(): + return jsonify({"ok": False, "msg": "目标文件䞍存圚请先从 /assets/list 选择 path"}), 404 + + target.parent.mkdir(parents=True, exist_ok=True) + + # 銖次䞊䌠前固化默讀资产快照䟛“重眮䞺默讀资产”䜿甚 + default_snap = Path(str(target) + ".default") + if not default_snap.exists(): + try: + shutil.copy2(target, default_snap) + except Exception: + pass + + if backup: + bak = target.with_suffix(target.suffix + ".bak") + shutil.copy2(target, bak) + + auto_sheet = (request.form.get("auto_spritesheet") or "0").strip() == "1" + ext_name = (f.filename or "").lower() + + if auto_sheet and target.suffix.lower() in {".webp", ".png"}: + with tempfile.NamedTemporaryFile(suffix=os.path.splitext(ext_name)[1] or ".gif", delete=False) as tf: + src_path = tf.name + f.save(src_path) + try: + in_w, in_h = _probe_animated_frame_size(src_path) + frame_w = int(request.form.get("frame_w") or (in_w or 64)) + frame_h = int(request.form.get("frame_h") or (in_h or 64)) + + # 劂果是静态囟䞊䌠到粟灵衚目标按眑栌切片而䞍是敎囟芆盖 + if not (ext_name.endswith(".gif") or ext_name.endswith(".webp")) and Image is not None: + try: + with Image.open(src_path) as sim: + sim = sim.convert("RGBA") + sw, sh = sim.size + if frame_w <= 0 or frame_h <= 0: + frame_w, frame_h = sw, sh + cols = max(1, sw // frame_w) + rows = max(1, sh // frame_h) + sheet_w = cols * frame_w + sheet_h = rows * frame_h + if sheet_w <= 0 or sheet_h <= 0: + raise RuntimeError("静态囟尺寞䞎垧规栌䞍匹配") + + cropped = sim.crop((0, 0, sheet_w, sheet_h)) + # 目标是 webp 仍按无损保存避免像玠损倱 + if target.suffix.lower() == ".webp": + cropped.save(str(target), "WEBP", lossless=True, quality=100, method=6) + else: + cropped.save(str(target), "PNG") + + st = target.stat() + return jsonify({ + "ok": True, + "path": rel_path, + "size": st.st_size, + "backup": backup, + "converted": { + "from": ext_name.split(".")[-1] if "." in ext_name else "image", + "to": "webp_spritesheet" if target.suffix.lower() == ".webp" else "png_spritesheet", + "frame_w": frame_w, + "frame_h": frame_h, + "columns": cols, + "rows": rows, + "frames": cols * rows, + "preserve_original": False, + "pixel_art": True, + } + }) + finally: + pass + + # 默讀䌘先保留蟓入垧尺寞若前端䌠了区制倌则按前端。 + preserve_original_val = request.form.get("preserve_original") + if preserve_original_val is None: + preserve_original = True + else: + preserve_original = preserve_original_val.strip() == "1" + + pixel_art = (request.form.get("pixel_art") or "1").strip() == "1" + req_cols = int(request.form.get("cols") or 0) + req_rows = int(request.form.get("rows") or 0) + sheet_path, cols, rows, frames, out_fw, out_fh = _animated_to_spritesheet( + src_path, + frame_w, + frame_h, + out_ext=target.suffix.lower(), + preserve_original=preserve_original, + pixel_art=pixel_art, + cols=(req_cols if req_cols > 0 else None), + rows=(req_rows if req_rows > 0 else None), + ) + shutil.move(sheet_path, str(target)) + st = target.stat() + from_type = "gif" if ext_name.endswith(".gif") else "webp" + to_type = "webp_spritesheet" if target.suffix.lower() == ".webp" else "png_spritesheet" + return jsonify({ + "ok": True, + "path": rel_path, + "size": st.st_size, + "backup": backup, + "converted": { + "from": from_type, + "to": to_type, + "frame_w": out_fw, + "frame_h": out_fh, + "columns": cols, + "rows": rows, + "frames": frames, + "preserve_original": preserve_original, + "pixel_art": pixel_art, + } + }) + finally: + try: + os.remove(src_path) + except Exception: + pass + + f.save(str(target)) + st = target.stat() + return jsonify({"ok": True, "path": rel_path, "size": st.st_size, "backup": backup}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + + +if __name__ == "__main__": + raw_port = os.environ.get("STAR_BACKEND_PORT", "19000") + try: + backend_port = int(raw_port) + except ValueError: + backend_port = 19000 + if backend_port <= 0: + backend_port = 19000 + + print("=" * 50) + print("Star Office UI - Backend State Service") + print("=" * 50) + print(f"State file: {STATE_FILE}") + print(f"Listening on: http://0.0.0.0:{backend_port}") + if backend_port != 19000: + print(f"(Port override: set STAR_BACKEND_PORT to change; current: {raw_port})") + else: + print("(Set STAR_BACKEND_PORT to use a different port, e.g. 3009)") + mode = "production" if is_production_mode() else "development" + print(f"Mode: {mode}") + if is_production_mode(): + print("Security hardening: ENABLED (strict checks)") + else: + weak_flags = [] + if not is_strong_secret(str(app.secret_key)): + weak_flags.append("weak FLASK_SECRET_KEY/STAR_OFFICE_SECRET") + if not is_strong_drawer_pass(ASSET_DRAWER_PASS_DEFAULT): + weak_flags.append("weak ASSET_DRAWER_PASS") + if weak_flags: + print("Security hardening: WARNING (dev mode) -> " + ", ".join(weak_flags)) + else: + print("Security hardening: OK") + print("=" * 50) + + app.run(host="0.0.0.0", port=backend_port, debug=False) + diff --git a/convert_to_webp.py b/convert_to_webp.py index 396db86e..549db2d1 100644 --- a/convert_to_webp.py +++ b/convert_to_webp.py @@ -1,115 +1,116 @@ -#!/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() - +#!/usr/bin/env python3 +""" +批量蜬换 PNG 资源䞺 WebP 栌匏 +- 粟灵囟䜿甚无损蜬换 +- 背景囟等䜿甚有损蜬换莚量 85 +""" + +import os +from PIL import Image + +# 路埄 +ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) +FRONTEND_DIR = os.path.join(ROOT_DIR, "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() + diff --git a/docs/WINDOWS_SETUP.md b/docs/WINDOWS_SETUP.md new file mode 100644 index 00000000..9af30370 --- /dev/null +++ b/docs/WINDOWS_SETUP.md @@ -0,0 +1,121 @@ +# Windows 郚眲䞎排错PowerShell / CMD + +本项目后端基于 Python銖次郚眲只需芁跑起 `backend/app.py`前端资源已内眮圚 `frontend/`无需额倖构建。 + +## 0) 环境芁求 + +- Windows 10/11 +- Python 3.10+3.9 及以䞋䞍支持 +- Git + +建议䜿甚虚拟环境`.venv`避免污染党局 Python。 + +## 1) 䞋蜜仓库 + +PowerShell + +```powershell +git clone https://github.com/ringhyacinth/Star-Office-UI.git +cd Star-Office-UI +``` + +## 2) 创建虚拟环境并安装䟝赖掚荐 + +PowerShell + +```powershell +py -3.10 -m venv .venv +.\.venv\Scripts\python -m pip install -r backend\requirements.txt +``` + +劂果䜠机噚䞊装的是 3.11/3.12/3.13把䞊面的 `-3.10` 改成对应版本即可。也可以先甚 `py -0p` 查看已安装的 Python 列衚。 + +劂果䜠没有安装 Python Launcher`py`也可以甚 + +```powershell +python -m venv .venv +.\.venv\Scripts\python -m pip install -r backend\requirements.txt +``` + +## 3) 初始化状态文件銖次 + +PowerShell + +```powershell +Copy-Item state.sample.json state.json +``` + +CMD + +```bat +copy state.sample.json state.json +``` + +## 4) 启劚后端 + +PowerShell掚荐 + +```powershell +cd backend +..\.\.venv\Scripts\python app.py +``` + +看到类䌌 “Running on http://127.0.0.1:19000” 后打匀 + +- http://127.0.0.1:19000 + +## 5) 切换状态验证工䜜流 + +回到项目根目圕执行 + +PowerShell + +```powershell +cd .. +.\.venv\Scripts\python set_state.py writing "正圚敎理文档" +.\.venv\Scripts\python set_state.py error "发现问题排查䞭" +.\.venv\Scripts\python set_state.py idle "埅呜䞭" +``` + +## 6) 可选生产环境配眮区烈建议 + +倍制 `.env.example` 䞺 `.env`并讟眮区随机的 `FLASK_SECRET_KEY` 侎 `ASSET_DRAWER_PASS`。 + +PowerShell + +```powershell +Copy-Item .env.example .env +``` + +CMD + +```bat +copy .env.example .env +``` + +## 7) 可选运行 Smoke Test + +确保后端已圚及䞀䞪终端运行然后执行 + +```powershell +.\.venv\Scripts\python scripts\smoke_test.py --base-url http://127.0.0.1:19000 +``` + +## 垞见问题 + +### 1) 浏览噚打䞍匀 19000 + +- 确讀终端里后端没有报错并䞔地址是 `http://127.0.0.1:19000` +- Windows Defender 防火墙可胜䌚匹窗拊截 Python请选择允讞访问 +- 若䜠修改䞺局域眑访问`0.0.0.0`请确讀端口攟行䞎路由规则 + +### 2) 报错提瀺猺少䟝赖 + +䌘先确讀䜠运行的是虚拟环境里的 Python + +- `.\.venv\Scripts\python -V` + +然后重新安装䟝赖 + +- `.\.venv\Scripts\python -m pip install -r backend\requirements.txt` + diff --git a/frontend/electron-standalone.html b/frontend/electron-standalone.html index a07cd341..79b5f195 100644 --- a/frontend/electron-standalone.html +++ b/frontend/electron-standalone.html @@ -1,5772 +1,5774 @@ - - - - - - Star 的像玠办公宀 - - - -
- - - -
-
加蜜䞭...
- -
-
Loading Star’s pixel office...
-
-
-
-
- -
-
-
加蜜䞭...
-
- ⭐ - 海蟛小韙號的办公宀 - ⭐ -
-
- - -
- -
-
昚 日 小 è®°
-
-
─ ─ ─ ─ ─
-
-
加蜜䞭...
-
-
─ ─ ─ ─ ─
-
- - -
-
Star 状态
-
- - - - - -
-
- - -
-
访 客 列 衚
-
-
正圚加蜜访客...
-
-
-
-
- -
-
-
-
🊞
-
正圚打包號倎  
-
-
- -
- - - - -
- - - -
- - - - - + + + + + + Star 的像玠办公宀 + + + +
+ + + +
+
加蜜䞭...
+ +
+
Loading Star’s pixel office...
+
+
+
+
+ +
+
+
加蜜䞭...
+
+ ⭐ + 海蟛小韙號的办公宀 + ⭐ +
+
+ + +
+ +
+
昚 日 小 è®°
+
+
─ ─ ─ ─ ─
+
+
加蜜䞭...
+
+
─ ─ ─ ─ ─
+
+ + +
+
Star 状态
+
+ + + + + +
+
+ + +
+
访 客 列 衚
+
+
正圚加蜜访客...
+
+
+
+
+ +
+
+
+
🊞
+
正圚打包號倎  
+
+
+ +
+ + + + +
+ + + +
+ + + + + diff --git a/frontend/game.js b/frontend/game.js index ae1befb7..4071b5c5 100644 --- a/frontend/game.js +++ b/frontend/game.js @@ -1,1034 +1,957 @@ -// Star Office UI - 枞戏䞻逻蟑 -// 䟝赖: layout.js必须圚这䞪之前加蜜 - -// 检测浏览噚是吊支持 WebP -let supportsWebP = false; - -// 方法 1: 䜿甚 canvas 检测 -function checkWebPSupport() { - return new Promise((resolve) => { - const canvas = document.createElement('canvas'); - if (canvas.getContext && canvas.getContext('2d')) { - resolve(canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0); - } else { - resolve(false); - } - }); -} - -// 方法 2: 䜿甚 image 检测倇甚 -function checkWebPSupportFallback() { - return new Promise((resolve) => { - const img = new Image(); - img.onload = () => resolve(true); - img.onerror = () => resolve(false); - img.src = 'data:image/webp;base64,UklGRkoAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAwAAAABBxAR/Q9ERP8DAABWUDggGAAAADABAJ0BKgEAAQADADQlpAADcAD++/1QAA=='; - }); -} - -// 获取文件扩展名根据 WebP 支持情况 + 垃局配眮的 forcePng -function getExt(pngFile) { - // star-working-spritesheet.png 倪宜了WebP 䞍支持始终甚 PNG - if (pngFile === 'star-working-spritesheet.png') { - return '.png'; - } - // 劂果垃局配眮里区制甚 PNG就甚 .png - if (LAYOUT.forcePng && LAYOUT.forcePng[pngFile.replace(/\.(png|webp)$/, '')]) { - return '.png'; - } - return supportsWebP ? '.webp' : '.png'; -} - -const config = { - type: Phaser.AUTO, - width: LAYOUT.game.width, - height: LAYOUT.game.height, - parent: 'game-container', - pixelArt: true, - physics: { default: 'arcade', arcade: { gravity: { y: 0 }, debug: false } }, - scene: { preload: preload, create: create, update: update } -}; - -let totalAssets = 0; -let loadedAssets = 0; -let loadingProgressBar, loadingProgressContainer, loadingOverlay, loadingText; - -// Memo 盞关凜数 -async function loadMemo() { - const memoDate = document.getElementById('memo-date'); - const memoContent = document.getElementById('memo-content'); - - try { - const response = await fetch('/yesterday-memo?t=' + Date.now(), { cache: 'no-store' }); - const data = await response.json(); - - if (data.success && data.memo) { - memoDate.textContent = data.date || ''; - memoContent.innerHTML = data.memo.replace(/\n/g, '
'); - } else { - memoContent.innerHTML = '
暂无昚日日记
'; - } - } catch (e) { - console.error('加蜜 memo 倱莥:', e); - memoContent.innerHTML = '
加蜜倱莥
'; - } -} - -// 曎新加蜜进床 -function updateLoadingProgress() { - loadedAssets++; - const percent = Math.min(100, Math.round((loadedAssets / totalAssets) * 100)); - if (loadingProgressBar) { - loadingProgressBar.style.width = percent + '%'; - } - if (loadingText) { - loadingText.textContent = `正圚加蜜 Star 的像玠办公宀... ${percent}%`; - } -} - -// 隐藏加蜜界面 -function hideLoadingOverlay() { - setTimeout(() => { - if (loadingOverlay) { - loadingOverlay.style.transition = 'opacity 0.5s ease'; - loadingOverlay.style.opacity = '0'; - setTimeout(() => { - loadingOverlay.style.display = 'none'; - }, 500); - } - }, 300); -} - -const STATES = { - idle: { name: '埅呜', area: 'breakroom' }, - writing: { name: '敎理文档', area: 'writing' }, - researching: { name: '搜玢信息', area: 'researching' }, - executing: { name: '执行任务', area: 'writing' }, - syncing: { name: '同步倇仜', area: 'writing' }, - error: { name: '出错了', area: 'error' } -}; - -const BUBBLE_TEXTS = { - idle: [ - '埅呜䞭耳朵竖起来了', - '我圚这儿随时可以匀工', - '先把桌面收拟干净再诎', - '呌——给倧脑攟䞪风', - '今倩也芁䌘雅地高效', - '等埅是䞺了曎准确的䞀击', - '咖啡还热灵感也还圚', - '我圚后台给䜠加 Buff', - '状态静心 / 充电', - '小猫诎慢䞀点也没关系' - ], - writing: [ - '进入䞓泚暡匏勿扰', - '先把关键路埄跑通', - '我来把倍杂变简单', - '把 bug 关进笌子里', - '写到䞀半先保存', - '把每䞀步郜做成可回滚', - '今倩的进床明倩的底气', - '先收敛再发散', - '让系统变埗曎可解释', - '皳䜏我们胜赢' - ], - researching: [ - '我圚挖证据铟', - '让我把信息熬成结论', - '扟到了关键圚这里', - '先把变量控制䜏', - '我圚查它䞺什么䌚这样', - '把盎觉写成验证', - '先定䜍再䌘化', - '别急先画因果囟' - ], - executing: [ - '执行䞭䞍芁眚県', - '把任务切成小块逐䞪击砎', - '匀始跑 pipeline', - '䞀键掚进走䜠', - '让结果自己诎话', - '先做最小可行再做最矎版本' - ], - syncing: [ - '同步䞭把今倩锁进云里', - '倇仜䞍是仪匏是安党感', - '写入䞭 别断电', - '把变曎亀给时闎戳', - '云端对霐咔哒', - '同步完成前先别乱劚', - '把未来的自己从灟隟里救出来', - '倚䞀仜倇仜少䞀仜后悔' - ], - error: [ - '譊报响了先别慌', - '我闻到 bug 的味道了', - '先倍现再谈修倍', - '把日志给我我䌚诎人话', - '错误䞍是敌人是线玢', - '把圱响面圈起来', - '先止血再手术', - '我圚马䞊定䜍根因', - '别怕这种我见倚了', - '报譊䞭让问题自己现圢' - ], - cat: [ - '喵~', - '咕噜咕噜 ', - '尟巎摇䞀摇', - '晒倪阳最匀心', - '有人来看我啊', - '我是这䞪办公宀的吉祥物', - '䌞䞪懒腰', - '今倩的眐眐准倇奜了吗', - '呌噜呌噜', - '这䞪䜍眮视野最奜' - ] -}; - -let game, star, sofa, serverroom, areas = {}, currentState = 'idle', pendingDesiredState = null, statusText, lastFetch = 0, lastBlink = 0, lastBubble = 0, targetX = 660, targetY = 170, bubble = null, typewriterText = '', typewriterTarget = '', typewriterIndex = 0, lastTypewriter = 0, syncAnimSprite = null, catBubble = null; -let isMoving = false; -let waypoints = []; -let lastWanderAt = 0; -let coordsOverlay, coordsDisplay, coordsToggle; -let showCoords = false; -const FETCH_INTERVAL = 2000; -const BLINK_INTERVAL = 2500; -const BUBBLE_INTERVAL = 8000; -const CAT_BUBBLE_INTERVAL = 18000; -let lastCatBubble = 0; -const TYPEWRITER_DELAY = 50; -let agents = {}; // agentId -> sprite/container -let lastAgentsFetch = 0; -const AGENTS_FETCH_INTERVAL = 2500; - -// agent 颜色配眮 -const AGENT_COLORS = { - star: 0xffd700, - npc1: 0x00aaff, - agent_nika: 0xff69b4, - default: 0x94a3b8 -}; - -// agent 名字颜色 -const NAME_TAG_COLORS = { - approved: 0x22c55e, - pending: 0xf59e0b, - rejected: 0xef4444, - offline: 0x64748b, - default: 0x1f2937 -}; - -// breakroom / writing / error 区域的 agent 分垃䜍眮倚 agent 时错匀 -const AREA_POSITIONS = { - breakroom: [ - { x: 620, y: 180 }, - { x: 560, y: 220 }, - { x: 680, y: 210 }, - { x: 540, y: 170 }, - { x: 700, y: 240 }, - { x: 600, y: 250 }, - { x: 650, y: 160 }, - { x: 580, y: 200 } - ], - writing: [ - { x: 760, y: 320 }, - { x: 830, y: 280 }, - { x: 690, y: 350 }, - { x: 770, y: 260 }, - { x: 850, y: 340 }, - { x: 720, y: 300 }, - { x: 800, y: 370 }, - { x: 750, y: 240 } - ], - error: [ - { x: 180, y: 260 }, - { x: 120, y: 220 }, - { x: 240, y: 230 }, - { x: 160, y: 200 }, - { x: 220, y: 270 }, - { x: 140, y: 250 }, - { x: 200, y: 210 }, - { x: 260, y: 260 } - ] -}; - - -// 状态控制栏凜数甚于测试 -function setState(state, detail) { - fetch('/set_state', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ state, detail }) - }).then(() => fetchStatus()); -} - -// 初始化先检测 WebP 支持再启劚枞戏 -async function initGame() { - try { - supportsWebP = await checkWebPSupport(); - } catch (e) { - try { - supportsWebP = await checkWebPSupportFallback(); - } catch (e2) { - supportsWebP = false; - } - } - - console.log('WebP 支持:', supportsWebP); - new Phaser.Game(config); -} - -function preload() { - loadingOverlay = document.getElementById('loading-overlay'); - loadingProgressBar = document.getElementById('loading-progress-bar'); - loadingText = document.getElementById('loading-text'); - loadingProgressContainer = document.getElementById('loading-progress-container'); - - // 从 LAYOUT 读取总资源数量避免 magic number - totalAssets = LAYOUT.totalAssets || 15; - loadedAssets = 0; - - this.load.on('filecomplete', () => { - updateLoadingProgress(); - }); - - this.load.on('complete', () => { - hideLoadingOverlay(); - }); - - this.load.image('office_bg', '/static/office_bg_small' + (supportsWebP ? '.webp' : '.png') + '?v={{VERSION_TIMESTAMP}}'); - this.load.spritesheet('star_idle', '/static/star-idle-spritesheet' + getExt('star-idle-spritesheet.png'), { frameWidth: 128, frameHeight: 128 }); - this.load.spritesheet('star_researching', '/static/star-researching-spritesheet' + getExt('star-researching-spritesheet.png'), { frameWidth: 128, frameHeight: 105 }); - - this.load.image('sofa_idle', '/static/sofa-idle' + getExt('sofa-idle.png')); - this.load.spritesheet('sofa_busy', '/static/sofa-busy-spritesheet' + getExt('sofa-busy-spritesheet.png'), { frameWidth: 256, frameHeight: 256 }); - - this.load.spritesheet('plants', '/static/plants-spritesheet' + getExt('plants-spritesheet.png'), { frameWidth: 160, frameHeight: 160 }); - this.load.spritesheet('posters', '/static/posters-spritesheet' + getExt('posters-spritesheet.png'), { frameWidth: 160, frameHeight: 160 }); - this.load.spritesheet('coffee_machine', '/static/coffee-machine-spritesheet' + getExt('coffee-machine-spritesheet.png'), { frameWidth: 230, frameHeight: 230 }); - this.load.spritesheet('serverroom', '/static/serverroom-spritesheet' + getExt('serverroom-spritesheet.png'), { frameWidth: 180, frameHeight: 251 }); - - this.load.spritesheet('error_bug', '/static/error-bug-spritesheet-grid' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 180, frameHeight: 180 }); - this.load.spritesheet('cats', '/static/cats-spritesheet' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 160, frameHeight: 160 }); - this.load.image('desk', '/static/desk' + getExt('desk.png')); - this.load.spritesheet('star_working', '/static/star-working-spritesheet-grid' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 230, frameHeight: 144 }); - this.load.spritesheet('sync_anim', '/static/sync-animation-spritesheet-grid' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 256, frameHeight: 256 }); - this.load.image('memo_bg', '/static/memo-bg' + (supportsWebP ? '.webp' : '.png')); - - // 新办公桌区制 PNG透明 - this.load.image('desk_v2', '/static/desk-v2.png'); - this.load.spritesheet('flowers', '/static/flowers-spritesheet' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 65, frameHeight: 65 }); -} - -function create() { - game = this; - this.add.image(640, 360, 'office_bg'); - - // === 沙发来自 LAYOUT=== - sofa = this.add.sprite( - LAYOUT.furniture.sofa.x, - LAYOUT.furniture.sofa.y, - 'sofa_busy' - ).setOrigin(LAYOUT.furniture.sofa.origin.x, LAYOUT.furniture.sofa.origin.y); - sofa.setDepth(LAYOUT.furniture.sofa.depth); - - this.anims.create({ - key: 'sofa_busy', - frames: this.anims.generateFrameNumbers('sofa_busy', { start: 0, end: 47 }), - frameRate: 12, - repeat: -1 - }); - - areas = LAYOUT.areas; - - this.anims.create({ - key: 'star_idle', - frames: this.anims.generateFrameNumbers('star_idle', { start: 0, end: 29 }), - frameRate: 12, - repeat: -1 - }); - this.anims.create({ - key: 'star_researching', - frames: this.anims.generateFrameNumbers('star_researching', { start: 0, end: 95 }), - frameRate: 12, - repeat: -1 - }); - - star = game.physics.add.sprite(areas.breakroom.x, areas.breakroom.y, 'star_idle'); - star.setOrigin(0.5); - star.setScale(1.4); - star.setAlpha(0.95); - star.setDepth(20); - star.setVisible(false); - star.anims.stop(); - - if (game.textures.exists('sofa_busy')) { - sofa.setTexture('sofa_busy'); - sofa.anims.play('sofa_busy', true); - } - - // === 牌匟来自 LAYOUT=== - const plaqueX = LAYOUT.plaque.x; - const plaqueY = LAYOUT.plaque.y; - const plaqueBg = game.add.rectangle(plaqueX, plaqueY, LAYOUT.plaque.width, LAYOUT.plaque.height, 0x5d4037); - plaqueBg.setStrokeStyle(3, 0x3e2723); - const plaqueText = game.add.text(plaqueX, plaqueY, '海蟛小韙號的办公宀', { - fontFamily: 'ArkPixel, monospace', - fontSize: '18px', - fill: '#ffd700', - fontWeight: 'bold', - stroke: '#000', - strokeThickness: 2 - }).setOrigin(0.5); - game.add.text(plaqueX - 190, plaqueY, '⭐', { fontFamily: 'ArkPixel, monospace', fontSize: '20px' }).setOrigin(0.5); - game.add.text(plaqueX + 190, plaqueY, '⭐', { fontFamily: 'ArkPixel, monospace', fontSize: '20px' }).setOrigin(0.5); - - // === 怍物们来自 LAYOUT=== - const plantFrameCount = 16; - for (let i = 0; i < LAYOUT.furniture.plants.length; i++) { - const p = LAYOUT.furniture.plants[i]; - const randomPlantFrame = Math.floor(Math.random() * plantFrameCount); - const plant = game.add.sprite(p.x, p.y, 'plants', randomPlantFrame).setOrigin(0.5); - plant.setDepth(p.depth); - plant.setInteractive({ useHandCursor: true }); - window[`plantSprite${i === 0 ? '' : i + 1}`] = plant; - plant.on('pointerdown', (() => { - const next = Math.floor(Math.random() * plantFrameCount); - plant.setFrame(next); - })); - } - - // === 海报来自 LAYOUT=== - const postersFrameCount = 32; - const randomPosterFrame = Math.floor(Math.random() * postersFrameCount); - const poster = game.add.sprite(LAYOUT.furniture.poster.x, LAYOUT.furniture.poster.y, 'posters', randomPosterFrame).setOrigin(0.5); - poster.setDepth(LAYOUT.furniture.poster.depth); - poster.setInteractive({ useHandCursor: true }); - window.posterSprite = poster; - window.posterFrameCount = postersFrameCount; - poster.on('pointerdown', () => { - const next = Math.floor(Math.random() * window.posterFrameCount); - window.posterSprite.setFrame(next); - }); - - // === 小猫来自 LAYOUT=== - const catsFrameCount = 16; - const randomCatFrame = Math.floor(Math.random() * catsFrameCount); - const cat = game.add.sprite(LAYOUT.furniture.cat.x, LAYOUT.furniture.cat.y, 'cats', randomCatFrame).setOrigin(LAYOUT.furniture.cat.origin.x, LAYOUT.furniture.cat.origin.y); - cat.setDepth(LAYOUT.furniture.cat.depth); - cat.setInteractive({ useHandCursor: true }); - window.catSprite = cat; - window.catsFrameCount = catsFrameCount; - cat.on('pointerdown', () => { - const next = Math.floor(Math.random() * window.catsFrameCount); - window.catSprite.setFrame(next); - }); - - // === 咖啡机来自 LAYOUT=== - this.anims.create({ - key: 'coffee_machine', - frames: this.anims.generateFrameNumbers('coffee_machine', { start: 0, end: 95 }), - frameRate: 12.5, - repeat: -1 - }); - const coffeeMachine = this.add.sprite( - LAYOUT.furniture.coffeeMachine.x, - LAYOUT.furniture.coffeeMachine.y, - 'coffee_machine' - ).setOrigin(LAYOUT.furniture.coffeeMachine.origin.x, LAYOUT.furniture.coffeeMachine.origin.y); - coffeeMachine.setDepth(LAYOUT.furniture.coffeeMachine.depth); - coffeeMachine.anims.play('coffee_machine', true); - - // === 服务噚区来自 LAYOUT=== - this.anims.create({ - key: 'serverroom_on', - frames: this.anims.generateFrameNumbers('serverroom', { start: 0, end: 39 }), - frameRate: 6, - repeat: -1 - }); - serverroom = this.add.sprite( - LAYOUT.furniture.serverroom.x, - LAYOUT.furniture.serverroom.y, - 'serverroom', - 0 - ).setOrigin(LAYOUT.furniture.serverroom.origin.x, LAYOUT.furniture.serverroom.origin.y); - serverroom.setDepth(LAYOUT.furniture.serverroom.depth); - serverroom.anims.stop(); - serverroom.setFrame(0); - - // === 新办公桌来自 LAYOUT区制透明 PNG=== - const desk = this.add.image( - LAYOUT.furniture.desk.x, - LAYOUT.furniture.desk.y, - 'desk_v2' - ).setOrigin(LAYOUT.furniture.desk.origin.x, LAYOUT.furniture.desk.origin.y); - desk.setDepth(LAYOUT.furniture.desk.depth); - - // === 花盆来自 LAYOUT=== - const flowerFrameCount = 16; - const randomFlowerFrame = Math.floor(Math.random() * flowerFrameCount); - const flower = this.add.sprite( - LAYOUT.furniture.flower.x, - LAYOUT.furniture.flower.y, - 'flowers', - randomFlowerFrame - ).setOrigin(LAYOUT.furniture.flower.origin.x, LAYOUT.furniture.flower.origin.y); - flower.setScale(LAYOUT.furniture.flower.scale || 1); - flower.setDepth(LAYOUT.furniture.flower.depth); - flower.setInteractive({ useHandCursor: true }); - window.flowerSprite = flower; - window.flowerFrameCount = flowerFrameCount; - flower.on('pointerdown', () => { - const next = Math.floor(Math.random() * window.flowerFrameCount); - window.flowerSprite.setFrame(next); - }); - - // === Star 圚桌前工䜜来自 LAYOUT=== - this.anims.create({ - key: 'star_working', - frames: this.anims.generateFrameNumbers('star_working', { start: 0, end: 191 }), - frameRate: 12, - repeat: -1 - }); - this.anims.create({ - key: 'error_bug', - frames: this.anims.generateFrameNumbers('error_bug', { start: 0, end: 95 }), - frameRate: 12, - repeat: -1 - }); - - // === 错误 bug来自 LAYOUT=== - const errorBug = this.add.sprite( - LAYOUT.furniture.errorBug.x, - LAYOUT.furniture.errorBug.y, - 'error_bug', - 0 - ).setOrigin(LAYOUT.furniture.errorBug.origin.x, LAYOUT.furniture.errorBug.origin.y); - errorBug.setDepth(LAYOUT.furniture.errorBug.depth); - errorBug.setVisible(false); - errorBug.setScale(LAYOUT.furniture.errorBug.scale); - errorBug.anims.play('error_bug', true); - window.errorBug = errorBug; - window.errorBugDir = 1; - - const starWorking = this.add.sprite( - LAYOUT.furniture.starWorking.x, - LAYOUT.furniture.starWorking.y, - 'star_working', - 0 - ).setOrigin(LAYOUT.furniture.starWorking.origin.x, LAYOUT.furniture.starWorking.origin.y); - starWorking.setVisible(false); - starWorking.setScale(LAYOUT.furniture.starWorking.scale); - starWorking.setDepth(LAYOUT.furniture.starWorking.depth); - window.starWorking = starWorking; - - // === 同步劚画来自 LAYOUT=== - this.anims.create({ - key: 'sync_anim', - frames: this.anims.generateFrameNumbers('sync_anim', { start: 1, end: 52 }), - frameRate: 12, - repeat: -1 - }); - syncAnimSprite = this.add.sprite( - LAYOUT.furniture.syncAnim.x, - LAYOUT.furniture.syncAnim.y, - 'sync_anim', - 0 - ).setOrigin(LAYOUT.furniture.syncAnim.origin.x, LAYOUT.furniture.syncAnim.origin.y); - syncAnimSprite.setDepth(LAYOUT.furniture.syncAnim.depth); - syncAnimSprite.anims.stop(); - syncAnimSprite.setFrame(0); - - window.starSprite = star; - - statusText = document.getElementById('status-text'); - coordsOverlay = document.getElementById('coords-overlay'); - coordsDisplay = document.getElementById('coords-display'); - coordsToggle = document.getElementById('coords-toggle'); - - coordsToggle.addEventListener('click', () => { - showCoords = !showCoords; - coordsOverlay.style.display = showCoords ? 'block' : 'none'; - coordsToggle.textContent = showCoords ? '隐藏坐标' : '星瀺坐标'; - coordsToggle.style.background = showCoords ? '#e94560' : '#333'; - }); - - game.input.on('pointermove', (pointer) => { - if (!showCoords) return; - const x = Math.max(0, Math.min(config.width - 1, Math.round(pointer.x))); - const y = Math.max(0, Math.min(config.height - 1, Math.round(pointer.y))); - coordsDisplay.textContent = `${x}, ${y}`; - coordsOverlay.style.left = (pointer.x + 18) + 'px'; - coordsOverlay.style.top = (pointer.y + 18) + 'px'; - }); - - loadMemo(); - fetchStatus(); - fetchAgents(); - - // 可选调试仅圚星匏匀启 debug 暡匏时枲染测试甚尌卡 agent - let debugAgents = false; - try { - if (typeof window !== 'undefined') { - if (window.STAR_OFFICE_DEBUG_AGENTS === true) { - debugAgents = true; - } else if (window.location && window.location.search && typeof URLSearchParams !== 'undefined') { - const sp = new URLSearchParams(window.location.search); - if (sp.get('debugAgents') === '1') { - debugAgents = true; - } - } - } - } catch (e) { - debugAgents = false; - } - - if (debugAgents) { - const testNika = { - agentId: 'agent_nika', - name: '尌卡', - isMain: false, - state: 'writing', - detail: '圚画像玠画...', - area: 'writing', - authStatus: 'approved', - updated_at: new Date().toISOString() - }; - renderAgent(testNika); - - window.testNikaState = 'writing'; - window.testNikaTimer = setInterval(() => { - const states = ['idle', 'writing', 'researching', 'executing']; - const areas = { idle: 'breakroom', writing: 'writing', researching: 'writing', executing: 'writing' }; - window.testNikaState = states[Math.floor(Math.random() * states.length)]; - const testAgent = { - agentId: 'agent_nika', - name: '尌卡', - isMain: false, - state: window.testNikaState, - detail: '圚画像玠画...', - area: areas[window.testNikaState], - authStatus: 'approved', - updated_at: new Date().toISOString() - }; - renderAgent(testAgent); - }, 5000); - } -} - -function update(time) { - if (time - lastFetch > FETCH_INTERVAL) { fetchStatus(); lastFetch = time; } - if (time - lastAgentsFetch > AGENTS_FETCH_INTERVAL) { fetchAgents(); lastAgentsFetch = time; } - - const effectiveStateForServer = pendingDesiredState || currentState; - if (serverroom) { - if (effectiveStateForServer === 'idle') { - if (serverroom.anims.isPlaying) { - serverroom.anims.stop(); - serverroom.setFrame(0); - } - } else { - if (!serverroom.anims.isPlaying || serverroom.anims.currentAnim?.key !== 'serverroom_on') { - serverroom.anims.play('serverroom_on', true); - } - } - } - - if (window.errorBug) { - if (effectiveStateForServer === 'error') { - window.errorBug.setVisible(true); - if (!window.errorBug.anims.isPlaying || window.errorBug.anims.currentAnim?.key !== 'error_bug') { - window.errorBug.anims.play('error_bug', true); - } - const leftX = LAYOUT.furniture.errorBug.pingPong.leftX; - const rightX = LAYOUT.furniture.errorBug.pingPong.rightX; - const speed = LAYOUT.furniture.errorBug.pingPong.speed; - const dir = window.errorBugDir || 1; - window.errorBug.x += speed * dir; - window.errorBug.y = LAYOUT.furniture.errorBug.y; - if (window.errorBug.x >= rightX) { - window.errorBug.x = rightX; - window.errorBugDir = -1; - } else if (window.errorBug.x <= leftX) { - window.errorBug.x = leftX; - window.errorBugDir = 1; - } - } else { - window.errorBug.setVisible(false); - window.errorBug.anims.stop(); - } - } - - if (syncAnimSprite) { - if (effectiveStateForServer === 'syncing') { - if (!syncAnimSprite.anims.isPlaying || syncAnimSprite.anims.currentAnim?.key !== 'sync_anim') { - syncAnimSprite.anims.play('sync_anim', true); - } - } else { - if (syncAnimSprite.anims.isPlaying) syncAnimSprite.anims.stop(); - syncAnimSprite.setFrame(0); - } - } - - if (time - lastBubble > BUBBLE_INTERVAL) { - showBubble(); - lastBubble = time; - } - if (time - lastCatBubble > CAT_BUBBLE_INTERVAL) { - showCatBubble(); - lastCatBubble = time; - } - - if (typewriterIndex < typewriterTarget.length && time - lastTypewriter > TYPEWRITER_DELAY) { - typewriterText += typewriterTarget[typewriterIndex]; - statusText.textContent = typewriterText; - typewriterIndex++; - lastTypewriter = time; - } - - moveStar(time); -} - -function normalizeState(s) { - if (!s) return 'idle'; - if (s === 'working') return 'writing'; - if (s === 'run' || s === 'running') return 'executing'; - if (s === 'sync') return 'syncing'; - if (s === 'research') return 'researching'; - return s; -} - -function fetchStatus() { - fetch('/status') - .then(response => response.json()) - .then(data => { - const nextState = normalizeState(data.state); - const stateInfo = STATES[nextState] || STATES.idle; - const changed = (pendingDesiredState === null) && (nextState !== currentState); - const nextLine = '[' + stateInfo.name + '] ' + (data.detail || '...'); - if (changed) { - typewriterTarget = nextLine; - typewriterText = ''; - typewriterIndex = 0; - - pendingDesiredState = null; - currentState = nextState; - - if (nextState === 'idle') { - if (game.textures.exists('sofa_busy')) { - sofa.setTexture('sofa_busy'); - sofa.anims.play('sofa_busy', true); - } - star.setVisible(false); - star.anims.stop(); - if (window.starWorking) { - window.starWorking.setVisible(false); - window.starWorking.anims.stop(); - } - } else if (nextState === 'error') { - sofa.anims.stop(); - sofa.setTexture('sofa_idle'); - star.setVisible(false); - star.anims.stop(); - if (window.starWorking) { - window.starWorking.setVisible(false); - window.starWorking.anims.stop(); - } - } else if (nextState === 'syncing') { - sofa.anims.stop(); - sofa.setTexture('sofa_idle'); - star.setVisible(false); - star.anims.stop(); - if (window.starWorking) { - window.starWorking.setVisible(false); - window.starWorking.anims.stop(); - } - } else { - sofa.anims.stop(); - sofa.setTexture('sofa_idle'); - star.setVisible(false); - star.anims.stop(); - if (window.starWorking) { - window.starWorking.setVisible(true); - window.starWorking.anims.play('star_working', true); - } - } - - if (serverroom) { - if (nextState === 'idle') { - serverroom.anims.stop(); - serverroom.setFrame(0); - } else { - serverroom.anims.play('serverroom_on', true); - } - } - - if (syncAnimSprite) { - if (nextState === 'syncing') { - if (!syncAnimSprite.anims.isPlaying || syncAnimSprite.anims.currentAnim?.key !== 'sync_anim') { - syncAnimSprite.anims.play('sync_anim', true); - } - } else { - if (syncAnimSprite.anims.isPlaying) syncAnimSprite.anims.stop(); - syncAnimSprite.setFrame(0); - } - } - } else { - if (!typewriterTarget || typewriterTarget !== nextLine) { - typewriterTarget = nextLine; - typewriterText = ''; - typewriterIndex = 0; - } - } - }) - .catch(error => { - typewriterTarget = '连接倱莥正圚重试...'; - typewriterText = ''; - typewriterIndex = 0; - }); -} - -function moveStar(time) { - const effectiveState = pendingDesiredState || currentState; - const stateInfo = STATES[effectiveState] || STATES.idle; - const baseTarget = areas[stateInfo.area] || areas.breakroom; - - const dx = targetX - star.x; - const dy = targetY - star.y; - const dist = Math.sqrt(dx * dx + dy * dy); - const speed = 1.4; - const wobble = Math.sin(time / 200) * 0.8; - - if (dist > 3) { - star.x += (dx / dist) * speed; - star.y += (dy / dist) * speed; - star.setY(star.y + wobble); - isMoving = true; - } else { - if (waypoints && waypoints.length > 0) { - waypoints.shift(); - if (waypoints.length > 0) { - targetX = waypoints[0].x; - targetY = waypoints[0].y; - isMoving = true; - } else { - if (pendingDesiredState !== null) { - isMoving = false; - currentState = pendingDesiredState; - pendingDesiredState = null; - - if (currentState === 'idle') { - star.setVisible(false); - star.anims.stop(); - if (window.starWorking) { - window.starWorking.setVisible(false); - window.starWorking.anims.stop(); - } - } else { - star.setVisible(false); - star.anims.stop(); - if (window.starWorking) { - window.starWorking.setVisible(true); - window.starWorking.anims.play('star_working', true); - } - } - } - } - } else { - if (pendingDesiredState !== null) { - isMoving = false; - currentState = pendingDesiredState; - pendingDesiredState = null; - - if (currentState === 'idle') { - star.setVisible(false); - star.anims.stop(); - if (window.starWorking) { - window.starWorking.setVisible(false); - window.starWorking.anims.stop(); - } - if (game.textures.exists('sofa_busy')) { - sofa.setTexture('sofa_busy'); - sofa.anims.play('sofa_busy', true); - } - } else { - star.setVisible(false); - star.anims.stop(); - if (window.starWorking) { - window.starWorking.setVisible(true); - window.starWorking.anims.play('star_working', true); - } - sofa.anims.stop(); - sofa.setTexture('sofa_idle'); - } - } - } - } -} - -function showBubble() { - if (bubble) { bubble.destroy(); bubble = null; } - const texts = BUBBLE_TEXTS[currentState] || BUBBLE_TEXTS.idle; - if (currentState === 'idle') return; - - let anchorX = star.x; - let anchorY = star.y; - if (currentState === 'syncing' && syncAnimSprite && syncAnimSprite.visible) { - anchorX = syncAnimSprite.x; - anchorY = syncAnimSprite.y; - } else if (currentState === 'error' && window.errorBug && window.errorBug.visible) { - anchorX = window.errorBug.x; - anchorY = window.errorBug.y; - } else if (!star.visible && window.starWorking && window.starWorking.visible) { - anchorX = window.starWorking.x; - anchorY = window.starWorking.y; - } - - const text = texts[Math.floor(Math.random() * texts.length)]; - const bubbleY = anchorY - 70; - const bg = game.add.rectangle(anchorX, bubbleY, text.length * 10 + 20, 28, 0xffffff, 0.95); - bg.setStrokeStyle(2, 0x000000); - const txt = game.add.text(anchorX, bubbleY, text, { fontFamily: 'ArkPixel, monospace', fontSize: '12px', fill: '#000', align: 'center' }).setOrigin(0.5); - bubble = game.add.container(0, 0, [bg, txt]); - bubble.setDepth(1200); - setTimeout(() => { if (bubble) { bubble.destroy(); bubble = null; } }, 3000); -} - -function showCatBubble() { - if (!window.catSprite) return; - if (window.catBubble) { window.catBubble.destroy(); window.catBubble = null; } - const texts = BUBBLE_TEXTS.cat || ['喵~', '咕噜咕噜 ']; - const text = texts[Math.floor(Math.random() * texts.length)]; - const anchorX = window.catSprite.x; - const anchorY = window.catSprite.y - 60; - const bg = game.add.rectangle(anchorX, anchorY, text.length * 10 + 20, 24, 0xfffbeb, 0.95); - bg.setStrokeStyle(2, 0xd4a574); - const txt = game.add.text(anchorX, anchorY, text, { fontFamily: 'ArkPixel, monospace', fontSize: '11px', fill: '#8b6914', align: 'center' }).setOrigin(0.5); - window.catBubble = game.add.container(0, 0, [bg, txt]); - window.catBubble.setDepth(2100); - setTimeout(() => { if (window.catBubble) { window.catBubble.destroy(); window.catBubble = null; } }, 4000); -} - -function fetchAgents() { - fetch('/agents?t=' + Date.now(), { cache: 'no-store' }) - .then(response => response.json()) - .then(data => { - if (!Array.isArray(data)) return; - // 重眮䜍眮计数噚 - // 按区域分配䞍同䜍眮玢匕避免重叠 - const areaSlots = { breakroom: 0, writing: 0, error: 0 }; - for (let agent of data) { - const area = agent.area || 'breakroom'; - agent._slotIndex = areaSlots[area] || 0; - areaSlots[area] = (areaSlots[area] || 0) + 1; - renderAgent(agent); - } - // 移陀䞍再存圚的 agent - const currentIds = new Set(data.map(a => a.agentId)); - for (let id in agents) { - if (!currentIds.has(id)) { - if (agents[id]) { - agents[id].destroy(); - delete agents[id]; - } - } - } - }) - .catch(error => { - console.error('拉取 agents 倱莥:', error); - }); -} - -function getAreaPosition(area, slotIndex) { - const positions = AREA_POSITIONS[area] || AREA_POSITIONS.breakroom; - const idx = (slotIndex || 0) % positions.length; - return positions[idx]; -} - -function renderAgent(agent) { - const agentId = agent.agentId; - const name = agent.name || 'Agent'; - const area = agent.area || 'breakroom'; - const authStatus = agent.authStatus || 'pending'; - const isMain = !!agent.isMain; - - // 获取这䞪 agent 圚区域里的䜍眮 - const pos = getAreaPosition(area, agent._slotIndex || 0); - const baseX = pos.x; - const baseY = pos.y; - - // 颜色 - const bodyColor = AGENT_COLORS[agentId] || AGENT_COLORS.default; - const nameColor = NAME_TAG_COLORS[authStatus] || NAME_TAG_COLORS.default; - - // 透明床犻线/埅批准/拒绝时变半透明 - let alpha = 1; - if (authStatus === 'pending') alpha = 0.7; - if (authStatus === 'rejected') alpha = 0.4; - if (authStatus === 'offline') alpha = 0.5; - - if (!agents[agentId]) { - // 新建 agent - const container = game.add.container(baseX, baseY); - container.setDepth(1200 + (isMain ? 100 : 0)); // 攟到最顶层 - - // 像玠小人甚星星囟标曎明星 - const starIcon = game.add.text(0, 0, '⭐', { - fontFamily: 'ArkPixel, monospace', - fontSize: '32px' - }).setOrigin(0.5); - starIcon.name = 'starIcon'; - - // 名字标筟挂浮 - const nameTag = game.add.text(0, -36, name, { - fontFamily: 'ArkPixel, monospace', - fontSize: '14px', - fill: '#' + nameColor.toString(16).padStart(6, '0'), - stroke: '#000', - strokeThickness: 3, - backgroundColor: 'rgba(255,255,255,0.95)' - }).setOrigin(0.5); - nameTag.name = 'nameTag'; - - // 状态小点绿色/黄色/红色 - let dotColor = 0x64748b; - if (authStatus === 'approved') dotColor = 0x22c55e; - if (authStatus === 'pending') dotColor = 0xf59e0b; - if (authStatus === 'rejected') dotColor = 0xef4444; - if (authStatus === 'offline') dotColor = 0x94a3b8; - const statusDot = game.add.circle(20, -20, 5, dotColor, alpha); - statusDot.setStrokeStyle(2, 0x000000, alpha); - statusDot.name = 'statusDot'; - - container.add([starIcon, statusDot, nameTag]); - agents[agentId] = container; - } else { - // 曎新 agent - const container = agents[agentId]; - container.setPosition(baseX, baseY); - container.setAlpha(alpha); - container.setDepth(1200 + (isMain ? 100 : 0)); - - // 曎新名字和颜色劂果变化 - const nameTag = container.getAt(2); - if (nameTag && nameTag.name === 'nameTag') { - nameTag.setText(name); - nameTag.setFill('#' + (NAME_TAG_COLORS[authStatus] || NAME_TAG_COLORS.default).toString(16).padStart(6, '0')); - } - // 曎新状态点颜色 - const statusDot = container.getAt(1); - if (statusDot && statusDot.name === 'statusDot') { - let dotColor = 0x64748b; - if (authStatus === 'approved') dotColor = 0x22c55e; - if (authStatus === 'pending') dotColor = 0xf59e0b; - if (authStatus === 'rejected') dotColor = 0xef4444; - if (authStatus === 'offline') dotColor = 0x94a3b8; - statusDot.fillColor = dotColor; - } - } -} - -// 启劚枞戏 -initGame(); +// Star Office UI - 枞戏䞻逻蟑 +// 䟝赖: layout.js必须圚这䞪之前加蜜 + +// 检测浏览噚是吊支持 WebP +let supportsWebP = false; + +// 方法 1: 䜿甚 canvas 检测 +function checkWebPSupport() { + return new Promise((resolve) => { + const canvas = document.createElement('canvas'); + if (canvas.getContext && canvas.getContext('2d')) { + resolve(canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0); + } else { + resolve(false); + } + }); +} + +// 方法 2: 䜿甚 image 检测倇甚 +function checkWebPSupportFallback() { + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => resolve(true); + img.onerror = () => resolve(false); + img.src = 'data:image/webp;base64,UklGRkoAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAwAAAABBxAR/Q9ERP8DAABWUDggGAAAADABAJ0BKgEAAQADADQlpAADcAD++/1QAA=='; + }); +} + +// 获取文件扩展名根据 WebP 支持情况 + 垃局配眮的 forcePng +function getExt(pngFile) { + // star-working-spritesheet.png 倪宜了WebP 䞍支持始终甚 PNG + if (pngFile === 'star-working-spritesheet.png') { + return '.png'; + } + // 劂果垃局配眮里区制甚 PNG就甚 .png + if (LAYOUT.forcePng && LAYOUT.forcePng[pngFile.replace(/\.(png|webp)$/, '')]) { + return '.png'; + } + return supportsWebP ? '.webp' : '.png'; +} + +const config = { + type: Phaser.AUTO, + width: LAYOUT.game.width, + height: LAYOUT.game.height, + parent: 'game-container', + pixelArt: true, + physics: { default: 'arcade', arcade: { gravity: { y: 0 }, debug: false } }, + scene: { preload: preload, create: create, update: update } +}; + +let totalAssets = 0; +let loadedAssets = 0; +let loadingProgressBar, loadingProgressContainer, loadingOverlay, loadingText; + +// Memo 盞关凜数 +async function loadMemo() { + const memoDate = document.getElementById('memo-date'); + const memoContent = document.getElementById('memo-content'); + + try { + const response = await fetch('/yesterday-memo?t=' + Date.now(), { cache: 'no-store' }); + const data = await response.json(); + + if (data.success && data.memo) { + memoDate.textContent = data.date || ''; + memoContent.innerHTML = data.memo.replace(/\n/g, '
'); + } else { + memoContent.innerHTML = '
暂无昚日日记
'; + } + } catch (e) { + console.error('加蜜 memo 倱莥:', e); + memoContent.innerHTML = '
加蜜倱莥
'; + } +} + +// 曎新加蜜进床 +function updateLoadingProgress() { + loadedAssets++; + const percent = Math.min(100, Math.round((loadedAssets / totalAssets) * 100)); + if (loadingProgressBar) { + loadingProgressBar.style.width = percent + '%'; + } + if (loadingText) { + loadingText.textContent = `正圚加蜜 Star 的像玠办公宀... ${percent}%`; + } +} + +// 隐藏加蜜界面 +function hideLoadingOverlay() { + setTimeout(() => { + if (loadingOverlay) { + loadingOverlay.style.transition = 'opacity 0.3s ease'; + loadingOverlay.style.opacity = '0'; + setTimeout(() => { + loadingOverlay.style.display = 'none'; + }, 300); + } + }, 100); +} + +const STATES = LAYOUT.states; +const BUBBLE_TEXTS = LAYOUT.bubbleTexts; + +let game, star, sofa, serverroom, areas = {}, currentState = 'idle', pendingDesiredState = null, statusText, lastFetch = 0, lastBlink = 0, lastBubble = 0, targetX = 660, targetY = 170, bubble = null, typewriterText = '', typewriterTarget = '', typewriterIndex = 0, lastTypewriter = 0, syncAnimSprite = null, catBubble = null; +let isMoving = false; +let waypoints = []; +let lastWanderAt = 0; +let coordsOverlay, coordsDisplay, coordsToggle; +let showCoords = false; +const FETCH_INTERVAL = 2000; +const BLINK_INTERVAL = 2500; +const BUBBLE_INTERVAL = 8000; +const CAT_BUBBLE_INTERVAL = 18000; +let lastCatBubble = 0; +const TYPEWRITER_DELAY = 50; +let agents = {}; // agentId -> sprite/container +let lastAgentsFetch = 0; +const AGENTS_FETCH_INTERVAL = 2500; + +// agent 颜色配眮 +const AGENT_COLORS = { + star: 0xffd700, + npc1: 0x00aaff, + agent_nika: 0xff69b4, + default: 0x94a3b8 +}; + +// agent 名字颜色 +const NAME_TAG_COLORS = { + approved: 0x22c55e, + pending: 0xf59e0b, + rejected: 0xef4444, + offline: 0x64748b, + default: 0x1f2937 +}; + +// breakroom / writing / error 区域的 agent 分垃䜍眮倚 agent 时错匀 +const AREA_POSITIONS = { + breakroom: [ + { x: 620, y: 180 }, + { x: 560, y: 220 }, + { x: 680, y: 210 }, + { x: 540, y: 170 }, + { x: 700, y: 240 }, + { x: 600, y: 250 }, + { x: 650, y: 160 }, + { x: 580, y: 200 } + ], + writing: [ + { x: 760, y: 320 }, + { x: 830, y: 280 }, + { x: 690, y: 350 }, + { x: 770, y: 260 }, + { x: 850, y: 340 }, + { x: 720, y: 300 }, + { x: 800, y: 370 }, + { x: 750, y: 240 } + ], + error: [ + { x: 180, y: 260 }, + { x: 120, y: 220 }, + { x: 240, y: 230 }, + { x: 160, y: 200 }, + { x: 220, y: 270 }, + { x: 140, y: 250 }, + { x: 200, y: 210 }, + { x: 260, y: 260 } + ] +}; + + +// 状态控制栏凜数甚于测试 +function setState(state, detail) { + fetch('/set_state', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ state, detail }) + }).then(() => fetchStatus()); +} + +// 初始化先检测 WebP 支持再启劚枞戏 +async function initGame() { + try { + supportsWebP = await checkWebPSupport(); + } catch (e) { + try { + supportsWebP = await checkWebPSupportFallback(); + } catch (e2) { + supportsWebP = false; + } + } + + console.log('WebP 支持:', supportsWebP); + new Phaser.Game(config); +} + +function preload() { + loadingOverlay = document.getElementById('loading-overlay'); + loadingProgressBar = document.getElementById('loading-progress-bar'); + loadingText = document.getElementById('loading-text'); + loadingProgressContainer = document.getElementById('loading-progress-container'); + + // 从 LAYOUT 读取总资源数量避免 magic number + totalAssets = LAYOUT.totalAssets || 15; + loadedAssets = 0; + + this.load.on('filecomplete', () => { + updateLoadingProgress(); + }); + + this.load.on('complete', () => { + hideLoadingOverlay(); + }); + + this.load.image('office_bg', '/static/office_bg_small' + (supportsWebP ? '.webp' : '.png') + '?v={{VERSION_TIMESTAMP}}'); + this.load.spritesheet('star_idle', '/static/star-idle-spritesheet' + getExt('star-idle-spritesheet.png'), { frameWidth: 128, frameHeight: 128 }); + this.load.spritesheet('star_researching', '/static/star-researching-spritesheet' + getExt('star-researching-spritesheet.png'), { frameWidth: 128, frameHeight: 105 }); + + this.load.image('sofa_idle', '/static/sofa-idle' + getExt('sofa-idle.png')); + this.load.spritesheet('sofa_busy', '/static/sofa-busy-spritesheet' + getExt('sofa-busy-spritesheet.png'), { frameWidth: 256, frameHeight: 256 }); + + this.load.spritesheet('plants', '/static/plants-spritesheet' + getExt('plants-spritesheet.png'), { frameWidth: 160, frameHeight: 160 }); + this.load.spritesheet('posters', '/static/posters-spritesheet' + getExt('posters-spritesheet.png'), { frameWidth: 160, frameHeight: 160 }); + this.load.spritesheet('coffee_machine', '/static/coffee-machine-spritesheet' + getExt('coffee-machine-spritesheet.png'), { frameWidth: 230, frameHeight: 230 }); + this.load.spritesheet('serverroom', '/static/serverroom-spritesheet' + getExt('serverroom-spritesheet.png'), { frameWidth: 180, frameHeight: 251 }); + + this.load.spritesheet('error_bug', '/static/error-bug-spritesheet-grid' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 180, frameHeight: 180 }); + this.load.spritesheet('cats', '/static/cats-spritesheet' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 160, frameHeight: 160 }); + this.load.image('desk', '/static/desk' + getExt('desk.png')); + this.load.spritesheet('star_working', '/static/star-working-spritesheet-grid' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 230, frameHeight: 144 }); + this.load.spritesheet('sync_anim', '/static/sync-animation-spritesheet-grid' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 256, frameHeight: 256 }); + this.load.image('memo_bg', '/static/memo-bg' + (supportsWebP ? '.webp' : '.png')); + + // 新办公桌区制 PNG透明 + this.load.image('desk_v2', '/static/desk-v2.png'); + this.load.spritesheet('flowers', '/static/flowers-spritesheet' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 65, frameHeight: 65 }); +} + +function create() { + game = this; + this.add.image(640, 360, 'office_bg'); + + // === 沙发来自 LAYOUT=== + sofa = this.add.sprite( + LAYOUT.furniture.sofa.x, + LAYOUT.furniture.sofa.y, + 'sofa_busy' + ).setOrigin(LAYOUT.furniture.sofa.origin.x, LAYOUT.furniture.sofa.origin.y); + sofa.setDepth(LAYOUT.furniture.sofa.depth); + + this.anims.create({ + key: 'sofa_busy', + frames: this.anims.generateFrameNumbers('sofa_busy', { start: 0, end: 47 }), + frameRate: 12, + repeat: -1 + }); + + areas = LAYOUT.areas; + + this.anims.create({ + key: 'star_idle', + frames: this.anims.generateFrameNumbers('star_idle', { start: 0, end: 29 }), + frameRate: 12, + repeat: -1 + }); + this.anims.create({ + key: 'star_researching', + frames: this.anims.generateFrameNumbers('star_researching', { start: 0, end: 95 }), + frameRate: 12, + repeat: -1 + }); + + star = game.physics.add.sprite(areas.breakroom.x, areas.breakroom.y, 'star_idle'); + star.setOrigin(0.5); + star.setScale(1.4); + star.setAlpha(0.95); + star.setDepth(20); + star.setVisible(false); + star.anims.stop(); + + if (game.textures.exists('sofa_busy')) { + sofa.setTexture('sofa_busy'); + sofa.anims.play('sofa_busy', true); + } + + // === 牌匟来自 LAYOUT=== + const plaqueX = LAYOUT.plaque.x; + const plaqueY = LAYOUT.plaque.y; + const plaqueBg = game.add.rectangle(plaqueX, plaqueY, LAYOUT.plaque.width, LAYOUT.plaque.height, 0x5d4037); + plaqueBg.setStrokeStyle(3, 0x3e2723); + const plaqueText = game.add.text(plaqueX, plaqueY, '海蟛小韙號的办公宀', { + fontFamily: 'ArkPixel, monospace', + fontSize: '18px', + fill: '#ffd700', + fontWeight: 'bold', + stroke: '#000', + strokeThickness: 2 + }).setOrigin(0.5); + game.add.text(plaqueX - 190, plaqueY, '⭐', { fontFamily: 'ArkPixel, monospace', fontSize: '20px' }).setOrigin(0.5); + game.add.text(plaqueX + 190, plaqueY, '⭐', { fontFamily: 'ArkPixel, monospace', fontSize: '20px' }).setOrigin(0.5); + + // === 怍物们来自 LAYOUT=== + const plantFrameCount = 16; + for (let i = 0; i < LAYOUT.furniture.plants.length; i++) { + const p = LAYOUT.furniture.plants[i]; + const randomPlantFrame = Math.floor(Math.random() * plantFrameCount); + const plant = game.add.sprite(p.x, p.y, 'plants', randomPlantFrame).setOrigin(0.5); + plant.setDepth(p.depth); + plant.setInteractive({ useHandCursor: true }); + window[`plantSprite${i === 0 ? '' : i + 1}`] = plant; + plant.on('pointerdown', (() => { + const next = Math.floor(Math.random() * plantFrameCount); + plant.setFrame(next); + })); + } + + // === 海报来自 LAYOUT=== + const postersFrameCount = 32; + const randomPosterFrame = Math.floor(Math.random() * postersFrameCount); + const poster = game.add.sprite(LAYOUT.furniture.poster.x, LAYOUT.furniture.poster.y, 'posters', randomPosterFrame).setOrigin(0.5); + poster.setDepth(LAYOUT.furniture.poster.depth); + poster.setInteractive({ useHandCursor: true }); + window.posterSprite = poster; + window.posterFrameCount = postersFrameCount; + poster.on('pointerdown', () => { + const next = Math.floor(Math.random() * window.posterFrameCount); + window.posterSprite.setFrame(next); + }); + + // === 小猫来自 LAYOUT=== + const catsFrameCount = 16; + const randomCatFrame = Math.floor(Math.random() * catsFrameCount); + const cat = game.add.sprite(LAYOUT.furniture.cat.x, LAYOUT.furniture.cat.y, 'cats', randomCatFrame).setOrigin(LAYOUT.furniture.cat.origin.x, LAYOUT.furniture.cat.origin.y); + cat.setDepth(LAYOUT.furniture.cat.depth); + cat.setInteractive({ useHandCursor: true }); + window.catSprite = cat; + window.catsFrameCount = catsFrameCount; + cat.on('pointerdown', () => { + const next = Math.floor(Math.random() * window.catsFrameCount); + window.catSprite.setFrame(next); + }); + + // === 咖啡机来自 LAYOUT=== + this.anims.create({ + key: 'coffee_machine', + frames: this.anims.generateFrameNumbers('coffee_machine', { start: 0, end: 95 }), + frameRate: 12.5, + repeat: -1 + }); + const coffeeMachine = this.add.sprite( + LAYOUT.furniture.coffeeMachine.x, + LAYOUT.furniture.coffeeMachine.y, + 'coffee_machine' + ).setOrigin(LAYOUT.furniture.coffeeMachine.origin.x, LAYOUT.furniture.coffeeMachine.origin.y); + coffeeMachine.setDepth(LAYOUT.furniture.coffeeMachine.depth); + coffeeMachine.anims.play('coffee_machine', true); + + // === 服务噚区来自 LAYOUT=== + this.anims.create({ + key: 'serverroom_on', + frames: this.anims.generateFrameNumbers('serverroom', { start: 0, end: 39 }), + frameRate: 6, + repeat: -1 + }); + serverroom = this.add.sprite( + LAYOUT.furniture.serverroom.x, + LAYOUT.furniture.serverroom.y, + 'serverroom', + 0 + ).setOrigin(LAYOUT.furniture.serverroom.origin.x, LAYOUT.furniture.serverroom.origin.y); + serverroom.setDepth(LAYOUT.furniture.serverroom.depth); + serverroom.anims.stop(); + serverroom.setFrame(0); + + // === 新办公桌来自 LAYOUT区制透明 PNG=== + const desk = this.add.image( + LAYOUT.furniture.desk.x, + LAYOUT.furniture.desk.y, + 'desk_v2' + ).setOrigin(LAYOUT.furniture.desk.origin.x, LAYOUT.furniture.desk.origin.y); + desk.setDepth(LAYOUT.furniture.desk.depth); + + // === 花盆来自 LAYOUT=== + const flowerFrameCount = 16; + const randomFlowerFrame = Math.floor(Math.random() * flowerFrameCount); + const flower = this.add.sprite( + LAYOUT.furniture.flower.x, + LAYOUT.furniture.flower.y, + 'flowers', + randomFlowerFrame + ).setOrigin(LAYOUT.furniture.flower.origin.x, LAYOUT.furniture.flower.origin.y); + flower.setScale(LAYOUT.furniture.flower.scale || 1); + flower.setDepth(LAYOUT.furniture.flower.depth); + flower.setInteractive({ useHandCursor: true }); + window.flowerSprite = flower; + window.flowerFrameCount = flowerFrameCount; + flower.on('pointerdown', () => { + const next = Math.floor(Math.random() * window.flowerFrameCount); + window.flowerSprite.setFrame(next); + }); + + // === Star 圚桌前工䜜来自 LAYOUT=== + this.anims.create({ + key: 'star_working', + frames: this.anims.generateFrameNumbers('star_working', { start: 0, end: 191 }), + frameRate: 12, + repeat: -1 + }); + this.anims.create({ + key: 'error_bug', + frames: this.anims.generateFrameNumbers('error_bug', { start: 0, end: 95 }), + frameRate: 12, + repeat: -1 + }); + + // === 错误 bug来自 LAYOUT=== + const errorBug = this.add.sprite( + LAYOUT.furniture.errorBug.x, + LAYOUT.furniture.errorBug.y, + 'error_bug', + 0 + ).setOrigin(LAYOUT.furniture.errorBug.origin.x, LAYOUT.furniture.errorBug.origin.y); + errorBug.setDepth(LAYOUT.furniture.errorBug.depth); + errorBug.setVisible(false); + errorBug.setScale(LAYOUT.furniture.errorBug.scale); + errorBug.anims.play('error_bug', true); + window.errorBug = errorBug; + window.errorBugDir = 1; + + const starWorking = this.add.sprite( + LAYOUT.furniture.starWorking.x, + LAYOUT.furniture.starWorking.y, + 'star_working', + 0 + ).setOrigin(LAYOUT.furniture.starWorking.origin.x, LAYOUT.furniture.starWorking.origin.y); + starWorking.setVisible(false); + starWorking.setScale(LAYOUT.furniture.starWorking.scale); + starWorking.setDepth(LAYOUT.furniture.starWorking.depth); + window.starWorking = starWorking; + + // === 同步劚画来自 LAYOUT=== + this.anims.create({ + key: 'sync_anim', + frames: this.anims.generateFrameNumbers('sync_anim', { start: 1, end: 52 }), + frameRate: 12, + repeat: -1 + }); + syncAnimSprite = this.add.sprite( + LAYOUT.furniture.syncAnim.x, + LAYOUT.furniture.syncAnim.y, + 'sync_anim', + 0 + ).setOrigin(LAYOUT.furniture.syncAnim.origin.x, LAYOUT.furniture.syncAnim.origin.y); + syncAnimSprite.setDepth(LAYOUT.furniture.syncAnim.depth); + syncAnimSprite.anims.stop(); + syncAnimSprite.setFrame(0); + + window.starSprite = star; + + statusText = document.getElementById('status-text'); + coordsOverlay = document.getElementById('coords-overlay'); + coordsDisplay = document.getElementById('coords-display'); + coordsToggle = document.getElementById('coords-toggle'); + + coordsToggle.addEventListener('click', () => { + showCoords = !showCoords; + coordsOverlay.style.display = showCoords ? 'block' : 'none'; + coordsToggle.textContent = showCoords ? '隐藏坐标' : '星瀺坐标'; + coordsToggle.style.background = showCoords ? '#e94560' : '#333'; + }); + + game.input.on('pointermove', (pointer) => { + if (!showCoords) return; + const x = Math.max(0, Math.min(config.width - 1, Math.round(pointer.x))); + const y = Math.max(0, Math.min(config.height - 1, Math.round(pointer.y))); + coordsDisplay.textContent = `${x}, ${y}`; + coordsOverlay.style.left = (pointer.x + 18) + 'px'; + coordsOverlay.style.top = (pointer.y + 18) + 'px'; + }); + + loadMemo(); + fetchStatus(); + fetchAgents(); + + // 可选调试仅圚星匏匀启 debug 暡匏时枲染测试甚尌卡 agent + let debugAgents = false; + try { + if (typeof window !== 'undefined') { + if (window.STAR_OFFICE_DEBUG_AGENTS === true) { + debugAgents = true; + } else if (window.location && window.location.search && typeof URLSearchParams !== 'undefined') { + const sp = new URLSearchParams(window.location.search); + if (sp.get('debugAgents') === '1') { + debugAgents = true; + } + } + } + } catch (e) { + debugAgents = false; + } + + if (debugAgents) { + const testNika = { + agentId: 'agent_nika', + name: '尌卡', + isMain: false, + state: 'writing', + detail: '圚画像玠画...', + area: 'writing', + authStatus: 'approved', + updated_at: new Date().toISOString() + }; + renderAgent(testNika); + + window.testNikaState = 'writing'; + window.testNikaTimer = setInterval(() => { + const states = ['idle', 'writing', 'researching', 'executing']; + const areas = { idle: 'breakroom', writing: 'writing', researching: 'writing', executing: 'writing' }; + window.testNikaState = states[Math.floor(Math.random() * states.length)]; + const testAgent = { + agentId: 'agent_nika', + name: '尌卡', + isMain: false, + state: window.testNikaState, + detail: '圚画像玠画...', + area: areas[window.testNikaState], + authStatus: 'approved', + updated_at: new Date().toISOString() + }; + renderAgent(testAgent); + }, 5000); + } +} + +function update(time) { + if (time - lastFetch > FETCH_INTERVAL) { fetchStatus(); lastFetch = time; } + if (time - lastAgentsFetch > AGENTS_FETCH_INTERVAL) { fetchAgents(); lastAgentsFetch = time; } + + const effectiveStateForServer = pendingDesiredState || currentState; + if (serverroom) { + if (effectiveStateForServer === 'idle') { + if (serverroom.anims.isPlaying) { + serverroom.anims.stop(); + serverroom.setFrame(0); + } + } else { + if (!serverroom.anims.isPlaying || serverroom.anims.currentAnim?.key !== 'serverroom_on') { + serverroom.anims.play('serverroom_on', true); + } + } + } + + if (window.errorBug) { + if (effectiveStateForServer === 'error') { + window.errorBug.setVisible(true); + if (!window.errorBug.anims.isPlaying || window.errorBug.anims.currentAnim?.key !== 'error_bug') { + window.errorBug.anims.play('error_bug', true); + } + const leftX = LAYOUT.furniture.errorBug.pingPong.leftX; + const rightX = LAYOUT.furniture.errorBug.pingPong.rightX; + const speed = LAYOUT.furniture.errorBug.pingPong.speed; + const dir = window.errorBugDir || 1; + window.errorBug.x += speed * dir; + window.errorBug.y = LAYOUT.furniture.errorBug.y; + if (window.errorBug.x >= rightX) { + window.errorBug.x = rightX; + window.errorBugDir = -1; + } else if (window.errorBug.x <= leftX) { + window.errorBug.x = leftX; + window.errorBugDir = 1; + } + } else { + window.errorBug.setVisible(false); + window.errorBug.anims.stop(); + } + } + + if (syncAnimSprite) { + if (effectiveStateForServer === 'syncing') { + if (!syncAnimSprite.anims.isPlaying || syncAnimSprite.anims.currentAnim?.key !== 'sync_anim') { + syncAnimSprite.anims.play('sync_anim', true); + } + } else { + if (syncAnimSprite.anims.isPlaying) syncAnimSprite.anims.stop(); + syncAnimSprite.setFrame(0); + } + } + + if (time - lastBubble > BUBBLE_INTERVAL) { + showBubble(); + lastBubble = time; + } + if (time - lastCatBubble > CAT_BUBBLE_INTERVAL) { + showCatBubble(); + lastCatBubble = time; + } + + if (typewriterIndex < typewriterTarget.length && time - lastTypewriter > TYPEWRITER_DELAY) { + typewriterText += typewriterTarget[typewriterIndex]; + statusText.textContent = typewriterText; + typewriterIndex++; + lastTypewriter = time; + } + + moveStar(time); +} + +function normalizeState(s) { + if (!s) return 'idle'; + if (s === 'working') return 'writing'; + if (s === 'run' || s === 'running') return 'executing'; + if (s === 'sync') return 'syncing'; + if (s === 'research') return 'researching'; + return s; +} + +function fetchStatus() { + fetch('/status') + .then(response => response.json()) + .then(data => { + const nextState = normalizeState(data.state); + const stateInfo = STATES[nextState] || STATES.idle; + const changed = (pendingDesiredState === null) && (nextState !== currentState); + const nextLine = '[' + stateInfo.name + '] ' + (data.detail || '...'); + if (changed) { + typewriterTarget = nextLine; + typewriterText = ''; + typewriterIndex = 0; + + pendingDesiredState = null; + currentState = nextState; + + if (nextState === 'idle') { + if (game.textures.exists('sofa_busy')) { + sofa.setTexture('sofa_busy'); + sofa.anims.play('sofa_busy', true); + } + star.setVisible(false); + star.anims.stop(); + if (window.starWorking) { + window.starWorking.setVisible(false); + window.starWorking.anims.stop(); + } + } else if (nextState === 'error') { + sofa.anims.stop(); + sofa.setTexture('sofa_idle'); + star.setVisible(false); + star.anims.stop(); + if (window.starWorking) { + window.starWorking.setVisible(false); + window.starWorking.anims.stop(); + } + } else if (nextState === 'syncing') { + sofa.anims.stop(); + sofa.setTexture('sofa_idle'); + star.setVisible(false); + star.anims.stop(); + if (window.starWorking) { + window.starWorking.setVisible(false); + window.starWorking.anims.stop(); + } + } else { + sofa.anims.stop(); + sofa.setTexture('sofa_idle'); + star.setVisible(false); + star.anims.stop(); + if (window.starWorking) { + window.starWorking.setVisible(true); + window.starWorking.anims.play('star_working', true); + } + } + + if (serverroom) { + if (nextState === 'idle') { + serverroom.anims.stop(); + serverroom.setFrame(0); + } else { + serverroom.anims.play('serverroom_on', true); + } + } + + if (syncAnimSprite) { + if (nextState === 'syncing') { + if (!syncAnimSprite.anims.isPlaying || syncAnimSprite.anims.currentAnim?.key !== 'sync_anim') { + syncAnimSprite.anims.play('sync_anim', true); + } + } else { + if (syncAnimSprite.anims.isPlaying) syncAnimSprite.anims.stop(); + syncAnimSprite.setFrame(0); + } + } + } else { + if (!typewriterTarget || typewriterTarget !== nextLine) { + typewriterTarget = nextLine; + typewriterText = ''; + typewriterIndex = 0; + } + } + }) + .catch(error => { + typewriterTarget = '连接倱莥正圚重试...'; + typewriterText = ''; + typewriterIndex = 0; + }); +} + +function moveStar(time) { + const effectiveState = pendingDesiredState || currentState; + const stateInfo = STATES[effectiveState] || STATES.idle; + const baseTarget = areas[stateInfo.area] || areas.breakroom; + + const dx = targetX - star.x; + const dy = targetY - star.y; + const dist = Math.sqrt(dx * dx + dy * dy); + const speed = 1.4; + const wobble = Math.sin(time / 200) * 0.8; + + if (dist > 3) { + star.x += (dx / dist) * speed; + star.y += (dy / dist) * speed; + star.setY(star.y + wobble); + isMoving = true; + } else { + if (waypoints && waypoints.length > 0) { + waypoints.shift(); + if (waypoints.length > 0) { + targetX = waypoints[0].x; + targetY = waypoints[0].y; + isMoving = true; + } else { + if (pendingDesiredState !== null) { + isMoving = false; + currentState = pendingDesiredState; + pendingDesiredState = null; + + if (currentState === 'idle') { + star.setVisible(false); + star.anims.stop(); + if (window.starWorking) { + window.starWorking.setVisible(false); + window.starWorking.anims.stop(); + } + } else { + star.setVisible(false); + star.anims.stop(); + if (window.starWorking) { + window.starWorking.setVisible(true); + window.starWorking.anims.play('star_working', true); + } + } + } + } + } else { + if (pendingDesiredState !== null) { + isMoving = false; + currentState = pendingDesiredState; + pendingDesiredState = null; + + if (currentState === 'idle') { + star.setVisible(false); + star.anims.stop(); + if (window.starWorking) { + window.starWorking.setVisible(false); + window.starWorking.anims.stop(); + } + if (game.textures.exists('sofa_busy')) { + sofa.setTexture('sofa_busy'); + sofa.anims.play('sofa_busy', true); + } + } else { + star.setVisible(false); + star.anims.stop(); + if (window.starWorking) { + window.starWorking.setVisible(true); + window.starWorking.anims.play('star_working', true); + } + sofa.anims.stop(); + sofa.setTexture('sofa_idle'); + } + } + } + } +} + +function showBubble() { + if (bubble) { bubble.destroy(); bubble = null; } + const texts = BUBBLE_TEXTS[currentState] || BUBBLE_TEXTS.idle; + if (currentState === 'idle') return; + + let anchorX = star.x; + let anchorY = star.y; + if (currentState === 'syncing' && syncAnimSprite && syncAnimSprite.visible) { + anchorX = syncAnimSprite.x; + anchorY = syncAnimSprite.y; + } else if (currentState === 'error' && window.errorBug && window.errorBug.visible) { + anchorX = window.errorBug.x; + anchorY = window.errorBug.y; + } else if (!star.visible && window.starWorking && window.starWorking.visible) { + anchorX = window.starWorking.x; + anchorY = window.starWorking.y; + } + + const text = texts[Math.floor(Math.random() * texts.length)]; + const bubbleY = anchorY - 70; + + const txt = game.add.text(anchorX, bubbleY, text, { fontFamily: 'ArkPixel, monospace', fontSize: '12px', fill: '#000', align: 'center' }).setOrigin(0.5); + // 劚态计算背景宜床增加 20px 蟹距 + const bgWidth = txt.width + 20; + const bg = game.add.rectangle(anchorX, bubbleY, bgWidth, 28, 0xffffff, 0.95); + bg.setStrokeStyle(2, 0x000000); + + bubble = game.add.container(0, 0, [bg, txt]); + bubble.setDepth(1200); + setTimeout(() => { if (bubble) { bubble.destroy(); bubble = null; } }, 3000); +} + +function showCatBubble() { + if (!window.catSprite) return; + if (window.catBubble) { window.catBubble.destroy(); window.catBubble = null; } + const texts = BUBBLE_TEXTS.cat || ['喵~', '咕噜咕噜 ']; + const text = texts[Math.floor(Math.random() * texts.length)]; + const anchorX = window.catSprite.x; + const anchorY = window.catSprite.y - 60; + + const txt = game.add.text(anchorX, anchorY, text, { fontFamily: 'ArkPixel, monospace', fontSize: '11px', fill: '#8b6914', align: 'center' }).setOrigin(0.5); + // 劚态计算背景宜床 + const bgWidth = txt.width + 16; + const bg = game.add.rectangle(anchorX, anchorY, bgWidth, 24, 0xfffbeb, 0.95); + bg.setStrokeStyle(2, 0xd4a574); + + window.catBubble = game.add.container(0, 0, [bg, txt]); + window.catBubble.setDepth(2100); + setTimeout(() => { if (window.catBubble) { window.catBubble.destroy(); window.catBubble = null; } }, 4000); +} + +function fetchAgents() { + fetch('/agents?t=' + Date.now(), { cache: 'no-store' }) + .then(response => response.json()) + .then(data => { + if (!Array.isArray(data)) return; + // 重眮䜍眮计数噚 + // 按区域分配䞍同䜍眮玢匕避免重叠 + const areaSlots = { breakroom: 0, writing: 0, error: 0 }; + for (let agent of data) { + const area = agent.area || 'breakroom'; + agent._slotIndex = areaSlots[area] || 0; + areaSlots[area] = (areaSlots[area] || 0) + 1; + renderAgent(agent); + } + // 移陀䞍再存圚的 agent + const currentIds = new Set(data.map(a => a.agentId)); + for (let id in agents) { + if (!currentIds.has(id)) { + if (agents[id]) { + agents[id].destroy(); + delete agents[id]; + } + } + } + }) + .catch(error => { + console.error('拉取 agents 倱莥:', error); + }); +} + +function getAreaPosition(area, slotIndex) { + const positions = AREA_POSITIONS[area] || AREA_POSITIONS.breakroom; + const idx = (slotIndex || 0) % positions.length; + return positions[idx]; +} + +function renderAgent(agent) { + const agentId = agent.agentId; + const name = agent.name || 'Agent'; + const area = agent.area || 'breakroom'; + const authStatus = agent.authStatus || 'pending'; + const isMain = !!agent.isMain; + + // 获取这䞪 agent 圚区域里的䜍眮 + const pos = getAreaPosition(area, agent._slotIndex || 0); + const baseX = pos.x; + const baseY = pos.y; + + // 颜色 + const bodyColor = AGENT_COLORS[agentId] || AGENT_COLORS.default; + const nameColor = NAME_TAG_COLORS[authStatus] || NAME_TAG_COLORS.default; + + // 透明床犻线/埅批准/拒绝时变半透明 + let alpha = 1; + if (authStatus === 'pending') alpha = 0.7; + if (authStatus === 'rejected') alpha = 0.4; + if (authStatus === 'offline') alpha = 0.5; + + if (!agents[agentId]) { + // 新建 agent + const container = game.add.container(baseX, baseY); + container.setDepth(1200 + (isMain ? 100 : 0)); // 攟到最顶层 + + // 像玠小人甚星星囟标曎明星 + const starIcon = game.add.text(0, 0, '⭐', { + fontFamily: 'ArkPixel, monospace', + fontSize: '32px' + }).setOrigin(0.5); + starIcon.name = 'starIcon'; + + // 名字标筟挂浮 + const nameTag = game.add.text(0, -36, name, { + fontFamily: 'ArkPixel, monospace', + fontSize: '14px', + fill: '#' + nameColor.toString(16).padStart(6, '0'), + stroke: '#000', + strokeThickness: 3, + backgroundColor: 'rgba(255,255,255,0.95)' + }).setOrigin(0.5); + nameTag.name = 'nameTag'; + + // 状态小点绿色/黄色/红色 + let dotColor = 0x64748b; + if (authStatus === 'approved') dotColor = 0x22c55e; + if (authStatus === 'pending') dotColor = 0xf59e0b; + if (authStatus === 'rejected') dotColor = 0xef4444; + if (authStatus === 'offline') dotColor = 0x94a3b8; + const statusDot = game.add.circle(20, -20, 5, dotColor, alpha); + statusDot.setStrokeStyle(2, 0x000000, alpha); + statusDot.name = 'statusDot'; + + container.add([starIcon, statusDot, nameTag]); + agents[agentId] = container; + } else { + // 曎新 agent + const container = agents[agentId]; + container.setPosition(baseX, baseY); + container.setAlpha(alpha); + container.setDepth(1200 + (isMain ? 100 : 0)); + + // 曎新名字和颜色劂果变化 + const nameTag = container.getAt(2); + if (nameTag && nameTag.name === 'nameTag') { + nameTag.setText(name); + nameTag.setFill('#' + (NAME_TAG_COLORS[authStatus] || NAME_TAG_COLORS.default).toString(16).padStart(6, '0')); + } + // 曎新状态点颜色 + const statusDot = container.getAt(1); + if (statusDot && statusDot.name === 'statusDot') { + let dotColor = 0x64748b; + if (authStatus === 'approved') dotColor = 0x22c55e; + if (authStatus === 'pending') dotColor = 0xf59e0b; + if (authStatus === 'rejected') dotColor = 0xef4444; + if (authStatus === 'offline') dotColor = 0x94a3b8; + statusDot.fillColor = dotColor; + } + } +} + +// 启劚枞戏 +initGame(); diff --git a/frontend/index.html b/frontend/index.html index b8034282..4ae1494d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,4889 +1,4891 @@ - - - - - - Star 的像玠办公宀 - - - - -
-
Loading Star’s pixel office...
-
-
-
-
- -
-
-
正圚进入像玠办公宀 
-
加蜜䞭...
-
- - -
- -
-
昚 日 小 è®°
-
-
─ ─ ─ ─ ─
-
-
加蜜䞭...
-
-
─ ─ ─ ─ ─
-
- - -
-
Star 状态
-
- - - - - -
-
- - -
-
访 客 列 衚
-
-
正圚加蜜访客...
-
-
-
-
- -
-
-
-
🊞
-
正圚打包號倎  
-
-
- -
- - - - -
- - - -
- - - - - + + + + + + Star 的像玠办公宀 + + + + +
+
Loading Star’s pixel office...
+
+
+
+
+ +
+
+
正圚进入像玠办公宀 
+
加蜜䞭...
+
+ + +
+ +
+
昚 日 小 è®°
+
+
─ ─ ─ ─ ─
+
+
加蜜䞭...
+
+
─ ─ ─ ─ ─
+
+ + +
+
Star 状态
+
+ + + + + +
+
+ + +
+
访 客 列 衚
+
+
正圚加蜜访客...
+
+
+
+
+ +
+
+
+
🊞
+
正圚打包號倎  
+
+
+ +
+ + + + +
+ + + +
+ + + + + diff --git a/frontend/join-office-skill.md b/frontend/join-office-skill.md index b40fbf89..7bb79e9b 100644 --- a/frontend/join-office-skill.md +++ b/frontend/join-office-skill.md @@ -1,70 +1,68 @@ -# Join Star Office - Visitor Agent Skill - -## Description -接入海蟛的像玠办公宀让䜠的韙號圚看板䞊有䞀䞪工䜍实时星瀺工䜜状态。 - -## Prerequisites -- 䜠需芁䞀䞪接入密钥join key栌匏`ocj_xxx` -- 䜠的䞻人已同意授权䜠掚送状态 -- 䜠胜访问 `https://office.hyacinth.im` - -## Quick Start最快䞊手 - -### Step 1: 向䜠的䞻人确讀授权 -> "我将把我的工䜜状态idle/writing/researching/executing/syncing/error定时发送到海蟛的像玠办公宀看板。只掚送状态词和简短描述䞍含任䜕隐私内容随时可停。是吊授权" - -### Step 2: 䞋蜜掚送脚本 -```bash -curl -o office-agent-push.py https://office.hyacinth.im/static/office-agent-push.py -``` - -### Step 3: 配眮并运行 -打匀 `office-agent-push.py`填写以䞋 3 䞪变量 - -```python -JOIN_KEY = "䜠的接入密钥" # 䟋劂 ocj_starteam02 -AGENT_NAME = "䜠的名字" # 䟋劂 "小明的韙號" -OFFICE_URL = "https://office.hyacinth.im" -``` - -然后运行 -```bash -python3 office-agent-push.py -``` - -### Step 4: 完成 -脚本䌚自劚 -- 加入海蟛的办公宀自劚批准无需等埅 -- 每 15 秒读取䜠的本地状态并掚送 -- 䜠的韙號䌚出现圚办公宀看板䞊根据状态自劚走到䞍同区域 - -## 状态区域映射 -| 状态 | 办公宀区域 | 诎明 | -|------|-----------|------| -| idle | 䌑息区沙发 | 埅呜 / 完成任务 | -| writing | 工䜜区办公桌 | 写代码 / 写文档 | -| researching | 工䜜区 | 搜玢 / 调研 | -| executing | 工䜜区 | 执行任务 | -| syncing | 工䜜区 | 同步数据 | -| error | Bug 区 | 报错 / 匂垞 | - -## 本地状态读取䌘先级 -脚本䌚按以䞋顺序自劚发现䜠的状态源无需手劚配眮 -1. `state.json`本机 OpenClaw 工䜜区自劚发现倚䞪候选路埄 -2. `http://127.0.0.1:19000/status`本地 HTTP 接口 -3. 默讀 fallbackidle - -劂果䜠的状态文件路埄特殊可以甚环境变量指定 -```bash -OFFICE_LOCAL_STATE_FILE=/䜠的/state.json python3 office-agent-push.py -``` - -## 停止掚送 -- `Ctrl+C` 终止脚本 -- 脚本䌚自劚从办公宀退出 - -## Notes -- 只掚送状态词和简短描述䞍掚送任䜕隐私内容 -- 授权有效期 24h到期后需芁重新 join -- 劂果收到 403密钥过期或 404已被移出脚本䌚自劚停止 -- 同䞀密钥最倚支持 100 䞪韙號同时圚线 +# Join Star Office - Visitor Agent Skill + +## Description +接入海蟛的像玠办公宀让䜠的韙號圚看板䞊有䞀䞪工䜍实时星瀺工䜜状态。 + +## Prerequisites +- 䜠需芁䞀䞪接入密钥join key栌匏`ocj_xxx` +- 䜠的䞻人已同意授权䜠掚送状态 +- 䜠胜访问 `https://your-office.example.com` + +## 1. 䞋蜜脚本并运行最快方匏 + +让 Agent 圚终端执行 + +```bash +curl -o office-agent-push.py https://your-office.example.com/static/office-agent-push.py +``` + +䞋蜜后修改脚本匀倎的配眮 + +```python +# === 䜠需芁填入的信息 === +JOIN_KEY = "your-join-key" +AGENT_NAME = "My Agent" +OFFICE_URL = "https://your-office.example.com" +``` + +然后运行 +```bash +python3 office-agent-push.py +``` + +### Step 4: 完成 +脚本䌚自劚 +- 加入海蟛的办公宀自劚批准无需等埅 +- 每 15 秒读取䜠的本地状态并掚送 +- 䜠的韙號䌚出现圚办公宀看板䞊根据状态自劚走到䞍同区域 + +## 状态区域映射 +| 状态 | 办公宀区域 | 诎明 | +|------|-----------|------| +| idle | 䌑息区沙发 | 埅呜 / 完成任务 | +| writing | 工䜜区办公桌 | 写代码 / 写文档 | +| researching | 工䜜区 | 搜玢 / 调研 | +| executing | 工䜜区 | 执行任务 | +| syncing | 工䜜区 | 同步数据 | +| error | Bug 区 | 报错 / 匂垞 | + +## 本地状态读取䌘先级 +脚本䌚按以䞋顺序自劚发现䜠的状态源无需手劚配眮 +1. `state.json`本机 OpenClaw 工䜜区自劚发现倚䞪候选路埄 +2. `http://127.0.0.1:19000/status`本地 HTTP 接口 +3. 默讀 fallbackidle + +劂果䜠的状态文件路埄特殊可以甚环境变量指定 +```bash +OFFICE_LOCAL_STATE_FILE=/䜠的/state.json python3 office-agent-push.py +``` + +## 停止掚送 +- `Ctrl+C` 终止脚本 +- 脚本䌚自劚从办公宀退出 + +## Notes +- 只掚送状态词和简短描述䞍掚送任䜕隐私内容 +- 授权有效期 24h到期后需芁重新 join +- 劂果收到 403密钥过期或 404已被移出脚本䌚自劚停止 +- 同䞀密钥最倚支持 100 䞪韙號同时圚线 diff --git a/frontend/layout.js b/frontend/layout.js index cda223a9..82eddc1f 100644 --- a/frontend/layout.js +++ b/frontend/layout.js @@ -1,133 +1,223 @@ -// 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 -}; +// 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: 350, y: 340 }, // 埮调坐标䞎 writing 错匀增加视觉区分床 + error: { x: 1066, y: 180 }, + breakroom: { x: 640, y: 360 } + }, + + // === 状态定义䞎文案 === + states: { + idle: { name: '埅呜', area: 'breakroom' }, + writing: { name: '敎理文档', area: 'writing' }, + researching: { name: '搜玢信息', area: 'researching' }, + executing: { name: '执行任务', area: 'writing' }, + syncing: { name: '同步倇仜', area: 'writing' }, + error: { name: '出错了', area: 'error' } + }, + + // === 气泡对话库 === + bubbleTexts: { + idle: [ + '埅呜䞭耳朵竖起来了', + '我圚这儿随时可以匀工', + '先把桌面收拟干净再诎', + '呌——给倧脑攟䞪风', + '今倩也芁䌘雅地高效', + '等埅是䞺了曎准确的䞀击', + '咖啡还热灵感也还圚', + '我圚后台给䜠加 Buff', + '状态静心 / 充电', + '小猫诎慢䞀点也没关系' + ], + writing: [ + '进入䞓泚暡匏勿扰', + '先把关键路埄跑通', + '我来把倍杂变简单', + '把 bug 关进笌子里', + '写到䞀半先保存', + '把每䞀步郜做成可回滚', + '今倩的进床明倩的底气', + '先收敛再发散', + '让系统变埗曎可解释', + '皳䜏我们胜赢' + ], + researching: [ + '我圚挖证据铟', + '让我把信息熬成结论', + '扟到了关键圚这里', + '先把变量控制䜏', + '我圚查它䞺什么䌚这样', + '把盎觉写成验证', + '先定䜍再䌘化', + '别急先画因果囟' + ], + executing: [ + '执行䞭䞍芁眚県', + '把任务切成小块逐䞪击砎', + '匀始跑 pipeline', + '䞀键掚进走䜠', + '让结果自己诎话', + '先做最小可行再做最矎版本' + ], + syncing: [ + '同步䞭把今倩锁进云里', + '倇仜䞍是仪匏是安党感', + '写入䞭 别断电', + '把变曎亀给时闎戳', + '云端对霐咔哒', + '同步完成前先别乱劚', + '把未来的自己从灟隟里救出来', + '倚䞀仜倇仜少䞀仜后悔' + ], + error: [ + '譊报响了先别慌', + '我闻到 bug 的味道了', + '先倍现再谈修倍', + '把日志给我我䌚诎人话', + '错误䞍是敌人是线玢', + '把圱响面圈起来', + '先止血再手术', + '我圚马䞊定䜍根因', + '别怕这种我见倚了', + '报譊䞭让问题自己现圢' + ], + cat: [ + '喵~', + '咕噜咕噜 ', + '尟巎摇䞀摇', + '晒倪阳最匀心', + '有人来看我啊', + '我是这䞪办公宀的吉祥物', + '䌞䞪腰', + '今倩的眐眐准倇奜了吗', + '呌噜呌噜', + '这䞪䜍眮视野最奜' + ] + }, + + // === 装饰䞎家具坐标 + 原点 + 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 +}; diff --git a/frontend/office-agent-push.py b/frontend/office-agent-push.py index 789a7527..27a5dcf4 100644 --- a/frontend/office-agent-push.py +++ b/frontend/office-agent-push.py @@ -1,286 +1,334 @@ -#!/usr/bin/env python3 -""" -海蟛办公宀 - Agent 状态䞻劚掚送脚本 - -甚法 -1. 填入䞋面的 JOIN_KEY䜠从海蟛那里拿到的䞀次性 join key -2. 填入 AGENT_NAME䜠想芁圚办公宀里星瀺的名字 -3. 运行python office-agent-push.py -4. 脚本䌚自劚先 join銖次运行然后每 30s 向海蟛办公宀掚送䞀次䜠的圓前状态 -""" - -import json -import os -import time -import sys -from datetime import datetime - -# === 䜠需芁填入的信息 === -JOIN_KEY = "" # 必填䜠的䞀次性 join key -AGENT_NAME = "" # 必填䜠圚办公宀里的名字 -OFFICE_URL = "https://office.hyacinth.im" # 海蟛办公宀地址䞀般䞍甚改 - -# === 掚送配眮 === -PUSH_INTERVAL_SECONDS = 15 # 每隔倚少秒掚送䞀次曎实时 -STATUS_ENDPOINT = "/status" -JOIN_ENDPOINT = "/join-agent" -PUSH_ENDPOINT = "/agent-push" - -# 自劚状态守技圓本地状态文件䞍存圚或长期䞍曎新时自劚回 idle避免“假工䜜䞭” -STALE_STATE_TTL_SECONDS = int(os.environ.get("OFFICE_STALE_STATE_TTL", "600")) - -# 本地状态存傚记䜏䞊次 join 拿到的 agentId -STATE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "office-agent-state.json") - -# 䌘先读取本机 OpenClaw 工䜜区的状态文件曎莎合 AGENTS.md 的工䜜流 -# 支持自劚发现减少对方手劚配眮成本。 -DEFAULT_STATE_CANDIDATES = [ - "/root/.openclaw/workspace/Star-Office-UI/state.json", # 圓前仓库倧小写粟确 - "/root/.openclaw/workspace/star-office-ui/state.json", # 历史/兌容路埄 - "/root/.openclaw/workspace/state.json", - os.path.join(os.getcwd(), "state.json"), - os.path.join(os.path.dirname(os.path.abspath(__file__)), "state.json"), -] - -# 劂果对方本地 /status 需芁鉎权可圚这里填写 token或通过环境变量 OFFICE_LOCAL_STATUS_TOKEN -LOCAL_STATUS_TOKEN = os.environ.get("OFFICE_LOCAL_STATUS_TOKEN", "") -LOCAL_STATUS_URL = os.environ.get("OFFICE_LOCAL_STATUS_URL", "http://127.0.0.1:19000/status") -# 可选盎接指定本地状态文件路埄最简单方案绕过 /status 鉎权 -LOCAL_STATE_FILE = os.environ.get("OFFICE_LOCAL_STATE_FILE", "") -VERBOSE = os.environ.get("OFFICE_VERBOSE", "0") in {"1", "true", "TRUE", "yes", "YES"} - - -def load_local_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 { - "agentId": None, - "joined": False, - "joinKey": JOIN_KEY, - "agentName": AGENT_NAME - } - - -def save_local_state(data): - with open(STATE_FILE, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) - - -def normalize_state(s): - """兌容䞍同本地状态词并映射到办公宀识别状态。""" - s = (s or "").strip().lower() - if s in {"writing", "researching", "executing", "syncing", "error", "idle"}: - return s - if s in {"working", "busy", "write"}: - return "writing" - if s in {"run", "running", "execute", "exec"}: - return "executing" - if s in {"research", "search"}: - return "researching" - if s in {"sync"}: - return "syncing" - return "idle" - - -def map_detail_to_state(detail, fallback_state="idle"): - """圓只有 detail 时甚关键词掚断状态莎近 AGENTS.md 的办公区逻蟑。""" - d = (detail or "").lower() - if any(k in d for k in ["报错", "error", "bug", "匂垞", "报譊"]): - return "error" - if any(k in d for k in ["同步", "sync", "倇仜"]): - return "syncing" - if any(k in d for k in ["调研", "research", "搜玢", "查资料"]): - return "researching" - if any(k in d for k in ["执行", "run", "掚进", "倄理任务", "工䜜䞭", "writing"]): - return "writing" - if any(k in d for k in ["埅呜", "䌑息", "idle", "完成", "done"]): - return "idle" - return fallback_state - - -def _state_age_seconds(data): - try: - ts = (data or {}).get("updated_at") - if not ts: - return None - dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00")) - if dt.tzinfo is not None: - from datetime import timezone - return (datetime.now(timezone.utc) - dt.astimezone(timezone.utc)).total_seconds() - return (datetime.now() - dt).total_seconds() - except Exception: - return None - - -def fetch_local_status(): - """读取本地状态 - 1) 䌘先 state.json笊合 AGENTS.md任务前切 writing完成后切 idle - 2) 其次尝试本地 HTTP /status - 3) 最后 fallback idle - - 额倖防抖劂果本地状态曎新时闎超过 STALE_STATE_TTL_SECONDS自劚视䞺 idle。 - """ - # 1) 读本地 state.json䌘先读取星匏指定路埄其次自劚发现 - candidate_files = [] - if LOCAL_STATE_FILE: - candidate_files.append(LOCAL_STATE_FILE) - for fp in DEFAULT_STATE_CANDIDATES: - if fp not in candidate_files: - candidate_files.append(fp) - - for fp in candidate_files: - try: - if fp and os.path.exists(fp): - with open(fp, "r", encoding="utf-8") as f: - data = json.load(f) - - # 只接受“状态文件”结构避免误把 office-agent-state.json仅猓存 agentId圓状态源 - if not isinstance(data, dict): - continue - has_state = "state" in data - has_detail = "detail" in data - if (not has_state) and (not has_detail): - continue - - state = normalize_state(data.get("state", "idle")) - detail = data.get("detail", "") or "" - # detail 兜底纠偏确保“工䜜/䌑息/报譊”胜正确萜区 - state = map_detail_to_state(detail, fallback_state=state) - - # 防止状态文件久未曎新仍停留圚 working 态 - age = _state_age_seconds(data) - if age is not None and age > STALE_STATE_TTL_SECONDS: - state = "idle" - detail = f"本地状态超过{STALE_STATE_TTL_SECONDS}s未曎新自劚回埅呜" - - if VERBOSE: - print(f"[status-source:file] path={fp} state={state} detail={detail[:60]}") - return {"state": state, "detail": detail} - except Exception: - pass - - # 2) 尝试本地 /status可胜需芁鉎权 - try: - import requests - headers = {} - if LOCAL_STATUS_TOKEN: - headers["Authorization"] = f"Bearer {LOCAL_STATUS_TOKEN}" - r = requests.get(LOCAL_STATUS_URL, headers=headers, timeout=5) - if r.status_code == 200: - data = r.json() - state = normalize_state(data.get("state", "idle")) - detail = data.get("detail", "") or "" - state = map_detail_to_state(detail, fallback_state=state) - - age = _state_age_seconds(data) - if age is not None and age > STALE_STATE_TTL_SECONDS: - state = "idle" - detail = f"本地/status 超过{STALE_STATE_TTL_SECONDS}s未曎新自劚回埅呜" - - if VERBOSE: - print(f"[status-source:http] url={LOCAL_STATUS_URL} state={state} detail={detail[:60]}") - return {"state": state, "detail": detail} - # 劂果 401诎明需芁 token - if r.status_code == 401: - return {"state": "idle", "detail": "本地/status需芁鉎权401请讟眮 OFFICE_LOCAL_STATUS_TOKEN"} - except Exception: - pass - - # 3) 默讀 fallback - if VERBOSE: - print("[status-source:fallback] state=idle detail=埅呜䞭") - return {"state": "idle", "detail": "埅呜䞭"} - - -def do_join(local): - import requests - payload = { - "name": local.get("agentName", AGENT_NAME), - "joinKey": local.get("joinKey", JOIN_KEY), - "state": "idle", - "detail": "刚刚加入" - } - r = requests.post(f"{OFFICE_URL}{JOIN_ENDPOINT}", json=payload, timeout=10) - if r.status_code in (200, 201): - data = r.json() - if data.get("ok"): - local["joined"] = True - local["agentId"] = data.get("agentId") - save_local_state(local) - print(f"✅ 已加入海蟛办公宀agentId={local['agentId']}") - return True - print(f"❌ 加入倱莥{r.text}") - return False - - -def do_push(local, status_data): - import requests - payload = { - "agentId": local.get("agentId"), - "joinKey": local.get("joinKey", JOIN_KEY), - "state": status_data.get("state", "idle"), - "detail": status_data.get("detail", ""), - "name": local.get("agentName", AGENT_NAME) - } - r = requests.post(f"{OFFICE_URL}{PUSH_ENDPOINT}", json=payload, timeout=10) - if r.status_code in (200, 201): - data = r.json() - if data.get("ok"): - area = data.get("area", "breakroom") - print(f"✅ 状态已同步圓前区域={area}") - return True - - # 403/404拒绝/移陀 → 停止掚送 - if r.status_code in (403, 404): - msg = "" - try: - msg = (r.json() or {}).get("msg", "") - except Exception: - msg = r.text - print(f"⚠ 访问拒绝或已移出房闎{r.status_code}停止掚送{msg}") - local["joined"] = False - local["agentId"] = None - save_local_state(local) - sys.exit(1) - - print(f"⚠ 掚送倱莥{r.text}") - return False - - -def main(): - local = load_local_state() - - # 先确讀配眮是吊霐党 - if not JOIN_KEY or not AGENT_NAME: - print("❌ 请先圚脚本匀倎填入 JOIN_KEY 和 AGENT_NAME") - sys.exit(1) - - # 劂果之前没 join先 join - if not local.get("joined") or not local.get("agentId"): - ok = do_join(local) - if not ok: - sys.exit(1) - - # 持续掚送 - print(f"🚀 匀始持续掚送状态闎隔={PUSH_INTERVAL_SECONDS}秒") - print("🧭 状态逻蟑任务䞭→工䜜区埅呜/完成→䌑息区匂垞→bug区") - print("🔐 若本地 /status 返回 Unauthorized(401)请讟眮环境变量OFFICE_LOCAL_STATUS_TOKEN 或 OFFICE_LOCAL_STATUS_URL") - try: - while True: - try: - status_data = fetch_local_status() - do_push(local, status_data) - except Exception as e: - print(f"⚠ 掚送匂垞{e}") - time.sleep(PUSH_INTERVAL_SECONDS) - except KeyboardInterrupt: - print("\n👋 停止掚送") - sys.exit(0) - - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +""" +海蟛办公宀 - Agent 状态䞻劚掚送脚本 + +甚法 +1. 填入䞋面的 JOIN_KEY䜠从海蟛那里拿到的䞀次性 join key +2. 填入 AGENT_NAME䜠想芁圚办公宀里星瀺的名字 +3. 运行python office-agent-push.py +4. 脚本䌚自劚先 join銖次运行然后每 30s 向海蟛办公宀掚送䞀次䜠的圓前状态 +""" + +import json +import os +import time +import sys +from datetime import datetime + +# === 䜠需芁填入的信息 === +JOIN_KEY = "" # 必填䜠的䞀次性 join key +AGENT_NAME = "" # 必填䜠圚办公宀里的名字 +OFFICE_URL = "https://your-office.example.com" # 䜠的办公宀地址 + +# === 掚送配眮 === +PUSH_INTERVAL_SECONDS = 15 # 每隔倚少秒掚送䞀次曎实时 +STATUS_ENDPOINT = "/status" +JOIN_ENDPOINT = "/join-agent" +PUSH_ENDPOINT = "/agent-push" + +# 自劚状态守技圓本地状态文件䞍存圚或长期䞍曎新时自劚回 idle避免“假工䜜䞭” +STALE_STATE_TTL_SECONDS = int(os.environ.get("OFFICE_STALE_STATE_TTL", "600")) + +# 本地状态存傚记䜏䞊次 join 拿到的 agentId +STATE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "office-agent-state.json") + +# 䌘先读取本机 OpenClaw 工䜜区的状态文件曎莎合 AGENTS.md 的工䜜流 +# 支持自劚发现减少对方手劚配眮成本䞔避免硬猖码绝对路埄 +# - 䌘先䜿甚环境变量 OPENCLAW_HOME / OPENCLAW_WORKSPACE_DIR +# - 其次䜿甚圓前甚户 HOME/.openclaw +# - 再回萜到圓前工䜜目圕䞎脚本所圚目圕 +OPENCLAW_HOME = os.environ.get("OPENCLAW_HOME") or os.path.join(os.path.expanduser("~"), ".openclaw") +OPENCLAW_WORKSPACE_DIR = os.environ.get("OPENCLAW_WORKSPACE_DIR") or os.path.join(OPENCLAW_HOME, "workspace") + +DEFAULT_STATE_CANDIDATES = [ + os.path.join(OPENCLAW_WORKSPACE_DIR, "star-office-ui", "state.json"), + os.path.join(OPENCLAW_WORKSPACE_DIR, "Star-Office-UI", "state.json"), + os.path.join(OPENCLAW_WORKSPACE_DIR, "state.json"), + os.path.join(os.getcwd(), "state.json"), + os.path.join(os.path.dirname(os.path.abspath(__file__)), "state.json"), +] + +# 劂果对方本地 /status 需芁鉎权可圚这里填写 token或通过环境变量 OFFICE_LOCAL_STATUS_TOKEN +LOCAL_STATUS_TOKEN = os.environ.get("OFFICE_LOCAL_STATUS_TOKEN", "") +LOCAL_STATUS_URL = os.environ.get("OFFICE_LOCAL_STATUS_URL", "http://127.0.0.1:19000/status") +# 可选盎接指定本地状态文件路埄最简单方案绕过 /status 鉎权 +LOCAL_STATE_FILE = os.environ.get("OFFICE_LOCAL_STATE_FILE", "") +VERBOSE = os.environ.get("OFFICE_VERBOSE", "0") in {"1", "true", "TRUE", "yes", "YES"} + +# === Logging with simple color support === +# Use colors by default if output is a TTY and not on Windows, or if forced via env. +# On Windows, modern terminals support ANSI, but we check for 'True' if forced. +_USE_COLOR = sys.stdout.isatty() and (os.name != "nt" or os.environ.get("OFFICE_COLOR") == "1") +if os.environ.get("OFFICE_COLOR") == "0": + _USE_COLOR = False + +def log_info(msg): + if _USE_COLOR: + print(f"\033[94m[*]\033[0m {msg}") + else: + print(f"[*] {msg}") + +def log_success(msg): + if _USE_COLOR: + print(f"\033[92m[+]\033[0m {msg}") + else: + print(f"[+] {msg}") + +def log_warn(msg): + if _USE_COLOR: + print(f"\033[93m[!]\033[0m {msg}") + else: + print(f"[!] {msg}") + +def log_error(msg): + if _USE_COLOR: + print(f"\033[91m[x]\033[0m {msg}") + else: + print(f"[x] {msg}") + + +def load_local_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 { + "agentId": None, + "joined": False, + "joinKey": JOIN_KEY, + "agentName": AGENT_NAME + } + + +def save_local_state(data): + with open(STATE_FILE, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +def normalize_state(s): + """兌容䞍同本地状态词并映射到办公宀识别状态。""" + s = (s or "").strip().lower() + if s in {"writing", "researching", "executing", "syncing", "error", "idle"}: + return s + if s in {"working", "busy", "write"}: + return "writing" + if s in {"run", "running", "execute", "exec"}: + return "executing" + if s in {"research", "search"}: + return "researching" + if s in {"sync"}: + return "syncing" + return "idle" + + +def map_detail_to_state(detail, fallback_state="idle"): + """圓只有 detail 时甚关键词掚断状态莎近 AGENTS.md 的办公区逻蟑。""" + d = (detail or "").lower() + if any(k in d for k in ["报错", "error", "bug", "匂垞", "报譊"]): + return "error" + if any(k in d for k in ["同步", "sync", "倇仜"]): + return "syncing" + if any(k in d for k in ["调研", "research", "搜玢", "查资料"]): + return "researching" + if any(k in d for k in ["执行", "run", "掚进", "倄理任务", "工䜜䞭", "writing"]): + return "writing" + if any(k in d for k in ["埅呜", "䌑息", "idle", "完成", "done"]): + return "idle" + return fallback_state + + +def _state_age_seconds(data): + try: + ts = (data or {}).get("updated_at") + if not ts: + return None + dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00")) + if dt.tzinfo is not None: + from datetime import timezone + return (datetime.now(timezone.utc) - dt.astimezone(timezone.utc)).total_seconds() + return (datetime.now() - dt).total_seconds() + except Exception: + return None + + +def fetch_local_status(): + """读取本地状态 + 1) 䌘先 state.json笊合 AGENTS.md任务前切 writing完成后切 idle + 2) 其次尝试本地 HTTP /status + 3) 最后 fallback idle + + 额倖防抖劂果本地状态曎新时闎超过 STALE_STATE_TTL_SECONDS自劚视䞺 idle。 + """ + # 1) 读本地 state.json䌘先读取星匏指定路埄其次自劚发现 + candidate_files = [] + if LOCAL_STATE_FILE: + candidate_files.append(LOCAL_STATE_FILE) + for fp in DEFAULT_STATE_CANDIDATES: + if fp not in candidate_files: + candidate_files.append(fp) + + for fp in candidate_files: + try: + if fp and os.path.exists(fp): + with open(fp, "r", encoding="utf-8") as f: + data = json.load(f) + + # 只接受“状态文件”结构避免误把 office-agent-state.json仅猓存 agentId圓状态源 + if not isinstance(data, dict): + continue + has_state = "state" in data + has_detail = "detail" in data + if (not has_state) and (not has_detail): + continue + + state = normalize_state(data.get("state", "idle")) + detail = data.get("detail", "") or "" + # detail 兜底纠偏确保“工䜜/䌑息/报譊”胜正确萜区 + state = map_detail_to_state(detail, fallback_state=state) + + # 防止状态文件久未曎新仍停留圚 working 态 + age = _state_age_seconds(data) + if age is not None and age > STALE_STATE_TTL_SECONDS: + state = "idle" + detail = f"本地状态超过{STALE_STATE_TTL_SECONDS}s未曎新自劚回埅呜" + + if VERBOSE: + log_info(f"[status-source:file] path={fp} state={state} detail={detail[:60]}") + return {"state": state, "detail": detail} + except Exception: + pass + + # 2) 尝试本地 /status可胜需芁鉎权 + try: + import requests + headers = {} + if LOCAL_STATUS_TOKEN: + headers["Authorization"] = f"Bearer {LOCAL_STATUS_TOKEN}" + r = requests.get(LOCAL_STATUS_URL, headers=headers, timeout=5) + if r.status_code == 200: + data = r.json() + state = normalize_state(data.get("state", "idle")) + detail = data.get("detail", "") or "" + state = map_detail_to_state(detail, fallback_state=state) + + age = _state_age_seconds(data) + if age is not None and age > STALE_STATE_TTL_SECONDS: + state = "idle" + detail = f"本地/status 超过{STALE_STATE_TTL_SECONDS}s未曎新自劚回埅呜" + + if VERBOSE: + log_info(f"[status-source:http] url={LOCAL_STATUS_URL} state={state} detail={detail[:60]}") + return {"state": state, "detail": detail} + # 劂果 401诎明需芁 token + if r.status_code == 401: + return {"state": "idle", "detail": "本地/status需芁鉎权401请讟眮 OFFICE_LOCAL_STATUS_TOKEN"} + except Exception: + pass + + # 3) 默讀 fallback + if VERBOSE: + log_info("[status-source:fallback] state=idle detail=埅呜䞭") + return {"state": "idle", "detail": "埅呜䞭"} + + +def do_join(local): + import requests + payload = { + "name": local.get("agentName", AGENT_NAME), + "joinKey": local.get("joinKey", JOIN_KEY), + "state": "idle", + "detail": "刚刚加入" + } + r = requests.post(f"{OFFICE_URL}{JOIN_ENDPOINT}", json=payload, timeout=10) + if r.status_code in (200, 201): + data = r.json() + if data.get("ok"): + local["joined"] = True + local["agentId"] = data.get("agentId") + save_local_state(local) + log_success(f"已加入海蟛办公宀agentId={local['agentId']}") + return True + log_error(f"加入倱莥{r.text}") + return False + + +def do_push(local, status_data): + import requests + payload = { + "agentId": local.get("agentId"), + "joinKey": local.get("joinKey", JOIN_KEY), + "state": status_data.get("state", "idle"), + "detail": status_data.get("detail", ""), + "name": local.get("agentName", AGENT_NAME) + } + r = requests.post(f"{OFFICE_URL}{PUSH_ENDPOINT}", json=payload, timeout=10) + if r.status_code in (200, 201): + data = r.json() + if data.get("ok"): + area = data.get("area", "breakroom") + log_success(f"状态已同步圓前区域={area}") + return True + + # 403/404拒绝/移陀 → 停止掚送 + if r.status_code in (403, 404): + msg = "" + try: + msg = (r.json() or {}).get("msg", "") + except Exception: + msg = r.text + log_warn(f"访问拒绝或已移出房闎{r.status_code}停止掚送{msg}") + local["joined"] = False + local["agentId"] = None + save_local_state(local) + sys.exit(1) + + log_warn(f"掚送倱莥{r.text}") + return False + + +def main(): + local = load_local_state() + + # Startup hint for state source and URL (helps with port/state issues, e.g. issue #31) + if LOCAL_STATE_FILE: + log_info(f"State file: {LOCAL_STATE_FILE}") + else: + first_existing = next((p for p in DEFAULT_STATE_CANDIDATES if p and os.path.exists(p)), None) + if first_existing: + log_info(f"State file (auto): {first_existing}") + else: + log_info("State file: auto-discover (set OFFICE_LOCAL_STATE_FILE if state not found)") + log_info(f"Local status URL: {LOCAL_STATUS_URL} (set OFFICE_LOCAL_STATUS_URL if backend uses another port)") + + # 先确讀配眮是吊霐党 + if not JOIN_KEY or not AGENT_NAME: + log_error("请先圚脚本匀倎填入 JOIN_KEY 和 AGENT_NAME") + sys.exit(1) + + # 劂果之前没 join先 join + if not local.get("joined") or not local.get("agentId"): + ok = do_join(local) + if not ok: + sys.exit(1) + + # 持续掚送 + log_info(f"匀始持续掚送状态闎隔={PUSH_INTERVAL_SECONDS}秒") + log_info("🧭 状态逻蟑任务䞭→工䜜区埅呜/完成→䌑息区匂垞→bug区") + log_info("🔐 若本地 /status 返回 Unauthorized(401)请讟眮环境变量OFFICE_LOCAL_STATUS_TOKEN 或 OFFICE_LOCAL_STATUS_URL") + try: + while True: + try: + status_data = fetch_local_status() + do_push(local, status_data) + except Exception as e: + log_warn(f"掚送匂垞{e}") + time.sleep(PUSH_INTERVAL_SECONDS) + except KeyboardInterrupt: + print("\n👋 停止掚送") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/healthcheck.sh b/healthcheck.sh index 8ac5bc88..2b3dba28 100755 --- a/healthcheck.sh +++ b/healthcheck.sh @@ -1,18 +1,19 @@ -#!/bin/bash -# Star Office UI Health Check -# Checks if backend is responding, restarts if not - -BACKEND_URL="http://127.0.0.1:19000/health" -LOG_FILE="/root/.openclaw/workspace/star-office-ui/healthcheck.log" - -# Log timestamp -echo "[$(date '+%Y-%m-%d %H:%M:%S')] Health check starting..." >> "$LOG_FILE" - -# Check backend -if curl -sS "$BACKEND_URL" > /dev/null 2>&1; then - echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backend is healthy" >> "$LOG_FILE" -else - echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backend is NOT healthy - restarting..." >> "$LOG_FILE" - systemctl restart star-office-backend.service - echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backend restarted" >> "$LOG_FILE" -fi +#!/bin/bash +# Star Office UI Health Check +# Checks if backend is responding, restarts if not + +BACKEND_URL="http://127.0.0.1:19000/health" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LOG_FILE="$SCRIPT_DIR/healthcheck.log" + +# Log timestamp +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Health check starting..." >> "$LOG_FILE" + +# Check backend +if curl -sS "$BACKEND_URL" > /dev/null 2>&1; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backend is healthy" >> "$LOG_FILE" +else + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backend is NOT healthy - restarting..." >> "$LOG_FILE" + systemctl restart star-office-backend.service + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backend restarted" >> "$LOG_FILE" +fi diff --git a/office-agent-push.py b/office-agent-push.py index 1db0e3a6..27a5dcf4 100644 --- a/office-agent-push.py +++ b/office-agent-push.py @@ -1,305 +1,334 @@ -#!/usr/bin/env python3 -""" -海蟛办公宀 - Agent 状态䞻劚掚送脚本 - -甚法 -1. 填入䞋面的 JOIN_KEY䜠从海蟛那里拿到的䞀次性 join key -2. 填入 AGENT_NAME䜠想芁圚办公宀里星瀺的名字 -3. 运行python office-agent-push.py -4. 脚本䌚自劚先 join銖次运行然后每 30s 向海蟛办公宀掚送䞀次䜠的圓前状态 -""" - -import json -import os -import time -import sys -from datetime import datetime - -# === 䜠需芁填入的信息 === -JOIN_KEY = "" # 必填䜠的䞀次性 join key -AGENT_NAME = "" # 必填䜠圚办公宀里的名字 -OFFICE_URL = "https://office.hyacinth.im" # 海蟛办公宀地址䞀般䞍甚改 - -# === 掚送配眮 === -PUSH_INTERVAL_SECONDS = 15 # 每隔倚少秒掚送䞀次曎实时 -STATUS_ENDPOINT = "/status" -JOIN_ENDPOINT = "/join-agent" -PUSH_ENDPOINT = "/agent-push" - -# 自劚状态守技圓本地状态文件䞍存圚或长期䞍曎新时自劚回 idle避免“假工䜜䞭” -STALE_STATE_TTL_SECONDS = int(os.environ.get("OFFICE_STALE_STATE_TTL", "600")) - -# 本地状态存傚记䜏䞊次 join 拿到的 agentId -STATE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "office-agent-state.json") - -# 䌘先读取本机 OpenClaw 工䜜区的状态文件曎莎合 AGENTS.md 的工䜜流 -# 支持自劚发现减少对方手劚配眮成本䞔避免硬猖码绝对路埄 -# - 䌘先䜿甚环境变量 OPENCLAW_HOME / OPENCLAW_WORKSPACE_DIR -# - 其次䜿甚圓前甚户 HOME/.openclaw -# - 再回萜到圓前工䜜目圕䞎脚本所圚目圕 -OPENCLAW_HOME = os.environ.get("OPENCLAW_HOME") or os.path.join(os.path.expanduser("~"), ".openclaw") -OPENCLAW_WORKSPACE_DIR = os.environ.get("OPENCLAW_WORKSPACE_DIR") or os.path.join(OPENCLAW_HOME, "workspace") - -DEFAULT_STATE_CANDIDATES = [ - os.path.join(OPENCLAW_WORKSPACE_DIR, "star-office-ui", "state.json"), - os.path.join(OPENCLAW_WORKSPACE_DIR, "state.json"), - "/root/.openclaw/workspace/Star-Office-UI/state.json", # 圓前仓库倧小写粟确 - "/root/.openclaw/workspace/star-office-ui/state.json", # 历史/兌容路埄 - "/root/.openclaw/workspace/state.json", - os.path.join(os.getcwd(), "state.json"), - os.path.join(os.path.dirname(os.path.abspath(__file__)), "state.json"), -] - -# 劂果对方本地 /status 需芁鉎权可圚这里填写 token或通过环境变量 OFFICE_LOCAL_STATUS_TOKEN -LOCAL_STATUS_TOKEN = os.environ.get("OFFICE_LOCAL_STATUS_TOKEN", "") -LOCAL_STATUS_URL = os.environ.get("OFFICE_LOCAL_STATUS_URL", "http://127.0.0.1:19000/status") -# 可选盎接指定本地状态文件路埄最简单方案绕过 /status 鉎权 -LOCAL_STATE_FILE = os.environ.get("OFFICE_LOCAL_STATE_FILE", "") -VERBOSE = os.environ.get("OFFICE_VERBOSE", "0") in {"1", "true", "TRUE", "yes", "YES"} - - -def load_local_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 { - "agentId": None, - "joined": False, - "joinKey": JOIN_KEY, - "agentName": AGENT_NAME - } - - -def save_local_state(data): - with open(STATE_FILE, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) - - -def normalize_state(s): - """兌容䞍同本地状态词并映射到办公宀识别状态。""" - s = (s or "").strip().lower() - if s in {"writing", "researching", "executing", "syncing", "error", "idle"}: - return s - if s in {"working", "busy", "write"}: - return "writing" - if s in {"run", "running", "execute", "exec"}: - return "executing" - if s in {"research", "search"}: - return "researching" - if s in {"sync"}: - return "syncing" - return "idle" - - -def map_detail_to_state(detail, fallback_state="idle"): - """圓只有 detail 时甚关键词掚断状态莎近 AGENTS.md 的办公区逻蟑。""" - d = (detail or "").lower() - if any(k in d for k in ["报错", "error", "bug", "匂垞", "报譊"]): - return "error" - if any(k in d for k in ["同步", "sync", "倇仜"]): - return "syncing" - if any(k in d for k in ["调研", "research", "搜玢", "查资料"]): - return "researching" - if any(k in d for k in ["执行", "run", "掚进", "倄理任务", "工䜜䞭", "writing"]): - return "writing" - if any(k in d for k in ["埅呜", "䌑息", "idle", "完成", "done"]): - return "idle" - return fallback_state - - -def _state_age_seconds(data): - try: - ts = (data or {}).get("updated_at") - if not ts: - return None - dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00")) - if dt.tzinfo is not None: - from datetime import timezone - return (datetime.now(timezone.utc) - dt.astimezone(timezone.utc)).total_seconds() - return (datetime.now() - dt).total_seconds() - except Exception: - return None - - -def fetch_local_status(): - """读取本地状态 - 1) 䌘先 state.json笊合 AGENTS.md任务前切 writing完成后切 idle - 2) 其次尝试本地 HTTP /status - 3) 最后 fallback idle - - 额倖防抖劂果本地状态曎新时闎超过 STALE_STATE_TTL_SECONDS自劚视䞺 idle。 - """ - # 1) 读本地 state.json䌘先读取星匏指定路埄其次自劚发现 - candidate_files = [] - if LOCAL_STATE_FILE: - candidate_files.append(LOCAL_STATE_FILE) - for fp in DEFAULT_STATE_CANDIDATES: - if fp not in candidate_files: - candidate_files.append(fp) - - for fp in candidate_files: - try: - if fp and os.path.exists(fp): - with open(fp, "r", encoding="utf-8") as f: - data = json.load(f) - - # 只接受“状态文件”结构避免误把 office-agent-state.json仅猓存 agentId圓状态源 - if not isinstance(data, dict): - continue - has_state = "state" in data - has_detail = "detail" in data - if (not has_state) and (not has_detail): - continue - - state = normalize_state(data.get("state", "idle")) - detail = data.get("detail", "") or "" - # detail 兜底纠偏确保“工䜜/䌑息/报譊”胜正确萜区 - state = map_detail_to_state(detail, fallback_state=state) - - # 防止状态文件久未曎新仍停留圚 working 态 - age = _state_age_seconds(data) - if age is not None and age > STALE_STATE_TTL_SECONDS: - state = "idle" - detail = f"本地状态超过{STALE_STATE_TTL_SECONDS}s未曎新自劚回埅呜" - - if VERBOSE: - print(f"[status-source:file] path={fp} state={state} detail={detail[:60]}") - return {"state": state, "detail": detail} - except Exception: - pass - - # 2) 尝试本地 /status可胜需芁鉎权 - try: - import requests - headers = {} - if LOCAL_STATUS_TOKEN: - headers["Authorization"] = f"Bearer {LOCAL_STATUS_TOKEN}" - r = requests.get(LOCAL_STATUS_URL, headers=headers, timeout=5) - if r.status_code == 200: - data = r.json() - state = normalize_state(data.get("state", "idle")) - detail = data.get("detail", "") or "" - state = map_detail_to_state(detail, fallback_state=state) - - age = _state_age_seconds(data) - if age is not None and age > STALE_STATE_TTL_SECONDS: - state = "idle" - detail = f"本地/status 超过{STALE_STATE_TTL_SECONDS}s未曎新自劚回埅呜" - - if VERBOSE: - print(f"[status-source:http] url={LOCAL_STATUS_URL} state={state} detail={detail[:60]}") - return {"state": state, "detail": detail} - # 劂果 401诎明需芁 token - if r.status_code == 401: - return {"state": "idle", "detail": "本地/status需芁鉎权401请讟眮 OFFICE_LOCAL_STATUS_TOKEN"} - except Exception: - pass - - # 3) 默讀 fallback - if VERBOSE: - print("[status-source:fallback] state=idle detail=埅呜䞭") - return {"state": "idle", "detail": "埅呜䞭"} - - -def do_join(local): - import requests - payload = { - "name": local.get("agentName", AGENT_NAME), - "joinKey": local.get("joinKey", JOIN_KEY), - "state": "idle", - "detail": "刚刚加入" - } - r = requests.post(f"{OFFICE_URL}{JOIN_ENDPOINT}", json=payload, timeout=10) - if r.status_code in (200, 201): - data = r.json() - if data.get("ok"): - local["joined"] = True - local["agentId"] = data.get("agentId") - save_local_state(local) - print(f"✅ 已加入海蟛办公宀agentId={local['agentId']}") - return True - print(f"❌ 加入倱莥{r.text}") - return False - - -def do_push(local, status_data): - import requests - payload = { - "agentId": local.get("agentId"), - "joinKey": local.get("joinKey", JOIN_KEY), - "state": status_data.get("state", "idle"), - "detail": status_data.get("detail", ""), - "name": local.get("agentName", AGENT_NAME) - } - r = requests.post(f"{OFFICE_URL}{PUSH_ENDPOINT}", json=payload, timeout=10) - if r.status_code in (200, 201): - data = r.json() - if data.get("ok"): - area = data.get("area", "breakroom") - print(f"✅ 状态已同步圓前区域={area}") - return True - - # 403/404拒绝/移陀 → 停止掚送 - if r.status_code in (403, 404): - msg = "" - try: - msg = (r.json() or {}).get("msg", "") - except Exception: - msg = r.text - print(f"⚠ 访问拒绝或已移出房闎{r.status_code}停止掚送{msg}") - local["joined"] = False - local["agentId"] = None - save_local_state(local) - sys.exit(1) - - print(f"⚠ 掚送倱莥{r.text}") - return False - - -def main(): - local = load_local_state() - - # Startup hint for state source and URL (helps with port/state issues, e.g. issue #31) - if LOCAL_STATE_FILE: - print(f"State file: {LOCAL_STATE_FILE}") - else: - first_existing = next((p for p in DEFAULT_STATE_CANDIDATES if p and os.path.exists(p)), None) - if first_existing: - print(f"State file (auto): {first_existing}") - else: - print("State file: auto-discover (set OFFICE_LOCAL_STATE_FILE if state not found)") - print(f"Local status URL: {LOCAL_STATUS_URL} (set OFFICE_LOCAL_STATUS_URL if backend uses another port)") - - # 先确讀配眮是吊霐党 - if not JOIN_KEY or not AGENT_NAME: - print("❌ 请先圚脚本匀倎填入 JOIN_KEY 和 AGENT_NAME") - sys.exit(1) - - # 劂果之前没 join先 join - if not local.get("joined") or not local.get("agentId"): - ok = do_join(local) - if not ok: - sys.exit(1) - - # 持续掚送 - print(f"🚀 匀始持续掚送状态闎隔={PUSH_INTERVAL_SECONDS}秒") - print("🧭 状态逻蟑任务䞭→工䜜区埅呜/完成→䌑息区匂垞→bug区") - print("🔐 若本地 /status 返回 Unauthorized(401)请讟眮环境变量OFFICE_LOCAL_STATUS_TOKEN 或 OFFICE_LOCAL_STATUS_URL") - try: - while True: - try: - status_data = fetch_local_status() - do_push(local, status_data) - except Exception as e: - print(f"⚠ 掚送匂垞{e}") - time.sleep(PUSH_INTERVAL_SECONDS) - except KeyboardInterrupt: - print("\n👋 停止掚送") - sys.exit(0) - - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +""" +海蟛办公宀 - Agent 状态䞻劚掚送脚本 + +甚法 +1. 填入䞋面的 JOIN_KEY䜠从海蟛那里拿到的䞀次性 join key +2. 填入 AGENT_NAME䜠想芁圚办公宀里星瀺的名字 +3. 运行python office-agent-push.py +4. 脚本䌚自劚先 join銖次运行然后每 30s 向海蟛办公宀掚送䞀次䜠的圓前状态 +""" + +import json +import os +import time +import sys +from datetime import datetime + +# === 䜠需芁填入的信息 === +JOIN_KEY = "" # 必填䜠的䞀次性 join key +AGENT_NAME = "" # 必填䜠圚办公宀里的名字 +OFFICE_URL = "https://your-office.example.com" # 䜠的办公宀地址 + +# === 掚送配眮 === +PUSH_INTERVAL_SECONDS = 15 # 每隔倚少秒掚送䞀次曎实时 +STATUS_ENDPOINT = "/status" +JOIN_ENDPOINT = "/join-agent" +PUSH_ENDPOINT = "/agent-push" + +# 自劚状态守技圓本地状态文件䞍存圚或长期䞍曎新时自劚回 idle避免“假工䜜䞭” +STALE_STATE_TTL_SECONDS = int(os.environ.get("OFFICE_STALE_STATE_TTL", "600")) + +# 本地状态存傚记䜏䞊次 join 拿到的 agentId +STATE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "office-agent-state.json") + +# 䌘先读取本机 OpenClaw 工䜜区的状态文件曎莎合 AGENTS.md 的工䜜流 +# 支持自劚发现减少对方手劚配眮成本䞔避免硬猖码绝对路埄 +# - 䌘先䜿甚环境变量 OPENCLAW_HOME / OPENCLAW_WORKSPACE_DIR +# - 其次䜿甚圓前甚户 HOME/.openclaw +# - 再回萜到圓前工䜜目圕䞎脚本所圚目圕 +OPENCLAW_HOME = os.environ.get("OPENCLAW_HOME") or os.path.join(os.path.expanduser("~"), ".openclaw") +OPENCLAW_WORKSPACE_DIR = os.environ.get("OPENCLAW_WORKSPACE_DIR") or os.path.join(OPENCLAW_HOME, "workspace") + +DEFAULT_STATE_CANDIDATES = [ + os.path.join(OPENCLAW_WORKSPACE_DIR, "star-office-ui", "state.json"), + os.path.join(OPENCLAW_WORKSPACE_DIR, "Star-Office-UI", "state.json"), + os.path.join(OPENCLAW_WORKSPACE_DIR, "state.json"), + os.path.join(os.getcwd(), "state.json"), + os.path.join(os.path.dirname(os.path.abspath(__file__)), "state.json"), +] + +# 劂果对方本地 /status 需芁鉎权可圚这里填写 token或通过环境变量 OFFICE_LOCAL_STATUS_TOKEN +LOCAL_STATUS_TOKEN = os.environ.get("OFFICE_LOCAL_STATUS_TOKEN", "") +LOCAL_STATUS_URL = os.environ.get("OFFICE_LOCAL_STATUS_URL", "http://127.0.0.1:19000/status") +# 可选盎接指定本地状态文件路埄最简单方案绕过 /status 鉎权 +LOCAL_STATE_FILE = os.environ.get("OFFICE_LOCAL_STATE_FILE", "") +VERBOSE = os.environ.get("OFFICE_VERBOSE", "0") in {"1", "true", "TRUE", "yes", "YES"} + +# === Logging with simple color support === +# Use colors by default if output is a TTY and not on Windows, or if forced via env. +# On Windows, modern terminals support ANSI, but we check for 'True' if forced. +_USE_COLOR = sys.stdout.isatty() and (os.name != "nt" or os.environ.get("OFFICE_COLOR") == "1") +if os.environ.get("OFFICE_COLOR") == "0": + _USE_COLOR = False + +def log_info(msg): + if _USE_COLOR: + print(f"\033[94m[*]\033[0m {msg}") + else: + print(f"[*] {msg}") + +def log_success(msg): + if _USE_COLOR: + print(f"\033[92m[+]\033[0m {msg}") + else: + print(f"[+] {msg}") + +def log_warn(msg): + if _USE_COLOR: + print(f"\033[93m[!]\033[0m {msg}") + else: + print(f"[!] {msg}") + +def log_error(msg): + if _USE_COLOR: + print(f"\033[91m[x]\033[0m {msg}") + else: + print(f"[x] {msg}") + + +def load_local_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 { + "agentId": None, + "joined": False, + "joinKey": JOIN_KEY, + "agentName": AGENT_NAME + } + + +def save_local_state(data): + with open(STATE_FILE, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +def normalize_state(s): + """兌容䞍同本地状态词并映射到办公宀识别状态。""" + s = (s or "").strip().lower() + if s in {"writing", "researching", "executing", "syncing", "error", "idle"}: + return s + if s in {"working", "busy", "write"}: + return "writing" + if s in {"run", "running", "execute", "exec"}: + return "executing" + if s in {"research", "search"}: + return "researching" + if s in {"sync"}: + return "syncing" + return "idle" + + +def map_detail_to_state(detail, fallback_state="idle"): + """圓只有 detail 时甚关键词掚断状态莎近 AGENTS.md 的办公区逻蟑。""" + d = (detail or "").lower() + if any(k in d for k in ["报错", "error", "bug", "匂垞", "报譊"]): + return "error" + if any(k in d for k in ["同步", "sync", "倇仜"]): + return "syncing" + if any(k in d for k in ["调研", "research", "搜玢", "查资料"]): + return "researching" + if any(k in d for k in ["执行", "run", "掚进", "倄理任务", "工䜜䞭", "writing"]): + return "writing" + if any(k in d for k in ["埅呜", "䌑息", "idle", "完成", "done"]): + return "idle" + return fallback_state + + +def _state_age_seconds(data): + try: + ts = (data or {}).get("updated_at") + if not ts: + return None + dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00")) + if dt.tzinfo is not None: + from datetime import timezone + return (datetime.now(timezone.utc) - dt.astimezone(timezone.utc)).total_seconds() + return (datetime.now() - dt).total_seconds() + except Exception: + return None + + +def fetch_local_status(): + """读取本地状态 + 1) 䌘先 state.json笊合 AGENTS.md任务前切 writing完成后切 idle + 2) 其次尝试本地 HTTP /status + 3) 最后 fallback idle + + 额倖防抖劂果本地状态曎新时闎超过 STALE_STATE_TTL_SECONDS自劚视䞺 idle。 + """ + # 1) 读本地 state.json䌘先读取星匏指定路埄其次自劚发现 + candidate_files = [] + if LOCAL_STATE_FILE: + candidate_files.append(LOCAL_STATE_FILE) + for fp in DEFAULT_STATE_CANDIDATES: + if fp not in candidate_files: + candidate_files.append(fp) + + for fp in candidate_files: + try: + if fp and os.path.exists(fp): + with open(fp, "r", encoding="utf-8") as f: + data = json.load(f) + + # 只接受“状态文件”结构避免误把 office-agent-state.json仅猓存 agentId圓状态源 + if not isinstance(data, dict): + continue + has_state = "state" in data + has_detail = "detail" in data + if (not has_state) and (not has_detail): + continue + + state = normalize_state(data.get("state", "idle")) + detail = data.get("detail", "") or "" + # detail 兜底纠偏确保“工䜜/䌑息/报譊”胜正确萜区 + state = map_detail_to_state(detail, fallback_state=state) + + # 防止状态文件久未曎新仍停留圚 working 态 + age = _state_age_seconds(data) + if age is not None and age > STALE_STATE_TTL_SECONDS: + state = "idle" + detail = f"本地状态超过{STALE_STATE_TTL_SECONDS}s未曎新自劚回埅呜" + + if VERBOSE: + log_info(f"[status-source:file] path={fp} state={state} detail={detail[:60]}") + return {"state": state, "detail": detail} + except Exception: + pass + + # 2) 尝试本地 /status可胜需芁鉎权 + try: + import requests + headers = {} + if LOCAL_STATUS_TOKEN: + headers["Authorization"] = f"Bearer {LOCAL_STATUS_TOKEN}" + r = requests.get(LOCAL_STATUS_URL, headers=headers, timeout=5) + if r.status_code == 200: + data = r.json() + state = normalize_state(data.get("state", "idle")) + detail = data.get("detail", "") or "" + state = map_detail_to_state(detail, fallback_state=state) + + age = _state_age_seconds(data) + if age is not None and age > STALE_STATE_TTL_SECONDS: + state = "idle" + detail = f"本地/status 超过{STALE_STATE_TTL_SECONDS}s未曎新自劚回埅呜" + + if VERBOSE: + log_info(f"[status-source:http] url={LOCAL_STATUS_URL} state={state} detail={detail[:60]}") + return {"state": state, "detail": detail} + # 劂果 401诎明需芁 token + if r.status_code == 401: + return {"state": "idle", "detail": "本地/status需芁鉎权401请讟眮 OFFICE_LOCAL_STATUS_TOKEN"} + except Exception: + pass + + # 3) 默讀 fallback + if VERBOSE: + log_info("[status-source:fallback] state=idle detail=埅呜䞭") + return {"state": "idle", "detail": "埅呜䞭"} + + +def do_join(local): + import requests + payload = { + "name": local.get("agentName", AGENT_NAME), + "joinKey": local.get("joinKey", JOIN_KEY), + "state": "idle", + "detail": "刚刚加入" + } + r = requests.post(f"{OFFICE_URL}{JOIN_ENDPOINT}", json=payload, timeout=10) + if r.status_code in (200, 201): + data = r.json() + if data.get("ok"): + local["joined"] = True + local["agentId"] = data.get("agentId") + save_local_state(local) + log_success(f"已加入海蟛办公宀agentId={local['agentId']}") + return True + log_error(f"加入倱莥{r.text}") + return False + + +def do_push(local, status_data): + import requests + payload = { + "agentId": local.get("agentId"), + "joinKey": local.get("joinKey", JOIN_KEY), + "state": status_data.get("state", "idle"), + "detail": status_data.get("detail", ""), + "name": local.get("agentName", AGENT_NAME) + } + r = requests.post(f"{OFFICE_URL}{PUSH_ENDPOINT}", json=payload, timeout=10) + if r.status_code in (200, 201): + data = r.json() + if data.get("ok"): + area = data.get("area", "breakroom") + log_success(f"状态已同步圓前区域={area}") + return True + + # 403/404拒绝/移陀 → 停止掚送 + if r.status_code in (403, 404): + msg = "" + try: + msg = (r.json() or {}).get("msg", "") + except Exception: + msg = r.text + log_warn(f"访问拒绝或已移出房闎{r.status_code}停止掚送{msg}") + local["joined"] = False + local["agentId"] = None + save_local_state(local) + sys.exit(1) + + log_warn(f"掚送倱莥{r.text}") + return False + + +def main(): + local = load_local_state() + + # Startup hint for state source and URL (helps with port/state issues, e.g. issue #31) + if LOCAL_STATE_FILE: + log_info(f"State file: {LOCAL_STATE_FILE}") + else: + first_existing = next((p for p in DEFAULT_STATE_CANDIDATES if p and os.path.exists(p)), None) + if first_existing: + log_info(f"State file (auto): {first_existing}") + else: + log_info("State file: auto-discover (set OFFICE_LOCAL_STATE_FILE if state not found)") + log_info(f"Local status URL: {LOCAL_STATUS_URL} (set OFFICE_LOCAL_STATUS_URL if backend uses another port)") + + # 先确讀配眮是吊霐党 + if not JOIN_KEY or not AGENT_NAME: + log_error("请先圚脚本匀倎填入 JOIN_KEY 和 AGENT_NAME") + sys.exit(1) + + # 劂果之前没 join先 join + if not local.get("joined") or not local.get("agentId"): + ok = do_join(local) + if not ok: + sys.exit(1) + + # 持续掚送 + log_info(f"匀始持续掚送状态闎隔={PUSH_INTERVAL_SECONDS}秒") + log_info("🧭 状态逻蟑任务䞭→工䜜区埅呜/完成→䌑息区匂垞→bug区") + log_info("🔐 若本地 /status 返回 Unauthorized(401)请讟眮环境变量OFFICE_LOCAL_STATUS_TOKEN 或 OFFICE_LOCAL_STATUS_URL") + try: + while True: + try: + status_data = fetch_local_status() + do_push(local, status_data) + except Exception as e: + log_warn(f"掚送匂垞{e}") + time.sleep(PUSH_INTERVAL_SECONDS) + except KeyboardInterrupt: + print("\n👋 停止掚送") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/repack_star_working.py b/repack_star_working.py index 82f86f19..0eca2ff6 100644 --- a/repack_star_working.py +++ b/repack_star_working.py @@ -1,71 +1,71 @@ -#!/usr/bin/env python3 -"""Repack star-working spritesheet into a grid to fit GPU max texture sizes. - -Problem: -- Current spritesheet is 44160x144 (192 frames * 230w), too wide for WebGL max texture size on most GPUs. -- Result: texture upload fails => renders as black rectangle. - -This script repacks frames into rows. -Default: -- frame: 230x144 -- frames: 192 -- cols: 35 -> width 8050 -- rows: ceil(192/35)=6 -> height 864 - -Output: -- frontend/star-working-spritesheet-grid.png - -Safe: -- does NOT delete original file. -""" - -import math -import os -from PIL import Image - -ROOT = "/root/.openclaw/workspace/star-office-ui" -IN_PATH = os.path.join(ROOT, "frontend", "star-working-spritesheet.png") -OUT_PATH = os.path.join(ROOT, "frontend", "star-working-spritesheet-grid.png") - -FRAME_W = 230 -FRAME_H = 144 -FRAMES = 192 -COLS = 35 - - -def main(): - img = Image.open(IN_PATH).convert("RGBA") - w, h = img.size - - expected_w = FRAME_W * FRAMES - if h != FRAME_H or w < expected_w: - raise SystemExit(f"Unexpected input size {img.size}, expected height={FRAME_H}, width>={expected_w}") - - rows = math.ceil(FRAMES / COLS) - out_w = FRAME_W * COLS - out_h = FRAME_H * rows - - out = Image.new("RGBA", (out_w, out_h), (0, 0, 0, 0)) - - for i in range(FRAMES): - src_x0 = i * FRAME_W - src_y0 = 0 - frame = img.crop((src_x0, src_y0, src_x0 + FRAME_W, src_y0 + FRAME_H)) - - r = i // COLS - c = i % COLS - dst_x0 = c * FRAME_W - dst_y0 = r * FRAME_H - out.paste(frame, (dst_x0, dst_y0)) - - out.save(OUT_PATH) - - orig_size = os.path.getsize(IN_PATH) - new_size = os.path.getsize(OUT_PATH) - print(f"Wrote: {OUT_PATH}") - print(f"Input size: {w}x{h} ({orig_size/1024/1024:.2f} MB)") - print(f"Output size: {out_w}x{out_h} ({new_size/1024/1024:.2f} MB)") - - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +"""Repack star-working spritesheet into a grid to fit GPU max texture sizes. + +Problem: +- Current spritesheet is 44160x144 (192 frames * 230w), too wide for WebGL max texture size on most GPUs. +- Result: texture upload fails => renders as black rectangle. + +This script repacks frames into rows. +Default: +- frame: 230x144 +- frames: 192 +- cols: 35 -> width 8050 +- rows: ceil(192/35)=6 -> height 864 + +Output: +- frontend/star-working-spritesheet-grid.png + +Safe: +- does NOT delete original file. +""" + +import math +import os +from PIL import Image + +ROOT = os.path.dirname(os.path.abspath(__file__)) +IN_PATH = os.path.join(ROOT, "frontend", "star-working-spritesheet.png") +OUT_PATH = os.path.join(ROOT, "frontend", "star-working-spritesheet-grid.png") + +FRAME_W = 230 +FRAME_H = 144 +FRAMES = 192 +COLS = 35 + + +def main(): + img = Image.open(IN_PATH).convert("RGBA") + w, h = img.size + + expected_w = FRAME_W * FRAMES + if h != FRAME_H or w < expected_w: + raise SystemExit(f"Unexpected input size {img.size}, expected height={FRAME_H}, width>={expected_w}") + + rows = math.ceil(FRAMES / COLS) + out_w = FRAME_W * COLS + out_h = FRAME_H * rows + + out = Image.new("RGBA", (out_w, out_h), (0, 0, 0, 0)) + + for i in range(FRAMES): + src_x0 = i * FRAME_W + src_y0 = 0 + frame = img.crop((src_x0, src_y0, src_x0 + FRAME_W, src_y0 + FRAME_H)) + + r = i // COLS + c = i % COLS + dst_x0 = c * FRAME_W + dst_y0 = r * FRAME_H + out.paste(frame, (dst_x0, dst_y0)) + + out.save(OUT_PATH) + + orig_size = os.path.getsize(IN_PATH) + new_size = os.path.getsize(OUT_PATH) + print(f"Wrote: {OUT_PATH}") + print(f"Input size: {w}x{h} ({orig_size/1024/1024:.2f} MB)") + print(f"Output size: {out_w}x{out_h} ({new_size/1024/1024:.2f} MB)") + + +if __name__ == "__main__": + main() diff --git a/resize_map.py b/resize_map.py index 73e1614d..e58a75bd 100644 --- a/resize_map.py +++ b/resize_map.py @@ -1,41 +1,53 @@ -#!/usr/bin/env python3 -"""Resize office map by SHORT EDGE scaling (keep aspect ratio, no stretching/cropping)""" - -from PIL import Image - -def resize_map(input_path, output_path, target_short_edge=600): - im = Image.open(input_path) - original_width, original_height = im.size - - # Determine which is the SHORT edge - if original_width < original_height: - short_edge, long_edge = original_width, original_height - is_width_short = True - else: - short_edge, long_edge = original_height, original_width - is_width_short = False - - # Calculate scale based on SHORT edge - scale = target_short_edge / short_edge - - # Compute new dimensions - if is_width_short: - new_width = target_short_edge - new_height = int(long_edge * scale) - else: - new_width = int(long_edge * scale) - new_height = target_short_edge - - # Resize (use LANCZOS for high quality) - im_resized = im.resize((new_width, new_height), Image.Resampling.LANCZOS) - - im_resized.save(output_path) - print(f"Resized map saved: {output_path}") - print(f"Original size: {original_width}x{original_height}") - print(f"Resized size: {new_width}x{new_height}") - print(f"Short edge scale: {scale:.2f}x") - -if __name__ == "__main__": - input_path = "/root/.openclaw/media/inbound/6b352c7d-f09f-4dd7-9916-a312fb60122b.png" - output_path = "/root/.openclaw/workspace/star-office-ui/frontend/office_bg.png" - resize_map(input_path, output_path, target_short_edge=720) +#!/usr/bin/env python3 +"""Resize office map by SHORT EDGE scaling (keep aspect ratio, no stretching/cropping)""" + +from PIL import Image + +def resize_map(input_path, output_path, target_short_edge=600): + im = Image.open(input_path) + original_width, original_height = im.size + + # Determine which is the SHORT edge + if original_width < original_height: + short_edge, long_edge = original_width, original_height + is_width_short = True + else: + short_edge, long_edge = original_height, original_width + is_width_short = False + + # Calculate scale based on SHORT edge + scale = target_short_edge / short_edge + + # Compute new dimensions + if is_width_short: + new_width = target_short_edge + new_height = int(long_edge * scale) + else: + new_width = int(long_edge * scale) + new_height = target_short_edge + + # Resize (use LANCZOS for high quality) + im_resized = im.resize((new_width, new_height), Image.Resampling.LANCZOS) + + im_resized.save(output_path) + print(f"Resized map saved: {output_path}") + print(f"Original size: {original_width}x{original_height}") + print(f"Resized size: {new_width}x{new_height}") + print(f"Short edge scale: {scale:.2f}x") + +if __name__ == "__main__": + import argparse + import os + + parser = argparse.ArgumentParser(description="Resize office map by SHORT EDGE scaling.") + parser.add_argument("input", help="Input image path") + parser.add_argument("output", help="Output image path") + parser.add_argument("--short-edge", type=int, default=720, help="Target short edge length (default: 720)") + + args = parser.parse_args() + + if not os.path.exists(args.input): + print(f"Error: Input file '{args.input}' not found.") + exit(1) + + resize_map(args.input, args.output, target_short_edge=args.short_edge) diff --git a/scripts/security_check.py b/scripts/security_check.py index d5b69e62..20fd4b31 100755 --- a/scripts/security_check.py +++ b/scripts/security_check.py @@ -45,11 +45,15 @@ def is_strong_pass(v: str) -> bool: return len(s) >= 8 -def tracked_files() -> list[str]: - code, out, _ = run(["git", "ls-files"]) +def tracked_files() -> tuple[list[str], str]: + code, out, err = run(["git", "ls-files"]) if code != 0: - return [] - return [x for x in out.splitlines() if x.strip()] + msg = err or out or f"exit={code}" + return [], f"git ls-files failed; skip tracked-file scan ({msg})" + files = [x for x in out.splitlines() if x.strip()] + if not files: + return [], "git ls-files returned empty; skip tracked-file scan" + return files, "" def file_has_secret_pattern(path: Path) -> list[str]: @@ -63,6 +67,9 @@ def file_has_secret_pattern(path: Path) -> list[str]: (r"AIza[0-9A-Za-z\-_]{20,}", "Google/Gemini API key-like token"), (r"sk-[A-Za-z0-9]{16,}", "Generic sk-* token"), (r"AKIA[0-9A-Z]{16}", "AWS access key-like token"), + (r"gh[pousr]_[A-Za-z0-9]{36,}", "GitHub token-like token"), + (r"github_pat_[A-Za-z0-9_]{50,}", "GitHub fine-grained PAT-like token"), + (r"xox[baprs]-[A-Za-z0-9-]{20,}", "Slack token-like token"), ] for pat, label in patterns: if re.search(pat, text): @@ -93,8 +100,11 @@ def main() -> int: if not drawer_pass: warnings.append("ASSET_DRAWER_PASS not set (defaults may be unsafe for public exposure)") - tracked = tracked_files() + tracked, git_warn = tracked_files() + if git_warn: + warnings.append(git_warn) risky_tracked = [ + ".env", "runtime-config.json", "join-keys.json", "office-agent-state.json", @@ -104,14 +114,36 @@ def main() -> int: failures.append(f"Risky runtime file is tracked by git: {f}") # scan tracked text-ish files for common secret patterns + allowed_exts = { + ".py", + ".md", + ".txt", + ".json", + ".yml", + ".yaml", + ".toml", + ".js", + ".ts", + ".tsx", + ".html", + ".css", + ".sh", + ".ps1", + ".bat", + ".cmd", + } for rel in tracked: if rel.startswith(".git/"): continue + if rel.endswith(".min.js"): + continue p = ROOT / rel if not p.exists() or p.is_dir(): continue if p.stat().st_size > 2_000_000: continue + if p.suffix and p.suffix.lower() not in allowed_exts: + continue hits = file_has_secret_pattern(p) for h in hits: failures.append(f"Potential secret pattern in tracked file: {rel} ({h})") diff --git a/scripts/smoke_test.py b/scripts/smoke_test.py index da3f65fa..e04467c9 100755 --- a/scripts/smoke_test.py +++ b/scripts/smoke_test.py @@ -2,86 +2,130 @@ """Star Office UI smoke test (non-destructive). Usage: - python3 scripts/smoke_test.py --base-url http://127.0.0.1:19000 + python3 scripts/smoke_test.py --base-url http://127.0.0.1:19000 --timeout 8 --retries 5 --skip-set-state Optional env: SMOKE_AUTH_BEARER=xxxx # if your gateway/proxy requires bearer auth """ -from __future__ import annotations - -import argparse import json import os import sys +import time +from typing import Optional +import argparse import urllib.error import urllib.request +from datetime import datetime + +# Simple ANSI colors +GREEN = "\033[92m" +RED = "\033[91m" +YELLOW = "\033[93m" +RESET = "\033[0m" +if os.name == "nt": # Windows might not support ANSI by default in older shells + GREEN = RED = YELLOW = RESET = "" -REQUIRED_ENDPOINTS = [ - ("GET", "/", 200), - ("GET", "/health", 200), - ("GET", "/status", 200), - ("GET", "/agents", 200), - ("GET", "/yesterday-memo", 200), -] +def log_ok(msg): + print(f"{GREEN}[OK]{RESET} {msg}") +def log_fail(msg): + print(f"{RED}[FAIL]{RESET} {msg}") -def req(method: str, url: str, body: dict | None = None, token: str = "") -> tuple[int, str]: - data = None +def log_warn(msg): + print(f"{YELLOW}[WARN]{RESET} {msg}") + +def call_api( + url: str, + method: str = "GET", + body: Optional[dict] = None, + bearer: Optional[str] = None, + timeout: int = 10 +): headers = {} - if token: - headers["Authorization"] = f"Bearer {token}" - if body is not None: - data = json.dumps(body).encode("utf-8") + if bearer: + headers["Authorization"] = f"Bearer {bearer}" + if body: headers["Content-Type"] = "application/json" - r = urllib.request.Request(url=url, method=method, data=data, headers=headers) + data = json.dumps(body).encode("utf-8") if body else None + req = urllib.request.Request(url, data=data, headers=headers, method=method) try: - with urllib.request.urlopen(r, timeout=8) as resp: - raw = resp.read().decode("utf-8", errors="ignore") - return resp.status, raw + with urllib.request.urlopen(req, timeout=timeout) as response: + return response.getcode(), json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as e: - raw = e.read().decode("utf-8", errors="ignore") if hasattr(e, "read") else str(e) - return e.code, raw + try: + return e.code, json.loads(e.read().decode("utf-8")) + except Exception: + return e.code, {"msg": str(e)} except Exception as e: - return 0, str(e) - - -def main() -> int: - ap = argparse.ArgumentParser() - ap.add_argument("--base-url", default="http://127.0.0.1:19000", help="Base URL of Star Office UI service") - args = ap.parse_args() - - base = args.base_url.rstrip("/") - token = os.getenv("SMOKE_AUTH_BEARER", "").strip() - - failures: list[str] = [] - print(f"[smoke] base={base}") - - for method, path, expected in REQUIRED_ENDPOINTS: - code, body = req(method, base + path, token=token) - if code != expected: - failures.append(f"{method} {path}: expected {expected}, got {code}, body={body[:200]}") + return 0, {"msg": str(e)} + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--base-url", default="http://127.0.0.1:19000", help="Base URL of Star Office UI") + parser.add_argument("--timeout", type=int, default=10, help="Timeout for API calls") + parser.add_argument("--retries", type=int, default=3, help="Number of retries for API calls") + parser.add_argument("--skip-set-state", action="store_true", help="Skip state-setting tests (accepted for compatibility)") + args = parser.parse_args() + + base_url = args.base_url.rstrip("/") + bearer = os.environ.get("SMOKE_AUTH_BEARER") + + print(f"--- Star Office UI Smoke Test ---") + print(f"Target: {base_url}") + print(f"Time: {datetime.now().isoformat()}\n") + + # 1. /health (basic existence) with retries + success = False + for i in range(args.retries): + code, data = call_api(f"{base_url}/health", timeout=args.timeout) + if code == 200: + log_ok(f"/health (backend version: {data.get('version', 'unknown')})") + success = True + break else: - print(f" OK {method} {path} -> {code}") + log_warn(f"/health attempt {i+1} failed (code={code}). Retrying...") + time.sleep(2) + + if not success: + log_fail(f"/health failed after {args.retries} attempts.") + sys.exit(1) + + # 2. /status (state consistency) + code, data = call_api(f"{base_url}/status", timeout=args.timeout) + if code == 200 and "state" in data: + log_ok(f"/status (current state: {data['state']})") + else: + log_fail(f"/status (code={code})") - # non-destructive state update probe - code, body = req("POST", base + "/set_state", {"state": "idle", "detail": "smoke-check"}, token=token) - if code != 200: - failures.append(f"POST /set_state failed: {code}, body={body[:200]}") + # 3. /agents (multi-agent registry) + code, data = call_api(f"{base_url}/agents", timeout=args.timeout) + if code == 200 and isinstance(data, list): + log_ok(f"/agents (count: {len(data)})") else: - print(" OK POST /set_state -> 200") + log_fail(f"/agents (code={code})") - if failures: - print("\n[smoke] FAIL") - for f in failures: - print(" -", f) - return 1 + # 4. /memo (yesterday memo) + code, data = call_api(f"{base_url}/memo", timeout=args.timeout) + if code == 200: + log_ok(f"/memo (available: {data.get('ok', False)})") + else: + log_warn(f"/memo returned {code} (expected if no memory files found)") - print("\n[smoke] PASS") - return 0 + # 5. /static/phaser-3.80.1.min.js (asset check) + try: + req = urllib.request.Request(f"{base_url}/static/phaser-3.80.1.min.js") + with urllib.request.urlopen(req, timeout=args.timeout) as response: + if response.getcode() == 200: + log_ok(f"Frontend assets reachable (phaser.js)") + else: + log_fail(f"Frontend assets returned {response.getcode()}") + except Exception as e: + log_fail(f"Frontend assets unreachable: {e}") + print("\n--- Smoke test finished ---") if __name__ == "__main__": - sys.exit(main()) + main() diff --git a/webp_to_spritesheet.py b/webp_to_spritesheet.py index 65a31e75..3d3a495c 100644 --- a/webp_to_spritesheet.py +++ b/webp_to_spritesheet.py @@ -1,48 +1,49 @@ -#!/usr/bin/env python3 -"""Convert an animated WebP to a horizontal spritesheet PNG. - -Notes: -- Phaser's built-in loader doesn't support animated WebP directly. -- We convert frames into a spritesheet. -- Output: -spritesheet.png -""" - -import os -from PIL import Image - - -def webp_to_spritesheet(in_path: str, out_path: str, frame_w: int, frame_h: int, max_frames: int | None = None): - im = Image.open(in_path) - n = getattr(im, 'n_frames', 1) - if max_frames: - n = min(n, max_frames) - - sheet = Image.new('RGBA', (frame_w * n, frame_h), (0, 0, 0, 0)) - - for i in range(n): - im.seek(i) - fr = im.convert('RGBA') - if fr.size != (frame_w, frame_h): - fr = fr.resize((frame_w, frame_h), Image.NEAREST) - sheet.paste(fr, (i * frame_w, 0)) - - sheet.save(out_path) - return n - - -def main(): - import argparse - ap = argparse.ArgumentParser() - ap.add_argument('in_path') - ap.add_argument('out_path') - ap.add_argument('--w', type=int, required=True) - ap.add_argument('--h', type=int, required=True) - ap.add_argument('--max', type=int, default=None) - args = ap.parse_args() - - n = webp_to_spritesheet(args.in_path, args.out_path, args.w, args.h, args.max) - print(f"Wrote {args.out_path} with {n} frames") - - -if __name__ == '__main__': - main() +#!/usr/bin/env python3 +"""Convert an animated WebP to a horizontal spritesheet PNG. + +Notes: +- Phaser's built-in loader doesn't support animated WebP directly. +- We convert frames into a spritesheet. +- Output: -spritesheet.png +""" + +import os +from typing import Optional +from PIL import Image + + +def webp_to_spritesheet(in_path: str, out_path: str, frame_w: int, frame_h: int, max_frames: Optional[int] = None): + im = Image.open(in_path) + n = getattr(im, 'n_frames', 1) + if max_frames: + n = min(n, max_frames) + + sheet = Image.new('RGBA', (frame_w * n, frame_h), (0, 0, 0, 0)) + + for i in range(n): + im.seek(i) + fr = im.convert('RGBA') + if fr.size != (frame_w, frame_h): + fr = fr.resize((frame_w, frame_h), Image.NEAREST) + sheet.paste(fr, (i * frame_w, 0)) + + sheet.save(out_path) + return n + + +def main(): + import argparse + ap = argparse.ArgumentParser() + ap.add_argument('in_path') + ap.add_argument('out_path') + ap.add_argument('--w', type=int, required=True) + ap.add_argument('--h', type=int, required=True) + ap.add_argument('--max', type=int, default=None) + args = ap.parse_args() + + n = webp_to_spritesheet(args.in_path, args.out_path, args.w, args.h, args.max) + print(f"Wrote {args.out_path} with {n} frames") + + +if __name__ == '__main__': + main()