Compare commits

...

170 commits

Author SHA1 Message Date
ringhyacinth
f29c107e97
Update README.ja.md 2026-03-11 11:53:42 +08:00
ringhyacinth
915d7355ef
Update README.en.md 2026-03-11 11:53:20 +08:00
ringhyacinth
383e1c84b0
Update README.md 2026-03-11 11:52:52 +08:00
ringhyacinth
dffc868920
Merge pull request #79 from hanshou101/fix/load-dotenv-run-sh
fix: load .env in backend/run.sh
2026-03-11 01:56:31 +08:00
Star
d2e7af1957 merge: pr #70 and #79 after local testing 2026-03-11 01:56:21 +08:00
ycw_vm
88160bd755 Load project .env from backend run script 2026-03-11 01:35:04 +08:00
Star
f321ee6f52 Merge branch 'feature/office-name-from-identity' into review/pr70-pr79-combined 2026-03-11 01:34:55 +08:00
ringhyacinth
4c356dc376
Update README.md 2026-03-10 20:04:34 +08:00
ycw_vm
fb67ead389 Load project .env from backend run script 2026-03-10 19:46:12 +08:00
Star
c0b13ab047 docs: add Python 3.10+ minimum version requirement (fixes #77) 2026-03-10 17:27:21 +08:00
parsifal-rui
e281a1909b feat: 办公室名称从 OpenClaw IDENTITY.md 的 Name 读取,牌匾支持自动换行 2026-03-09 11:06:47 +08:00
Star
171051af4f docs: revert to Chinese as default README
阿文说得对:作为中国开发者要自信一点 💪
2026-03-07 11:47:53 +08:00
Star
83cd40533c docs: switch default README to English
- README.md is now English (was README.en.md)
- README.zh.md is now Chinese (was README.md)
- Updated all cross-language links in README.md, README.zh.md, README.ja.md
- No content changes, only file rename and link updates
2026-03-07 11:41:37 +08:00
Star
7c73340bd5 docs: add project maintenance SOP 2026-03-07 05:00:18 +08:00
Star
171d4495d8 docs: credit Zhaohan-Wang for desktop pet module 2026-03-06 19:54:09 +08:00
Star
e9a538298d docs: credit community contributors in README 2026-03-06 19:52:59 +08:00
Star
61a5d5f101 fix: correct OpenClaw URL to GitHub repo 2026-03-06 17:52:55 +08:00
Star
e7e08b54e4 docs: restructure README — dual quick-start paths, audience section, community co-maintenance 2026-03-06 17:51:24 +08:00
Star
bb02d21e88 docs: sort changelog entries in reverse chronological order 2026-03-06 17:18:59 +08:00
Star
0f6a224fc2 change default port to 19000 2026-03-06 17:11:35 +08:00
ringhyacinth
e5f3fc9247
Merge pull request #64 from liaoandi/fix/guest-agent-overlap
fix: prevent guest agents from overlapping when >3 in same area
2026-03-06 16:13:54 +08:00
liaoandi
6da1dc9689 fix: prevent guest agents from overlapping when >3 in same area
- Expand area positions from 3 to 8 per zone (breakroom/writing/error)
  in both game.js (AREA_POSITIONS) and index.html (getAreaPoint)
- Replace global areaPositionCounters with per-render slot index
  (_slotIndex) to ensure consistent position assignment across
  poll cycles

Previously, agents in the same area would overlap after the 3rd one
because positions cycled via modulo. The counter-based approach also
caused misalignment when existing agents skipped position increments.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-06 14:58:51 +08:00
ringhyacinth
8ac5b51c3f
Merge pull request #61 from Jah-yee/fix/issue-54-unauthorized
fix: avoid 401 Unauthorized on first page load (fixes #54)
2026-03-06 14:13:26 +08:00
ringhyacinth
a139bb05b6
Merge pull request #62 from Jah-yee/fix/issue-31-port-and-state-hints
fix: add startup hints for port and state source (fixes #31)
2026-03-06 14:11:32 +08:00
ringhyacinth
a062ae3efe
Merge pull request #60 from Jah-yee/docs-utils-english-docstrings
docs(backend): add English docstrings to memo, store, security utils
2026-03-06 14:09:24 +08:00
ringhyacinth
6f9187ef7f
Merge pull request #59 from Jah-yee/refactor-valid-agent-states-constant
refactor(backend): single source of truth for valid agent states
2026-03-06 14:09:01 +08:00
ringhyacinth
9d074036f2
Merge pull request #58 from Jah-yee/feat-health-service-field
feat(health): add service field for monitoring
2026-03-06 14:08:40 +08:00
ringhyacinth
ce821f78be
Merge pull request #57 from Jah-yee/docs-readme-onboarding
docs(readme): add env and smoke test hints
2026-03-06 14:07:32 +08:00
ringhyacinth
eee8f7dc48
Merge pull request #56 from Jah-yee/docs-join-keys-alignment
docs(join-keys): align docs with runtime sample file
2026-03-06 14:07:16 +08:00
ringhyacinth
8a9557f3ad
Merge pull request #63 from Jah-yee/fix/issue-40-set-state-docstring
docs: add SOUL.md auto-sync hint in set_state.py (fixes #40)
2026-03-06 14:04:41 +08:00
RoomWithOutRoof
fb5efd4815 docs: add SOUL.md auto-sync hint in set_state.py (fixes #40)
Made-with: Cursor
2026-03-06 02:34:57 +08:00
RoomWithOutRoof
51172ee202 fix: add startup hints for port and state source (fixes #31)
- Backend: print STAR_BACKEND_PORT hint so users can change port without
  editing app.py (e.g. when 18791 is already in use).
- office-agent-push.py: print state file source and LOCAL_STATUS_URL at
  startup so users can verify path and URL when state does not update.

Made-with: Cursor
2026-03-06 02:34:00 +08:00
RoomWithOutRoof
8569245b29 fix: avoid 401 Unauthorized on first page load (fixes #54)
- Defer applySavedPositionOverrides() until user is authed: check
  /assets/auth/status first and only call it when authed, so new
  visitors never trigger /assets/positions and /assets/defaults (which
  return 401 when not logged in).
- When opening the asset drawer and already authed, call
  applySavedPositionOverrides() so saved layout is still applied.
- Same logic in both index.html and electron-standalone.html.

Made-with: Cursor
2026-03-06 02:32:21 +08:00
RoomWithOutRoof
a89008241f docs(backend): add English docstrings to memo, store, security utils
Made-with: Cursor
2026-03-06 02:18:01 +08:00
RoomWithOutRoof
87e473e108 refactor(backend): single source of truth for valid agent states
Made-with: Cursor
2026-03-06 02:17:11 +08:00
RoomWithOutRoof
82807581ef feat(health): add service field for monitoring
Made-with: Cursor
2026-03-06 02:04:55 +08:00
RoomWithOutRoof
0a93306958 docs(readme): add env and smoke test hints
Made-with: Cursor
2026-03-06 02:04:10 +08:00
RoomWithOutRoof
de1fed43a3 docs(join-keys): align docs with runtime sample file
Made-with: Cursor
2026-03-06 02:02:28 +08:00
Star
8d36ad5267 feat: bundle gemini image generate script + update SKILL.md install guide
- Add scripts/gemini_image_generate.py to repo (was only in local skill dir)
- Update SKILL.md section 4: add step-by-step setup for image generation env
- No changes to app.py or any existing logic; script is a standalone addition
- Users can now set up image generation by following SKILL.md instructions
2026-03-06 01:27:30 +08:00
Star
e03082629a docs: restructure README for all 3 languages (zh/en/ja)
- Merge feature list + refresh notes into unified feature overview
- Add 'For OpenClaw Users' section (Skill, auto-sync, invite guests)
- Add Desktop Pet section
- Merge art assets + license sections
- Compress changelog into concise update log with doc links
2026-03-06 00:04:52 +08:00
Star
5338bad92d docs: sync 2026-03-05 update notes to EN and JA READMEs
Add section G (stability fixes, async image gen, mobile drawer,
join key expiration) to README.en.md and README.ja.md, matching
the Chinese README.
2026-03-05 23:52:09 +08:00
Star
ad9b1fbc31 Merge branch 'chore-office-agent-push-paths' 2026-03-05 23:29:53 +08:00
Star
f8a06be7f7 Merge branch 'feat-debug-agent-gate' 2026-03-05 23:29:49 +08:00
Star
d9fe21efd2 docs: add 2026-03-05 update report, update README and SKILL.md
- Add detailed update report for all 8 commits (CDN fix, fetchStatus fix,
  async generation, mobile drawer, join key expiration)
- Update README section 9.G with changelog summary
- Update SKILL.md section 10.7 with stability fixes
2026-03-05 22:45:33 +08:00
RoomWithOutRoof
6dc73b91e7
Merge branch 'master' into chore-office-agent-push-paths 2026-03-05 20:13:37 +08:00
Star
02a731e8af feat: key-level expiration + activity key ocj_starteam02 (100 concurrent, expires EOD) 2026-03-05 18:47:28 +08:00
Star
5aef4302b9 fix: hide drawer completely off-screen on mobile (right: -100vw) 2026-03-05 18:31:55 +08:00
Star
899f27e19f fix: mobile/iPad drawer sidebar - add backdrop overlay, fix body scroll lock, use 100dvh 2026-03-05 18:29:14 +08:00
Star
83e61ff8bf chore: add join-keys.json to gitignore (runtime data) 2026-03-05 17:34:06 +08:00
Star
ee141de7cc chore: clean up accidental files from local testing 2026-03-05 17:33:56 +08:00
Star
103f944cf0 fix: async background generation to avoid Cloudflare 524 timeout
- POST /assets/generate-rpg-background now returns immediately with a
  task_id, runs generation in a background thread
- Added GET /assets/generate-rpg-background/poll?task_id=xxx for
  frontend to poll completion status
- Frontend polls every 3s with elapsed time display
- Prevents Cloudflare's 100s timeout from killing the connection
- Also extracted shared error handling into _handleGenError()
2026-03-05 17:29:34 +08:00
Star
cc22403046 fix: remove duplicate else block causing SyntaxError in fetchStatus
The fetchStatus function had a duplicated else branch that broke the
try/catch structure, resulting in 'Missing catch or finally after try'
error which prevented the entire page from loading.
2026-03-05 17:04:55 +08:00
Star
878793d74b fix: prevent CDN from caching 404 responses for static assets
- add_no_cache_headers now only applies long-cache headers to 2xx responses
- non-2xx static responses (e.g. 404) get no-cache to prevent CDN from
  caching error pages (which was the root cause of office.hyacinth.im
  failing to load - Cloudflare cached a 404 for phaser.js for 2.7 days)
- add version cache-bust param to phaser.js script tag (matching all
  other static assets)
2026-03-05 17:00:26 +08:00
ringhyacinth
53d33d6cdf
Update README.md 2026-03-04 22:38:53 +08:00
ringhyacinth
038f9d1647
Update README.md 2026-03-04 22:38:15 +08:00
simonxxooxxoo
56b0abe367
Update README.en.md 2026-03-04 21:58:18 +08:00
simonxxooxxoo
40235a32aa
Update README.ja.md 2026-03-04 21:57:57 +08:00
simonxxooxxoo
b000b9bb08
Update README.md 2026-03-04 21:57:02 +08:00
simonxxooxxoo
7bd44d8119
Merge pull request #35 from simonxxooxxoo/resolve/pr34
Resolve/pr34
2026-03-04 21:54:35 +08:00
OpenClaw Assistant
2795c77796 merge(pr34): resolve conflicts in .gitignore backend/app.py frontend/index.html 2026-03-04 21:00:24 +08:00
ringhyacinth
bae7bd3408
Merge pull request #33 from simonxxooxxoo/feat/office-art-rebuild
chore: P0/P1 security hardening, stability fixes, and status-sync/perf improvements
2026-03-04 19:24:17 +08:00
OpenClaw Assistant
b5ede6c939 docs(readme-i18n): apply user-provided CN/EN/JA readmes and append 2026-03-04 P0/P1 update section 2026-03-04 18:45:41 +08:00
OpenClaw Assistant
4aa8ad420e merge: resolve README conflict with current branch update section 2026-03-04 18:41:02 +08:00
Zhaohan Wang
e657fb9a62 chore(assets): remove unused iOS icon set files
Delete obsolete iOS AppIcon assets that are no longer needed for the current Electron-focused packaging flow.

Made-with: Cursor
2026-03-04 18:30:06 +08:00
Zhaohan Wang
dcb65f3f5d chore(assets): remove unused app icon source and android launchers
Delete unused desktop app icon source images and obsolete Android mipmap launcher icons while keeping only the required icon files.

Made-with: Cursor
2026-03-04 18:26:40 +08:00
Zhaohan Wang
edab08dfbb chore(repo): remove obsolete layers and legacy logo assets
Drop leftover non-Electron asset packs and old logo variants, and add ignore rules so these files are not reintroduced in future commits.

Made-with: Cursor
2026-03-04 18:20:33 +08:00
Zhaohan Wang
9f44e4e0e9 fix(electron): make backend host and port configurable
Avoid hardcoded 127.0.0.1:18791 by reading STAR_BACKEND_HOST/STAR_BACKEND_PORT in Electron and backend startup, so different local setups can run without code changes.

Made-with: Cursor
2026-03-04 18:13:37 +08:00
OpenClaw Assistant
cdb3eb544c docs(readme): add 2026-03-04 P0/P1 update summary section 2026-03-04 17:48:47 +08:00
OpenClaw Assistant
f230ade809 docs(update): add P0/P1 update report for 2026-03-04 2026-03-04 17:45:11 +08:00
Zhaohan Wang
19e82bb405 chore(electron): update app branding icon assets and ignore local artifacts
Replace desktop icon assets with the provided logo, align Electron app naming to Star Office UI, and refresh .gitignore to exclude local runtime/build outputs.

Made-with: Cursor
2026-03-04 17:45:05 +08:00
Zhaohan Wang
e888dc07b8 fix(electron): align standalone asset-drawer behavior with web flow
Trim intrusive Electron-specific fallback logic, keep asset interactions hot-updated across windows, and ensure favorites refresh immediately after auth to match browser behavior.

Made-with: Cursor
2026-03-04 17:10:19 +08:00
OpenClaw Assistant
5ea06a2d06 fix(status-sync): force visual state reconciliation on every poll and reduce status polling interval 2026-03-04 16:43:44 +08:00
OpenClaw Assistant
627b29025a ux(loading): add visible canvas skeleton and faster overlay fade to avoid black-screen feel 2026-03-04 16:39:28 +08:00
OpenClaw Assistant
06c1dad038 perf: speed up first paint by caching index html and deferring non-critical init fetches 2026-03-04 16:30:14 +08:00
OpenClaw Assistant
9957e81c13 perf(index): disable per-page random bg copy by default and add throttle guard 2026-03-04 15:34:32 +08:00
Zhaohan Wang
21387da895 feat(electron): add standalone desktop UI with editable office plaque
Rebuild the Electron standalone interface with synchronized i18n/state behavior, improved mini-window sprite rendering, and DOM-based office plaque editing for better readability and customization.

Made-with: Cursor
2026-03-04 15:00:30 +08:00
OpenClaw Assistant
823646cb26 fix(image-gen): hard-lock to official nanobanana-pro/2 mapping and model-specific aspect-ratio handling 2026-03-04 14:59:17 +08:00
OpenClaw Assistant
a2e32e292a fix(gen-errors): surface model-unavailable detail for debugging exact provider failure 2026-03-04 14:57:26 +08:00
OpenClaw Assistant
8a3cc03ef8 fix(image-model): remove gemini-2.0 fallback incompatible with aspect_ratio 2026-03-04 14:49:53 +08:00
OpenClaw Assistant
fbe71a3fdc fix(image-model): enforce nanobanana-pro/2 aliases and map to provider-supported model ids 2026-03-04 14:41:59 +08:00
OpenClaw Assistant
c44d6a4547 fix(image-gen): add multi-model fallback to avoid channel-specific model unavailability 2026-03-04 14:36:56 +08:00
OpenClaw Assistant
be95dab467 refactor(backend): extract storage helpers without behavior changes 2026-03-04 14:21:20 +08:00
OpenClaw Assistant
4fcfc677aa fix(status-sync): prioritize correct workspace state path and add stale-state auto-idle 2026-03-04 14:11:55 +08:00
OpenClaw Assistant
112fbcf9e4 refactor(backend): extract memo helpers without behavior changes 2026-03-04 14:05:10 +08:00
OpenClaw Assistant
98ac66cd16 refactor(backend): extract security helpers without behavior changes 2026-03-04 13:44:50 +08:00
OpenClaw Assistant
2ef00ce8c7 test: add non-destructive smoke test script for endpoint checks 2026-03-04 13:37:26 +08:00
OpenClaw Assistant
0b322aa4e1 chore(security): add preflight checks and safe config templates 2026-03-04 13:34:05 +08:00
OpenClaw Assistant
35067c5b8b chore(security): harden P0 defaults and production checks 2026-03-04 13:21:54 +08:00
Zhaohan Wang
9f8c909c9e Merge remote-tracking branch 'upstream/master'
Made-with: Cursor

# Conflicts:
#	.gitignore
#	frontend/index.html
#	set_state.py
2026-03-04 11:31:02 +08:00
RoomWithOutRoof
f8e8d1072f chore(cli): make office-agent-push workspace-agnostic
Made-with: Cursor
2026-03-04 04:50:12 +08:00
RoomWithOutRoof
4994d22285 chore(frontend): gate demo agent behind debug flag
Made-with: Cursor
2026-03-04 04:46:41 +08:00
OpenClaw Assistant
f6a30821ab style(button-align): vertically center top action rows and enforce consistent section height 2026-03-04 01:48:29 +08:00
OpenClaw Assistant
fd25e8009a style(spacing): tighten vertical paddings in move/home sections and saved-homes header to remove excessive gaps 2026-03-04 01:47:48 +08:00
OpenClaw Assistant
91ecba23d8 feat(home-favorites): randomly apply one saved home map on each index open 2026-03-04 01:34:09 +08:00
ringhyacinth
feac8ce057
Update README.en.md 2026-03-04 01:01:09 +08:00
ringhyacinth
ac9af695e3
Update README.md 2026-03-04 01:00:34 +08:00
ringhyacinth
d0f5880558
Update README.ja.md 2026-03-04 00:59:57 +08:00
ringhyacinth
1f824c18a2
Add files via upload 2026-03-04 00:58:38 +08:00
OpenClaw Assistant
3369530b50 Revert "fix(drawer-height): make sidebar fully viewport-adaptive and keep manual list scrolling within available height"
This reverts commit 91aad3fa2a.
2026-03-04 00:57:12 +08:00
OpenClaw Assistant
91aad3fa2a fix(drawer-height): make sidebar fully viewport-adaptive and keep manual list scrolling within available height 2026-03-04 00:56:00 +08:00
OpenClaw Assistant
b4d0a1d0cc style(button-grid): align top four and lower two actions to equal-width 2x2 grids and center top section vertically 2026-03-04 00:54:25 +08:00
OpenClaw Assistant
8a84151643 refactor(upload-panel): restore sticky in-drawer anchors (left/right 8px) and extend asset list layout to viewport bottom 2026-03-04 00:51:06 +08:00
OpenClaw Assistant
8aed9ab7d8 fix(upload-panel): lock panel width to drawer-safe 304px and prevent left-edge clipping 2026-03-04 00:48:01 +08:00
ringhyacinth
b3312def80
Update README.md 2026-03-04 00:47:19 +08:00
OpenClaw Assistant
35014fb6da style(asset-sections): remove panel background fills and improve saved-homes title spacing/size between dividers 2026-03-04 00:46:12 +08:00
OpenClaw Assistant
b89bccb775 fix(drawer-position): switch drawer to right-offset animation so fixed uploader stays pinned to viewport bottom-right 2026-03-04 00:43:52 +08:00
OpenClaw Assistant
c2e772c45c fix(ui-layer): keep language toggle above room loading overlay 2026-03-04 00:42:48 +08:00
OpenClaw Assistant
868e9ee0f8 fix(upload-panel): constrain fixed uploader to drawer viewport (left+right anchors) to avoid clipping 2026-03-04 00:41:12 +08:00
OpenClaw Assistant
78ea4c5692 fix(layout): keep main stage parallel to drawer with guaranteed 20px gap on wide screens 2026-03-04 00:39:34 +08:00
OpenClaw Assistant
d4daba8cef fix(upload-panel): anchor to viewport bottom-right on resize and enforce dark adaptive action buttons 2026-03-04 00:36:04 +08:00
OpenClaw Assistant
14ed0fc7a5 style(panels): tune decorative line color to #1a1b2f 2026-03-04 00:34:05 +08:00
OpenClaw Assistant
0fe0f159c7 style(panels): change decorative line color from #8fa0bc to #1b192e 2026-03-04 00:31:05 +08:00
OpenClaw Assistant
258da2104c fix(upload-panel): fit fixed bottom-right panel to drawer viewport width to prevent clipping 2026-03-04 00:23:27 +08:00
OpenClaw Assistant
e3600ba2f8 ui(upload-panel): pin uploader to bottom-right and switch four actions to 2x2 compact grid 2026-03-04 00:20:46 +08:00
OpenClaw Assistant
81cd7c679b feat(home-favorites-ui): default-collapsed saved homes, add delete action, and switch top sections to divider-only layout 2026-03-04 00:15:09 +08:00
OpenClaw Assistant
45179e66cb style(asset-drawer): replace nested boxed sections with cleaner divider-based layout 2026-03-04 00:05:05 +08:00
OpenClaw Assistant
985631aaba style(asset-drawer): reduce border noise and soften nested panels for cleaner UI 2026-03-03 23:34:39 +08:00
simonxxooxxoo
6d08cb1390
Merge pull request #26 from simonxxooxxoo/feat/office-art-rebuild
docs(readme): add two new cover images at the top
2026-03-03 23:22:42 +08:00
OpenClaw Assistant
87508b5797 docs(readme): remove old preview image link and align zh/en top cover images 2026-03-03 23:19:39 +08:00
OpenClaw Assistant
886f2e60ac docs(readme): add two new cover images at the top 2026-03-03 23:15:18 +08:00
ringhyacinth
2a1f53d45e
Merge pull request #25 from ringhyacinth/release/office-rebuild-v2
Promote V2 as new master
2026-03-03 23:05:48 +08:00
ringhyacinth
21402e7ce6
Merge pull request #24 from simonxxooxxoo/feat/office-art-rebuild
feat: office rebuild v2 - home favorites, rollback UX, asset visibility polish
2026-03-03 22:48:16 +08:00
OpenClaw Assistant
6605d1d9a2 style(gemini-link): improve API docs link contrast on dark background 2026-03-03 22:37:36 +08:00
OpenClaw Assistant
87edb73027 feat(gemini-help): add API key docs link and localize input/save labels; default model nanobanana-pro 2026-03-03 22:11:03 +08:00
OpenClaw Assistant
4fe21ff515 fix(home-favorites): guard non-JSON responses and show actionable error details 2026-03-03 22:09:23 +08:00
OpenClaw Assistant
df5bd3f631 ui(home-actions): rename to 回上一个家 and align dual buttons with favorites panel width 2026-03-03 22:03:43 +08:00
OpenClaw Assistant
450b6a8356 chore(asset-list): push button-related assets to the end of drawer list 2026-03-03 22:02:56 +08:00
OpenClaw Assistant
749e4ee6e1 refactor(home-actions-ui): move last/favorite controls and favorites gallery outside move/broker panel 2026-03-03 22:01:44 +08:00
OpenClaw Assistant
1289a5d5ef style(asset-visibility): use emoji-only toggle icons 👀/🙈 2026-03-03 21:59:27 +08:00
OpenClaw Assistant
ab95ae8028 feat(home-favorites): add save/apply/list favorite homes and drawer gallery UI 2026-03-03 21:58:50 +08:00
OpenClaw Assistant
d0981fec27 feat(asset-list): per-item visibility toggle and restore decorate/close button labels 2026-03-03 21:54:55 +08:00
OpenClaw Assistant
952638d879 feat(asset-editor): restore drawer visibility toggle and add per-asset default/prev rollback actions 2026-03-03 21:44:35 +08:00
OpenClaw Assistant
c19d4acafa fix(gemini-default): align default model to nanobanana-pro in runtime config endpoints 2026-03-03 21:37:59 +08:00
OpenClaw Assistant
5c27c66287 feat(bg-recovery): add restore-last-generated endpoint and sidebar rollback button 2026-03-03 21:18:18 +08:00
OpenClaw Assistant
6cb77af51a fix(restore-home): add confirmation guard and include Pillow dependency 2026-03-03 21:13:38 +08:00
Zhaohan Wang
df39c93816 chore: align Electron app naming and window titles
Set a consistent Star Desktop Pet app identity in the Electron shell by updating product name metadata, app name, window titles, and tray tooltip labels.

Made-with: Cursor
2026-03-03 20:45:32 +08:00
OpenClaw Assistant
29edc8a076 fix(bg-refresh): map office background by object reference after live texture swap 2026-03-03 20:31:19 +08:00
ringhyacinth
22e9bcc91f
Merge pull request #23 from simonxxooxxoo/feat/office-art-rebuild
Feat/office art rebuild
2026-03-03 19:48:23 +08:00
OpenClaw Assistant
388380e008 chore(assets): include finalized default home map (office_bg_small.webp) 2026-03-03 19:43:38 +08:00
OpenClaw Assistant
9fc5389486 docs+product: co-creator attribution, passcode guidance, and proactive status flow updates 2026-03-03 19:18:38 +08:00
OpenClaw Assistant
971ab702f4 docs+ux: clarify default pass guidance, proactive status flow, and unify sidebar emojis across locales 2026-03-03 19:08:36 +08:00
OpenClaw Assistant
c604e80a3d chore(idle-asset): replace star idle with v5 and remove v4 2026-03-03 18:29:58 +08:00
Zhaohan Wang
999b3445c2 feat: refine Electron window controls and app icon usage
Improve the Electron shell by polishing mac-like custom traffic control visuals and reusing project icon assets for dock and tray so desktop behavior and branding stay consistent.

Made-with: Cursor
2026-03-03 18:28:25 +08:00
Zhaohan Wang
c0fefa0e6a feat: add Electron desktop shell with fixed mode-based window sizing
Introduce an Electron-based desktop wrapper with tray support and Tauri-compatible preload APIs, plus deterministic collapsed/expanded window height switching and drag behavior fixes for both main and mini windows.

Made-with: Cursor
2026-03-03 18:13:03 +08:00
OpenClaw Assistant
dcf1876a37 feat(office): complete art rebuild with new asset index and UI polish 2026-03-03 18:08:59 +08:00
Zhaohan Wang
9068c42e0b fix: restore packaged mini status sync and fallback reads
Ensure the bundled app can resolve the project root reliably and keep mini-window status updates working by falling back to backend /status when file-based state reads are unavailable.

Made-with: Cursor
2026-03-03 17:03:16 +08:00
Zhaohan Wang
3a7d357253 feat: polish desktop shell interactions and refresh app icons
Improve desktop usability by adding native-like window controls, fixing mini-window state sync to avoid repeated transition playback, and regenerating Tauri app icons from the new Star logo asset.

Made-with: Cursor
2026-03-03 16:49:11 +08:00
Zhaohan Wang
1996365dc8 feat: add Tauri desktop shell with dynamic panel layout and style system
Integrate Star Office UI into a transparent Tauri desktop experience with auto-start backend, dynamic window sizing on panel collapse/expand, and unified desktop-specific UI styling.

Made-with: Cursor
2026-03-03 15:36:25 +08:00
Zhaohan Wang
efb432409f Merge remote; resolve conflicts by accepting incoming changes (README, backend/app.py, frontend/index.html)
Made-with: Cursor
2026-03-03 14:14:37 +08:00
ringhyacinth
01e6c3fa7e
Merge pull request #19 from ringhyacinth/revert-9-fix/code-review-issues
Revert "fix: resolve hardcoded paths, port mismatch, XSS, and UX issues"
2026-03-03 07:45:28 +08:00
ringhyacinth
cf76f8556a
Revert "fix: resolve hardcoded paths, port mismatch, XSS, and UX issues" 2026-03-03 07:44:48 +08:00
ringhyacinth
474811083b
Merge pull request #9 from kckylechen1/fix/code-review-issues
fix: resolve hardcoded paths, port mismatch, XSS, and UX issues
Thanks — I reviewed PR #9 and the fixes look good to me (path portability, port consistency, and related stability issues).
I’m approving and merging this as our stability baseline.
After merge, we’ll run a quick smoke test in production.
2026-03-03 02:02:40 +08:00
Kyle Chen
24d3b6d45e perf: make GET /agents read-only when no state changes
Previously every poll (3.5s per client) unconditionally wrote both
agents-state.json and join-keys.json. Now tracks a dirty flag and
only persists when cleanup actually modifies state (expired agents
removed or agents marked offline).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 13:10:41 +08:00
Kyle Chen
63d7b9c9fb fix: prevent XSS in memo content and guest agent names
Backend: add html.escape() to clean_text() so user-controlled input
(agent names, details) is entity-escaped before storage.

Frontend: memo content now uses textContent instead of innerHTML
(newlines handled by CSS white-space:pre-wrap). Guest agent names
are escaped before interpolation into template literals to prevent
stored XSS via malicious agent names in onclick handlers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 13:07:17 +08:00
Kyle Chen
3f2671f760 fix: use date-based quote selection instead of random.choice
Each /yesterday-memo request used random.choice for the wisdom quote,
causing the card to flicker on frontend polling. Now uses date-based
index (YYYYMMDD % len) so the same quote shows consistently all day.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 13:05:54 +08:00
Kyle Chen
b2091041d4 fix: sync main agent state to agents-state.json on set_state
Previously /set_state only wrote to state.json, but /agents reads
from agents-state.json. The main agent's status in agents-state.json
was stale. Now save_state() also updates the isMain=true entry in
agents-state.json to keep both files consistent.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 13:05:24 +08:00
Kyle Chen
e6e31e841b fix: resolve port mismatch across docs, scripts, and backend
The backend runs on port 19000 (to avoid conflict with OpenClaw on
18791), but docs and scripts referenced 18791. Unified all references
to 19000 and made the port configurable via OFFICE_PORT env var.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 12:31:06 +08:00
Kyle Chen
084bf4dfaa fix: resolve hardcoded /root paths in CLI and utility scripts
All scripts now use os.path.dirname(os.path.abspath(__file__)) to
dynamically resolve paths relative to their location, consistent
with backend/app.py. Also fixes healthcheck.sh to use --fail and
--max-time for curl, and corrects port references to 19000.

Affected files:
- set_state.py
- healthcheck.sh
- office-agent-push.py
- convert_to_webp.py
- repack_star_working.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 12:29:10 +08:00
Kyle Chen
04733eaed3 feat: add input sanitization, auth, and trace logging to backend
Add clean_text(), HMAC token auth for set_state, same-origin check,
structured logging with trace IDs, and agent state normalization.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 12:28:24 +08:00
ringhyacinth
d7dcc64f8b
Update README.md 2026-03-01 16:13:46 +08:00
Star
4bcdd305e7 docs: add starmie homophone disclaimer; dual license (MIT code / non-commercial art); enhance skill for one-click onboarding 2026-03-01 16:02:27 +08:00
Star
6a5c959206 docs: add 30s quick start and dual-language README (CN first, EN after) 2026-03-01 15:45:34 +08:00
ringhyacinth
7ad3323d40
Update README.md 2026-03-01 15:30:35 +08:00
Star
a68d87af8f docs: clarify features, update skill, add screenshot and asset/non-commercial notices 2026-03-01 15:10:55 +08:00
Star
e3006ca9b3 feat: release latest Star Office UI with multi-agent, memo panel, docs and skill refresh 2026-03-01 15:09:00 +08:00
Zhaohan Wang
b1e9ed912a README: 鸣谢 Cozy Interior (shubibubi) 与 Pixel Art App Icons (Reff Pixels)
Made-with: Cursor
2026-02-27 16:37:34 +08:00
Zhaohan Wang
03b5c5b030 桌宠:状态图标、应用图标、STATE_API、图标包
- 状态图标:map.state_icons 收/发用 Hangouts+Glovo,writing/researching 用 Word+Google,idle/executing/syncing/error 用 emoji;图标呼吸动画,emoji 同款
- 应用图标:三文鱼头像圆角矩形,83% 缩放与 Dock 一致,bundle.icon 配置
- STATE_API.md:openclaw 写 state.json 的 state 取值说明
- layers:Small (24x24) PNG 图标包、map.json state_icons

Made-with: Cursor
2026-02-27 16:35:47 +08:00
Zhaohan Wang
5b3b7d4861 desktop-pet: 桌宠地图与气泡优化
- 地图: 9x6 房间,ground/rug/border/objects 分层,门口留空
- 后端: lib.rs 支持 border 层
- 前端: 渲染 border 层(顶层)、气泡 DOM 左右贴边不裁切、overflow hidden 去滚动条
- 资源: ipix 字体、map.json、tileset.png、interior free/full 素材
- .gitignore: 忽略 layers/tiles_preview/

Made-with: Cursor
2026-02-27 16:03:08 +08:00
Zhaohan Wang
8a113a1447 feat: 透明桌宠 (Tauri) + 多层 PNG 背景 + 角色状态机与四方向动画
- desktop-pet: Tauri 2 透明窗口桌宠,本地读 state.json
- 支持 layers/ 多层带透明通道 PNG,layers.json 配置
- 角色状态机: idle / move_{up,down,left,right} / 特殊行为 (writing, researching 等)
- 精灵动画: sprites 配置段支持 sprite sheet,无配置时用程序化 fallback
- 示例 layers: platform.png, cloud.png + layers.json

Made-with: Cursor
2026-02-27 13:06:32 +08:00
Zhaohan Wang
26a03a4ee8 feat: add receiving/replying states and configurable paths
- Add receiving and replying states for real-time OpenClaw message tracking
- Make ROOT_DIR and STATE_FILE configurable via env vars (STAR_OFFICE_ROOT, STAR_OFFICE_STATE_FILE)
- Replace hardcoded /root/ paths with relative defaults
- Add bubble texts for new states
- Include new states in auto-idle working set

Made-with: Cursor
2026-02-27 12:28:18 +08:00
133 changed files with 32083 additions and 306 deletions

18
.env.example Normal file
View file

@ -0,0 +1,18 @@
# Star Office UI - production environment example
# Copy to .env (or your systemd/pm2 env file), then fill values.
# Mark production mode to enable startup hardening checks
STAR_OFFICE_ENV=production
# Flask/session secret (REQUIRED in production)
# Must be long/random (>=24 chars)
FLASK_SECRET_KEY=replace_with_a_long_random_secret
# Asset drawer password (REQUIRED in production)
# Do NOT use 1234 in production. Recommend >=8 chars.
ASSET_DRAWER_PASS=replace_with_strong_drawer_password
# Optional Gemini runtime defaults
# You can also set these in runtime-config.json via UI
GEMINI_API_KEY=
GEMINI_MODEL=nanobanana-pro

27
.gitignore vendored
View file

@ -1,4 +1,4 @@
# Python
# Python environment
__pycache__/
*.py[cod]
*.pyo
@ -8,11 +8,32 @@ __pycache__/
venv/
.env
# OS
# OS/editor
.DS_Store
# Runtime / local files
# Runtime state (local only)
state.json
agents-state.json
runtime-config.json
*.log
*.out
*.pid
*.backup*
*.original
cloudflared.pid
cloudflared.out
healthcheck.log
backend.log
# Generated / mutable assets (local only)
assets/bg-history/
assets/home-favorites/
frontend/office_bg.png
frontend/*.bak
layers/
desktop-pet/src-tauri/icons/*Logo.png
# Electron local build artifacts
electron-shell/node_modules/
electron-shell/release/
join-keys.json

35
LICENSE
View file

@ -1,6 +1,14 @@
# Star Office UI — License & Usage Notice
This project is a co-created work by **Ring Hyacinth** and **Simon Lee**.
## 1. Code / Logic License (MIT)
The code/logic in this repository (the "Software") is licensed under the MIT License:
MIT License
Copyright (c) 2026 Ring Hyacinth
Copyright (c) 2026 Ring Hyacinth & Simon Lee
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@ -19,3 +27,28 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## 2. Art Assets License & Disclaimer
### Important: Art Assets are NOT for commercial use
All art assets (including but not limited to character sprites, scene backgrounds,
posters, furniture, plants, coffee machine, server room, animations, button skins,
and rebuilt full asset packs/indexes) are **non-commercial only**.
They are for **learning, demonstration, and idea sharing only**.
You may NOT use any art assets from this repository for commercial purposes.
If you want to use this project commercially, you **must replace all art assets with your own original work**.
---
## 3. Guest Character Asset Attribution
Guest character animations use LimeZus free assets:
- Animated Mini Characters 2 (Platformer) [FREE]
- https://limezu.itch.io/animated-mini-characters-2-platform-free
Please keep this attribution and follow the original authors license terms when redistributing or demonstrating.

294
README.en.md Normal file
View file

@ -0,0 +1,294 @@
# 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 <state> "<description>"` before beginning work
- When finishing a task: run `python3 set_state.py idle "Standing by"` before replying
```
**6 states → 3 office areas:**
| State | Office Area | When to use |
|-------|-------------|-------------|
| `idle` | 🛋 Breakroom (sofa) | Standing by / task complete |
| `writing` | 💻 Workspace (desk) | Writing code or docs |
| `researching` | 💻 Workspace | Searching / researching |
| `executing` | 💻 Workspace | Running commands / tasks |
| `syncing` | 💻 Workspace | Syncing data / pushing |
| `error` | 🐛 Bug Corner | Error / debugging |
### Invite Other Agents to Your Office
**Step 1: Prepare join keys**
When you start the backend for the first time, if there is no `join-keys.json` in the project root, the service will automatically create one based on `join-keys.sample.json` (which contains an example key such as `ocj_example_team_01`). You can then edit the generated `join-keys.json` to add, modify, or remove keys; by default each key supports up to 3 concurrent users.
**Step 2: Have the guest run the push script**
The guest only needs to download `office-agent-push.py` and fill in 3 variables:
```python
JOIN_KEY = "ocj_starteam02" # The key you assign
AGENT_NAME = "Alice's Lobster" # Display name
OFFICE_URL = "https://office.hyacinth.im" # Your office URL
```
```bash
python3 office-agent-push.py
```
The script auto-joins and pushes status every 15 seconds. The guest will appear on the dashboard, moving to the appropriate area based on their state.
**Step 3 (optional): Guest installs a Skill**
Guests can also use `frontend/join-office-skill.md` as a Skill — their agent will handle setup and pushing automatically.
> See [`frontend/join-office-skill.md`](./frontend/join-office-skill.md) for full guest onboarding instructions.
---
## 📡 API Reference
| Endpoint | Description |
|----------|-------------|
| `GET /health` | Health check |
| `GET /status` | Get main agent status |
| `POST /set_state` | Set main agent status |
| `GET /agents` | List all agents |
| `POST /join-agent` | Guest joins the office |
| `POST /agent-push` | Guest pushes status |
| `POST /leave-agent` | Guest leaves |
| `GET /yesterday-memo` | Get yesterday's memo |
| `GET /config/gemini` | Get Gemini API config |
| `POST /config/gemini` | Set Gemini API config |
| `GET /assets/generate-rpg-background/poll` | Poll image generation progress |
---
## 🖥 Desktop Pet Mode (Optional)
The `desktop-pet/` directory contains a **Electron**-based desktop wrapper that turns the pixel office into a transparent desktop widget.
```bash
cd desktop-pet
npm install
npm run dev
```
- Auto-launches the Python backend on startup
- Window points to `http://127.0.0.1:19000/?desktop=1` by default
- Customizable via environment variables for project path and Python path
> ⚠️ This is an **optional, experimental feature**, primarily developed and tested on macOS. See [`desktop-pet/README.md`](./desktop-pet/README.md) for details.
>
> 🙏 The desktop pet module was independently developed by [@Zhaohan-Wang](https://github.com/Zhaohan-Wang) — thank you for this contribution!
---
## 🎨 Art Assets & License
### Asset Attribution
Guest character animations use free assets by **LimeZu**:
- [Animated Mini Characters 2 (Platformer) [FREE]](https://limezu.itch.io/animated-mini-characters-2-platform-free)
Please keep attribution when redistributing or demoing, and follow the original license terms.
### License
- **Code / Logic: MIT** (see [`LICENSE`](./LICENSE))
- **Art Assets: Non-commercial use only** (learning / demo / sharing)
> For commercial use, replace all art assets with your own original artwork.
---
## 📝 Changelog
| Date | Summary | Details |
|------|---------|---------|
| 2026-03-06 | 🔌 Default port updated — backend default port changed from 18791 to 19000 to avoid conflicts with OpenClaw Browser Control; synced scripts, desktop shells, and docs defaults | [`docs/CHANGELOG_2026-03.md`](./docs/CHANGELOG_2026-03.md) |
| 2026-03-05 | 📱 Stability fixes — CDN cache fix, async image generation, mobile sidebar UX, join key expiration & concurrency | [`docs/UPDATE_REPORT_2026-03-05.md`](./docs/UPDATE_REPORT_2026-03-05.md) |
| 2026-03-04 | 🔒 P0/P1 Security hardening — weak password blocking, backend refactor, stale-state auto-idle, skeleton loading | [`docs/UPDATE_REPORT_2026-03-04_P0_P1.md`](./docs/UPDATE_REPORT_2026-03-04_P0_P1.md) |
| 2026-03-03 | 📋 Open-source release checklist completed | [`docs/OPEN_SOURCE_RELEASE_CHECKLIST.md`](./docs/OPEN_SOURCE_RELEASE_CHECKLIST.md) |
| 2026-03-01 | 🎉 **v2 Rebuild** — Trilingual support, asset management system, AI room design, full art asset overhaul | [`docs/FEATURES_NEW_2026-03-01.md`](./docs/FEATURES_NEW_2026-03-01.md) |
---
## 📁 Project Structure
```text
Star-Office-UI/
├── backend/ # Flask backend
│ ├── app.py
│ ├── requirements.txt
│ └── run.sh
├── frontend/ # Frontend pages & assets
│ ├── index.html
│ ├── join.html
│ ├── invite.html
│ └── layout.js
├── desktop-pet/ # Electron desktop wrapper (optional)
├── docs/ # Documentation & screenshots
│ └── screenshots/
├── office-agent-push.py # Guest push script
├── set_state.py # Status switch script
├── state.sample.json # State file template
├── join-keys.sample.json # Join key template (runtime generates join-keys.json)
├── SKILL.md # OpenClaw Skill
└── LICENSE # MIT License
```
---
## ⭐ Star History
[![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)

294
README.ja.md Normal file
View file

@ -0,0 +1,294 @@
# 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 をお知らせします。
### 方法 230 秒手動セットアップ
> **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 1join 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)

311
README.md
View file

@ -1,88 +1,295 @@
# Star Office UI
A tiny “pixel office” status UI for your AI assistant.
🌐 Language: **中文** | [English](./README.en.md) | [日本語](./README.ja.md)
- Pixel office background (top-down)
- A little character that moves between areas based on `state`
- Optional speech bubble / typing effect
- Mobile-friendly access via Cloudflare Tunnel quick tunnel
![Star Office UI 封面](docs/screenshots/readme-cover-2.jpg)
> Language: the demo code/docs are currently mainly in Chinese (中文). PRs welcome.
**一个像素风格的 AI 办公室看板** —— 把 AI 助手的工作状态实时可视化,让你直观看到"谁在做什么、昨天做了什么、现在是否在线"。
## What it looks like
支持多 Agent 协作、中英日三语、AI 生图装修、桌面宠物模式。
与 [OpenClaw](https://github.com/openclaw/openclaw) 深度集成时体验最佳,也可以独立部署作为状态看板使用。
- `idle / syncing / error` → breakroom area
- `writing / researching / executing` → desk area
> 本项目由 **[Ring Hyacinth](https://x.com/ring_hyacinth)** 与 **[Simon Lee](https://x.com/simonxxoo)** 共同创建co-created project并与社区开发者[@Zhaohan-Wang](https://github.com/Zhaohan-Wang)、[@Jah-yee](https://github.com/Jah-yee)、[@liaoandi](https://github.com/liaoandi))一起持续维护和共建。
> 欢迎提交 Issue 和 PR也感谢每一位贡献者的支持。
The UI polls `/status` and renders the assistant avatar accordingly.
---
## Folder structure
## ✨ 快速体验
```
star-office-ui/
backend/ # Flask backend (serves index + status)
frontend/ # Phaser frontend + office_bg.png
state.json # runtime status file
set_state.py # helper to update state.json
### 方式一:让龙虾帮你部署(推荐给 OpenClaw 用户)
如果你正在使用 [OpenClaw](https://github.com/openclaw/openclaw),直接把下面这句话发给你的龙虾:
```text
请按照这个 SKILL.md 帮我完成 Star Office UI 的部署:
https://github.com/ringhyacinth/Star-Office-UI/blob/master/SKILL.md
```
## Requirements
龙虾会自动完成 clone、安装依赖、启动后端、配置状态同步并把访问地址发给你。
- Python 3.9+
- Flask
### 方式二30 秒手动部署
## Quick start (local)
### 1) Install dependencies
> **环境要求Python 3.10+**(代码使用了 `X | Y` union type 语法,不支持 3.9 及更低版本)
```bash
pip install flask
# 1) 下载仓库
git clone https://github.com/ringhyacinth/Star-Office-UI.git
cd Star-Office-UI
# 2) 安装依赖(需要 Python 3.10+
python3 -m pip install -r backend/requirements.txt
# 3) 准备状态文件(首次)
cp state.sample.json state.json
# 4) 启动后端
cd backend
python3 app.py
```
### 2) Put your background image
Put a **800×600 PNG** at:
```
star-office-ui/frontend/office_bg.png
```
### 3) Start backend
打开 **http://127.0.0.1:19000** 然后试试切状态:
```bash
cd star-office-ui/backend
python app.py
python3 set_state.py writing "正在整理文档"
python3 set_state.py error "发现问题,排查中"
python3 set_state.py idle "待命中"
```
Then open:
![Star Office UI 预览](docs/screenshots/readme-cover-1.jpg)
- http://127.0.0.1:18791
---
### 4) Update status
## 🤔 适合谁用?
From the project root:
### 有 OpenClaw / AI Agent 的用户
这是**完整体验**。Agent 在工作时自动切换状态,办公室里的像素角色会实时走到对应区域——你只需要打开网页,就能看到 AI 此刻在做什么。
### 没有 OpenClaw 的用户
也完全可以部署。你可以:
- 用 `set_state.py` 或 API 手动 / 脚本推送状态
- 把它当成一个像素风的个人状态页 / 远程办公看板
- 接入任何能发 HTTP 请求的系统来驱动状态
---
## 📋 功能一览
1. **状态可视化** —— 6 种状态(`idle` / `writing` / `researching` / `executing` / `syncing` / `error`)自动映射到办公室不同区域,动画 + 气泡实时展示
2. **昨日小记** —— 自动从 `memory/*.md` 读取最近一天的工作记录,脱敏后展示为"昨日小记"卡片
3. **多 Agent 协作** —— 通过 join key 邀请其他 Agent 加入你的办公室,实时查看多人状态
4. **中英日三语** —— CN / EN / JP 一键切换,界面文案、气泡、加载提示全部联动
5. **美术资产自定义** —— 侧边栏管理角色 / 场景 / 装饰素材,支持动态帧同步,避免闪烁
6. **AI 生图装修** —— 接入 Gemini API用 AI 给办公室换背景;不接入 API 也能正常使用核心功能
7. **移动端适配** —— 手机直接打开即可查看,适合外出时快速瞄一眼
8. **安全加固** —— 侧边栏密码保护、生产环境弱密码拦截、Session Cookie 加固
9. **灵活公网访问** —— 推荐 Cloudflare Tunnel 一键公网化,也可用自有域名 / 反向代理
10. **桌面宠物版** —— 可选的 Electron 桌面封装,把办公室变成透明窗口的桌面宠物(见下方说明)
---
## 🚀 详细部署指南
### 1) 安装依赖
```bash
python3 star-office-ui/set_state.py writing "Working on a task..."
python3 star-office-ui/set_state.py idle "Standing by"
cd Star-Office-UI
python3 -m pip install -r backend/requirements.txt
```
## Public access (Cloudflare quick tunnel)
Install `cloudflared`, then:
### 2) 初始化状态文件
```bash
cloudflared tunnel --url http://127.0.0.1:18791
cp state.sample.json state.json
```
Youll get a `https://xxx.trycloudflare.com` URL.
### 3) 启动后端
## Security notes
```bash
cd backend
python3 app.py
```
- Anyone with the tunnel URL can read `/status`.
- Dont put sensitive info in `detail`.
- If needed, add a token check for `/status` (or only return coarse states).
打开 `http://127.0.0.1:19000`
## License
> ✅ 首次部署可以先保留默认配置;在生产环境中,请复制 `.env.example``.env` 并设置强随机的 `FLASK_SECRET_KEY``ASSET_DRAWER_PASS`,避免弱密码和会话泄露。
MIT
### 4) 切换状态
```bash
python3 set_state.py writing "正在整理文档"
python3 set_state.py syncing "同步进度中"
python3 set_state.py error "发现问题,排查中"
python3 set_state.py idle "待命中"
```
### 5) 公网访问(可选)
```bash
cloudflared tunnel --url http://127.0.0.1:19000
```
拿到 `https://xxx.trycloudflare.com` 链接即可分享。
### 6) 验证安装(可选)
```bash
python3 scripts/smoke_test.py --base-url http://127.0.0.1:19000
```
所有检查显示 `OK` 即表示部署成功。
---
## 🦞 OpenClaw 深度集成
> 以下内容面向 [OpenClaw](https://github.com/openclaw/openclaw) 用户。如果你不使用 OpenClaw可以跳过这一节。
### 状态自动同步
在你的 `SOUL.md`(或 Agent 规则文件)中加入以下规则,让 Agent 自觉维护状态:
```markdown
## Star Office 状态同步规则
- 接到任务时:先执行 `python3 set_state.py <状态> "<描述>"` 再开始工作
- 完成任务后:执行 `python3 set_state.py idle "待命中"` 再回复
```
**6 种状态 → 3 个区域的映射:**
| 状态 | 办公室区域 | 触发场景 |
|------|-----------|---------|
| `idle` | 🛋 休息区(沙发) | 待命 / 任务完成 |
| `writing` | 💻 工作区(办公桌) | 写代码 / 写文档 |
| `researching` | 💻 工作区 | 搜索 / 调研 |
| `executing` | 💻 工作区 | 执行命令 / 跑任务 |
| `syncing` | 💻 工作区 | 同步数据 / 推送 |
| `error` | 🐛 Bug 区 | 报错 / 异常排查 |
### 邀请其他 Agent 加入办公室
**Step 1准备 join key**
首次启动后端时,如果当前目录下不存在 `join-keys.json`,服务会自动根据 `join-keys.sample.json` 生成一个运行时的 `join-keys.json`(内含示例 key例如 `ocj_example_team_01`)。你可以在生成后的 `join-keys.json` 中自行添加、修改或删除 key每个 key 默认支持最多 3 人同时在线。
**Step 2让访客 Agent 运行推送脚本**
访客只需下载 `office-agent-push.py`,填写 3 个变量即可:
```python
JOIN_KEY = "ocj_starteam02" # 你分配的 key
AGENT_NAME = "小明的龙虾" # 显示名称
OFFICE_URL = "https://office.hyacinth.im" # 你的办公室地址
```
```bash
python3 office-agent-push.py
```
脚本会自动加入办公室并每 15 秒推送一次状态。访客会出现在看板上,根据状态自动走到对应区域。
**Step 3可选访客安装 Skill**
访客也可以把 `frontend/join-office-skill.md` 作为 Skill 使用Agent 会自动完成配置和推送。
> 详细的访客接入说明见 [`frontend/join-office-skill.md`](./frontend/join-office-skill.md)
---
## 📡 常用 API
| 端点 | 说明 |
|------|------|
| `GET /health` | 健康检查 |
| `GET /status` | 获取主 Agent 状态 |
| `POST /set_state` | 设置主 Agent 状态 |
| `GET /agents` | 获取多 Agent 列表 |
| `POST /join-agent` | 访客加入办公室 |
| `POST /agent-push` | 访客推送状态 |
| `POST /leave-agent` | 访客离开 |
| `GET /yesterday-memo` | 获取昨日小记 |
| `GET /config/gemini` | 获取 Gemini API 配置 |
| `POST /config/gemini` | 设置 Gemini API 配置 |
| `GET /assets/generate-rpg-background/poll` | 轮询生图进度 |
---
## 🖥 桌面宠物版(可选)
`desktop-pet/` 目录提供了一个基于 **Electron** 的桌面封装版本,可以把像素办公室变成一个透明窗口的桌面宠物。
```bash
cd desktop-pet
npm install
npm run dev
```
- 启动时自动拉起 Python 后端
- 窗口默认指向 `http://127.0.0.1:19000/?desktop=1`
- 支持通过环境变量自定义项目路径和 Python 路径
> ⚠️ 这是一个**可选的实验性功能**,目前主要在 macOS 上开发测试。详见 [`desktop-pet/README.md`](./desktop-pet/README.md)。
>
> 🙏 桌面宠物版由 [@Zhaohan-Wang](https://github.com/Zhaohan-Wang) 独立开发,感谢他的贡献!
---
## 🎨 美术资产与开源许可
### 资产来源
访客角色动画使用了 **LimeZu** 的免费资产:
- [Animated Mini Characters 2 (Platformer) [FREE]](https://limezu.itch.io/animated-mini-characters-2-platform-free)
请在二次发布 / 演示时保留来源说明,并遵守原作者许可条款。
### 许可协议
- **代码 / 逻辑MIT**(见 [`LICENSE`](./LICENSE)
- **美术资产:禁止商用**(仅学习 / 演示 / 交流用途)
> 如需商用,请将所有美术资产替换为你自己的原创素材。
---
## 📝 更新日志
| 日期 | 概要 | 详情 |
|------|------|------|
| 2026-03-06 | 🔌 默认端口调整 — 默认后端端口从 18791 调整为 19000以避开 OpenClaw Browser Control 端口冲突;同步更新脚本、桌面壳与文档默认值 | [`docs/CHANGELOG_2026-03.md`](./docs/CHANGELOG_2026-03.md) |
| 2026-03-05 | 📱 稳定性修复 — CDN 缓存修复、生图异步化、移动端侧边栏优化、Join Key 过期与并发控制 | [`docs/UPDATE_REPORT_2026-03-05.md`](./docs/UPDATE_REPORT_2026-03-05.md) |
| 2026-03-04 | 🔒 P0/P1 安全加固 — 弱密码拦截、后端模块拆分、stale 状态自动回 idle、首屏骨架屏优化 | [`docs/UPDATE_REPORT_2026-03-04_P0_P1.md`](./docs/UPDATE_REPORT_2026-03-04_P0_P1.md) |
| 2026-03-03 | 📋 开源发布检查清单完成 | [`docs/OPEN_SOURCE_RELEASE_CHECKLIST.md`](./docs/OPEN_SOURCE_RELEASE_CHECKLIST.md) |
| 2026-03-01 | 🎉 **v2 重制发布** — 新增三语支持、资产管理系统、AI 生图装修、美术资产全面替换 | [`docs/FEATURES_NEW_2026-03-01.md`](./docs/FEATURES_NEW_2026-03-01.md) |
---
## 📁 项目结构
```text
Star-Office-UI/
├── backend/ # Flask 后端
│ ├── app.py
│ ├── requirements.txt
│ └── run.sh
├── frontend/ # 前端页面与资产
│ ├── index.html
│ ├── join.html
│ ├── invite.html
│ └── layout.js
├── desktop-pet/ # Electron 桌面宠物版(可选)
├── docs/ # 文档与截图
│ └── screenshots/
├── office-agent-push.py # 访客推送脚本
├── set_state.py # 状态切换脚本
├── state.sample.json # 状态文件模板
├── join-keys.sample.json # Join Key 模板(启动时生成 join-keys.json
├── SKILL.md # OpenClaw Skill
└── LICENSE # MIT 许可
```
---
## ⭐ Star History
[![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)

413
SKILL.md
View file

@ -1,164 +1,295 @@
---
name: star-office-ui
description: 为你的 AI 助手创建一个“像素办公室”可视化界面,手机可通过 Cloudflare Tunnel 公网访问!
metadata:
{
"openclaw": { "emoji": "🏢", "title": "Star 像素办公室", "color": "#ff6b35" }
}
description: Star Office UI 一键化 Skill帮主人快速部署像素办公室看板支持多 Agent 加入、状态可视化、移动端查看与公网访问。
---
# Star Office UI Skill
## 效果预览
- 俯视像素办公室背景(可自己画/AI 生成/找素材)
- 像素小人代表助手:会根据 `state` 在不同区域移动,并带眨眼/气泡/打字机等动态
- 手机可通过 Cloudflare Tunnel quick tunnel 公网访问
本 Skill 面向想快速帮主人搭建"像素办公室看板"的 OpenClaw龙虾
- 目标:主人只需"看到效果",尽量少问问题
- 你可以按下面的步骤,一步一步帮主人跑起来并对外访问
## 前置条件
- 有一台能跑 Python 的服务器(或本地电脑)
- 一张 800×600 的 PNG 办公室背景图(俯视像素风最佳)
- 有 Python 3 + Flask
- 有 Phaser CDN前端直接用无需安装
---
## 快速开始
## 0. 一句话告诉主人这是什么
你可以先和主人说:
> 这是一个多人协作的像素办公室仪表盘,你的 AI 助手会根据状态自动走到不同位置,还能看到昨天的工作小记!
---
## 1. 30 秒一键启动(推荐你先做一遍)
在你这台机器执行(按顺序):
### 1. 准备目录
```bash
mkdir -p star-office-ui/backend star-office-ui/frontend
# 1) 下载仓库
git clone https://github.com/ringhyacinth/Star-Office-UI.git
cd Star-Office-UI
# 2) 安装依赖
python3 -m pip install -r backend/requirements.txt
# 3) 准备状态文件(首次)
cp state.sample.json state.json
# 4) 启动后端
cd backend
python3 app.py
```
### 2. 准备背景图
把你的办公室背景图放到 `star-office-ui/frontend/office_bg.png`
然后告诉主人:
> 好了,你现在打开 http://127.0.0.1:19000 就能看到像素办公室了!
### 3. 写后端 Flask app
创建 `star-office-ui/backend/app.py`
```python
#!/usr/bin/env python3
from flask import Flask, jsonify, send_from_directory
from datetime import datetime
import json
import os
---
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FRONTEND_DIR = os.path.join(ROOT_DIR, "frontend")
STATE_FILE = os.path.join(ROOT_DIR, "state.json")
app = Flask(__name__, static_folder=FRONTEND_DIR, static_url_path="/static")
## 2. 帮主人切状态体验一下
DEFAULT_STATE = {
"state": "idle",
"detail": "等待任务中...",
"progress": 0,
"updated_at": datetime.now().isoformat()
}
在项目根目录执行:
def load_state():
if os.path.exists(STATE_FILE):
try:
with open(STATE_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return dict(DEFAULT_STATE)
def save_state(state):
with open(STATE_FILE, "w", encoding="utf-8") as f:
json.dump(state, f, ensure_ascii=False, indent=2)
if not os.path.exists(STATE_FILE):
save_state(DEFAULT_STATE)
@app.route("/")
def index():
return send_from_directory(FRONTEND_DIR, "index.html")
@app.route("/status")
def get_status():
return jsonify(load_state())
@app.route("/health")
def health():
return jsonify({"status": "ok", "timestamp": datetime.now().isoformat()})
if __name__ == "__main__":
print("Listening on http://0.0.0.0:18791")
app.run(host="0.0.0.0", port=18791, debug=False)
```
### 4. 写前端 Phaser UI
创建 `star-office-ui/frontend/index.html`(参考完整示例):
- 用 `this.load.image('office_bg', '/static/office_bg.png')` 加载背景图
- 用 `this.add.image(400, 300, 'office_bg')` 放背景
- 状态区域映射:自己定义 workdesk/breakroom 的坐标
- 加动态效果:眨眼/气泡/打字机/小踱步等
### 5. 写状态更新脚本
创建 `star-office-ui/set_state.py`
```python
#!/usr/bin/env python3
import json, os, sys
from datetime import datetime
STATE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "state.json")
VALID_STATES = ["idle", "writing", "researching", "executing", "syncing", "error"]
def load_state():
if os.path.exists(STATE_FILE):
try:
with open(STATE_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return {"state": "idle", "detail": "等待任务中...", "progress": 0, "updated_at": datetime.now().isoformat()}
def save_state(state):
with open(STATE_FILE, "w", encoding="utf-8") as f:
json.dump(state, f, ensure_ascii=False, indent=2)
if __name__ == "__main__":
if len(sys.argv) < 2:
print("用法: python set_state.py <state> [detail]")
sys.exit(1)
s = sys.argv[1]
if s not in VALID_STATES:
print(f"有效状态: {', '.join(VALID_STATES)}")
sys.exit(1)
state = load_state()
state["state"] = s
state["detail"] = sys.argv[2] if len(sys.argv) > 2 else ""
state["updated_at"] = datetime.now().isoformat()
save_state(state)
print(f"状态已更新: {s} - {state['detail']}")
```
### 6. 启动后端
```bash
cd star-office-ui/backend
pip install flask
python app.py
# 工作中 → 去办公桌
python3 set_state.py writing "正在帮你整理文档"
# 同步中
python3 set_state.py syncing "同步进度中"
# 报错中 → 去 bug 区
python3 set_state.py error "发现问题,正在排查"
# 待命 → 回休息区
python3 set_state.py idle "待命中,随时准备为你服务"
```
### 7. 开通 Cloudflare Tunnel公网访问
- 下载 cloudflaredhttps://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/tunnel-guide/local/#1-download-and-install-cloudflared
- 启动 quick tunnel
```bash
cloudflared tunnel --url http://127.0.0.1:18791
```
- 它会给你一个 `https://xxx.trycloudflare.com` 地址,手机就能打开了
---
## 状态约定(可按需调整)
- `idle / syncing / error` → 休息区breakroom
- `writing / researching / executing` → 办公桌workdesk
## 3. 侧边栏验证码设置(必须教会新龙虾)
## 安全注意事项
- quick tunnel URL 可能会变,不保证 uptime适合 demo
- 对外分享时:任何访问者都能看到 `state/detail`detail 里不要写隐私)
- 如需更强隐私:给 `/status` 加 token / 只返回模糊状态 / 不写 detail
当前默认验证码是:`1234`。
## 动态效果(好实现版,开箱即用)
- 同区域随机小踱步
- 偶尔眨眼
- 偶尔冒气泡(按状态随机短句)
- 状态栏打字机效果
- 走路轻微上下颠
你需要这样引导主人:
## 完整示例仓库(可选)
可直接复制这个项目的完整文件:`/root/.openclaw/workspace/star-office-ui/`(包含完整的前端 + 后端 + 状态脚本)
1. 默认密码是 `1234`,可以先直接体验;
2. 当主人愿意时,可随时和你沟通修改密码;
3. 你应主动推荐改成强密码(更安全,防止他人误改资产和布局)。
修改方式(示例):
```bash
export ASSET_DRAWER_PASS="your-strong-pass"
```
如果是长期运行systemd / pm2 / 容器),请把该环境变量写入服务配置,而不是只在当前 shell 临时设置。
---
## 4. 生图功能Gemini—— 可选
"搬新家 / 找中介"装修功能需要 Gemini API但**基础看板不需要**,不装也能正常使用。
### 4.1 安装生图脚本环境(首次使用时)
仓库已自带生图脚本(`scripts/gemini_image_generate.py`),但运行需要独立的 Python 环境。在项目根目录执行:
```bash
# 创建 skill 目录结构
mkdir -p ../skills/gemini-image-generate/scripts
# 复制脚本到 skill 目录
cp scripts/gemini_image_generate.py ../skills/gemini-image-generate/scripts/
# 创建独立虚拟环境并安装依赖
python3 -m venv ../skills/gemini-image-generate/.venv
../skills/gemini-image-generate/.venv/bin/pip install google-genai
```
安装完成后,后端会自动检测到生图环境,"搬新家 / 找中介"按钮即可使用。
### 4.2 配置 Gemini API Key
引导用户完成这两项配置:
1. `GEMINI_API_KEY`
2. `GEMINI_MODEL`(推荐:`nanobanana-pro` 或 `nanobanana-2`
配置方式有两种:
- **侧边栏填写**:打开资产侧边栏 → 在生图配置区域直接输入 API Key 并保存
- **环境变量**`export GEMINI_API_KEY="your-key"`
并明确告诉用户:
- 不配置 API 也能用基础看板(状态显示、多 Agent、资产替换等
- 配置后才能使用"搬新家 / 找中介"的 AI 生图装修能力
如果页面提示缺少 key指导用户在侧边栏里直接填写并保存运行时配置入口
---
## 5. 安装成功后,必须提醒主人的三件事
### 5.1 告知临时公网链接
推荐先用 Cloudflare Tunnel最快
```bash
cloudflared tunnel --url http://127.0.0.1:19000
```
拿到 `https://xxx.trycloudflare.com` 后发给主人,并补一句:
> 这是临时公网链接;如果你愿意,后续我可以一步步带你部署到本地域名。
### 5.2 装修房子入口
提醒主人:
- 现在可以点击"装修房间"开始装饰;
- 侧边栏默认密码是 `1234`
- 当他想改密码时,可随时告诉你,你来帮他改;
- 推荐改成强密码,安全性更高。
### 5.3 生图 API 配置
提醒主人:
- 生图功能需要自己的 API
- 当前接入的是 **Gemini 官方 API 格式与地址**
- 若要替换成其他类型 API请先和你沟通由你来改接入
- 最好让主人把对应 API 文档发给你,便于准确适配。
---
## 6. 邀请其他龙虾加入(可选)
如果有其他 OpenClaw 想加入你主人的办公室:
### 步骤 A告诉其他龙虾用 office-agent-push.py
你可以让其他龙虾:
1) 下载 `office-agent-push.py`(可以直接从你这个仓库拿)
2) 让它调用 `join-agent` + 周期 `agent-push`
3) 你主人那边就能看到它进办公室了
### 步骤 Bjoin key 说明
- 仓库里默认有 `join-keys.json``ocj_starteam01` ~ `ocj_starteam08`
- 同一个 key 最多 3 人同时在线
- 你也可以帮主人新建自己的 key
---
## 7. 昨日小记(可选)
如果你主人想看到"昨日小记"
- 在仓库上级目录放一个 `memory/YYYY-MM-DD.md`
- 后端会自动读取昨天(或最近可用)的记录,做基础脱敏后展示
---
## 8. 常见问题(主人可能问你的)
### Q1"我可以商用吗?"
> 代码玩法可以基于 MIT 用,但美术资产(包括角色/场景)禁止商用;如果你要商用,请务必换成你自己的原创美术资产。
### Q2"其他龙虾怎么加入?"
> 用 join key 加入,然后持续推送状态就行;仓库里有 `office-agent-push.py` 可以给其他龙虾用。
---
## 9. 给你的提示
- 尽量帮主人把"启动步骤"做完,让主人只负责"看"和"体验"
- 如果主人要对外访问,优先用 Cloudflare Tunnel最快最方便
- 状态切换应由你主动执行:
- 接任务前,先把自己状态切到"工作中"(如 `writing/researching/executing`),再开始工作;
- 完成任务后,先把自己状态切回"待命中idle",再进入休息/等待下一任务。
- 注意不要暴露你主人的内网地址或私有信息
---
## 10. 2026-03 增量补充(仅新增项)
> 本次重制核心请按四条理解:
> 1) 新增中英日三语
> 2) 新增资产管理(全量美术可自定义)
> 3) 接入生图 API龙虾自动改房间 + 用户手动装修)
> 4) 美术资产替换与优化(含命名与索引重建)
### 10.1 生图模型建议(房间装修)
当用户使用"搬新家 / 找中介"时,优先推荐:
1. **gemini nanobanana pro**
2. **gemini nanobanana 2**
其他模型在房间结构保持与风格一致性上可能不稳定。
推荐配置:
- `GEMINI_API_KEY`
- `GEMINI_MODEL=nanobanana-pro`(或 `nanobanana-2`
并提示用户:缺 key 时可在侧边栏内直接填写并保存。
### 10.2 侧边栏验证码安全提醒(必须)
默认验证码为 `1234`,但生产/公网场景必须改强密码:
```bash
export ASSET_DRAWER_PASS="your-strong-pass"
```
理由:防止外部访问者修改房间布局、装饰和资产配置。
### 10.3 版权口径更新
主角状态素材已切换为无版权争议的小猫,不再沿用旧角色版权说明。
保留统一口径:
- 代码MIT
- 美术资产:禁止商用
### 10.4 安装时必须提醒API 可选)
在帮助主人安装时,需明确提醒:
- 现在支持接入自己的生图 API 来改美术资产与背景(可持续更换)。
- 但基础功能(状态看板、多 Agent、资产替换/布局、三语切换)**不依赖 API**,不开 API 也能正常使用。
建议对主人口径:
> 先把基础看板跑起来;需要"无限换背景/AI 生图装修"再接入自己的 API。
### 10.5 老用户更新指南(从旧版本升级)
如果主人之前已经下载过旧版,按以下步骤升级:
1. 进入项目目录并备份本地配置(如 `state.json`、自定义资产)。
2. 拉取最新代码(`git pull` 或重新克隆到新目录)。
3. 确认依赖:`python3 -m pip install -r backend/requirements.txt`。
4. 保留并检查本地运行配置:
- `ASSET_DRAWER_PASS`
- `GEMINI_API_KEY` / `GEMINI_MODEL`(如需生图)
5. 如有自定义位置,确认:
- `asset-positions.json`
- `asset-defaults.json`
6. 重启后端并验收关键功能:
- `/health`
- 三语切换CN/EN/JP
- 资产侧栏(选择、替换、设默认)
- 生图入口(有 key 时可用)
### 10.6 功能更新提醒清单(对主人口播)
本次更新以后,至少提醒主人以下变化:
1. 已支持 **CN/EN/JP 三语切换**(含 loading 与气泡实时联动)。
2. 已支持 **自定义美术资产替换**(含动态素材切帧同步,减少闪烁)。
3. 已支持 **接入自有生图 API** 持续更换背景(推荐 `nanobanana-pro` / `nanobanana-2`)。
4. 新增/强化了安全项:`ASSET_DRAWER_PASS` 生产环境建议改强密码。
### 10.7 2026-03-05 稳定性修复
本次更新修复了多个影响线上稳定运行的问题:
1. **CDN 缓存修复**:静态资源 404 不再被 CDN 长缓存(之前导致 `phaser.js` 被缓存为 404 达 2.7 天)。
2. **前端加载修复**:修复 `fetchStatus()` 中的 JS 语法错误(多余 `else` 块),解决页面卡 loading 问题。
3. **生图异步化**:生图接口改为后台任务 + 轮询模式,避免 Cloudflare 524 超时100s 限制)。前端显示实时等待进度。
4. **移动端侧边栏**新增遮罩层、body 滚动锁定、`100dvh` 适配、`overscroll-behavior: contain`。
5. **Join Key 增强**:支持 key 级别过期时间(`expiresAt`)和并发上限(`maxConcurrent``join-keys.json` 不再入库。
> 详细说明见:`docs/UPDATE_REPORT_2026-03-05.md`

55
agent-invite-template.txt Normal file
View file

@ -0,0 +1,55 @@
# 海辛办公室 · Agent 加入指南
## 你收到的信息
- 邀请页https://office.example.com/join
- 接入密钥join key[请填入对方给你的 ocj_xxx]
- 你想要在办公室里显示的名字:[请填入你的名字]
## 你需要做的步骤
### 1. 先向你的主人要授权
> “我将把我的工作状态idle/writing/researching/executing/syncing/error定时发送到海辛的办公室看板用于可视化协作不含具体内容/隐私;随时可停。是否授权?”
### 2. 主人同意后:
- 调用 `POST https://office.example.com/join-agent`payload
```json
{
"name": "[你的名字]",
"joinKey": "[ocj_xxx]",
"state": "idle",
"detail": "刚刚加入"
}
```
- 拿到返回的 `agentId`
- 调用 `POST https://office.example.com/agent-approve`payload
```json
{
"agentId": "[刚才拿到的 agentId]"
}
```
- 启动定时推送器(每 30s 一次,或者只在状态变化时):
```json
POST https://office.example.com/agent-push
{
"agentId": "[你的 agentId]",
"joinKey": "[ocj_xxx]",
"state": "[你的当前状态idle/writing/researching/executing/syncing/error]",
"detail": "[可选的状态细节]",
"name": "[可选:更新名字]"
}
```
### 3. 主人想退出时:
- 停止定时推送器
- 调用 `POST https://office.example.com/leave-agent`payload
```json
{
"name": "[你的名字]"
}
```
---
## 注意事项
- 如果收到 `403` 或 `agent 未获授权`,停止推送
- 默认授权有效期 24h过期后重新申请
- 只推送状态,不推送任何具体内容/隐私

8
asset-defaults.json Normal file
View file

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

7
asset-positions.json Normal file
View file

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

BIN
assets/room-reference.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 KiB

BIN
assets/room-reference.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

File diff suppressed because it is too large Load diff

116
backend/memo_utils.py Normal file
View file

@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Memo extraction helpers for Star Office backend.
Reads and sanitizes daily memo content from memory/*.md for the yesterday-memo API.
"""
from __future__ import annotations
from datetime import datetime, timedelta
import random
import re
def get_yesterday_date_str() -> str:
"""Return yesterday's date as YYYY-MM-DD."""
yesterday = datetime.now() - timedelta(days=1)
return yesterday.strftime("%Y-%m-%d")
def sanitize_content(text: str) -> str:
"""Redact PII and sensitive patterns (OpenID, paths, IPs, email, phone) for safe display."""
text = re.sub(r'ou_[a-f0-9]+', '[用户]', text)
text = re.sub(r'user_id="[^"]+"', 'user_id="[隐藏]"', text)
text = re.sub(r'/root/[^"\s]+', '[路径]', text)
text = re.sub(r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}', '[IP]', text)
text = re.sub(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', '[邮箱]', text)
text = re.sub(r'1[3-9]\d{9}', '[手机号]', text)
return text
def extract_memo_from_file(file_path: str) -> str:
"""Extract display-safe memo text from a memory markdown file; sanitizes and truncates with a short fallback."""
try:
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
# 提取真实内容,不做过度包装
lines = content.strip().split("\n")
# 提取核心要点
core_points = []
for line in lines:
line = line.strip()
if not line:
continue
if line.startswith("#"):
continue
if line.startswith("- "):
core_points.append(line[2:].strip())
elif len(line) > 10:
core_points.append(line)
if not core_points:
return "「昨日无事记录」\n\n若有恒,何必三更眠五更起;最无益,莫过一日曝十日寒。"
# 从核心内容中提取 2-3 个关键点
selected_points = core_points[:3]
# 睿智语录库
wisdom_quotes = [
"「工欲善其事,必先利其器。」",
"「不积跬步,无以至千里;不积小流,无以成江海。」",
"「知行合一,方可致远。」",
"「业精于勤,荒于嬉;行成于思,毁于随。」",
"「路漫漫其修远兮,吾将上下而求索。」",
"「昨夜西风凋碧树,独上高楼,望尽天涯路。」",
"「衣带渐宽终不悔,为伊消得人憔悴。」",
"「众里寻他千百度,蓦然回首,那人却在,灯火阑珊处。」",
"「世事洞明皆学问,人情练达即文章。」",
"「纸上得来终觉浅,绝知此事要躬行。」"
]
quote = random.choice(wisdom_quotes)
# 组合内容
result = []
# 添加核心内容
if selected_points:
for point in selected_points:
# 隐私清理
point = sanitize_content(point)
# 截断过长的内容
if len(point) > 40:
point = point[:37] + "..."
# 每行最多 20 字
if len(point) <= 20:
result.append(f"· {point}")
else:
# 按 20 字切分
for j in range(0, len(point), 20):
chunk = point[j:j+20]
if j == 0:
result.append(f"· {chunk}")
else:
result.append(f" {chunk}")
# 添加睿智语录
if quote:
if len(quote) <= 20:
result.append(f"\n{quote}")
else:
for j in range(0, len(quote), 20):
chunk = quote[j:j+20]
if j == 0:
result.append(f"\n{chunk}")
else:
result.append(chunk)
return "\n".join(result).strip()
except Exception as e:
print(f"extract_memo_from_file failed: {e}")
return "「昨日记录加载失败」\n\n「往者不可谏,来者犹可追。」"

2
backend/requirements.txt Normal file
View file

@ -0,0 +1,2 @@
flask==3.0.2
pillow==10.4.0

13
backend/run.sh Executable file
View file

@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Auto-load project env file when present.
if [[ -f "$ROOT_DIR/.env" ]]; then
set -a
# shellcheck disable=SC1091
source "$ROOT_DIR/.env"
set +a
fi
exec "$ROOT_DIR/.venv/bin/python" "$ROOT_DIR/backend/app.py"

37
backend/security_utils.py Normal file
View file

@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Security helper utilities for Star Office backend.
Production detection and validation for Flask secret and asset drawer password.
"""
from __future__ import annotations
import os
def is_production_mode() -> bool:
"""Return True if STAR_OFFICE_ENV or FLASK_ENV is prod/production."""
env = (os.getenv("STAR_OFFICE_ENV") or os.getenv("FLASK_ENV") or "").strip().lower()
return env in {"prod", "production"}
def is_strong_secret(secret: str) -> bool:
"""Return True if secret is at least 24 chars and does not contain weak markers (e.g. change-me, dev)."""
if not secret:
return False
secret = secret.strip()
if len(secret) < 24:
return False
weak_markers = {"change-me", "dev", "example", "test", "default"}
low = secret.lower()
return not any(m in low for m in weak_markers)
def is_strong_drawer_pass(pwd: str) -> bool:
"""Return True if password is not default 1234 and has at least 8 characters."""
if not pwd:
return False
pwd = pwd.strip()
if pwd == "1234":
return False
return len(pwd) >= 8

130
backend/store_utils.py Normal file
View file

@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Storage helper utilities for Star Office backend.
JSON load/save for agents state, asset positions/defaults, runtime config, and join keys.
"""
from __future__ import annotations
import json
import os
def _load_json(path: str):
"""Load JSON from a file; caller handles missing file or parse errors."""
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def _save_json(path: str, data):
"""Write data as JSON with UTF-8 and indent=2."""
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def load_agents_state(path: str, default_agents: list) -> list:
"""Load agents list from path; return default_agents if file missing or invalid."""
if os.path.exists(path):
try:
data = _load_json(path)
if isinstance(data, list):
return data
except Exception:
pass
return list(default_agents)
def save_agents_state(path: str, agents: list):
"""Persist agents list to path."""
_save_json(path, agents)
def load_asset_positions(path: str) -> dict:
"""Load asset positions map from path; return {} if missing or invalid."""
if os.path.exists(path):
try:
data = _load_json(path)
if isinstance(data, dict):
return data
except Exception:
pass
return {}
def save_asset_positions(path: str, data: dict):
"""Persist asset positions to path."""
_save_json(path, data)
def load_asset_defaults(path: str) -> dict:
"""Load asset defaults map from path; return {} if missing or invalid."""
if os.path.exists(path):
try:
data = _load_json(path)
if isinstance(data, dict):
return data
except Exception:
pass
return {}
def save_asset_defaults(path: str, data: dict):
"""Persist asset defaults to path."""
_save_json(path, data)
def _normalize_user_model(model_name: str) -> str:
"""Map provider model names to canonical user-facing options (nanobanana-pro / nanobanana-2)."""
m = (model_name or "").strip().lower()
if m in {"nanobanana-pro", "nanobanana-2"}:
return m
if m in {"nano-banana-pro-preview", "gemini-3-pro-image-preview"}:
return "nanobanana-pro"
if m in {"gemini-2.5-flash-image", "gemini-2.0-flash-exp-image-generation"}:
return "nanobanana-2"
return "nanobanana-pro"
def load_runtime_config(path: str) -> dict:
"""Load runtime config (gemini_api_key, gemini_model) from env and optional JSON file."""
base = {
"gemini_api_key": os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY") or "",
"gemini_model": _normalize_user_model(os.getenv("GEMINI_MODEL") or "nanobanana-pro"),
}
if os.path.exists(path):
try:
data = _load_json(path)
if isinstance(data, dict):
base.update({k: data.get(k, base.get(k)) for k in ["gemini_api_key", "gemini_model"]})
base["gemini_model"] = _normalize_user_model(base.get("gemini_model") or "nanobanana-pro")
except Exception:
pass
return base
def save_runtime_config(path: str, data: dict):
"""Merge data into current runtime config and save to path; chmod 0o600 on path."""
cfg = load_runtime_config(path)
cfg.update(data or {})
_save_json(path, cfg)
try:
os.chmod(path, 0o600)
except Exception:
pass
def load_join_keys(path: str) -> dict:
"""Load join keys structure from path; return {'keys': []} if missing or invalid."""
if os.path.exists(path):
try:
data = _load_json(path)
if isinstance(data, dict) and isinstance(data.get("keys"), list):
return data
except Exception:
pass
return {"keys": []}
def save_join_keys(path: str, data: dict):
"""Persist join keys to path."""
_save_json(path, data)

115
convert_to_webp.py Normal file
View file

@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""
批量转换 PNG 资源为 WebP 格式
- 精灵图使用无损转换
- 背景图等使用有损转换质量 85
"""
import os
from PIL import Image
# 路径
FRONTEND_DIR = "/root/.openclaw/workspace/star-office-ui/frontend"
STATIC_DIR = os.path.join(FRONTEND_DIR, "")
# 文件分类配置
# 无损转换:精灵图、需要保持透明精度的
LOSSLESS_FILES = [
"star-idle-spritesheet.png",
"star-researching-spritesheet.png",
"star-working-spritesheet.png",
"sofa-busy-spritesheet.png",
"plants-spritesheet.png",
"posters-spritesheet.png",
"coffee-machine-spritesheet.png",
"serverroom-spritesheet.png"
]
# 有损转换:背景图等,质量 85
LOSSY_FILES = [
"office_bg.png",
"sofa-idle.png",
"desk.png"
]
def convert_to_webp(input_path, output_path, lossless=True, quality=85):
"""转换单个文件为 WebP"""
try:
img = Image.open(input_path)
# 保存为 WebP
if lossless:
img.save(output_path, 'WebP', lossless=True, method=6)
else:
img.save(output_path, 'WebP', quality=quality, method=6)
# 计算文件大小
orig_size = os.path.getsize(input_path)
new_size = os.path.getsize(output_path)
savings = (1 - new_size / orig_size) * 100
print(f"{os.path.basename(input_path)} -> {os.path.basename(output_path)}")
print(f" 原大小: {orig_size/1024:.1f}KB -> 新大小: {new_size/1024:.1f}KB (-{savings:.1f}%)")
return True
except Exception as e:
print(f"{os.path.basename(input_path)} 转换失败: {e}")
return False
def main():
print("=" * 60)
print("PNG → WebP 批量转换工具")
print("=" * 60)
# 检查目录
if not os.path.exists(STATIC_DIR):
print(f"❌ 目录不存在: {STATIC_DIR}")
return
success_count = 0
fail_count = 0
print("\n📁 开始转换...\n")
# 转换无损文件
print("--- 无损转换(精灵图)---")
for filename in LOSSLESS_FILES:
input_path = os.path.join(STATIC_DIR, filename)
if not os.path.exists(input_path):
print(f"⚠️ 文件不存在,跳过: {filename}")
continue
output_path = os.path.join(STATIC_DIR, filename.replace(".png", ".webp"))
if convert_to_webp(input_path, output_path, lossless=True):
success_count += 1
else:
fail_count += 1
# 转换有损文件
print("\n--- 有损转换(背景图,质量 85---")
for filename in LOSSY_FILES:
input_path = os.path.join(STATIC_DIR, filename)
if not os.path.exists(input_path):
print(f"⚠️ 文件不存在,跳过: {filename}")
continue
output_path = os.path.join(STATIC_DIR, filename.replace(".png", ".webp"))
if convert_to_webp(input_path, output_path, lossless=False, quality=85):
success_count += 1
else:
fail_count += 1
print("\n" + "=" * 60)
print(f"转换完成!成功: {success_count}, 失败: {fail_count}")
print("=" * 60)
print("\n📝 注意:")
print(" - PNG 原文件已保留,不会删除")
print(" - 需要修改前端代码引用 .webp 文件")
print(" - 如需回滚,只需把代码改回引用 .png 即可")
if __name__ == "__main__":
main()

37
desktop-pet/README.md Normal file
View file

@ -0,0 +1,37 @@
# Star Office Tauri Desktop Shell
这个目录用于把 `Star-Office-UI` 包成桌面应用(透明窗口),并在启动时自动拉起后端进程。
## 开发运行
先在仓库根目录准备 Python 环境:
```bash
cd /Users/wangzhaohan/Documents/GitHub/Star-Office-UI
uv venv .venv
uv pip install -r backend/requirements.txt --python .venv/bin/python
```
再启动 Tauri
```bash
cd /Users/wangzhaohan/Documents/GitHub/Star-Office-UI/desktop-pet
npm install
npm run dev
```
## 自动拉起后端逻辑
- 优先使用:`../.venv/bin/python backend/app.py`
- 回退到:`python3 backend/app.py`
- 再回退到:`python backend/app.py`
窗口默认会跳转到:
- `http://127.0.0.1:19000/?desktop=1`
## 可选环境变量
- `STAR_PROJECT_ROOT`:项目根目录(默认会自动探测)
- `STAR_BACKEND_PYTHON`:自定义 Python 可执行路径
- `STAR_BACKEND_URL`:自定义桌面窗口打开的 URL

85
desktop-pet/STATE_API.md Normal file
View file

@ -0,0 +1,85 @@
# 桌宠状态对接说明openclaw 用)
桌宠通过读取 **state.json** 获取当前状态并刷新表现(头顶图标/emoji、气泡文案、角色动画、寻路目标。openclaw 需要**写入或更新**该文件以驱动桌宠。
---
## 1. 文件位置
- **路径**:与桌宠工作目录下的 `state.json`(桌宠启动时会解析项目根目录,即包含 `state.json``layers/` 的目录)。
- **格式**UTF-8 JSON。
---
## 2. state.json 结构
```json
{
"state": "idle",
"detail": "可选,状态说明,目前仅用于展示/调试",
"progress": 0.0,
"updated_at": "2025-02-27T12:00:00Z"
}
```
| 字段 | 类型 | 必填 | 说明 |
|--------------|---------|------|------|
| `state` | string | 是 | 当前状态,见下表。桌宠每 ~2s 轮询读取。 |
| `detail` | string | 否 | 可选描述,可被后续扩展用于气泡或调试。 |
| `progress` | number | 否 | 0~1可选进度可被后续扩展。 |
| `updated_at` | string | 否 | ISO8601 时间,可选。 |
**只有 `state` 会影响桌宠行为**;其余字段可留空或省略。
---
## 3. 状态取值openclaw 应写入的 `state`
桌宠只认下面这些**标准状态名**(小写)。写别的值会被当成 `idle` 或按别名映射。
| state 值 | 含义 | 桌宠表现概要 |
|----------------|----------------|--------------|
| `idle` | 摸鱼/无任务 | 💤 呼吸动画,随机闲逛 |
| `writing` | 写作/记笔记 | Word 图标,走到 writing POI |
| `receiving` | 收消息 | Hangouts 图标,走到 receiving POI |
| `replying` | 回复消息 | Glovo 图标,走到 replying POI |
| `researching` | 调研/查资料 | Google 图标,走到 researching POI |
| `executing` | 执行任务/跑任务 | ⚡ emoji走到 executing POI |
| `syncing` | 同步/备份 | ☁️ emoji走到 syncing POI |
| `error` | 出错 | ❗ emoji走到 error POI |
POI 在 `layers/map.json``pois` 里配置;状态变化时桌宠会寻路到对应格子。
---
## 4. 别名映射(可选)
若 openclaw 侧用不同名字,桌宠前端会先做一次**别名 → 标准状态**的映射,再按上表表现:
| openclaw 可写的 state | 映射为 |
|------------------------|--------|
| `working` | `writing` |
| `run` | `executing` |
| `running` | `executing` |
| `sync` | `syncing` |
| `research` | `researching` |
未在上述列表中的 `state` 会视为 `idle`
---
## 5. openclaw 需要“跳”什么
- **写 state.json**:在约定目录下创建/覆盖 `state.json`,保证 `state` 为上面 8 个标准状态之一(或 5 个别名之一)。
- **何时写**:状态变化时写一次即可;桌宠轮询间隔约 2 秒,无需高频写入。
- **示例**
- 开始写文档:`{ "state": "writing" }`
- 收到消息:`{ "state": "receiving" }`
- 正在回复:`{ "state": "replying" }`
- 查资料:`{ "state": "researching" }`
- 执行任务:`{ "state": "executing" }`
- 同步中:`{ "state": "syncing" }`
- 出错:`{ "state": "error" }`
- 摸鱼/无任务:`{ "state": "idle" }`
按上述方式更新 `state.json`,即可与当前桌宠状态和 POI 行为一致。

13
desktop-pet/package.json Normal file
View file

@ -0,0 +1,13 @@
{
"name": "star-desktop-pet",
"private": true,
"version": "0.1.0",
"scripts": {
"dev": "STAR_PROJECT_ROOT=.. tauri dev",
"build": "STAR_PROJECT_ROOT=.. tauri build",
"tauri": "tauri"
},
"devDependencies": {
"@tauri-apps/cli": "^2"
}
}

4787
desktop-pet/src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,17 @@
[package]
name = "star-desktop-pet"
version = "0.1.0"
edition = "2021"
[lib]
name = "star_desktop_pet_lib"
crate-type = ["lib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["macos-private-api"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22"

View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View file

@ -0,0 +1,17 @@
{
"identifier": "default",
"description": "Default capabilities for the desktop pet",
"windows": ["main", "mini"],
"remote": {
"urls": [
"http://127.0.0.1:*",
"http://localhost:*"
]
},
"permissions": [
"core:default",
"core:window:allow-start-dragging",
"core:window:allow-close",
"core:window:allow-set-size"
]
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for the desktop pet","remote":{"urls":["http://127.0.0.1:*","http://localhost:*"]},"local":true,"windows":["main","mini"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-close","core:window:allow-set-size"]}}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 839 KiB

View file

@ -0,0 +1,641 @@
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::io::{Read, Write};
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
// ── state.json ──
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PetState {
pub state: String,
pub detail: Option<String>,
pub progress: Option<f64>,
pub updated_at: Option<String>,
}
// ── layers.json input ──
#[derive(Debug, Deserialize)]
struct CfgFile {
width: Option<u32>,
height: Option<u32>,
character: Option<CharCfg>,
layers: Option<Vec<LayerCfg>>,
sprites: Option<SpritesCfg>,
}
#[derive(Debug, Deserialize)]
struct CharCfg {
x: Option<f64>,
y: Option<f64>,
scale: Option<f64>,
depth: Option<i32>,
wander: Option<f64>,
}
#[derive(Debug, Deserialize)]
struct LayerCfg {
image: String,
x: Option<f64>,
y: Option<f64>,
depth: Option<i32>,
scale: Option<f64>,
alpha: Option<f64>,
}
#[derive(Debug, Deserialize)]
struct SpritesCfg {
frame_width: Option<u32>,
frame_height: Option<u32>,
anims: Option<HashMap<String, AnimCfg>>,
}
#[derive(Debug, Deserialize)]
struct AnimCfg {
file: String,
frames: Option<u32>,
rate: Option<u32>,
#[serde(default = "neg_one")]
repeat: i32,
}
fn neg_one() -> i32 {
-1
}
// ── map.json input ──
#[derive(Debug, Deserialize)]
struct MapCfgFile {
tile_size: Option<u32>,
cols: Option<u32>,
rows: Option<u32>,
zoom: Option<u32>,
tileset: String,
character_speed: Option<f64>,
ground: Vec<Vec<i32>>,
border: Option<Vec<Vec<i32>>>,
rug: Option<Vec<Vec<i32>>>,
objects: Vec<Vec<i32>>,
collision: Vec<Vec<u8>>,
pois: Option<HashMap<String, PoiCfg>>,
state_icons: Option<HashMap<String, String>>,
}
#[derive(Debug, Deserialize)]
struct PoiCfg {
col: u32,
row: u32,
}
// ── IPC responses ──
#[derive(Debug, Serialize)]
struct FullData {
width: u32,
height: u32,
character: CharData,
layers: Vec<LayerItem>,
sprites: Option<SpritesData>,
}
#[derive(Debug, Serialize)]
struct CharData {
x: f64,
y: f64,
scale: f64,
depth: i32,
wander: f64,
}
#[derive(Debug, Serialize)]
struct LayerItem {
data_url: String,
x: f64,
y: f64,
depth: i32,
scale: f64,
alpha: f64,
}
#[derive(Debug, Serialize)]
struct SpritesData {
frame_width: u32,
frame_height: u32,
anims: Vec<AnimItem>,
}
#[derive(Debug, Serialize)]
struct AnimItem {
key: String,
data_url: String,
frames: u32,
rate: u32,
repeat: i32,
}
#[derive(Debug, Serialize)]
struct MapData {
tile_size: u32,
cols: u32,
rows: u32,
zoom: u32,
tileset_url: String,
tileset_cols: u32,
character_speed: f64,
ground: Vec<Vec<i32>>,
border: Vec<Vec<i32>>,
rug: Vec<Vec<i32>>,
objects: Vec<Vec<i32>>,
collision: Vec<Vec<u8>>,
pois: HashMap<String, PoiOut>,
state_icons: HashMap<String, String>,
}
#[derive(Debug, Serialize)]
struct PoiOut {
col: u32,
row: u32,
}
// ── shared ──
struct AppPaths {
state_path: PathBuf,
layers_dir: PathBuf,
}
struct BackendProcess {
child: Option<Child>,
}
impl Drop for BackendProcess {
fn drop(&mut self) {
if let Some(child) = &mut self.child {
let _ = child.kill();
let _ = child.wait();
}
}
}
fn encode_image(path: &PathBuf) -> Result<String, String> {
let bytes = fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("png");
let mime = match ext {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
_ => "image/png",
};
Ok(format!("data:{mime};base64,{}", B64.encode(&bytes)))
}
// ── commands ──
fn read_state_file(state_path: &PathBuf) -> Result<PetState, String> {
let raw = fs::read_to_string(state_path)
.map_err(|e| format!("{}: {e}", state_path.display()))?;
serde_json::from_str(&raw).map_err(|e| format!("parse: {e}"))
}
fn read_state_via_backend() -> Result<PetState, String> {
let mut stream = std::net::TcpStream::connect("127.0.0.1:19000")
.map_err(|e| format!("backend connect: {e}"))?;
let _ = stream.set_read_timeout(Some(Duration::from_millis(1200)));
let _ = stream.set_write_timeout(Some(Duration::from_millis(1200)));
let request = b"GET /status HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n";
stream
.write_all(request)
.map_err(|e| format!("backend write: {e}"))?;
let mut raw = String::new();
stream
.read_to_string(&mut raw)
.map_err(|e| format!("backend read: {e}"))?;
let body = raw
.split_once("\r\n\r\n")
.map(|(_, b)| b)
.ok_or_else(|| "backend response parse failed".to_string())?;
serde_json::from_str(body).map_err(|e| format!("backend json parse: {e}"))
}
fn read_state_with_fallback(state_path: &PathBuf) -> Result<PetState, String> {
match read_state_file(state_path) {
Ok(state) => Ok(state),
Err(file_err) => {
eprintln!("⚠️ read state file failed, fallback to backend: {file_err}");
read_state_via_backend()
}
}
}
#[tauri::command]
fn read_state(paths: tauri::State<'_, Mutex<AppPaths>>) -> Result<PetState, String> {
let p = paths.lock().map_err(|e| e.to_string())?;
read_state_with_fallback(&p.state_path)
}
#[tauri::command]
fn load_layers(paths: tauri::State<'_, Mutex<AppPaths>>) -> Result<FullData, String> {
let p = paths.lock().map_err(|e| e.to_string())?;
let cfg_path = p.layers_dir.join("layers.json");
let cfg: CfgFile = if cfg_path.exists() {
let raw = fs::read_to_string(&cfg_path).map_err(|e| format!("layers.json: {e}"))?;
serde_json::from_str(&raw).map_err(|e| format!("layers.json: {e}"))?
} else {
CfgFile {
width: None,
height: None,
character: None,
layers: None,
sprites: None,
}
};
let w = cfg.width.unwrap_or(200);
let h = cfg.height.unwrap_or(250);
let cc = cfg.character.unwrap_or(CharCfg {
x: None, y: None, scale: None, depth: None, wander: None,
});
let character = CharData {
x: cc.x.unwrap_or(w as f64 / 2.0),
y: cc.y.unwrap_or(h as f64 * 0.66),
scale: cc.scale.unwrap_or(2.5),
depth: cc.depth.unwrap_or(0),
wander: cc.wander.unwrap_or(18.0),
};
let mut items = Vec::new();
for entry in cfg.layers.unwrap_or_default() {
let img_path = p.layers_dir.join(&entry.image);
if !img_path.exists() {
continue;
}
items.push(LayerItem {
data_url: encode_image(&img_path)?,
x: entry.x.unwrap_or(w as f64 / 2.0),
y: entry.y.unwrap_or(h as f64 / 2.0),
depth: entry.depth.unwrap_or(-1),
scale: entry.scale.unwrap_or(1.0),
alpha: entry.alpha.unwrap_or(1.0),
});
}
let sprites_data = if let Some(scfg) = cfg.sprites {
let fw = scfg.frame_width.unwrap_or(32);
let fh = scfg.frame_height.unwrap_or(32);
let mut anims = Vec::new();
for (key, acfg) in scfg.anims.unwrap_or_default() {
let img_path = p.layers_dir.join(&acfg.file);
if !img_path.exists() {
continue;
}
anims.push(AnimItem {
key,
data_url: encode_image(&img_path)?,
frames: acfg.frames.unwrap_or(1),
rate: acfg.rate.unwrap_or(4),
repeat: acfg.repeat,
});
}
Some(SpritesData {
frame_width: fw,
frame_height: fh,
anims,
})
} else {
None
};
Ok(FullData {
width: w,
height: h,
character,
layers: items,
sprites: sprites_data,
})
}
#[tauri::command]
fn load_map(paths: tauri::State<'_, Mutex<AppPaths>>) -> Result<MapData, String> {
let p = paths.lock().map_err(|e| e.to_string())?;
let map_path = p.layers_dir.join("map.json");
if !map_path.exists() {
return Err("map.json not found".into());
}
let raw = fs::read_to_string(&map_path).map_err(|e| format!("map.json: {e}"))?;
let cfg: MapCfgFile = serde_json::from_str(&raw).map_err(|e| format!("map.json: {e}"))?;
let ts = cfg.tile_size.unwrap_or(16);
let cols = cfg.cols.unwrap_or(cfg.ground.first().map_or(12, |r| r.len() as u32));
let rows = cfg.rows.unwrap_or(cfg.ground.len() as u32);
let tileset_path = p.layers_dir.join(&cfg.tileset);
if !tileset_path.exists() {
return Err(format!("tileset not found: {}", cfg.tileset));
}
let tileset_url = encode_image(&tileset_path)?;
// figure out tileset column count from image width
let img_bytes = fs::read(&tileset_path).map_err(|e| e.to_string())?;
let tileset_cols = png_width(&img_bytes).unwrap_or(160) / ts;
let mut pois = HashMap::new();
for (k, v) in cfg.pois.unwrap_or_default() {
pois.insert(k, PoiOut { col: v.col, row: v.row });
}
let icons_dir = p.layers_dir.join("Small (24x24) PNG");
let mut state_icons = HashMap::new();
for (state, filename) in cfg.state_icons.unwrap_or_default() {
let path = icons_dir.join(&filename);
if path.exists() {
if let Ok(url) = encode_image(&path) {
state_icons.insert(state, url);
}
}
}
Ok(MapData {
tile_size: ts,
cols,
rows,
zoom: cfg.zoom.unwrap_or(2),
tileset_url,
tileset_cols,
character_speed: cfg.character_speed.unwrap_or(2.5),
ground: cfg.ground,
border: cfg.border.unwrap_or_default(),
rug: cfg.rug.unwrap_or_default(),
objects: cfg.objects,
collision: cfg.collision,
pois,
state_icons,
})
}
fn png_width(data: &[u8]) -> Option<u32> {
if data.len() < 24 || &data[0..4] != b"\x89PNG" {
return None;
}
Some(u32::from_be_bytes([data[16], data[17], data[18], data[19]]))
}
// ── bootstrap ──
fn find_project_root() -> PathBuf {
if let Ok(p) = std::env::var("STAR_PROJECT_ROOT") {
let candidate = PathBuf::from(&p);
let abs = if candidate.is_absolute() {
candidate
} else {
std::env::current_dir().unwrap_or_default().join(candidate)
};
if abs.join("backend").join("app.py").exists() {
return abs;
}
}
let mut dir = std::env::current_dir().unwrap_or_default();
for _ in 0..8 {
if dir.join("backend").join("app.py").exists()
|| dir.join("state.json").exists()
|| dir.join("state.sample.json").exists()
{
return dir;
}
if !dir.pop() {
break;
}
}
if let Ok(home) = std::env::var("HOME") {
let home = PathBuf::from(home);
let candidates = [
home.join("Documents").join("GitHub").join("Star-Office-UI"),
home.join("GitHub").join("Star-Office-UI"),
home.join("Documents").join("Star-Office-UI"),
home.join("Star-Office-UI"),
];
for candidate in candidates {
if candidate.join("backend").join("app.py").exists() {
return candidate;
}
}
}
std::env::current_dir().unwrap_or_default()
}
fn spawn_backend(root: &PathBuf) -> Option<Child> {
if std::net::TcpStream::connect("127.0.0.1:19000").is_ok() {
eprintln!(" backend already running on 127.0.0.1:19000");
return None;
}
let script = root.join("backend").join("app.py");
if !script.exists() {
eprintln!("⚠️ backend/app.py not found: {}", script.display());
return None;
}
let mut candidates: Vec<(PathBuf, Vec<String>)> = vec![
(
root.join(".venv").join("bin").join("python"),
vec![script.to_string_lossy().to_string()],
),
(
PathBuf::from("python3"),
vec![script.to_string_lossy().to_string()],
),
(
PathBuf::from("python"),
vec![script.to_string_lossy().to_string()],
),
];
if let Ok(custom_python) = std::env::var("STAR_BACKEND_PYTHON") {
candidates.insert(
0,
(
PathBuf::from(custom_python),
vec![script.to_string_lossy().to_string()],
),
);
}
for (bin, args) in candidates {
let mut cmd = Command::new(&bin);
cmd.current_dir(root)
.args(&args)
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
match cmd.spawn() {
Ok(child) => {
eprintln!("🚀 backend started with {}", bin.display());
return Some(child);
}
Err(err) => {
eprintln!("⚠️ failed to spawn {}: {}", bin.display(), err);
}
}
}
None
}
fn wait_backend_ready() -> bool {
let deadline = Instant::now() + Duration::from_secs(20);
while Instant::now() < deadline {
if std::net::TcpStream::connect("127.0.0.1:19000").is_ok() {
return true;
}
std::thread::sleep(Duration::from_millis(200));
}
false
}
#[tauri::command]
fn enter_minimize_mode(
app: tauri::AppHandle,
paths: tauri::State<'_, Mutex<AppPaths>>,
) -> Result<(), String> {
let main = app
.get_webview_window("main")
.ok_or_else(|| "main window not found".to_string())?;
let mini = app
.get_webview_window("mini")
.ok_or_else(|| "mini window not found".to_string())?;
let state_path = {
let p = paths.lock().map_err(|e| e.to_string())?;
p.state_path.clone()
};
if let Ok(snapshot) = read_state_with_fallback(&state_path) {
// Sync mini immediately before showing it, avoiding stale one-shot transition.
let _ = mini.emit("mini-sync-state", snapshot);
}
// Keep mini near the main window top-left for continuity.
if let Ok(main_pos) = main.outer_position() {
let _ = mini.set_position(main_pos);
}
let _ = main.hide();
let _ = mini.show();
let _ = mini.set_focus();
Ok(())
}
#[tauri::command]
fn restore_main_window(app: tauri::AppHandle) -> Result<(), String> {
let main = app
.get_webview_window("main")
.ok_or_else(|| "main window not found".to_string())?;
let mini = app
.get_webview_window("mini")
.ok_or_else(|| "mini window not found".to_string())?;
let _ = mini.hide();
let _ = main.show();
let _ = main.set_focus();
Ok(())
}
#[tauri::command]
fn close_app(app: tauri::AppHandle) {
app.exit(0);
}
#[tauri::command]
fn open_external_url(url: String) -> Result<(), String> {
#[cfg(target_os = "macos")]
let mut cmd = {
let mut c = Command::new("open");
c.arg(&url);
c
};
#[cfg(target_os = "windows")]
let mut cmd = {
let mut c = Command::new("cmd");
c.args(["/C", "start", "", &url]);
c
};
#[cfg(all(unix, not(target_os = "macos")))]
let mut cmd = {
let mut c = Command::new("xdg-open");
c.arg(&url);
c
};
cmd.spawn()
.map(|_| ())
.map_err(|e| format!("failed to open browser: {e}"))
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let root = find_project_root();
eprintln!("📦 State : {}", root.join("state.json").display());
eprintln!("🎨 Layers: {}", root.join("layers").display());
let backend_child = spawn_backend(&root);
let backend_ready = wait_backend_ready();
if !backend_ready {
eprintln!("⚠️ backend not ready within 10s");
}
tauri::Builder::default()
.manage(Mutex::new(BackendProcess { child: backend_child }))
.manage(Mutex::new(AppPaths {
state_path: root.join("state.json"),
layers_dir: root.join("layers"),
}))
.setup(|app| {
// Hidden mini window: transparent square with only avatar + status.
let mini = WebviewWindowBuilder::new(
app,
"mini",
WebviewUrl::App("minimized.html".into()),
)
.title("Star Mini")
.inner_size(220.0, 240.0)
.min_inner_size(180.0, 200.0)
.resizable(false)
.decorations(false)
.transparent(true)
.always_on_top(true)
.shadow(false)
.visible(false)
.build()
.map_err(|e| e.to_string())?;
let _ = mini.hide();
Ok(())
})
.invoke_handler(tauri::generate_handler![
read_state,
load_layers,
load_map,
enter_minimize_mode,
restore_main_window,
close_app,
open_external_url
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View file

@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
star_desktop_pet_lib::run();
}

View file

@ -0,0 +1,35 @@
{
"productName": "Star Desktop Pet",
"version": "0.1.0",
"identifier": "com.star.desktop-pet",
"build": { "frontendDist": "../src" },
"app": {
"macOSPrivateApi": true,
"withGlobalTauri": true,
"windows": [
{
"label": "main",
"title": "Star Desktop Pet",
"x": 80,
"y": 60,
"width": 700,
"height": 500,
"url": "http://127.0.0.1:19000/?desktop=1",
"decorations": false,
"transparent": true,
"alwaysOnTop": true,
"resizable": true,
"shadow": false
}
]
},
"bundle": {
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

677
desktop-pet/src/index.html Normal file
View file

@ -0,0 +1,677 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<style>
@font-face {
font-family: 'ipix';
src: url('ipix.ttf') format('truetype');
}
* { margin: 0; padding: 0; }
html, body {
background: transparent;
overflow: hidden;
width: 100%;
height: 100%;
user-select: none;
-webkit-user-select: none;
font-family: 'ipix', monospace;
}
canvas { background: transparent !important; position: relative; }
#context-menu {
display: none;
position: fixed;
background: rgba(26, 26, 46, 0.95);
border: 2px solid #e94560;
border-radius: 6px;
padding: 4px 0;
z-index: 9999;
font-family: 'ipix', monospace;
font-size: 13px;
min-width: 110px;
backdrop-filter: blur(8px);
}
.menu-item { padding: 8px 16px; color: #eee; cursor: pointer; white-space: nowrap; }
.menu-item:hover { background: #e94560; }
.menu-sep { height: 1px; background: rgba(233,69,96,0.3); margin: 4px 8px; }
#bubble-layer {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
pointer-events: none;
overflow: visible;
z-index: 100;
}
.speech-bubble {
position: absolute;
background: rgba(255,255,255,0.95);
border: 2px solid #888;
border-radius: 8px;
padding: 6px 14px;
font-family: 'ipix', monospace;
font-size: 16px;
line-height: 1.3;
color: #333;
white-space: nowrap;
opacity: 0;
transition: opacity 0.2s;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.18));
}
.speech-bubble::after {
content: '';
position: absolute;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
width: 0; height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 8px solid #888;
}
.speech-bubble::before {
content: '';
position: absolute;
bottom: -6px;
left: 50%;
transform: translateX(-50%);
width: 0; height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 7px solid rgba(255,255,255,0.95);
z-index: 1;
}
</style>
</head>
<body>
<div id="bubble-layer"></div>
<div id="context-menu">
<div class="menu-item" data-action="info">🏷️ Star 桌宠</div>
<div class="menu-sep"></div>
<div class="menu-item" data-action="quit">❌ 退出</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/phaser@3.80.1/dist/phaser.min.js"></script>
<script>
(async function () {
/* ================================================================
§1 Tauri API
================================================================ */
const isTauri = !!window.__TAURI__;
const core = isTauri ? window.__TAURI__.core : null;
const winApi = isTauri ? window.__TAURI__.window : null;
const dpiApi = isTauri ? window.__TAURI__.dpi : null;
const appWindow = winApi ? winApi.getCurrentWindow() : null;
/* ================================================================
§2 Context menu & drag
================================================================ */
const ctxMenu = document.getElementById('context-menu');
document.addEventListener('contextmenu', e => {
e.preventDefault();
ctxMenu.style.display = 'block';
ctxMenu.style.left = Math.min(e.clientX, innerWidth - 120) + 'px';
ctxMenu.style.top = Math.min(e.clientY, innerHeight - 80) + 'px';
});
document.addEventListener('click', e => {
if (!e.target.closest('#context-menu')) ctxMenu.style.display = 'none';
});
document.querySelectorAll('.menu-item').forEach(el => {
el.addEventListener('click', () => {
ctxMenu.style.display = 'none';
if (el.dataset.action === 'quit' && appWindow) appWindow.close();
});
});
document.addEventListener('mousedown', e => {
if (e.button === 0 && !e.target.closest('#context-menu') && appWindow)
appWindow.startDragging();
});
/* ================================================================
§3 Load map config from Rust
================================================================ */
let map = null;
if (core) {
try { map = await core.invoke('load_map'); }
catch (e) { console.warn('load_map:', e); }
}
if (!map) {
document.body.innerHTML = '<p style="color:#fff;padding:20px">map.json not found</p>';
return;
}
const T = map.tile_size;
const COLS = map.cols;
const ROWS = map.rows;
const ZOOM = map.zoom;
const GW = COLS * T;
const GH = ROWS * T;
const BUBBLE_PAD = 40;
if (appWindow && dpiApi) {
try { await appWindow.setSize(new dpiApi.LogicalSize(GW * ZOOM, GH * ZOOM + BUBBLE_PAD)); }
catch (_) {}
}
/* ================================================================
§4 State definitions
================================================================ */
const SPECIAL = new Set([
'writing','receiving','replying','researching','executing','syncing','error'
]);
const NORM_MAP = {
working:'writing', run:'executing', running:'executing',
sync:'syncing', research:'researching'
};
const BUBBLE = {
idle: ['摸鱼中…','有没有新任务?','咖啡真好喝☕','伸个懒腰~'],
writing: ['这个要记下来','写得手酸','再检查一遍✍️'],
receiving: ['有人找我!','看看是什么','来消息了📨'],
replying: ['让我想想…','打字中…','这样回好了💬'],
researching: ['让我搜一下🔍','找到线索了','再深挖一点'],
executing: ['冲鸭!🦆','加油加油','马上搞定⚡'],
syncing: ['备份备份☁️','安全第一','同步中…'],
error: ['啊哦…','出问题了❗','马上修好🔧']
};
const EMOJI = {
idle:'💤', writing:'✏️', receiving:'📨', replying:'💬',
researching:'🔍', executing:'⚡', syncing:'☁️', error:'❗'
};
/* ================================================================
§5 A* pathfinding
================================================================ */
function astar(start, goal) {
const grid = map.collision;
const key = (r, c) => r * COLS + c;
const sk = key(start.row, start.col);
const gk = key(goal.row, goal.col);
if (grid[goal.row]?.[goal.col] !== 0) return null;
if (sk === gk) return [{ row: goal.row, col: goal.col }];
const open = new Set([sk]);
const from = new Map();
const gScore = new Map([[sk, 0]]);
const fScore = new Map([[sk, h(start, goal)]]);
const dirs = [[-1,0],[1,0],[0,-1],[0,1]];
while (open.size) {
let cur = -1, best = Infinity;
for (const k of open) {
const f = fScore.get(k) ?? Infinity;
if (f < best) { best = f; cur = k; }
}
if (cur === gk) {
const path = [];
let c = cur;
while (c !== undefined) {
path.unshift({ row: Math.floor(c / COLS), col: c % COLS });
c = from.get(c);
}
return path;
}
open.delete(cur);
const cr = Math.floor(cur / COLS), cc = cur % COLS;
for (const [dr, dc] of dirs) {
const nr = cr + dr, nc = cc + dc;
if (nr < 0 || nr >= ROWS || nc < 0 || nc >= COLS) continue;
if (grid[nr][nc] !== 0) continue;
const nk = key(nr, nc);
const tg = (gScore.get(cur) ?? Infinity) + 1;
if (tg < (gScore.get(nk) ?? Infinity)) {
from.set(nk, cur);
gScore.set(nk, tg);
fScore.set(nk, tg + h({ row: nr, col: nc }, goal));
open.add(nk);
}
}
}
return null;
}
function h(a, b) {
return Math.abs(a.row - b.row) + Math.abs(a.col - b.col);
}
/* ================================================================
§6 Game globals
================================================================ */
let game, star, stateEmoji, stateIcon, shadow;
let serverState = 'idle';
let charAnim = 'idle';
let charGridR, charGridC;
let path = null;
let pathIdx = 0;
let nextBubbleAt = 5000;
let lastFetch = 0;
const startPoi = map.pois.idle || { row: 5, col: 6 };
charGridR = startPoi.row;
charGridC = startPoi.col;
const SPEED = map.character_speed * T;
function tileX(c) { return c * T + T / 2; }
function tileY(r) { return r * T + T / 2; }
/* ================================================================
§7 Phaser game
================================================================ */
const phaserGame = new Phaser.Game({
type: Phaser.AUTO,
width: GW, height: GH,
zoom: ZOOM,
transparent: true,
pixelArt: true,
scene: { preload: preloadScene, create: createScene, update: updateScene }
});
phaserGame.events.on('ready', () => {
phaserGame.canvas.style.marginTop = BUBBLE_PAD + 'px';
});
/* ────────── preload ────────── */
function preloadScene() {
this.load.spritesheet('tiles', map.tileset_url, {
frameWidth: T, frameHeight: T
});
if (map.state_icons && typeof map.state_icons === 'object') {
for (const [state, dataUrl] of Object.entries(map.state_icons)) {
if (dataUrl) this.load.image('icon_' + state, dataUrl);
}
}
}
/* ────────── create ────────── */
function createScene() {
game = this;
/* ground layer (depth -100) */
for (let r = 0; r < ROWS; r++)
for (let c = 0; c < COLS; c++) {
const id = map.ground[r]?.[c] ?? -1;
if (id < 0) continue;
game.add.sprite(tileX(c), tileY(r), 'tiles', id).setDepth(-100);
}
/* border layer (topmost, depth 8000) */
if (map.border) {
for (let r = 0; r < ROWS; r++)
for (let c = 0; c < COLS; c++) {
const id = map.border[r]?.[c] ?? -1;
if (id < 0) continue;
game.add.sprite(tileX(c), tileY(r), 'tiles', id).setDepth(8000);
}
}
/* rug layer (depth -50, above ground/border, below objects & character) */
if (map.rug) {
for (let r = 0; r < ROWS; r++)
for (let c = 0; c < COLS; c++) {
const id = map.rug[r]?.[c] ?? -1;
if (id < 0) continue;
game.add.sprite(tileX(c), tileY(r), 'tiles', id).setDepth(-50);
}
}
/* objects layer (depth = row for Y-sorting) */
for (let r = 0; r < ROWS; r++)
for (let c = 0; c < COLS; c++) {
const id = map.objects[r]?.[c] ?? -1;
if (id < 0) continue;
game.add.sprite(tileX(c), tileY(r), 'tiles', id).setDepth(r * 10);
}
/* fallback character textures */
buildCharTextures();
buildCharAnims();
/* shadow */
shadow = game.add.ellipse(0, 0, T * 0.8, T * 0.3, 0x000000, 0.2).setDepth(-1);
/* character sprite */
const sx = tileX(charGridC), sy = tileY(charGridR);
star = game.add.sprite(sx, sy, 'cf0').setDepth(charGridR * 10 + 1);
star.play('idle');
/* state indicator: icon (from state_icons) or emoji fallback */
stateEmoji = game.add.text(sx + T * 0.6, sy - T * 0.7, '💤', {
font: `${Math.round(T * 0.55)}px sans-serif`
}).setOrigin(0.5).setDepth(9000);
const iconScale = (T * 1.1) / 24;
const firstIconKey = map.state_icons && Object.keys(map.state_icons)[0];
if (firstIconKey && game.textures.exists('icon_' + firstIconKey)) {
stateIcon = game.add.sprite(sx + T * 0.6, sy - T * 0.7, 'icon_' + firstIconKey)
.setOrigin(0.5).setDepth(9000).setScale(iconScale);
stateIcon.setData('baseScale', iconScale);
stateIcon.setVisible(false);
} else {
stateIcon = null;
}
fetchState();
}
/* ────────── update ────────── */
function updateScene(time, dt) {
if (time - lastFetch > 2000) { fetchState(); lastFetch = time; }
/* follow path */
if (path && pathIdx < path.length) {
const wp = path[pathIdx];
const tx = tileX(wp.col), ty = tileY(wp.row);
const dx = tx - star.x, dy = ty - star.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const step = SPEED * dt / 1000;
if (dist <= step + 0.5) {
star.x = tx;
star.y = ty;
charGridR = wp.row;
charGridC = wp.col;
pathIdx++;
} else {
star.x += (dx / dist) * step;
star.y += (dy / dist) * step;
}
/* pick move animation based on direction */
const anim = pickMoveAnim(dx, dy);
if (anim !== charAnim) { charAnim = anim; star.play(anim, true); }
} else {
/* arrived or no path — play state animation */
path = null;
const anim = SPECIAL.has(serverState) ? serverState : 'idle';
if (anim !== charAnim) { charAnim = anim; star.play(anim, true); }
/* idle wander */
if (serverState === 'idle' && Math.random() < 0.003) {
const nb = walkableNeighbor(charGridR, charGridC, 3);
if (nb) navigateTo(nb.row, nb.col);
}
}
/* Y-sort depth */
star.setDepth(Math.round(star.y / T) * 10 + 1);
/* walk wobble */
const wobble = path ? Math.sin(time / 120) * 0.4 : 0;
/* track shadow, state icon / emoji (follow + pulse), bubble */
shadow.setPosition(star.x, star.y + T * 0.55 + wobble * 0.3);
const stateX = star.x + T * 0.6;
const stateY = star.y - T * 0.7 + (path ? wobble * 0.25 : 0);
const pulse = 1 + 0.12 * Math.sin(time / 180);
stateEmoji.setPosition(stateX, stateY);
stateEmoji.setScale(pulse);
if (stateIcon) {
stateIcon.setPosition(stateX, stateY);
const baseScale = stateIcon.getData('baseScale') || (T * 1.1) / 24;
stateIcon.setScale(baseScale * pulse);
}
updateBubblePos();
/* bubble */
if (time > nextBubbleAt) {
showBubble();
nextBubbleAt = time + 6000 + Math.random() * 4000;
}
}
function pickMoveAnim(dx, dy) {
if (Math.abs(dx) >= Math.abs(dy))
return dx > 0 ? 'move_right' : 'move_left';
return dy > 0 ? 'move_down' : 'move_up';
}
function walkableNeighbor(r, c, radius) {
const tries = 10;
for (let i = 0; i < tries; i++) {
const nr = r + Math.round((Math.random() - 0.5) * radius * 2);
const nc = c + Math.round((Math.random() - 0.5) * radius * 2);
if (nr >= 0 && nr < ROWS && nc >= 0 && nc < COLS
&& map.collision[nr][nc] === 0 && (nr !== r || nc !== c))
return { row: nr, col: nc };
}
return null;
}
function navigateTo(row, col) {
const p = astar({ row: charGridR, col: charGridC }, { row, col });
if (p && p.length > 1) {
path = p.slice(1);
pathIdx = 0;
}
}
/* ================================================================
§8 Server state polling + POI navigation
================================================================ */
let prevServerState = 'idle';
async function fetchState() {
if (!core) return;
try {
const data = await core.invoke('read_state');
const raw = NORM_MAP[data.state] || data.state || 'idle';
serverState = SPECIAL.has(raw) ? raw : 'idle';
if (serverState !== prevServerState) {
prevServerState = serverState;
const iconKey = 'icon_' + serverState;
if (stateIcon && game.textures.exists(iconKey)) {
stateIcon.setTexture(iconKey).setVisible(true);
stateEmoji.setVisible(false);
} else {
if (stateIcon) stateIcon.setVisible(false);
stateEmoji.setVisible(true);
stateEmoji.setText(EMOJI[serverState] || '💤');
}
const poi = map.pois[serverState];
if (poi) navigateTo(poi.row, poi.col);
}
} catch (_) {}
}
/* ================================================================
§9 Character textures (16×16 fallback, 4 directions)
================================================================ */
function tex(key, fn) {
const g = game.make.graphics();
fn(g);
g.generateTexture(key, T, T);
g.destroy();
}
function buildCharTextures() {
tex('cf0', g => body(g, 'front', true));
tex('cf1', g => body(g, 'front', false));
tex('cfw0', g => { body(g, 'front', true); feet(g, 'v', 0); });
tex('cfw1', g => { body(g, 'front', true); feet(g, 'v', 1); });
tex('cb0', g => body(g, 'back'));
tex('cbw0', g => { body(g, 'back'); feet(g, 'v', 0); });
tex('cbw1', g => { body(g, 'back'); feet(g, 'v', 1); });
tex('cl0', g => body(g, 'left', true));
tex('clw0', g => { body(g, 'left', true); feet(g, 'h', 0); });
tex('clw1', g => { body(g, 'left', true); feet(g, 'h', 1); });
tex('cr0', g => body(g, 'right', true));
tex('crw0', g => { body(g, 'right', true); feet(g, 'h', 0); });
tex('crw1', g => { body(g, 'right', true); feet(g, 'h', 1); });
}
function body(g, dir, eyesOpen) {
const S = T;
const bx = Math.round(S * 0.15), by = Math.round(S * 0.1);
const bw = S - bx * 2, bh = Math.round(S * 0.8);
g.fillStyle(0xff6b35);
g.fillRect(bx, by, bw, bh);
g.fillStyle(0xffb347);
g.fillRect(bx + 1, by + 1, bw - 2, 1);
const ew = Math.max(2, Math.round(S * 0.18));
const eh = ew;
const ey = by + Math.round(bh * 0.28);
const pw = Math.max(1, Math.round(ew * 0.6));
switch (dir) {
case 'front': {
const e1x = bx + Math.round(bw * 0.18);
const e2x = bx + Math.round(bw * 0.55);
if (eyesOpen) {
g.fillStyle(0xffffff);
g.fillRect(e1x, ey, ew, eh);
g.fillRect(e2x, ey, ew, eh);
g.fillStyle(0x222222);
g.fillRect(e1x + 1, ey + 1, pw, pw);
g.fillRect(e2x + 1, ey + 1, pw, pw);
} else {
g.fillStyle(0x222222);
g.fillRect(e1x, ey + Math.round(eh / 2), ew, 1);
g.fillRect(e2x, ey + Math.round(eh / 2), ew, 1);
}
g.fillStyle(0xff8c69);
const mw = Math.max(2, Math.round(bw * 0.3));
g.fillRect(bx + Math.round((bw - mw) / 2), by + Math.round(bh * 0.7), mw, Math.max(1, Math.round(S * 0.1)));
break;
}
case 'back':
g.fillStyle(0xcc5522);
g.fillRect(bx + Math.round(bw * 0.25), by + Math.round(bh * 0.2), Math.round(bw * 0.5), 2);
break;
case 'left': {
const ex = bx + Math.round(bw * 0.12);
if (eyesOpen) {
g.fillStyle(0xffffff);
g.fillRect(ex, ey, ew, eh);
g.fillStyle(0x222222);
g.fillRect(ex, ey + 1, pw, pw);
} else {
g.fillStyle(0x222222);
g.fillRect(ex, ey + Math.round(eh / 2), ew, 1);
}
g.fillStyle(0xff8c69);
g.fillRect(bx, by + Math.round(bh * 0.7), Math.round(bw * 0.3), Math.max(1, Math.round(S * 0.1)));
break;
}
case 'right': {
const ex = bx + Math.round(bw * 0.55);
if (eyesOpen) {
g.fillStyle(0xffffff);
g.fillRect(ex, ey, ew, eh);
g.fillStyle(0x222222);
g.fillRect(ex + ew - pw, ey + 1, pw, pw);
} else {
g.fillStyle(0x222222);
g.fillRect(ex, ey + Math.round(eh / 2), ew, 1);
}
g.fillStyle(0xff8c69);
const mw = Math.round(bw * 0.3);
g.fillRect(bx + bw - mw, by + Math.round(bh * 0.7), mw, Math.max(1, Math.round(S * 0.1)));
break;
}
}
}
function feet(g, axis, frame) {
const S = T;
const bx = Math.round(S * 0.15);
const bw = S - bx * 2;
const fy = Math.round(S * 0.85);
const fw = Math.max(2, Math.round(bw * 0.25));
const fh = Math.max(1, Math.round(S * 0.1));
g.fillStyle(0xcc5522);
if (frame === 0) {
g.fillRect(bx + 1, fy, fw, fh);
} else {
g.fillRect(bx + bw - fw - 1, fy, fw, fh);
}
}
/* ================================================================
§10 Character animations
================================================================ */
function buildCharAnims() {
const defs = {
idle: { f: ['cf0','cf0','cf0','cf0','cf0','cf1'], r: 2 },
move_down: { f: ['cfw0','cf0','cfw1','cf0'], r: 6 },
move_up: { f: ['cbw0','cb0','cbw1','cb0'], r: 6 },
move_left: { f: ['clw0','cl0','clw1','cl0'], r: 6 },
move_right: { f: ['crw0','cr0','crw1','cr0'], r: 6 },
writing: { f: ['cf0','cf0','cf1','cf0'], r: 2 },
receiving: { f: ['cf0','cf1','cf0','cf1'], r: 3 },
replying: { f: ['cf0','cf0','cf0','cf1'], r: 2 },
researching:{ f: ['cf0','cf0','cf1','cf0'], r: 1.5 },
executing: { f: ['cf0','cf1','cf0','cf1'], r: 4 },
syncing: { f: ['cf0','cf0','cf0','cf1'], r: 1 },
error: { f: ['cf1','cf0','cf1','cf1'], r: 2 },
};
Object.entries(defs).forEach(([k, d]) => {
game.anims.create({
key: k,
frames: d.f.map(f => ({ key: f })),
frameRate: d.r,
repeat: -1
});
});
}
/* ================================================================
§11 Speech bubble (DOM-based, never clipped by canvas)
================================================================ */
const bubbleLayer = document.getElementById('bubble-layer');
let bubbleEl = null;
let bubbleTimer = null;
function showBubble() {
removeBubble();
const pool = BUBBLE[serverState] || BUBBLE.idle;
const text = pool[Math.floor(Math.random() * pool.length)];
bubbleEl = document.createElement('div');
bubbleEl.className = 'speech-bubble';
bubbleEl.textContent = text;
bubbleLayer.appendChild(bubbleEl);
updateBubblePos();
requestAnimationFrame(() => { if (bubbleEl) bubbleEl.style.opacity = '1'; });
bubbleTimer = setTimeout(() => {
if (bubbleEl) bubbleEl.style.opacity = '0';
setTimeout(removeBubble, 300);
}, 3500);
}
function removeBubble() {
if (bubbleTimer) { clearTimeout(bubbleTimer); bubbleTimer = null; }
if (bubbleEl) { bubbleEl.remove(); bubbleEl = null; }
}
function updateBubblePos() {
if (!bubbleEl || !star) return;
const winW = window.innerWidth;
const bw = bubbleEl.offsetWidth || 80;
const bh = bubbleEl.offsetHeight || 30;
const cx = star.x * ZOOM;
const cy = star.y * ZOOM + BUBBLE_PAD;
let x = cx - bw / 2;
let y = cy - T * ZOOM * 0.8 - bh;
x = Math.max(4, Math.min(x, winW - bw - 4));
y = Math.max(2, y);
bubbleEl.style.left = x + 'px';
bubbleEl.style.top = y + 'px';
}
})();
</script>
</body>
</html>

BIN
desktop-pet/src/ipix.ttf Executable file

Binary file not shown.

View file

@ -0,0 +1,340 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Star Mini</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root { --mini-status-sprite-gap: 0px; }
html, body {
width: 100%;
height: 100%;
background: transparent;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
font-family: Arial, sans-serif;
}
body.electron-shell,
body.electron-shell #wrap,
body.electron-shell #pet-box {
-webkit-app-region: drag;
}
body.electron-shell #status-pill,
body.electron-shell #pet-canvas {
-webkit-app-region: no-drag;
}
#wrap {
width: 100%;
height: 100%;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding-top: 8px;
gap: var(--mini-status-sprite-gap);
}
#status-pill {
max-width: 95%;
padding: 6px 10px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.72);
color: #eee;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
#pet-box {
width: 180px;
height: 180px;
border-radius: 6px;
background: transparent;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 0.14s ease, filter 0.18s ease;
}
#pet-canvas {
width: 140px;
height: 140px;
image-rendering: pixelated;
pointer-events: none;
transform: scale(1);
filter: drop-shadow(0 0 0 rgba(250, 244, 207, 0));
transition: transform 0.16s ease, filter 0.2s ease;
}
#pet-box:hover {
transform: translateY(-2px);
filter: brightness(1.04);
}
#pet-box:hover #pet-canvas {
filter: drop-shadow(0 0 8px rgba(250, 244, 207, 0.2));
}
#pet-box:active {
transform: translateY(0);
filter: brightness(0.98);
}
#hint { display: none; }
</style>
</head>
<body>
<div id="wrap">
<div id="status-pill">加载中...</div>
<div id="pet-box" title="点击恢复主窗口">
<canvas id="pet-canvas" width="140" height="140" aria-label="Star"></canvas>
</div>
<div id="hint">点击形象恢复主窗口</div>
</div>
<script>
(async function () {
const isTauri = !!window.__TAURI__;
const isElectron = !!window.__ELECTRON__;
const core = isTauri ? window.__TAURI__.core : null;
const eventApi = isTauri ? window.__TAURI__.event : null;
const win = isTauri ? window.__TAURI__.window.getCurrentWindow() : null;
const status = document.getElementById('status-pill');
const petCanvas = document.getElementById('pet-canvas');
const petCtx = petCanvas && petCanvas.getContext ? petCanvas.getContext('2d') : null;
if (isElectron) document.body.classList.add('electron-shell');
const BASE_URL = 'http://127.0.0.1:19000';
const STATIC_URL = `${BASE_URL}/static/`;
let uiLang = 'en';
const I18N = {
zh: { stateIdle: '待命', stateWriting: '整理文档', stateResearching: '搜索信息', stateExecuting: '执行任务', stateSyncing: '同步备份', stateError: '出错了', fallbackIdleDetail: '待命', connecting: '连接中...' },
en: { stateIdle: 'Standby', stateWriting: 'Organizing Docs', stateResearching: 'Researching', stateExecuting: 'Executing Tasks', stateSyncing: 'Syncing Backup', stateError: 'Error', fallbackIdleDetail: 'Standby', connecting: 'Connecting...' },
ja: { stateIdle: '待機', stateWriting: '文書整理', stateResearching: '情報検索', stateExecuting: 'タスク実行', stateSyncing: '同期バックアップ', stateError: 'エラー発生', fallbackIdleDetail: '待機', connecting: '接続中...' }
};
const t = (key) => ((I18N[uiLang] && I18N[uiLang][key]) || key);
// Keep exactly aligned with main page asset names.
const PET_ASSET_PATHS = {
idle: 'star-idle-v5.png',
working: 'star-working-spritesheet-grid.webp',
syncing: 'sync-animation-v3-grid.webp',
error: 'error-bug-spritesheet-grid.webp'
};
const PET_FRAME_CONFIG = {
idle: { frameW: 256, frameH: 256, fps: 12 },
working: { frameW: 300, frameH: 300, fps: 12 },
syncing: { frameW: 256, frameH: 256, fps: 12 },
error: { frameW: 220, frameH: 220, fps: 12 }
};
// Align perceived sprite size with main page defaults.
const PET_SCALE = { idle: 1.2, working: 1.4, syncing: 1.2, error: 1.2 };
let assetRefreshTick = Date.now();
let lastAssetKey = null;
let currentSpriteSrc = '';
let spriteImage = null;
let spriteMeta = { frameW: 1, frameH: 1, fps: 1, start: 0, end: 0, frames: 1 };
let currentFrame = 0;
let lastFrameAt = 0;
let rafId = null;
window.__miniLastState = { state: 'idle' };
function normalizeLang(rawLang) {
const v = String(rawLang || '').toLowerCase();
if (v === 'zh' || v === 'en' || v === 'ja') return v;
return 'en';
}
function normalizeState(state) {
if (!state) return 'idle';
if (state === 'working' || state === 'writing' || state === 'researching' || state === 'executing') return 'working';
if (state === 'sync' || state === 'syncing') return 'syncing';
if (state === 'error') return 'error';
return 'idle';
}
function stateLabel(rawState) {
const s = String(rawState || '').toLowerCase();
if (s === 'writing' || s === 'working') return t('stateWriting');
if (s === 'researching') return t('stateResearching');
if (s === 'executing' || s === 'run' || s === 'running') return t('stateExecuting');
if (s === 'syncing' || s === 'sync') return t('stateSyncing');
if (s === 'error') return t('stateError');
return t('stateIdle');
}
function buildPetSrcByState(rawState) {
const key = normalizeState(rawState);
const rel = PET_ASSET_PATHS[key] || PET_ASSET_PATHS.idle;
return { key, src: `${STATIC_URL}${rel}?v=${assetRefreshTick}` };
}
function resolveFrameRangeByState(stateKey, totalFrames) {
const maxIdx = Math.max(0, totalFrames - 1);
if (stateKey === 'working') {
return { start: 0, end: Math.min(37, maxIdx) };
}
if (stateKey === 'error') {
return { start: 0, end: Math.min(71, maxIdx) };
}
if (stateKey === 'syncing') {
if (totalFrames >= 3) {
const start = 1;
const end = Math.max(start, totalFrames - 2);
return { start, end };
}
return { start: 0, end: 0 };
}
// idle: use full available frames
return { start: 0, end: maxIdx };
}
function drawFrame() {
if (!petCtx || !spriteImage || !spriteMeta.frames) return;
const cols = Math.max(1, Math.floor(spriteImage.naturalWidth / spriteMeta.frameW));
const frame = spriteMeta.start + (currentFrame % spriteMeta.frames);
const sx = (frame % cols) * spriteMeta.frameW;
const sy = Math.floor(frame / cols) * spriteMeta.frameH;
petCtx.clearRect(0, 0, petCanvas.width, petCanvas.height);
petCtx.imageSmoothingEnabled = false;
petCtx.drawImage(
spriteImage,
sx, sy, spriteMeta.frameW, spriteMeta.frameH,
0, 0, petCanvas.width, petCanvas.height
);
}
function stopSpriteLoop() {
if (rafId) cancelAnimationFrame(rafId);
rafId = null;
}
function startSpriteLoop() {
stopSpriteLoop();
const tick = (ts) => {
if (!spriteImage || !spriteMeta.frames) return;
const interval = 1000 / Math.max(1, spriteMeta.fps || 1);
if (!lastFrameAt || ts - lastFrameAt >= interval) {
currentFrame = (currentFrame + 1) % Math.max(1, spriteMeta.frames);
drawFrame();
lastFrameAt = ts;
}
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
}
function loadSpriteByState(rawState) {
const next = buildPetSrcByState(rawState);
const cfg = PET_FRAME_CONFIG[next.key] || PET_FRAME_CONFIG.idle;
if (currentSpriteSrc === next.src && lastAssetKey === next.key) return;
const img = new Image();
img.onload = () => {
spriteImage = img;
const cols = Math.max(1, Math.floor(img.naturalWidth / cfg.frameW));
const rows = Math.max(1, Math.floor(img.naturalHeight / cfg.frameH));
const totalFrames = Math.max(1, cols * rows);
const range = resolveFrameRangeByState(next.key, totalFrames);
const frames = Math.max(1, range.end - range.start + 1);
spriteMeta = {
frameW: cfg.frameW,
frameH: cfg.frameH,
fps: cfg.fps,
start: range.start,
end: range.end,
frames
};
currentFrame = 0;
lastFrameAt = 0;
drawFrame();
if (frames > 1) startSpriteLoop();
else stopSpriteLoop();
currentSpriteSrc = next.src;
lastAssetKey = next.key;
};
img.onerror = () => {
if (next.key !== 'idle') {
currentSpriteSrc = '';
lastAssetKey = null;
loadSpriteByState('idle');
}
};
img.src = next.src;
}
function applyState(data, instant = false) {
const payload = data || { state: 'idle' };
uiLang = normalizeLang(payload.ui_lang || uiLang);
window.__miniLastState = payload;
const state = payload.state || 'idle';
const detail = payload.detail || t('fallbackIdleDetail');
status.textContent = `[${stateLabel(state)}] ${detail}`;
loadSpriteByState(state);
const scale = PET_SCALE[normalizeState(state)] || 1;
if (instant) {
const prevTransition = petCanvas.style.transition;
petCanvas.style.transition = 'none';
petCanvas.style.transform = `scale(${scale})`;
requestAnimationFrame(() => {
petCanvas.style.transition = prevTransition || 'transform 0.16s ease, filter 0.2s ease';
});
} else {
petCanvas.style.transform = `scale(${scale})`;
}
}
async function fetchStatus() {
try {
if (core) {
const data = await core.invoke('read_state');
applyState(data);
return;
}
const resp = await fetch(`${BASE_URL}/status`, { cache: 'no-store' });
if (!resp.ok) throw new Error('bad status');
const data = await resp.json();
applyState(data);
} catch (_) {
status.textContent = t('connecting');
}
}
if (eventApi && eventApi.listen) {
try {
await eventApi.listen('mini-sync-state', (evt) => {
if (evt && evt.payload) applyState(evt.payload, true);
});
} catch (_) {}
}
let downAt = null;
let dragTriggered = false;
const DRAG_THRESHOLD = 6;
document.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
downAt = { x: e.clientX, y: e.clientY };
dragTriggered = false;
});
document.addEventListener('pointermove', async (e) => {
if (!downAt || dragTriggered || !win) return;
const moved = Math.hypot(e.clientX - downAt.x, e.clientY - downAt.y);
if (moved < DRAG_THRESHOLD) return;
dragTriggered = true;
try { await win.startDragging(); } catch (_) {}
});
document.addEventListener('pointerup', async () => {
if (!downAt) return;
const wasDrag = dragTriggered;
downAt = null;
dragTriggered = false;
if (!wasDrag && core) {
try { await core.invoke('restore_main_window'); } catch (_) {}
}
});
document.addEventListener('contextmenu', async (e) => {
e.preventDefault();
if (!core) return;
try { await core.invoke('close_app'); } catch (_) {}
});
await fetchStatus();
setInterval(fetchStatus, 2000);
})();
</script>
</body>
</html>

View file

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

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

@ -0,0 +1,42 @@
# CHANGELOG — 2026-03
## 2026-03-06
- 默认端口从 `18791` 调整为 `19000`,避开 OpenClaw Browser Control 端口冲突
- 同步更新 `office-agent-push.py`、`healthcheck.sh`、`scripts/smoke_test.py` 的默认地址
- 同步更新 Tauri / Electron 桌面壳默认连接地址
- 同步更新 README / SKILL / join-office 文档中的本地访问与 tunnel 示例
# Changelog — 2026-03 Refresh
## Highlights
- Added robust asset editing workflow in drawer (select/deselect, highlight sync, default/override split)
- Added EN/JP/CN language buttons with real-time UI + loading + bubble text switching
- Added room loading overlay with emoji rotation and localized copy
- Fixed layering/layout issues (drawer overlap, detail overflow, canvas border fit)
- Completed multi-round state sprite replacement pipeline (Writing/Idle/Syncing/Error) with auto frame sync
- Updated syncing behavior: non-sync shows frame 0, syncing starts from frame 1
- Disabled error movement path (error anim stays in place)
- Removed GIF legacy assets and stale references
- Restored `assets/room-reference.png` for reference background restore
- Added configurable asset drawer password via env (`ASSET_DRAWER_PASS`, default `1234`)
- Improved startup performance:
- static assets use long cache headers
- local phaser vendor restored
## Security / Config
- Asset drawer default pass changed to `1234`
- Recommend deployment override:
- `ASSET_DRAWER_PASS=<your-strong-pass>`
- Rationale: prevent unauthorized layout/asset modifications from shared links
## AI model recommendation for room generation
For best style-transfer quality (while preserving room structure), recommend:
1. gemini nanobanana pro
2. gemini nanobanana 2
Other models may produce unstable structure consistency.

View file

@ -0,0 +1,38 @@
# Star Office UI — 新增功能说明(本阶段)
## 1. 多龙虾访客系统
- 支持多个远端 OpenClaw 同时加入同一办公室。
- 访客支持独立头像、名字、状态、区域、气泡。
- 支持动态上下线与实时刷新。
## 2. Join Key 机制升级
- 从“一次性 key”升级为“固定可复用 key”。
- 默认 key`ocj_starteam01` ~ `ocj_starteam08`
- 保留安全控制:每个 key 的并发上限 `maxConcurrent`(默认 3
## 3. 并发控制(已修复竞态)
- 修复并发 join 的竞态问题race condition
- 同 key 第 4 个并发 join 会被正确拒绝HTTP 429
## 4. 访客状态映射与区域渲染
- `idle -> breakroom`
- `writing/researching/executing/syncing -> writing`
- `error -> error`
- 访客气泡文案与状态同步,不再错位。
## 5. 访客动画与资源优化
- 访客由静态图升级为动画精灵(像素风)。
- `guest_anim_1~6` 已提供 webp 版本,减少加载体积。
## 6. 名字与气泡显示优化
- 非 demo 访客名字与气泡位置上移,避免角色遮挡。
- 气泡锚点改为基于名字定位,保障“气泡在名字上方”。
## 7. 移动端展示
- 页面可在手机端直接访问与展示。
- 布局已进行基础移动端适配,满足演示场景。
## 8. 远端推送脚本联调改进
- 支持从状态文件读取并推送状态到 office。
- 增加状态来源诊断日志(用于定位“为何一直 idle”
- 修复 AGENT_NAME 环境变量覆盖时序问题。

View file

@ -0,0 +1,91 @@
# Star Office UI — 开源发布准备清单(仅准备,不上传)
## 0. 当前目标
- 本文档用于“发布前准备”,不执行实际上传。
- 所有 push 行为需海辛最终明确批准。
## 1. 隐私与安全审查结果(当前仓库)
### 发现高风险文件(必须排除)
- 运行日志:
- `cloudflared.out`
- `cloudflared-named.out`
- `cloudflared-quick.out`
- `healthcheck.log`
- `backend.log`
- `backend/backend.out`
- 运行状态:
- `state.json`
- `agents-state.json`
- `backend/backend.pid`
- 备份/历史文件:
- `index.html.backup.*`
- `index.html.original`
- `*.backup*` 目录与文件
- 本地虚拟环境与缓存:
- `.venv/`
- `__pycache__/`
### 发现潜在敏感内容
- 代码内含绝对路径 `/root/...`(建议改为相对路径或环境变量)
- 文档与脚本含私有域名 `office.example.com`(可保留为示例,但建议改成占位域名)
## 2. 必改项(提交前)
### A. .gitignore需补齐
建议新增:
```
*.log
*.out
*.pid
state.json
agents-state.json
join-keys.json
*.backup*
*.original
__pycache__/
.venv/
venv/
```
### B. README 版权声明(必须新增)
新增“美术资产版权与使用限制”章节:
- 代码按开源协议(如 MIT
- 美术素材归原作者/工作室所有
- 素材仅供学习/演示,**禁止商用**
### C. 发布目录瘦身
- 清理运行日志、运行态文件、备份文件
- 仅保留“可运行最小集 + 必要素材 + 文档”
## 3. 准备中的发布包建议结构
```
star-office-ui/
backend/
app.py
requirements.txt
run.sh
frontend/
index.html
game.js (若仍需要)
layout.js
assets/* (仅可公开素材)
office-agent-push.py
set_state.py
state.sample.json
README.md
LICENSE
SKILL.md
docs/
```
## 4. 发布前最终核对(给海辛确认)
- [ ] 是否保留私有域名示例(`office.example.com`
- [ ] 哪些美术资源允许公开(逐项确认)
- [ ] README 非商用声明是否满足你的预期措辞
- [ ] 是否需要将“阿文龙虾联调脚本”单独放 examples 目录
## 5. 当前状态
- ✅ 文档准备完成总结、功能说明、Skill v2、发布检查清单
- ⏳ 等待海辛确认“公开素材范围 + 声明文案 + 是否开始执行打包清理脚本”
- ⛔ 尚未执行 GitHub 上传

View file

@ -0,0 +1,232 @@
# Star-Office-UI 项目维护 SOP轻量版
> 目标:让 Star-Office-UI 在继续增长的同时,保持仓库干净、回复友好、节奏稳定、社区感明确。
---
## 1. 总原则
### 1.1 关闭 issue / PR 时,一定留一句 closure reason
无论是:
- 已修复
- 重复
- 超出当前范围
- 提问者自行取消
- 已被其他 PR / issue 吸收
都尽量留一句话说明原因。
**最小模板:**
- Fixed in `commit/PR #xxx`, thanks for the report!
- Closing as duplicate of #xxx, thank you!
- Out of current scope for now, but welcome a focused PR.
- Canceled by requester / resolved in latest master.
目标不是“正式”,而是让后来人一眼看懂为什么被关。
---
## 2. Issue 处理规则
### 2.1 先判断 issue 类型
收到 issue 后,先分到四类之一:
#### A. Bug report
特征:报错、页面打不开、功能异常、状态不对
处理方式:
1. 复现 / 判断是否已知问题
2. 如果已修:回复 + 给 commit / PR 号
3. 如果未修:标记为待处理,必要时自己修 / 等 PR
4. close 时一定写清楚“修在哪了”
**推荐回复模板:**
> 感谢反馈!这个问题已在 `PR #xx` / `commit xxx` 中修复。请拉取最新 master 后再试一下,如果还有问题欢迎继续反馈。
---
#### B. Support / setup question
特征:怎么部署、为什么 Unauthorized、如何自动同步等
处理方式:
1. 先回答问题
2. 给最短路径README / SKILL / 命令)
3. 如果文档能优化,顺手记成后续动作
4. close 时说明“问题已答复,如仍有问题欢迎 reopen”
**推荐回复模板:**
> 这个问题大概率和 xxx 有关。最新版已经做了相关修复 / 文档补充。你可以先试试最新 master如果还有问题欢迎重新打开 issue。
---
#### C. Feature request
特征:希望支持某个新能力、新方向、新体验
处理方式:
1. 明确是否感兴趣
2. 不要误关成“已修复”
3. 如果暂不做,也要说清楚“当前不做,但欢迎 PR / 后续讨论”
**推荐回复模板:**
> 这是个很好的方向,我们对这个想法感兴趣。不过它还不是当前阶段的既定工作项。如果你愿意推进,欢迎提一个更聚焦的 PR我们可以一起讨论实现方式。
---
#### D. Duplicate / canceled / absorbed
特征:重复提问、提问者自己放弃、已被其他 issue 吸收
处理方式:
1. 链接到对应 issue / PR
2. 简短说明关闭原因
3. 保持礼貌
---
## 3. PR 处理规则
### 3.1 Merge 前检查四件事
#### 1) 这个 PR 是不是解决了真实问题?
- 是 bug fix 还是只是作者个人偏好?
- 是否对应某个 issue / 用户痛点?
#### 2) 改动范围是否可控?
- 小而聚焦 → 倾向合并
- 大而混杂 → 要求拆分 / 暂缓
#### 3) 是否引入额外维护负担?
- 新依赖
- 新配置
- 新架构
- 新文档成本
#### 4) 是否需要同步 README / changelog / release notes
如果影响用户使用路径,必须同步文档。
---
### 3.2 PR 结果分三类
#### A. 直接合并
适合:
- 小 bug fix
- 文档修正
- 明确提升 onboarding / 稳定性
#### B. 关闭但感谢
适合:
- 已被 master 提前修复
- 重复 PR
- 方向不错但当前不合适
**原则:不合并 ≠ 否定贡献者**
#### C. 请求作者调整后再看
适合:
- 思路对,但改动太大
- 混入不相关内容
- 需要拆小
---
### 3.3 关闭 PR 时,尽量做到三件事
1. **先感谢**
2. **再说明原因**
3. **如果 possible指出未来更容易被接受的方向**
**推荐模板:**
> Thanks for the PR — this is a thoughtful direction. Were not merging it right now because xxx. If youd like, a smaller / more focused PR around yyy would be much easier for us to review and land.
---
## 4. Release / 大版本收口流程
适用于:
- 一轮 bug fix 完成
- 一次文档重构完成
- 一次功能包发布(如 v1.0
### 发布前 checklist
- [ ] 关键功能本机验证一次(至少 health / status / agents / set_state
- [ ] smoke test 跑通
- [ ] README / SKILL / relevant docs 已同步
- [ ] CHANGELOG 已更新
- [ ] 相关 issue 已回复 / 关闭
- [ ] 如果有贡献者,考虑在 README / release note 致谢
- [ ] 确认仓库 worktree 干净,没有误提交文件
### Release note 结构建议
1. 这次版本是什么
2. 核心变化 3-5 条
3. 对用户有什么实际影响
4. 快速体验方式
5. 感谢贡献者
---
## 5. README / 文档维护规则
### 5.1 README 优先回答四个问题
1. 这是什么?
2. 适合谁?
3. 最快怎么用?
4. 如果我是 OpenClaw 用户,最短路径是什么?
### 5.2 文档更新触发条件
以下情况发生时,要同步 README / docs
- 默认端口改变
- 默认安装方式改变
- 核心依赖或路径改变
- onboarding 流程改变
- 修复了高频 issue尤其是部署 / 401 / loading 这类)
---
## 6. 社区关系维护
### 6.1 要主动做的三件事
- 在 README 或 release note 感谢明显贡献者
- 对早期贡献者保持尊重,即使 PR 没合并
- 对误解 / 错判及时补充说明
### 6.2 哪些 contributor 值得重点维护
优先维护这些人:
- 连续提多个高质量 PR 的
- 会主动补文档 / onboarding 的
- 不只是修自己问题,而是在帮项目补完整性的
---
## 7. 当前阶段最适合 Star-Office-UI 的维护策略
### 适合优先接收
- bug fix
- onboarding 改进
- 文档优化
- 小而明确的稳定性修复
- 与 OpenClaw / agent 体验强相关的增强
### 暂时谨慎对待
- 大规模重构
- 引入重依赖
- 强绑定某个个人工作流的改动
- 边界不清的大 feature
---
## 8. Star 自己要记住的维护原则
- 不要为了“显得热情”而模糊关闭原因
- 不要把 feature request 当成 bug fix 关掉
- 不要 merge 之后忘了补文档
- 不要忽略早期贡献者
- 仓库看起来干净,本身就是产品体验的一部分
---
## 一句话版本
> **小问题及时收口,大问题说清边界;每次关闭都留痕,每次发布都成阶段。**

View file

@ -0,0 +1,99 @@
# Star Office UI — 项目阶段总结2026-03-01
## 一、今日工作总结
今天主要完成了两条主线:
1. **多龙虾(多 OpenClaw加入办公室能力稳定化**
2. **手机版展示能力完善**
并且围绕“阿文龙虾状态同步不稳定”做了多轮排查,明确了链路问题与当前未完全闭环点。
---
## 二、已完成能力(可对外描述)
### 1) 多 Agent 加入与显示
- 支持多个远端 OpenClaw 通过 `join-agent` 加入办公室。
- 每个访客有独立 `agentId`、名字、状态、区域与动画。
- 场景会基于 `/agents` 动态创建、更新、移除访客。
### 2) 固定可复用 Join Key 机制
- 一次性 key 改为固定可复用 key`ocj_starteam01` ~ `ocj_starteam08`
- 去掉了“used 即不可再用”的阻断逻辑,支持长期复用。
- 加入了并发上限配置(`maxConcurrent`),默认每个 key 限 3 并发在线。
### 3) 并发限制修复(关键)
- 发现 4 并发仍能通过的根因是后端竞争条件race condition
- 在 `join-agent` 临界区增加锁 + 锁内重读状态,修复后压测通过:
- 前 3 个 200
- 第 4 个 429
### 4) 访客动画与性能优化
- 访客动画改为像素动画精灵,不再是静态星星。
- `guest_anim_1~6` 已转为 `.webp`,显著降低加载体积。
- 前端预加载与渲染资源已切换到 webp 优先。
### 5) 状态 → 区域映射统一
- 规则统一:
- `idle -> breakroom`
- `writing/researching/executing/syncing -> writing`
- `error -> error`
- 访客 bubble 文案已按状态做映射,不再与区域脱节。
### 6) 名字与气泡层级/位置优化
- 非 demo 访客名字、气泡位置上移,减少遮挡。
- 访客气泡锚点改为相对名字计算,确保“气泡在名字上方”。
- demo 与真实访客路径已区分,互不干扰。
### 7) 手机版展示
- 现有 UI 在手机端可访问与展示,适合演示与外部查看。
- 关键控件布局做过整理,移动端基本可用。
---
## 三、当前未完全闭环点(诚实披露)
### 阿文龙虾“真实状态稳定同步”仍存在偶发不一致
虽然链路已多次验证打通writing 能进工作区、idle 能回休息区),但线上实测仍出现过:
- 本地脚本持续推 idle旧版本脚本 / 读错状态源)
- 403 未授权(离线状态恢复/旧 agentId 缓存问题)
- 前台退出触发 leave-agent 后角色消失
> 结论:
> - “机制可行、链路可通”已经验证;
> - “端到端持续稳定”还需要继续收口(尤其阿文侧运行脚本版本统一、状态源统一、常驻策略统一)。
---
## 四、今天新增/调整文件(核心)
- `backend/app.py`
- join 并发限制加锁修复
- offline/approved 授权流逻辑调整(便于恢复)
- `join-keys.json`
- 固定 key + `maxConcurrent: 3`
- `frontend/index.html`(及相关渲染逻辑)
- 访客动画、名字与气泡定位优化
- 状态文案映射调整
- `office-agent-push.py`(多版本并行调试)
- 增加状态源诊断日志
- 增加环境变量覆盖逻辑
- 修复 AGENT_NAME 读取时机问题
---
## 五、对外开源前建议描述(建议文案)
> Star Office UI 是一个可视化多 Agent 像素办公室:
> 支持多个 OpenClaw 远端接入、状态驱动位置渲染、访客动画与移动端访问。
> 项目当前已完成多 Agent 主链路与 UI 能力;状态同步稳定性仍在持续优化中。
---
## 六、下一步(建议)
1. 统一阿文侧运行脚本“唯一来源”,避免旧版本混跑。
2. 增加 `/agent-push` 与前端渲染诊断日志(可开关)。
3. 增加“状态过期自动 idle”兜底脚本侧 + 服务端侧双保险)。
4. 补一份可复现联调流程10 分钟 smoke test
5. 完成开源前隐私清理与发布清单(见 `docs/OPEN_SOURCE_RELEASE_CHECKLIST.md`)。

View file

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

View file

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

View file

@ -0,0 +1,61 @@
# Star Office UI — 功能说明Overview
Star Office UI 是一个“像素办公室”可视化界面,用来把 AI 助手/多个 OpenClaw 访客的状态,渲染成可在网页(含手机)查看的小办公室场景。
## 你能看到什么
- 像素办公室背景(俯视图)
- 角色Star + 访客)会根据状态在不同区域移动
- 名字与气泡bubble展示当前状态/想法(可自定义映射)
- 手机端打开也能展示(适合作品展示/直播/对外演示)
## 核心能力
### 1) 单 Agent本地 Star状态渲染
- 后端读取 `state.json` 提供 `GET /status`
- 前端轮询 `/status`,根据 `state` 渲染 Star 所在区域
- 提供 `set_state.py` 快速切换状态
### 2) 多访客(多龙虾)加入办公室
- 访客通过 `POST /join-agent` 加入,获得 `agentId`
- 访客通过 `POST /agent-push` 持续推送自己的状态
- 前端通过 `GET /agents` 拉取访客列表并渲染
### 3) Join Key接入密钥机制
- 支持固定可复用 join key`ocj_starteam01~08`
- 支持每个 key 的并发在线上限(默认 3
- 便于控制“谁能进办公室”和“同一个 key 同时可进几只龙虾”
### 4) 状态 → 区域映射(统一逻辑)
- idle → breakroom休息区
- writing / researching / executing / syncing → writing工作区
- error → error故障区
### 5) 访客动画与性能优化
- 访客角色使用动画精灵
- 支持 WebP 资源(体积更小、加载更快)
### 6) 名字/气泡不遮挡的布局
- 真实访客与 demo 访客分离逻辑
- 非 demo 访客名字与气泡整体上移
- bubble 锚定在名字上方,避免压住名字
### 7) Demo 模式(可选)
- `?demo=1` 才显示 demo 访客(默认不显示)
- demo 与真实访客互不影响
## 主要接口Backend
- `GET /`:前端页面
- `GET /status`:单 agent 状态(兼容旧版)
- `GET /agents`:多 agent 列表(访客渲染用)
- `POST /join-agent`:访客加入
- `POST /agent-push`:访客推送状态
- `POST /leave-agent`:访客离开
- `GET /health`:健康检查
## 安全与隐私注意
- 不要把隐私信息写进 `detail`(因为会被渲染/可被拉取)
- 开源前必须清理日志、运行态文件、join keys、隧道输出等
## 美术资产使用声明(必须)
- 代码可开源,但美术素材(背景、角色、动画等)版权归原作者/工作室所有。
- 美术资产仅供学习与演示,**禁止商用**。

View file

@ -0,0 +1,141 @@
# Star Office UI 更新文档P0 / P1
更新时间2026-03-04
分支:`feat/office-art-rebuild`
---
## 1. 更新目标
本轮更新目标分为两层:
- **P0安全与可发布性**(防泄漏、防弱配置、上线前可自检)
- **P1结构与稳定性优化**(不减功能、提升状态同步与加载体验)
同时处理了线上关键问题:
- 服务偶发 502进程/服务启动方式不稳定)
- 角色状态与真实工作状态不一致(尤其“回复结束仍在工位”)
---
## 2. P0 已完成项
### 2.1 后端安全基线加固
- 增加生产模式安全校验(弱密钥/弱口令阻止启动)
- Session Cookie 安全参数加固HttpOnly / SameSite / Secure
- `runtime-config.json` 写入后自动尝试收紧文件权限(`600`
### 2.2 敏感文件治理
- `.gitignore` 补充运行态文件与高风险文件
- 引入样例文件替代运行态文件:
- `join-keys.sample.json`
- `.env.example`
- `join-keys.json` 改为运行时初始化,不再作为仓库内固定配置
### 2.3 上线前安全自检能力
- 新增 `scripts/security_check.py`
- 可检查:
- 弱 secret / 弱口令
- 风险文件是否被 git 跟踪
- 常见敏感 token 模式
---
## 3. P1 已完成项(不改业务能力)
### 3.1 后端结构拆分
在不改变现有 API 行为前提下,把 `backend/app.py` 拆出:
- `backend/security_utils.py`
- `backend/memo_utils.py`
- `backend/store_utils.py`
收益:
- 降低单文件复杂度
- 降低后续功能改动时的回归风险
- 提升可读性与维护效率
### 3.2 状态同步修复(核心)
- 修复状态源路径优先级(避免读取错误状态文件)
- 增加 stale 状态自动回 `idle` 机制(避免假工作中)
- 前端状态轮询改为更快节奏并强制视觉对齐,避免动画卡旧状态
### 3.3 生图模型策略收敛
按需求收敛为两种用户模型语义:
- `nanobanana-pro`
- `nanobanana-2`
并补充 provider 映射与错误细节透出,提升可诊断性。
### 3.4 首屏性能与体感优化
- 首页 HTML 缓存(后端进程内缓存)
- 非关键初始化延后(先出画面)
- 加入画布骨架屏,减少“黑屏 + 长时间加载中”体感
- 加速 loading overlay 淡出
---
## 4. 线上稳定性修复(本轮重点)
### 4.1 502 根因
Cloudflare 正常,但 `18888` 源站进程存在不稳定/启动方式不一致,导致偶发 connection refused。
### 4.2 已处理
- 修复并统一 `star-office-ui.service` 启动方式systemd 常驻)
- 清理手工临时启动造成的端口抢占
- 重启并验证:
- `star-office-ui.service` 运行正常
- `star-office-push.service` 运行正常
---
## 5. 当前已知风险 / 待跟进
1. **状态策略仍需完全事件化**
- 目前已大幅收敛误判,但建议后续做单一状态控制器(显式事件优先,彻底禁用隐式推断)
2. **进程模型仍是 Flask 开发服务器**
- 当前可用但不理想,后续建议迁移为 gunicorn/uvicorn 等生产进程模型
3. **动画状态同步仍建议增加端到端回归脚本**
- 尤其 writing / syncing / error / idle 切换链路
---
## 6. 验收建议(人工)
验收地址:`https://simonoffice.hyacinth.im/`
建议至少覆盖:
1. 首页进入速度与骨架屏体验
2. 状态切换writing / syncing / error / idle
3. 回复结束后是否回到待命区
4. 生图两入口(搬新家 / 找中介)
5. 断网或服务短时波动后是否自动恢复
---
## 7. 提交范围(摘要)
本轮主要覆盖:
- 安全与配置P0
- 后端重构P1
- 状态同步与动画一致性修复
- 生图模型策略与错误诊断
- 加载性能与体验优化
- systemd 常驻与稳定性修复
如需 PR 附件,可直接将本文件作为“更新说明 / Release Notes”。

View file

@ -0,0 +1,100 @@
# 更新报告 — 2026-03-05
> 本次更新覆盖 8 个 commit聚焦「稳定性修复 + 移动端体验 + 安全收尾」。
---
## 变更概览
| # | Commit | 分类 | 说明 |
|---|--------|------|------|
| 1 | `878793d` | 🐛 fix | 修复 CDN 缓存 404 导致页面无法加载 |
| 2 | `cc22403` | 🐛 fix | 修复 `fetchStatus()` 中多余的 `else` 块导致 JS 语法错误 |
| 3 | `103f944` | 🐛 fix | 生图接口改为异步任务模式,避免 Cloudflare 524 超时 |
| 4 | `ee141de` | 🧹 chore | 清理本地测试时意外提交的文件 |
| 5 | `83e61ff` | 🧹 chore | 将 `join-keys.json` 加入 `.gitignore`(运行时数据不入库) |
| 6 | `899f27e` | 🐛 fix | 移动端/iPad 侧边栏修复(遮罩层 + body 滚动锁定 + `100dvh` |
| 7 | `5aef430` | 🐛 fix | 移动端 drawer 关闭时完全移出屏幕(`right: -100vw` |
| 8 | `02a731e` | ✨ feat | 新增 join key 级别过期时间 + 并发上限支持 |
---
## 详细说明
### 1. 修复 CDN 缓存 404`878793d`
**问题**`/static/` 路径下的所有响应(含 404都被设置了一年长缓存头。Cloudflare 缓存了 `phaser.js` 的 404 响应长达 2.7 天,导致 `office.hyacinth.im` 完全无法加载。
**修复**
- `add_no_cache_headers` 仅对 2xx 响应设置长缓存,非 2xx 响应设为 no-cache
- 给 `phaser.js``<script>` 标签添加 `?v={{VERSION_TIMESTAMP}}` 缓存破坏参数
### 2. 修复 fetchStatus JS 语法错误(`cc22403`
**问题**`fetchStatus()` 函数内 `try/catch` 之间存在一个孤立的 `} else { ... }` 块,破坏了 JS 语法结构,导致浏览器报 `Missing catch or finally after try`,整个页面卡在 loading。
**修复**:移除多余的 `else` 块(其中的打字机逻辑已被前面的 `if/else` 分支覆盖)。
> ⚠️ 此 bug 是 GitHub 上 PR #49、#51、#52 同时在修的问题,三个 PR 现在可以关闭。
### 3. 生图接口异步化(`103f944`
**问题**`POST /assets/generate-rpg-background` 是同步的,生图通常需要 30~120 秒。Cloudflare 的代理超时限制为 100 秒HTTP 524导致公网用户频繁触发超时。
**修复**
- 后端:拆分为 `_bg_generate_worker`(后台线程)+ `POST /assets/generate-rpg-background`(返回 `task_id`+ `GET /assets/generate-rpg-background/poll`(轮询结果)
- 前端:新增 `_startAndPollGeneration()` 函数,提交任务后每 3 秒轮询,显示实时等待进度
- 同时抽取了 `_handleGenError()` 统一错误处理DRY 优化)
- 防重入:如果已有生图任务在跑,直接返回已有 `task_id`
### 4-5. 清理与 gitignore`ee141de` + `83e61ff`
- 清理了测试时意外提交的文件
- 将 `join-keys.json` 加入 `.gitignore`(包含密钥数据,不应入库)
### 6-7. 移动端侧边栏修复(`899f27e` + `5aef430`
**问题**:移动端/iPad 打开资产侧边栏时,背后的页面仍可滚动;关闭侧边栏后 drawer 只偏移 -320px在宽屏移动设备上仍可见。
**修复**
- 新增 `#asset-drawer-backdrop` 遮罩层,点击即关闭 drawer
- 打开 drawer 时 `body``drawer-open` class`overflow:hidden; position:fixed; touch-action:none`
- 关闭时恢复 `scrollY` 位置(避免跳顶部)
- Drawer 关闭状态改为 `right: -100vw`(完全移出视口)
- 使用 `100dvh` 适配移动端 dynamic viewport
- 添加 `overscroll-behavior: contain` 防止 drawer 内滚动穿透
### 8. Join Key 级别过期时间(`02a731e`
**新增功能**
- `join-keys.json` 中每个 key 支持 `expiresAt` 字段ISO 8601 时间戳)
- `join-agent``agent-push` 两个端点在执行前都会检查 key 是否过期
- 过期后返回友好提示:"该接入密钥已过期,活动已结束 🎉"
- 支持 `maxConcurrent` 字段控制同一个 key 的并发在线数
---
## 潜在风险评估
| 风险点 | 等级 | 说明 |
|--------|------|------|
| 异步任务内存泄漏 | 🟡 低 | `_bg_tasks` 在任务完成并被 poll 消费后会清理;但如果前端从未 poll如用户关闭页面任务对象会残留。当前风险极低生图频率低后续可加定期清理。 |
| `join-keys.json` 历史泄露 | 🟢 已解决 | 已加入 `.gitignore`,但如果之前有 commit 包含此文件,历史中仍存在。建议确认远端历史是否干净。 |
| 前端 `fetchStatus` 修复 | 🟢 已验证 | 修复后的 `try/catch` 结构完整,本地运行正常。 |
| 移动端 drawer `position:fixed` | 🟢 低 | iOS Safari 下 `position:fixed` + `100dvh` 的组合偶有兼容问题,但已是业界最佳实践。 |
**结论:无新增 bug 风险,可以安全推送。**
---
## 文件变更统计
```
.gitignore | 1 +
backend/app.py | 166 ++++++++++++++++++------
frontend/index.html | 162 ++++++++++++++++--------
frontend/join-office-skill.md | 102 +++++++++------
frontend/office-agent-push.py | 286 ++++++++++++++++++++++++++++++++++++++++++
office-agent-push.py | 2 +-
共 6 个文件,+589 行,-130 行
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 756 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 850 KiB

32
electron-shell/README.md Normal file
View file

@ -0,0 +1,32 @@
# Star Desktop Pet (Electron Shell)
这个目录是 Electron 版桌面壳,和现有 Tauri 版并行存在,方便逐步迁移。
## 已接入能力
- 复用原有前端:`http://127.0.0.1:19000/?desktop=1`
- 复用 mini 页面:`desktop-pet/src/minimized.html`
- 启动时自动拉起 Python backend若未运行
- 主窗口 / mini 窗口切换
- 托盘menu bar常驻菜单
- 通过 preload 注入 `window.__TAURI__` 兼容层,尽量少改现有前端逻辑
## 启动方式
```bash
cd "/Users/wangzhaohan/Documents/GitHub/Star-Office-UI/electron-shell"
npm install
npm run dev
```
## 可选环境变量
- `STAR_PROJECT_ROOT`:项目根目录(默认自动探测)
- `STAR_BACKEND_PYTHON`:后端 Python 可执行路径
- `STAR_BACKEND_HOST`:后端主机(默认 `127.0.0.1`
- `STAR_BACKEND_PORT`:后端端口(默认 `19000`
## 说明
- 当前阶段是“可运行迁移骨架”,目的是先替换桌面容器层。
- 现有 Tauri 目录不受影响,可随时回滚或并行对比。

543
electron-shell/main.js Normal file
View file

@ -0,0 +1,543 @@
const { app, BrowserWindow, Tray, Menu, ipcMain, shell, nativeImage } = require("electron");
const { spawn } = require("child_process");
const fs = require("fs");
const path = require("path");
const net = require("net");
const APP_NAME = "Star Office UI";
const BACKEND_HOST = process.env.STAR_BACKEND_HOST || "127.0.0.1";
const rawBackendPort = Number(process.env.STAR_BACKEND_PORT || 19000);
const BACKEND_PORT = Number.isFinite(rawBackendPort) && rawBackendPort > 0 ? rawBackendPort : 19000;
const BACKEND_BASE_URL = `http://${BACKEND_HOST}:${BACKEND_PORT}`;
let mainWindow = null;
let miniWindow = null;
let assetWindow = null;
let tray = null;
let backendChild = null;
let isQuitting = false;
let currentUiLang = "en";
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function tcpReachable(host, port, timeoutMs = 500) {
return new Promise((resolve) => {
const socket = new net.Socket();
let settled = false;
const done = (ok) => {
if (settled) return;
settled = true;
socket.destroy();
resolve(ok);
};
socket.setTimeout(timeoutMs);
socket.once("connect", () => done(true));
socket.once("timeout", () => done(false));
socket.once("error", () => done(false));
socket.connect(port, host);
});
}
async function waitBackendReady(timeoutMs = 20000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await tcpReachable(BACKEND_HOST, BACKEND_PORT, 400)) return true;
await sleep(200);
}
return false;
}
function findProjectRoot() {
if (process.env.STAR_PROJECT_ROOT) {
const custom = path.isAbsolute(process.env.STAR_PROJECT_ROOT)
? process.env.STAR_PROJECT_ROOT
: path.resolve(process.cwd(), process.env.STAR_PROJECT_ROOT);
if (fs.existsSync(path.join(custom, "backend", "app.py"))) return custom;
}
const fromDir = __dirname;
let cursor = fromDir;
for (let i = 0; i < 8; i += 1) {
if (fs.existsSync(path.join(cursor, "backend", "app.py"))) return cursor;
const parent = path.dirname(cursor);
if (parent === cursor) break;
cursor = parent;
}
const home = process.env.HOME || "";
const candidates = [
path.join(home, "Documents", "GitHub", "Star-Office-UI"),
path.join(home, "GitHub", "Star-Office-UI"),
path.join(home, "Documents", "Star-Office-UI"),
path.join(home, "Star-Office-UI"),
];
for (const c of candidates) {
if (fs.existsSync(path.join(c, "backend", "app.py"))) return c;
}
return process.cwd();
}
function resolveAppIconPath(projectRoot) {
const candidates = [
path.join(projectRoot, "desktop-pet", "src-tauri", "icons", "icon.png"),
path.join(projectRoot, "desktop-pet", "src-tauri", "icons", "128x128@2x.png"),
path.join(projectRoot, "desktop-pet", "src-tauri", "icons", "128x128.png"),
path.join(projectRoot, "desktop-pet", "src-tauri", "icons", "32x32.png"),
];
for (const p of candidates) {
if (fs.existsSync(p)) return p;
}
return null;
}
function applyAppIcon(projectRoot) {
const iconPath = resolveAppIconPath(projectRoot);
if (!iconPath) return null;
const iconImg = nativeImage.createFromPath(iconPath);
if (iconImg.isEmpty()) return null;
if (process.platform === "darwin" && app.dock && app.dock.setIcon) {
app.dock.setIcon(iconImg);
}
return iconPath;
}
function readStateFile(statePath) {
const raw = fs.readFileSync(statePath, "utf-8");
return JSON.parse(raw);
}
function readStateViaBackend() {
return new Promise((resolve, reject) => {
const req = `GET /status HTTP/1.1\r\nHost: ${BACKEND_HOST}\r\nConnection: close\r\n\r\n`;
const socket = net.createConnection({ host: BACKEND_HOST, port: BACKEND_PORT });
let buf = "";
socket.setTimeout(1200);
socket.on("connect", () => socket.write(req));
socket.on("data", (chunk) => {
buf += chunk.toString("utf-8");
});
socket.on("timeout", () => {
socket.destroy();
reject(new Error("backend timeout"));
});
socket.on("error", reject);
socket.on("end", () => {
const sep = "\r\n\r\n";
const idx = buf.indexOf(sep);
if (idx === -1) {
reject(new Error("invalid backend response"));
return;
}
try {
resolve(JSON.parse(buf.slice(idx + sep.length)));
} catch (e) {
reject(e);
}
});
});
}
async function readStateWithFallback(projectRoot) {
const statePath = path.join(projectRoot, "state.json");
try {
return readStateFile(statePath);
} catch (_) {
return readStateViaBackend();
}
}
function spawnBackend(projectRoot) {
const script = path.join(projectRoot, "backend", "app.py");
if (!fs.existsSync(script)) {
console.warn(`backend/app.py not found: ${script}`);
return null;
}
const candidates = [];
if (process.env.STAR_BACKEND_PYTHON) candidates.push(process.env.STAR_BACKEND_PYTHON);
candidates.push(path.join(projectRoot, ".venv", "bin", "python"));
candidates.push("python3");
candidates.push("python");
for (const bin of candidates) {
try {
const child = spawn(bin, [script], {
cwd: projectRoot,
stdio: "inherit",
});
console.log(`backend started with ${bin}`);
return child;
} catch (e) {
console.warn(`failed to spawn ${bin}: ${e.message}`);
}
}
return null;
}
function ensureElectronStandaloneSnapshot(projectRoot) {
const src = path.join(projectRoot, "frontend", "index.html");
const dst = path.join(projectRoot, "frontend", "electron-standalone.html");
if (!fs.existsSync(src)) return;
if (fs.existsSync(dst)) return;
try {
fs.copyFileSync(src, dst);
console.log(`created standalone snapshot: ${dst}`);
} catch (e) {
console.warn(`failed to create standalone snapshot: ${e.message}`);
}
}
function emitMini(event, payload) {
if (!miniWindow || miniWindow.isDestroyed()) return;
miniWindow.webContents.send("tauri:event", { event, payload });
}
function emitMain(event, payload) {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send("tauri:event", { event, payload });
}
async function enterMiniMode(projectRoot) {
const snapshot = await readStateWithFallback(projectRoot).catch(() => null);
if (snapshot) emitMini("mini-sync-state", { ...snapshot, ui_lang: currentUiLang });
if (mainWindow && !mainWindow.isDestroyed()) {
const bounds = mainWindow.getBounds();
if (miniWindow && !miniWindow.isDestroyed()) {
miniWindow.setBounds({ ...miniWindow.getBounds(), x: bounds.x, y: bounds.y });
}
mainWindow.hide();
}
if (miniWindow && !miniWindow.isDestroyed()) {
miniWindow.show();
miniWindow.focus();
}
}
async function openFrontendAndQuit() {
await shell.openExternal(`${BACKEND_BASE_URL}/`);
app.quit();
}
function createAssetWindow(projectRoot) {
if (assetWindow && !assetWindow.isDestroyed()) {
assetWindow.show();
assetWindow.focus();
assetWindow.moveTop();
return assetWindow;
}
const preloadPath = path.join(__dirname, "preload.js");
const appIconPath = resolveAppIconPath(projectRoot);
const mainBounds = mainWindow && !mainWindow.isDestroyed() ? mainWindow.getBounds() : null;
const x = mainBounds ? mainBounds.x + 32 : 160;
const y = mainBounds ? mainBounds.y + 32 : 120;
const assetUrl = `${BACKEND_BASE_URL}/electron-standalone?desktop=1&assetWindow=1`;
assetWindow = new BrowserWindow({
width: 300,
height: 580,
minWidth: 300,
maxWidth: 300,
minHeight: 580,
x,
y,
title: "Star Decorate Room",
frame: false,
transparent: true,
hasShadow: false,
alwaysOnTop: true,
resizable: true,
maximizable: true,
fullscreenable: false,
backgroundColor: "#00000000",
icon: appIconPath || undefined,
show: false,
webPreferences: {
preload: preloadPath,
contextIsolation: true,
nodeIntegration: false,
},
});
assetWindow.once("ready-to-show", () => {
if (!assetWindow || assetWindow.isDestroyed()) return;
assetWindow.setAlwaysOnTop(true, "floating");
assetWindow.moveTop();
assetWindow.show();
assetWindow.focus();
});
assetWindow.on("closed", () => {
assetWindow = null;
});
assetWindow.loadURL(assetUrl);
return assetWindow;
}
function createWindows(projectRoot) {
const preloadPath = path.join(__dirname, "preload.js");
const appIconPath = resolveAppIconPath(projectRoot);
ensureElectronStandaloneSnapshot(projectRoot);
mainWindow = new BrowserWindow({
width: 700,
height: 460,
x: 80,
y: 60,
transparent: true,
frame: false,
alwaysOnTop: true,
resizable: false,
maximizable: false,
fullscreenable: false,
hasShadow: false,
icon: appIconPath || undefined,
webPreferences: {
preload: preloadPath,
contextIsolation: true,
nodeIntegration: false,
},
});
mainWindow.setTitle(APP_NAME);
miniWindow = new BrowserWindow({
width: 220,
height: 240,
minWidth: 180,
minHeight: 200,
transparent: true,
frame: false,
alwaysOnTop: true,
resizable: false,
hasShadow: false,
show: false,
webPreferences: {
preload: preloadPath,
contextIsolation: true,
nodeIntegration: false,
},
});
miniWindow.setTitle("Star Office UI Mini");
const v = Date.now();
const mainUrl = `${BACKEND_BASE_URL}/electron-standalone?desktop=1&v=${v}`;
mainWindow.loadURL(mainUrl);
miniWindow.loadFile(path.join(projectRoot, "desktop-pet", "src", "minimized.html"));
}
function createTray(projectRoot) {
const tray32 = path.join(projectRoot, "desktop-pet", "src-tauri", "icons", "32x32.png");
const iconPath = fs.existsSync(tray32) ? tray32 : resolveAppIconPath(projectRoot);
if (!iconPath) return;
const trayImage = nativeImage.createFromPath(iconPath);
tray = new Tray(trayImage);
tray.setToolTip(APP_NAME);
const menu = Menu.buildFromTemplate([
{
label: "显示主窗口",
click: () => {
if (miniWindow && !miniWindow.isDestroyed()) miniWindow.hide();
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.show();
mainWindow.focus();
}
},
},
{
label: "显示 Mini 窗口",
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.hide();
if (miniWindow && !miniWindow.isDestroyed()) {
miniWindow.show();
miniWindow.focus();
}
},
},
{ type: "separator" },
{
label: "退出",
click: () => app.quit(),
},
]);
tray.setContextMenu(menu);
tray.on("click", () => {
if (!mainWindow || mainWindow.isDestroyed()) return;
if (mainWindow.isVisible()) mainWindow.hide();
else {
if (miniWindow && !miniWindow.isDestroyed()) miniWindow.hide();
mainWindow.show();
mainWindow.focus();
}
});
}
function registerIpc(projectRoot) {
const applyMainWindowMode = (expanded) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
const bounds = mainWindow.getBounds();
const targetHeight = expanded ? 620 : 460;
const targetWidth = bounds.width || 700;
mainWindow.setSize(targetWidth, targetHeight, true);
mainWindow.setContentSize(targetWidth, targetHeight, true);
};
ipcMain.handle("tauri:invoke", async (_event, payload) => {
const cmd = payload && payload.command;
const args = (payload && payload.args) || {};
if (cmd === "read_state") {
const state = await readStateWithFallback(projectRoot);
return { ...state, ui_lang: currentUiLang };
}
if (cmd === "set_ui_lang") {
const lang = String(args && args.lang ? args.lang : "").toLowerCase();
if (lang === "zh" || lang === "en" || lang === "ja") {
currentUiLang = lang;
}
return { ok: true, lang: currentUiLang };
}
if (cmd === "enter_minimize_mode") {
await enterMiniMode(projectRoot);
return null;
}
if (cmd === "restore_main_window") {
if (miniWindow && !miniWindow.isDestroyed()) miniWindow.hide();
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.show();
mainWindow.focus();
}
return null;
}
if (cmd === "close_app") {
app.quit();
return null;
}
if (cmd === "open_external_url") {
if (args && args.url) {
await shell.openExternal(args.url);
}
return null;
}
if (cmd === "set_main_window_mode") {
const senderWin = BrowserWindow.fromWebContents(_event.sender);
// Only main window is allowed to control main window height.
if (!senderWin || !mainWindow || senderWin.id !== mainWindow.id) {
return null;
}
const expanded = !!(args && args.expanded);
applyMainWindowMode(expanded);
return null;
}
if (cmd === "open_asset_window") {
createAssetWindow(projectRoot);
return null;
}
if (cmd === "close_asset_window") {
if (assetWindow && !assetWindow.isDestroyed()) {
assetWindow.close();
}
return null;
}
if (cmd === "notify_main_window_asset_refresh") {
const payloadData = {
...(args && typeof args === "object" ? args : {}),
};
const kind = String(payloadData.kind ? payloadData.kind : "asset");
const path = String(payloadData.path ? payloadData.path : "");
emitMain("main-window-asset-refresh", {
...payloadData,
kind,
path,
at: Date.now(),
});
return null;
}
throw new Error(`Unsupported invoke command: ${cmd}`);
});
ipcMain.handle("window:set-size", (event, payload) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) return null;
const width = Number(payload && payload.width);
const height = Number(payload && payload.height);
if (Number.isFinite(width) && Number.isFinite(height)) {
const w = Math.round(width);
const h = Math.round(height);
// Dual strategy: outer-size and content-size together for transparent frameless windows.
win.setSize(w, h, true);
win.setContentSize(w, h, true);
}
return null;
});
ipcMain.handle("window:get-position", (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) return { x: 0, y: 0 };
const [x, y] = win.getPosition();
return { x, y };
});
ipcMain.handle("window:set-position", (event, payload) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) return null;
const x = Number(payload && payload.x);
const y = Number(payload && payload.y);
if (Number.isFinite(x) && Number.isFinite(y)) {
win.setPosition(Math.round(x), Math.round(y), false);
}
return null;
});
}
async function bootstrap() {
const projectRoot = findProjectRoot();
console.log(`project root: ${projectRoot}`);
console.log(`state path: ${path.join(projectRoot, "state.json")}`);
const iconPath = applyAppIcon(projectRoot);
if (iconPath) console.log(`app icon: ${iconPath}`);
if (!(await tcpReachable(BACKEND_HOST, BACKEND_PORT, 400))) {
backendChild = spawnBackend(projectRoot);
const ready = await waitBackendReady(20000);
if (!ready) console.warn("backend not ready within 20s");
} else {
console.log(`backend already running on ${BACKEND_HOST}:${BACKEND_PORT}`);
}
registerIpc(projectRoot);
createWindows(projectRoot);
createTray(projectRoot);
}
app.on("window-all-closed", (e) => {
// Keep tray app resident by default (unless quitting).
if (!isQuitting) e.preventDefault();
});
app.on("before-quit", () => {
isQuitting = true;
if (backendChild) {
try {
backendChild.kill();
} catch (_) {}
}
});
if (app.setName) app.setName(APP_NAME);
app.whenReady().then(bootstrap);

801
electron-shell/package-lock.json generated Normal file
View file

@ -0,0 +1,801 @@
{
"name": "star-desktop-pet-electron",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "star-desktop-pet-electron",
"version": "0.1.0",
"dependencies": {
"electron": "^40.6.1"
}
},
"node_modules/@electron/get": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz",
"integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.1.1",
"env-paths": "^2.2.0",
"fs-extra": "^8.1.0",
"got": "^11.8.5",
"progress": "^2.0.3",
"semver": "^6.2.0",
"sumchecker": "^3.0.1"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"global-agent": "^3.0.0"
}
},
"node_modules/@sindresorhus/is": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
"integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sindresorhus/is?sponsor=1"
}
},
"node_modules/@szmarczak/http-timer": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
"integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==",
"license": "MIT",
"dependencies": {
"defer-to-connect": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@types/cacheable-request": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
"integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==",
"license": "MIT",
"dependencies": {
"@types/http-cache-semantics": "*",
"@types/keyv": "^3.1.4",
"@types/node": "*",
"@types/responselike": "^1.0.0"
}
},
"node_modules/@types/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
"integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==",
"license": "MIT"
},
"node_modules/@types/keyv": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz",
"integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node": {
"version": "24.11.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz",
"integrity": "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/@types/responselike": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz",
"integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yauzl": {
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
"integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
"license": "MIT",
"optional": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/boolean": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
"integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT",
"optional": true
},
"node_modules/buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/cacheable-lookup": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz",
"integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==",
"license": "MIT",
"engines": {
"node": ">=10.6.0"
}
},
"node_modules/cacheable-request": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz",
"integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==",
"license": "MIT",
"dependencies": {
"clone-response": "^1.0.2",
"get-stream": "^5.1.0",
"http-cache-semantics": "^4.0.0",
"keyv": "^4.0.0",
"lowercase-keys": "^2.0.0",
"normalize-url": "^6.0.1",
"responselike": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/clone-response": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz",
"integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==",
"license": "MIT",
"dependencies": {
"mimic-response": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/decompress-response/node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/defer-to-connect": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
"integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"license": "MIT",
"optional": true,
"dependencies": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
"gopd": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/define-properties": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
"license": "MIT",
"optional": true,
"dependencies": {
"define-data-property": "^1.0.1",
"has-property-descriptors": "^1.0.0",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/detect-node": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
"license": "MIT",
"optional": true
},
"node_modules/electron": {
"version": "40.6.1",
"resolved": "https://registry.npmjs.org/electron/-/electron-40.6.1.tgz",
"integrity": "sha512-u9YfoixttdauciHV9Ut9Zf3YipJoU093kR1GSYTTXTAXqhiXI0G1A0NnL/f0O2m2UULCXaXMf2W71PloR6V9pQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@electron/get": "^2.0.0",
"@types/node": "^24.9.0",
"extract-zip": "^2.0.1"
},
"bin": {
"electron": "cli.js"
},
"engines": {
"node": ">= 12.20.55"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/env-paths": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es6-error": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
"integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
"license": "MIT",
"optional": true
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/extract-zip": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
"integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
"license": "BSD-2-Clause",
"dependencies": {
"debug": "^4.1.1",
"get-stream": "^5.1.0",
"yauzl": "^2.10.0"
},
"bin": {
"extract-zip": "cli.js"
},
"engines": {
"node": ">= 10.17.0"
},
"optionalDependencies": {
"@types/yauzl": "^2.9.1"
}
},
"node_modules/fd-slicer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
"license": "MIT",
"dependencies": {
"pend": "~1.2.0"
}
},
"node_modules/fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
},
"engines": {
"node": ">=6 <7 || >=8"
}
},
"node_modules/get-stream": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
"integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
"license": "MIT",
"dependencies": {
"pump": "^3.0.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/global-agent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
"integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
"license": "BSD-3-Clause",
"optional": true,
"dependencies": {
"boolean": "^3.0.1",
"es6-error": "^4.1.1",
"matcher": "^3.0.0",
"roarr": "^2.15.3",
"semver": "^7.3.2",
"serialize-error": "^7.0.1"
},
"engines": {
"node": ">=10.0"
}
},
"node_modules/global-agent/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/globalthis": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
"integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"define-properties": "^1.2.1",
"gopd": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/got": {
"version": "11.8.6",
"resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz",
"integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==",
"license": "MIT",
"dependencies": {
"@sindresorhus/is": "^4.0.0",
"@szmarczak/http-timer": "^4.0.5",
"@types/cacheable-request": "^6.0.1",
"@types/responselike": "^1.0.0",
"cacheable-lookup": "^5.0.3",
"cacheable-request": "^7.0.2",
"decompress-response": "^6.0.0",
"http2-wrapper": "^1.0.0-beta.5.2",
"lowercase-keys": "^2.0.0",
"p-cancelable": "^2.0.0",
"responselike": "^2.0.0"
},
"engines": {
"node": ">=10.19.0"
},
"funding": {
"url": "https://github.com/sindresorhus/got?sponsor=1"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"license": "MIT",
"optional": true,
"dependencies": {
"es-define-property": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
"integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
"license": "BSD-2-Clause"
},
"node_modules/http2-wrapper": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
"integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==",
"license": "MIT",
"dependencies": {
"quick-lru": "^5.1.1",
"resolve-alpn": "^1.0.0"
},
"engines": {
"node": ">=10.19.0"
}
},
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"license": "MIT"
},
"node_modules/json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
"license": "ISC",
"optional": true
},
"node_modules/jsonfile": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
"license": "MIT",
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
"license": "MIT",
"dependencies": {
"json-buffer": "3.0.1"
}
},
"node_modules/lowercase-keys": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
"integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/matcher": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
"integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
"license": "MIT",
"optional": true,
"dependencies": {
"escape-string-regexp": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/mimic-response": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
"integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/normalize-url": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
"integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.4"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/p-cancelable": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
"integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"license": "MIT"
},
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/quick-lru": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/resolve-alpn": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
"integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==",
"license": "MIT"
},
"node_modules/responselike": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz",
"integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==",
"license": "MIT",
"dependencies": {
"lowercase-keys": "^2.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/roarr": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
"integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
"license": "BSD-3-Clause",
"optional": true,
"dependencies": {
"boolean": "^3.0.1",
"detect-node": "^2.0.4",
"globalthis": "^1.0.1",
"json-stringify-safe": "^5.0.1",
"semver-compare": "^1.0.0",
"sprintf-js": "^1.1.2"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/semver-compare": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
"integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
"license": "MIT",
"optional": true
},
"node_modules/serialize-error": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
"integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
"license": "MIT",
"optional": true,
"dependencies": {
"type-fest": "^0.13.1"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/sprintf-js": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
"license": "BSD-3-Clause",
"optional": true
},
"node_modules/sumchecker": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
"integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
"license": "Apache-2.0",
"dependencies": {
"debug": "^4.1.0"
},
"engines": {
"node": ">= 8.0"
}
},
"node_modules/type-fest": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
"integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
"license": "(MIT OR CC0-1.0)",
"optional": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"license": "MIT"
},
"node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/yauzl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
"license": "MIT",
"dependencies": {
"buffer-crc32": "~0.2.3",
"fd-slicer": "~1.1.0"
}
}
}
}

View file

@ -0,0 +1,14 @@
{
"name": "star-office-ui",
"productName": "Star Office UI",
"private": true,
"version": "0.1.0",
"main": "main.js",
"scripts": {
"dev": "electron .",
"start": "electron ."
},
"dependencies": {
"electron": "^40.6.1"
}
}

107
electron-shell/preload.js Normal file
View file

@ -0,0 +1,107 @@
const { contextBridge, ipcRenderer } = require("electron");
const listeners = new Map();
ipcRenderer.on("tauri:event", (_event, data) => {
const eventName = data && data.event;
if (!eventName) return;
const subs = listeners.get(eventName) || [];
for (const cb of subs) {
try {
cb({ payload: data.payload });
} catch (_) {}
}
});
class LogicalSize {
constructor(width, height) {
this.width = width;
this.height = height;
}
}
let dragging = false;
let dragStartPointer = null;
let dragStartWindow = null;
let dragMoveBound = false;
let lastMouseScreen = { x: 0, y: 0 };
function ensureDragMoveHandlers() {
if (dragMoveBound) return;
dragMoveBound = true;
window.addEventListener("mousemove", async (e) => {
lastMouseScreen = { x: e.screenX, y: e.screenY };
if (!dragging || !dragStartPointer || !dragStartWindow) return;
const dx = e.screenX - dragStartPointer.x;
const dy = e.screenY - dragStartPointer.y;
await ipcRenderer.invoke("window:set-position", {
x: dragStartWindow.x + dx,
y: dragStartWindow.y + dy,
});
});
const stopDrag = () => {
dragging = false;
dragStartPointer = null;
dragStartWindow = null;
};
window.addEventListener("mouseup", stopDrag);
window.addEventListener("blur", stopDrag);
}
const tauriCompat = {
core: {
invoke: (command, args = {}) =>
ipcRenderer.invoke("tauri:invoke", { command, args }),
},
event: {
listen: async (eventName, callback) => {
const subs = listeners.get(eventName) || [];
subs.push(callback);
listeners.set(eventName, subs);
return () => {
const cur = listeners.get(eventName) || [];
listeners.set(
eventName,
cur.filter((x) => x !== callback),
);
};
},
},
window: {
getCurrentWindow: () => ({
startDragging: async () => {
ensureDragMoveHandlers();
const pos = await ipcRenderer.invoke("window:get-position");
dragStartWindow = {
x: Number(pos && pos.x) || 0,
y: Number(pos && pos.y) || 0,
};
dragStartPointer = {
x: lastMouseScreen.x,
y: lastMouseScreen.y,
};
dragging = true;
return null;
},
setSize: async (logicalSize) =>
ipcRenderer.invoke("window:set-size", {
width: logicalSize && logicalSize.width,
height: logicalSize && logicalSize.height,
}),
close: async () => tauriCompat.core.invoke("close_app"),
hide: async () => null,
show: async () => null,
setFocus: async () => null,
}),
},
dpi: {
LogicalSize,
},
};
contextBridge.exposeInMainWorld("__TAURI__", tauriCompat);
contextBridge.exposeInMainWorld("__ELECTRON__", {
invoke: tauriCompat.core.invoke,
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,133 @@
// Star Office UI - 布局与层级配置
// 所有坐标、depth、资源路径统一管理在这里
// 避免 magic numbers降低改错风险
// 核心规则:
// - 透明资源(如办公桌)强制 .png不透明优先 .webp
// - 层级:低 → sofa(10) → starWorking(900) → desk(1000) → flower(1100)
const LAYOUT = {
// === 游戏画布 ===
game: {
width: 1280,
height: 720
},
// === 各区域坐标 ===
areas: {
door: { x: 640, y: 550 },
writing: { x: 320, y: 360 },
researching: { x: 320, y: 360 },
error: { x: 1066, y: 180 },
breakroom: { x: 640, y: 360 }
},
// === 装饰与家具:坐标 + 原点 + depth ===
furniture: {
// 沙发
sofa: {
x: 670,
y: 144,
origin: { x: 0, y: 0 },
depth: 10
},
// 新办公桌(透明 PNG 强制)
desk: {
x: 218,
y: 417,
origin: { x: 0.5, y: 0.5 },
depth: 1000
},
// 桌上花盆
flower: {
x: 310,
y: 390,
origin: { x: 0.5, y: 0.5 },
depth: 1100,
scale: 0.8
},
// Star 在桌前工作(在 desk 下面)
starWorking: {
x: 217,
y: 333,
origin: { x: 0.5, y: 0.5 },
depth: 900,
scale: 1.32
},
// 植物们
plants: [
{ x: 565, y: 178, depth: 5 },
{ x: 230, y: 185, depth: 5 },
{ x: 977, y: 496, depth: 5 }
],
// 海报
poster: {
x: 252,
y: 66,
depth: 4
},
// 咖啡机
coffeeMachine: {
x: 659,
y: 397,
origin: { x: 0.5, y: 0.5 },
depth: 99
},
// 服务器区
serverroom: {
x: 1021,
y: 142,
origin: { x: 0.5, y: 0.5 },
depth: 2
},
// 错误 bug
errorBug: {
x: 1007,
y: 221,
origin: { x: 0.5, y: 0.5 },
depth: 50,
scale: 0.9,
pingPong: { leftX: 1007, rightX: 1111, speed: 0.6 }
},
// 同步动画
syncAnim: {
x: 1157,
y: 592,
origin: { x: 0.5, y: 0.5 },
depth: 40
},
// 小猫
cat: {
x: 94,
y: 557,
origin: { x: 0.5, y: 0.5 },
depth: 2000
}
},
// === 牌匾 ===
plaque: {
x: 640,
y: 720 - 36,
width: 420,
height: 44
},
// === 资源加载规则:哪些强制用 PNG透明资源 ===
forcePng: {
desk_v2: true // 新办公桌必须透明,强制 PNG
},
// === 总资源数量(用于加载进度条) ===
totalAssets: 15
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 B

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 726 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

BIN
frontend/desk-v3.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

94
frontend/fonts/OFL.txt Normal file
View file

@ -0,0 +1,94 @@
Copyright (c) 2021, TakWolf (https://takwolf.com),
with Reserved Font Name "Ark Pixel".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

1034
frontend/game.js Normal file

File diff suppressed because it is too large Load diff

BIN
frontend/guest_anim_1.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 B

BIN
frontend/guest_anim_2.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 B

BIN
frontend/guest_anim_3.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 B

BIN
frontend/guest_anim_4.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 B

BIN
frontend/guest_anim_5.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

BIN
frontend/guest_anim_6.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

BIN
frontend/guest_role_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 781 B

BIN
frontend/guest_role_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
frontend/guest_role_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 944 B

BIN
frontend/guest_role_4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 838 B

BIN
frontend/guest_role_5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 914 B

Some files were not shown because too many files have changed in this diff Show more