tao-shen Claude Opus 4.6 commited on
Commit
6cf7429
·
1 Parent(s): 3227ad9

feat: add pixel animation homepage with Express proxy layer

Browse files

- Add Star-Office-UI pixel animation as the default homepage (/)
- Create Express proxy (proxy.cjs) on port 7860, OpenClaw moves to 7861
- Admin panel accessible via /admin with auto token injection
- State bridge maps OpenClaw status to animation states
- Frontend assets cloned from Star-Office-UI during Docker build
- Add A2A gateway plugin support in Dockerfile and config
- Prepare multi-agent infrastructure (peer polling, guest rendering)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Dockerfile CHANGED
@@ -1,5 +1,5 @@
1
- # OpenClaw on Hugging Face Spaces — 优化构建(v2)
2
- # 优化点:node 用户构建(消除 chown)、合并 RUN 层(减少层开销)
3
  FROM node:22-bookworm
4
  SHELL ["/bin/bash", "-c"]
5
 
@@ -52,10 +52,32 @@ RUN echo "[build][layer2] Clone + install + build..." && START=$(date +%s) \
52
  && echo "[build] version: $(cat /app/openclaw/.version)" \
53
  && echo "[build][layer2] Total clone+install+build: $(($(date +%s) - START))s"
54
 
55
- # ── Layer 3 (node): Scripts + Config ──────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  COPY --chown=node:node scripts /home/node/scripts
 
 
 
57
  COPY --chown=node:node openclaw.json /home/node/scripts/openclaw.json.default
58
- RUN chmod +x /home/node/scripts/entrypoint.sh /home/node/scripts/sync_hf.py
 
 
 
59
 
60
  ENV NODE_ENV=production
61
  ENV OPENCLAW_BUNDLED_PLUGINS_DIR=/app/openclaw/empty-bundled-plugins
 
1
+ # OpenClaw on Hugging Face Spaces — v3 with Pixel Animation + A2A
2
+ # Features: Star-Office-UI animation homepage, Express proxy, A2A gateway
3
  FROM node:22-bookworm
4
  SHELL ["/bin/bash", "-c"]
5
 
 
52
  && echo "[build] version: $(cat /app/openclaw/.version)" \
53
  && echo "[build][layer2] Total clone+install+build: $(($(date +%s) - START))s"
54
 
55
+ # ── Layer 2b (node): A2A Gateway Plugin ──────────────────────────────────────
56
+ RUN echo "[build][layer2b] A2A gateway plugin..." && START=$(date +%s) \
57
+ && git clone --depth 1 https://github.com/win4r/openclaw-a2a-gateway.git \
58
+ /app/openclaw/extensions/a2a-gateway \
59
+ && cd /app/openclaw/extensions/a2a-gateway \
60
+ && npm install --production 2>/dev/null || echo "[build] A2A npm install skipped (will retry at runtime)" \
61
+ && echo "[build][layer2b] A2A gateway: $(($(date +%s) - START))s"
62
+
63
+ # ── Layer 2c (node): Star-Office-UI frontend assets ──────────────────────────
64
+ # Clone full asset set, then overlay our modified text files on top
65
+ RUN echo "[build][layer2c] Star-Office-UI assets..." && START=$(date +%s) \
66
+ && git clone --depth 1 https://github.com/ringhyacinth/Star-Office-UI.git /tmp/star-office \
67
+ && cp -r /tmp/star-office/frontend /home/node/frontend \
68
+ && rm -rf /tmp/star-office \
69
+ && echo "[build][layer2c] Star-Office-UI: $(($(date +%s) - START))s"
70
+
71
+ # ── Layer 3 (node): Scripts + Config + Frontend overrides ────────────────────
72
  COPY --chown=node:node scripts /home/node/scripts
73
+ # Overlay modified frontend files (index.html, game.js, layout.js) on top of cloned assets
74
+ COPY --chown=node:node frontend/*.html /home/node/frontend/
75
+ COPY --chown=node:node frontend/*.js /home/node/frontend/
76
  COPY --chown=node:node openclaw.json /home/node/scripts/openclaw.json.default
77
+ COPY --chown=node:node package.json /home/node/package.json
78
+ RUN chmod +x /home/node/scripts/entrypoint.sh /home/node/scripts/sync_hf.py \
79
+ && cd /home/node && npm install --production \
80
+ && echo "[build] Proxy dependencies installed"
81
 
82
  ENV NODE_ENV=production
83
  ENV OPENCLAW_BUNDLED_PLUGINS_DIR=/app/openclaw/empty-bundled-plugins
frontend/.gitignore ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Binary assets are cloned from Star-Office-UI during Docker build
2
+ # Only modified text files (index.html, game.js, layout.js) are tracked in git
3
+ *.webp
4
+ *.png
5
+ *.jpg
6
+ *.woff2
7
+ *.zip
8
+ *.min.js
9
+ vendor/
10
+ fonts/
11
+ *.py
12
+ *.md
13
+ electron-standalone.html
14
+ invite.html
15
+ join.html
frontend/game.js ADDED
@@ -0,0 +1,1034 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Star Office UI - 游戏主逻辑
2
+ // 依赖: layout.js(必须在这个之前加载)
3
+
4
+ // 检测浏览器是否支持 WebP
5
+ let supportsWebP = false;
6
+
7
+ // 方法 1: 使用 canvas 检测
8
+ function checkWebPSupport() {
9
+ return new Promise((resolve) => {
10
+ const canvas = document.createElement('canvas');
11
+ if (canvas.getContext && canvas.getContext('2d')) {
12
+ resolve(canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0);
13
+ } else {
14
+ resolve(false);
15
+ }
16
+ });
17
+ }
18
+
19
+ // 方法 2: 使用 image 检测(备用)
20
+ function checkWebPSupportFallback() {
21
+ return new Promise((resolve) => {
22
+ const img = new Image();
23
+ img.onload = () => resolve(true);
24
+ img.onerror = () => resolve(false);
25
+ img.src = 'data:image/webp;base64,UklGRkoAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAwAAAABBxAR/Q9ERP8DAABWUDggGAAAADABAJ0BKgEAAQADADQlpAADcAD++/1QAA==';
26
+ });
27
+ }
28
+
29
+ // 获取文件扩展名(根据 WebP 支持情况 + 布局配置的 forcePng)
30
+ function getExt(pngFile) {
31
+ // star-working-spritesheet.png 太宽了,WebP 不支持,始终用 PNG
32
+ if (pngFile === 'star-working-spritesheet.png') {
33
+ return '.png';
34
+ }
35
+ // 如果布局配置里强制用 PNG,就用 .png
36
+ if (LAYOUT.forcePng && LAYOUT.forcePng[pngFile.replace(/\.(png|webp)$/, '')]) {
37
+ return '.png';
38
+ }
39
+ return supportsWebP ? '.webp' : '.png';
40
+ }
41
+
42
+ const config = {
43
+ type: Phaser.AUTO,
44
+ width: LAYOUT.game.width,
45
+ height: LAYOUT.game.height,
46
+ parent: 'game-container',
47
+ pixelArt: true,
48
+ physics: { default: 'arcade', arcade: { gravity: { y: 0 }, debug: false } },
49
+ scene: { preload: preload, create: create, update: update }
50
+ };
51
+
52
+ let totalAssets = 0;
53
+ let loadedAssets = 0;
54
+ let loadingProgressBar, loadingProgressContainer, loadingOverlay, loadingText;
55
+
56
+ // Memo 相关函数
57
+ async function loadMemo() {
58
+ const memoDate = document.getElementById('memo-date');
59
+ const memoContent = document.getElementById('memo-content');
60
+
61
+ try {
62
+ const response = await fetch('/yesterday-memo?t=' + Date.now(), { cache: 'no-store' });
63
+ const data = await response.json();
64
+
65
+ if (data.success && data.memo) {
66
+ memoDate.textContent = data.date || '';
67
+ memoContent.innerHTML = data.memo.replace(/\n/g, '<br>');
68
+ } else {
69
+ memoContent.innerHTML = '<div id="memo-placeholder">暂无昨日日记</div>';
70
+ }
71
+ } catch (e) {
72
+ console.error('加载 memo 失败:', e);
73
+ memoContent.innerHTML = '<div id="memo-placeholder">加载失败</div>';
74
+ }
75
+ }
76
+
77
+ // 更新加载进度
78
+ function updateLoadingProgress() {
79
+ loadedAssets++;
80
+ const percent = Math.min(100, Math.round((loadedAssets / totalAssets) * 100));
81
+ if (loadingProgressBar) {
82
+ loadingProgressBar.style.width = percent + '%';
83
+ }
84
+ if (loadingText) {
85
+ loadingText.textContent = `正在加载 Star 的像素办公室... ${percent}%`;
86
+ }
87
+ }
88
+
89
+ // 隐藏加载界面
90
+ function hideLoadingOverlay() {
91
+ setTimeout(() => {
92
+ if (loadingOverlay) {
93
+ loadingOverlay.style.transition = 'opacity 0.5s ease';
94
+ loadingOverlay.style.opacity = '0';
95
+ setTimeout(() => {
96
+ loadingOverlay.style.display = 'none';
97
+ }, 500);
98
+ }
99
+ }, 300);
100
+ }
101
+
102
+ const STATES = {
103
+ idle: { name: '待命', area: 'breakroom' },
104
+ writing: { name: '整理文档', area: 'writing' },
105
+ researching: { name: '搜索信息', area: 'researching' },
106
+ executing: { name: '执行任务', area: 'writing' },
107
+ syncing: { name: '同步备份', area: 'writing' },
108
+ error: { name: '出错了', area: 'error' }
109
+ };
110
+
111
+ const BUBBLE_TEXTS = {
112
+ idle: [
113
+ '待命中:耳朵竖起来了',
114
+ '我在这儿,随时可以开工',
115
+ '先把桌面收拾干净再说',
116
+ '呼——给大脑放个风',
117
+ '今天也要优雅地高效',
118
+ '等待,是为了更准确的一击',
119
+ '咖啡还热,灵感也还在',
120
+ '我在后台给你加 Buff',
121
+ '状态:静心 / 充电',
122
+ '小猫说:慢一点也没关系'
123
+ ],
124
+ writing: [
125
+ '进入专注模式:勿扰',
126
+ '先把关键路径跑通',
127
+ '我来把复杂变简单',
128
+ '把 bug 关进笼子里',
129
+ '写到一半,先保存',
130
+ '把每一步都做成可回滚',
131
+ '今天的进度,明天的底气',
132
+ '先收敛,再发散',
133
+ '让系统变得更可解释',
134
+ '稳住,我们能赢'
135
+ ],
136
+ researching: [
137
+ '我在挖证据链',
138
+ '让我把信息熬成结论',
139
+ '找到了:关键在这里',
140
+ '先把变量控制住',
141
+ '我在查:它为什么会这样',
142
+ '把直觉写成验证',
143
+ '先定位,再优化',
144
+ '别急,先画因果图'
145
+ ],
146
+ executing: [
147
+ '执行中:不要眨眼',
148
+ '把任务切成小块逐个击破',
149
+ '开始跑 pipeline',
150
+ '一键推进:走你',
151
+ '让结果自己说话',
152
+ '先做最小可行,再做最美版本'
153
+ ],
154
+ syncing: [
155
+ '同步中:把今天锁进云里',
156
+ '备份不是仪式,是安全感',
157
+ '写入中…别断电',
158
+ '把变更交给时间戳',
159
+ '云端对齐:咔哒',
160
+ '同步完成前先��乱动',
161
+ '把未来的自己从灾难里救出来',
162
+ '多一份备份,少一份后悔'
163
+ ],
164
+ error: [
165
+ '警报响了:先别慌',
166
+ '我闻到 bug 的味道了',
167
+ '先复现,再谈修复',
168
+ '把日志给我,我会说人话',
169
+ '错误不是敌人,是线索',
170
+ '把影响面圈起来',
171
+ '先止血,再手术',
172
+ '我在:马上定位根因',
173
+ '别怕,这种我见多了',
174
+ '报警中:让问题自己现形'
175
+ ],
176
+ cat: [
177
+ '喵~',
178
+ '咕噜咕噜…',
179
+ '尾巴摇一摇',
180
+ '晒太阳最开心',
181
+ '有人来看我啦',
182
+ '我是这个办公室的吉祥物',
183
+ '伸个懒腰',
184
+ '今天的罐罐准备好了吗',
185
+ '呼噜呼噜',
186
+ '这个位置视野最好'
187
+ ]
188
+ };
189
+
190
+ let game, star, sofa, serverroom, areas = {}, currentState = 'idle', pendingDesiredState = null, statusText, lastFetch = 0, lastBlink = 0, lastBubble = 0, targetX = 660, targetY = 170, bubble = null, typewriterText = '', typewriterTarget = '', typewriterIndex = 0, lastTypewriter = 0, syncAnimSprite = null, catBubble = null;
191
+ let isMoving = false;
192
+ let waypoints = [];
193
+ let lastWanderAt = 0;
194
+ let coordsOverlay, coordsDisplay, coordsToggle;
195
+ let showCoords = false;
196
+ const FETCH_INTERVAL = 2000;
197
+ const BLINK_INTERVAL = 2500;
198
+ const BUBBLE_INTERVAL = 8000;
199
+ const CAT_BUBBLE_INTERVAL = 18000;
200
+ let lastCatBubble = 0;
201
+ const TYPEWRITER_DELAY = 50;
202
+ let agents = {}; // agentId -> sprite/container
203
+ let lastAgentsFetch = 0;
204
+ const AGENTS_FETCH_INTERVAL = 2500;
205
+
206
+ // agent 颜色配置
207
+ const AGENT_COLORS = {
208
+ star: 0xffd700,
209
+ npc1: 0x00aaff,
210
+ agent_nika: 0xff69b4,
211
+ default: 0x94a3b8
212
+ };
213
+
214
+ // agent 名字颜色
215
+ const NAME_TAG_COLORS = {
216
+ approved: 0x22c55e,
217
+ pending: 0xf59e0b,
218
+ rejected: 0xef4444,
219
+ offline: 0x64748b,
220
+ default: 0x1f2937
221
+ };
222
+
223
+ // breakroom / writing / error 区域的 agent 分布位置(多 agent 时错开)
224
+ const AREA_POSITIONS = {
225
+ breakroom: [
226
+ { x: 620, y: 180 },
227
+ { x: 560, y: 220 },
228
+ { x: 680, y: 210 },
229
+ { x: 540, y: 170 },
230
+ { x: 700, y: 240 },
231
+ { x: 600, y: 250 },
232
+ { x: 650, y: 160 },
233
+ { x: 580, y: 200 }
234
+ ],
235
+ writing: [
236
+ { x: 760, y: 320 },
237
+ { x: 830, y: 280 },
238
+ { x: 690, y: 350 },
239
+ { x: 770, y: 260 },
240
+ { x: 850, y: 340 },
241
+ { x: 720, y: 300 },
242
+ { x: 800, y: 370 },
243
+ { x: 750, y: 240 }
244
+ ],
245
+ error: [
246
+ { x: 180, y: 260 },
247
+ { x: 120, y: 220 },
248
+ { x: 240, y: 230 },
249
+ { x: 160, y: 200 },
250
+ { x: 220, y: 270 },
251
+ { x: 140, y: 250 },
252
+ { x: 200, y: 210 },
253
+ { x: 260, y: 260 }
254
+ ]
255
+ };
256
+
257
+
258
+ // 状态控制栏函数(用于测试)
259
+ function setState(state, detail) {
260
+ fetch('/set_state', {
261
+ method: 'POST',
262
+ headers: { 'Content-Type': 'application/json' },
263
+ body: JSON.stringify({ state, detail })
264
+ }).then(() => fetchStatus());
265
+ }
266
+
267
+ // 初始化:先检测 WebP 支持,再启动游戏
268
+ async function initGame() {
269
+ try {
270
+ supportsWebP = await checkWebPSupport();
271
+ } catch (e) {
272
+ try {
273
+ supportsWebP = await checkWebPSupportFallback();
274
+ } catch (e2) {
275
+ supportsWebP = false;
276
+ }
277
+ }
278
+
279
+ console.log('WebP 支持:', supportsWebP);
280
+ new Phaser.Game(config);
281
+ }
282
+
283
+ function preload() {
284
+ loadingOverlay = document.getElementById('loading-overlay');
285
+ loadingProgressBar = document.getElementById('loading-progress-bar');
286
+ loadingText = document.getElementById('loading-text');
287
+ loadingProgressContainer = document.getElementById('loading-progress-container');
288
+
289
+ // 从 LAYOUT 读取总资源数量(避免 magic number)
290
+ totalAssets = LAYOUT.totalAssets || 15;
291
+ loadedAssets = 0;
292
+
293
+ this.load.on('filecomplete', () => {
294
+ updateLoadingProgress();
295
+ });
296
+
297
+ this.load.on('complete', () => {
298
+ hideLoadingOverlay();
299
+ });
300
+
301
+ this.load.image('office_bg', '/static/office_bg_small' + (supportsWebP ? '.webp' : '.png') + '?v={{VERSION_TIMESTAMP}}');
302
+ this.load.spritesheet('star_idle', '/static/star-idle-spritesheet' + getExt('star-idle-spritesheet.png'), { frameWidth: 128, frameHeight: 128 });
303
+ this.load.spritesheet('star_researching', '/static/star-researching-spritesheet' + getExt('star-researching-spritesheet.png'), { frameWidth: 128, frameHeight: 105 });
304
+
305
+ this.load.image('sofa_idle', '/static/sofa-idle' + getExt('sofa-idle.png'));
306
+ this.load.spritesheet('sofa_busy', '/static/sofa-busy-spritesheet' + getExt('sofa-busy-spritesheet.png'), { frameWidth: 256, frameHeight: 256 });
307
+
308
+ this.load.spritesheet('plants', '/static/plants-spritesheet' + getExt('plants-spritesheet.png'), { frameWidth: 160, frameHeight: 160 });
309
+ this.load.spritesheet('posters', '/static/posters-spritesheet' + getExt('posters-spritesheet.png'), { frameWidth: 160, frameHeight: 160 });
310
+ this.load.spritesheet('coffee_machine', '/static/coffee-machine-spritesheet' + getExt('coffee-machine-spritesheet.png'), { frameWidth: 230, frameHeight: 230 });
311
+ this.load.spritesheet('serverroom', '/static/serverroom-spritesheet' + getExt('serverroom-spritesheet.png'), { frameWidth: 180, frameHeight: 251 });
312
+
313
+ this.load.spritesheet('error_bug', '/static/error-bug-spritesheet-grid' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 180, frameHeight: 180 });
314
+ this.load.spritesheet('cats', '/static/cats-spritesheet' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 160, frameHeight: 160 });
315
+ this.load.image('desk', '/static/desk' + getExt('desk.png'));
316
+ this.load.spritesheet('star_working', '/static/star-working-spritesheet-grid' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 230, frameHeight: 144 });
317
+ this.load.spritesheet('sync_anim', '/static/sync-animation-spritesheet-grid' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 256, frameHeight: 256 });
318
+ this.load.image('memo_bg', '/static/memo-bg' + (supportsWebP ? '.webp' : '.png'));
319
+
320
+ // 新办公桌:强制 PNG(透明)
321
+ this.load.image('desk_v2', '/static/desk-v2.png');
322
+ this.load.spritesheet('flowers', '/static/flowers-spritesheet' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 65, frameHeight: 65 });
323
+ }
324
+
325
+ function create() {
326
+ game = this;
327
+ this.add.image(640, 360, 'office_bg');
328
+
329
+ // === 沙发(来自 LAYOUT)===
330
+ sofa = this.add.sprite(
331
+ LAYOUT.furniture.sofa.x,
332
+ LAYOUT.furniture.sofa.y,
333
+ 'sofa_busy'
334
+ ).setOrigin(LAYOUT.furniture.sofa.origin.x, LAYOUT.furniture.sofa.origin.y);
335
+ sofa.setDepth(LAYOUT.furniture.sofa.depth);
336
+
337
+ this.anims.create({
338
+ key: 'sofa_busy',
339
+ frames: this.anims.generateFrameNumbers('sofa_busy', { start: 0, end: 47 }),
340
+ frameRate: 12,
341
+ repeat: -1
342
+ });
343
+
344
+ areas = LAYOUT.areas;
345
+
346
+ this.anims.create({
347
+ key: 'star_idle',
348
+ frames: this.anims.generateFrameNumbers('star_idle', { start: 0, end: 29 }),
349
+ frameRate: 12,
350
+ repeat: -1
351
+ });
352
+ this.anims.create({
353
+ key: 'star_researching',
354
+ frames: this.anims.generateFrameNumbers('star_researching', { start: 0, end: 95 }),
355
+ frameRate: 12,
356
+ repeat: -1
357
+ });
358
+
359
+ star = game.physics.add.sprite(areas.breakroom.x, areas.breakroom.y, 'star_idle');
360
+ star.setOrigin(0.5);
361
+ star.setScale(1.4);
362
+ star.setAlpha(0.95);
363
+ star.setDepth(20);
364
+ star.setVisible(false);
365
+ star.anims.stop();
366
+
367
+ if (game.textures.exists('sofa_busy')) {
368
+ sofa.setTexture('sofa_busy');
369
+ sofa.anims.play('sofa_busy', true);
370
+ }
371
+
372
+ // === 牌匾(来自 LAYOUT)===
373
+ const plaqueX = LAYOUT.plaque.x;
374
+ const plaqueY = LAYOUT.plaque.y;
375
+ const plaqueBg = game.add.rectangle(plaqueX, plaqueY, LAYOUT.plaque.width, LAYOUT.plaque.height, 0x5d4037);
376
+ plaqueBg.setStrokeStyle(3, 0x3e2723);
377
+ const plaqueText = game.add.text(plaqueX, plaqueY, 'HuggingClaw Pixel Office', {
378
+ fontFamily: 'ArkPixel, monospace',
379
+ fontSize: '18px',
380
+ fill: '#ffd700',
381
+ fontWeight: 'bold',
382
+ stroke: '#000',
383
+ strokeThickness: 2
384
+ }).setOrigin(0.5);
385
+ game.add.text(plaqueX - 190, plaqueY, '⭐', { fontFamily: 'ArkPixel, monospace', fontSize: '20px' }).setOrigin(0.5);
386
+ game.add.text(plaqueX + 190, plaqueY, '⭐', { fontFamily: 'ArkPixel, monospace', fontSize: '20px' }).setOrigin(0.5);
387
+
388
+ // === 植物们(来自 LAYOUT)===
389
+ const plantFrameCount = 16;
390
+ for (let i = 0; i < LAYOUT.furniture.plants.length; i++) {
391
+ const p = LAYOUT.furniture.plants[i];
392
+ const randomPlantFrame = Math.floor(Math.random() * plantFrameCount);
393
+ const plant = game.add.sprite(p.x, p.y, 'plants', randomPlantFrame).setOrigin(0.5);
394
+ plant.setDepth(p.depth);
395
+ plant.setInteractive({ useHandCursor: true });
396
+ window[`plantSprite${i === 0 ? '' : i + 1}`] = plant;
397
+ plant.on('pointerdown', (() => {
398
+ const next = Math.floor(Math.random() * plantFrameCount);
399
+ plant.setFrame(next);
400
+ }));
401
+ }
402
+
403
+ // === 海报(来自 LAYOUT)===
404
+ const postersFrameCount = 32;
405
+ const randomPosterFrame = Math.floor(Math.random() * postersFrameCount);
406
+ const poster = game.add.sprite(LAYOUT.furniture.poster.x, LAYOUT.furniture.poster.y, 'posters', randomPosterFrame).setOrigin(0.5);
407
+ poster.setDepth(LAYOUT.furniture.poster.depth);
408
+ poster.setInteractive({ useHandCursor: true });
409
+ window.posterSprite = poster;
410
+ window.posterFrameCount = postersFrameCount;
411
+ poster.on('pointerdown', () => {
412
+ const next = Math.floor(Math.random() * window.posterFrameCount);
413
+ window.posterSprite.setFrame(next);
414
+ });
415
+
416
+ // === 小猫(来自 LAYOUT)===
417
+ const catsFrameCount = 16;
418
+ const randomCatFrame = Math.floor(Math.random() * catsFrameCount);
419
+ const cat = game.add.sprite(LAYOUT.furniture.cat.x, LAYOUT.furniture.cat.y, 'cats', randomCatFrame).setOrigin(LAYOUT.furniture.cat.origin.x, LAYOUT.furniture.cat.origin.y);
420
+ cat.setDepth(LAYOUT.furniture.cat.depth);
421
+ cat.setInteractive({ useHandCursor: true });
422
+ window.catSprite = cat;
423
+ window.catsFrameCount = catsFrameCount;
424
+ cat.on('pointerdown', () => {
425
+ const next = Math.floor(Math.random() * window.catsFrameCount);
426
+ window.catSprite.setFrame(next);
427
+ });
428
+
429
+ // === 咖啡机(来自 LAYOUT)===
430
+ this.anims.create({
431
+ key: 'coffee_machine',
432
+ frames: this.anims.generateFrameNumbers('coffee_machine', { start: 0, end: 95 }),
433
+ frameRate: 12.5,
434
+ repeat: -1
435
+ });
436
+ const coffeeMachine = this.add.sprite(
437
+ LAYOUT.furniture.coffeeMachine.x,
438
+ LAYOUT.furniture.coffeeMachine.y,
439
+ 'coffee_machine'
440
+ ).setOrigin(LAYOUT.furniture.coffeeMachine.origin.x, LAYOUT.furniture.coffeeMachine.origin.y);
441
+ coffeeMachine.setDepth(LAYOUT.furniture.coffeeMachine.depth);
442
+ coffeeMachine.anims.play('coffee_machine', true);
443
+
444
+ // === 服务器区(来自 LAYOUT)===
445
+ this.anims.create({
446
+ key: 'serverroom_on',
447
+ frames: this.anims.generateFrameNumbers('serverroom', { start: 0, end: 39 }),
448
+ frameRate: 6,
449
+ repeat: -1
450
+ });
451
+ serverroom = this.add.sprite(
452
+ LAYOUT.furniture.serverroom.x,
453
+ LAYOUT.furniture.serverroom.y,
454
+ 'serverroom',
455
+ 0
456
+ ).setOrigin(LAYOUT.furniture.serverroom.origin.x, LAYOUT.furniture.serverroom.origin.y);
457
+ serverroom.setDepth(LAYOUT.furniture.serverroom.depth);
458
+ serverroom.anims.stop();
459
+ serverroom.setFrame(0);
460
+
461
+ // === 新办公桌(来自 LAYOUT,强制透明 PNG)===
462
+ const desk = this.add.image(
463
+ LAYOUT.furniture.desk.x,
464
+ LAYOUT.furniture.desk.y,
465
+ 'desk_v2'
466
+ ).setOrigin(LAYOUT.furniture.desk.origin.x, LAYOUT.furniture.desk.origin.y);
467
+ desk.setDepth(LAYOUT.furniture.desk.depth);
468
+
469
+ // === 花盆(来自 LAYOUT)===
470
+ const flowerFrameCount = 16;
471
+ const randomFlowerFrame = Math.floor(Math.random() * flowerFrameCount);
472
+ const flower = this.add.sprite(
473
+ LAYOUT.furniture.flower.x,
474
+ LAYOUT.furniture.flower.y,
475
+ 'flowers',
476
+ randomFlowerFrame
477
+ ).setOrigin(LAYOUT.furniture.flower.origin.x, LAYOUT.furniture.flower.origin.y);
478
+ flower.setScale(LAYOUT.furniture.flower.scale || 1);
479
+ flower.setDepth(LAYOUT.furniture.flower.depth);
480
+ flower.setInteractive({ useHandCursor: true });
481
+ window.flowerSprite = flower;
482
+ window.flowerFrameCount = flowerFrameCount;
483
+ flower.on('pointerdown', () => {
484
+ const next = Math.floor(Math.random() * window.flowerFrameCount);
485
+ window.flowerSprite.setFrame(next);
486
+ });
487
+
488
+ // === Star 在桌前工作(来自 LAYOUT)===
489
+ this.anims.create({
490
+ key: 'star_working',
491
+ frames: this.anims.generateFrameNumbers('star_working', { start: 0, end: 191 }),
492
+ frameRate: 12,
493
+ repeat: -1
494
+ });
495
+ this.anims.create({
496
+ key: 'error_bug',
497
+ frames: this.anims.generateFrameNumbers('error_bug', { start: 0, end: 95 }),
498
+ frameRate: 12,
499
+ repeat: -1
500
+ });
501
+
502
+ // === 错误 bug(来自 LAYOUT)===
503
+ const errorBug = this.add.sprite(
504
+ LAYOUT.furniture.errorBug.x,
505
+ LAYOUT.furniture.errorBug.y,
506
+ 'error_bug',
507
+ 0
508
+ ).setOrigin(LAYOUT.furniture.errorBug.origin.x, LAYOUT.furniture.errorBug.origin.y);
509
+ errorBug.setDepth(LAYOUT.furniture.errorBug.depth);
510
+ errorBug.setVisible(false);
511
+ errorBug.setScale(LAYOUT.furniture.errorBug.scale);
512
+ errorBug.anims.play('error_bug', true);
513
+ window.errorBug = errorBug;
514
+ window.errorBugDir = 1;
515
+
516
+ const starWorking = this.add.sprite(
517
+ LAYOUT.furniture.starWorking.x,
518
+ LAYOUT.furniture.starWorking.y,
519
+ 'star_working',
520
+ 0
521
+ ).setOrigin(LAYOUT.furniture.starWorking.origin.x, LAYOUT.furniture.starWorking.origin.y);
522
+ starWorking.setVisible(false);
523
+ starWorking.setScale(LAYOUT.furniture.starWorking.scale);
524
+ starWorking.setDepth(LAYOUT.furniture.starWorking.depth);
525
+ window.starWorking = starWorking;
526
+
527
+ // === 同步动画(来自 LAYOUT)===
528
+ this.anims.create({
529
+ key: 'sync_anim',
530
+ frames: this.anims.generateFrameNumbers('sync_anim', { start: 1, end: 52 }),
531
+ frameRate: 12,
532
+ repeat: -1
533
+ });
534
+ syncAnimSprite = this.add.sprite(
535
+ LAYOUT.furniture.syncAnim.x,
536
+ LAYOUT.furniture.syncAnim.y,
537
+ 'sync_anim',
538
+ 0
539
+ ).setOrigin(LAYOUT.furniture.syncAnim.origin.x, LAYOUT.furniture.syncAnim.origin.y);
540
+ syncAnimSprite.setDepth(LAYOUT.furniture.syncAnim.depth);
541
+ syncAnimSprite.anims.stop();
542
+ syncAnimSprite.setFrame(0);
543
+
544
+ window.starSprite = star;
545
+
546
+ statusText = document.getElementById('status-text');
547
+ coordsOverlay = document.getElementById('coords-overlay');
548
+ coordsDisplay = document.getElementById('coords-display');
549
+ coordsToggle = document.getElementById('coords-toggle');
550
+
551
+ coordsToggle.addEventListener('click', () => {
552
+ showCoords = !showCoords;
553
+ coordsOverlay.style.display = showCoords ? 'block' : 'none';
554
+ coordsToggle.textContent = showCoords ? '隐藏坐标' : '显示坐标';
555
+ coordsToggle.style.background = showCoords ? '#e94560' : '#333';
556
+ });
557
+
558
+ game.input.on('pointermove', (pointer) => {
559
+ if (!showCoords) return;
560
+ const x = Math.max(0, Math.min(config.width - 1, Math.round(pointer.x)));
561
+ const y = Math.max(0, Math.min(config.height - 1, Math.round(pointer.y)));
562
+ coordsDisplay.textContent = `${x}, ${y}`;
563
+ coordsOverlay.style.left = (pointer.x + 18) + 'px';
564
+ coordsOverlay.style.top = (pointer.y + 18) + 'px';
565
+ });
566
+
567
+ loadMemo();
568
+ fetchStatus();
569
+ fetchAgents();
570
+
571
+ // 可选调试:仅在显式开启 debug 模式时渲染测试用尼卡 agent
572
+ let debugAgents = false;
573
+ try {
574
+ if (typeof window !== 'undefined') {
575
+ if (window.STAR_OFFICE_DEBUG_AGENTS === true) {
576
+ debugAgents = true;
577
+ } else if (window.location && window.location.search && typeof URLSearchParams !== 'undefined') {
578
+ const sp = new URLSearchParams(window.location.search);
579
+ if (sp.get('debugAgents') === '1') {
580
+ debugAgents = true;
581
+ }
582
+ }
583
+ }
584
+ } catch (e) {
585
+ debugAgents = false;
586
+ }
587
+
588
+ if (debugAgents) {
589
+ const testNika = {
590
+ agentId: 'agent_nika',
591
+ name: '尼卡',
592
+ isMain: false,
593
+ state: 'writing',
594
+ detail: '在画像素画...',
595
+ area: 'writing',
596
+ authStatus: 'approved',
597
+ updated_at: new Date().toISOString()
598
+ };
599
+ renderAgent(testNika);
600
+
601
+ window.testNikaState = 'writing';
602
+ window.testNikaTimer = setInterval(() => {
603
+ const states = ['idle', 'writing', 'researching', 'executing'];
604
+ const areas = { idle: 'breakroom', writing: 'writing', researching: 'writing', executing: 'writing' };
605
+ window.testNikaState = states[Math.floor(Math.random() * states.length)];
606
+ const testAgent = {
607
+ agentId: 'agent_nika',
608
+ name: '尼卡',
609
+ isMain: false,
610
+ state: window.testNikaState,
611
+ detail: '在画像素画...',
612
+ area: areas[window.testNikaState],
613
+ authStatus: 'approved',
614
+ updated_at: new Date().toISOString()
615
+ };
616
+ renderAgent(testAgent);
617
+ }, 5000);
618
+ }
619
+ }
620
+
621
+ function update(time) {
622
+ if (time - lastFetch > FETCH_INTERVAL) { fetchStatus(); lastFetch = time; }
623
+ if (time - lastAgentsFetch > AGENTS_FETCH_INTERVAL) { fetchAgents(); lastAgentsFetch = time; }
624
+
625
+ const effectiveStateForServer = pendingDesiredState || currentState;
626
+ if (serverroom) {
627
+ if (effectiveStateForServer === 'idle') {
628
+ if (serverroom.anims.isPlaying) {
629
+ serverroom.anims.stop();
630
+ serverroom.setFrame(0);
631
+ }
632
+ } else {
633
+ if (!serverroom.anims.isPlaying || serverroom.anims.currentAnim?.key !== 'serverroom_on') {
634
+ serverroom.anims.play('serverroom_on', true);
635
+ }
636
+ }
637
+ }
638
+
639
+ if (window.errorBug) {
640
+ if (effectiveStateForServer === 'error') {
641
+ window.errorBug.setVisible(true);
642
+ if (!window.errorBug.anims.isPlaying || window.errorBug.anims.currentAnim?.key !== 'error_bug') {
643
+ window.errorBug.anims.play('error_bug', true);
644
+ }
645
+ const leftX = LAYOUT.furniture.errorBug.pingPong.leftX;
646
+ const rightX = LAYOUT.furniture.errorBug.pingPong.rightX;
647
+ const speed = LAYOUT.furniture.errorBug.pingPong.speed;
648
+ const dir = window.errorBugDir || 1;
649
+ window.errorBug.x += speed * dir;
650
+ window.errorBug.y = LAYOUT.furniture.errorBug.y;
651
+ if (window.errorBug.x >= rightX) {
652
+ window.errorBug.x = rightX;
653
+ window.errorBugDir = -1;
654
+ } else if (window.errorBug.x <= leftX) {
655
+ window.errorBug.x = leftX;
656
+ window.errorBugDir = 1;
657
+ }
658
+ } else {
659
+ window.errorBug.setVisible(false);
660
+ window.errorBug.anims.stop();
661
+ }
662
+ }
663
+
664
+ if (syncAnimSprite) {
665
+ if (effectiveStateForServer === 'syncing') {
666
+ if (!syncAnimSprite.anims.isPlaying || syncAnimSprite.anims.currentAnim?.key !== 'sync_anim') {
667
+ syncAnimSprite.anims.play('sync_anim', true);
668
+ }
669
+ } else {
670
+ if (syncAnimSprite.anims.isPlaying) syncAnimSprite.anims.stop();
671
+ syncAnimSprite.setFrame(0);
672
+ }
673
+ }
674
+
675
+ if (time - lastBubble > BUBBLE_INTERVAL) {
676
+ showBubble();
677
+ lastBubble = time;
678
+ }
679
+ if (time - lastCatBubble > CAT_BUBBLE_INTERVAL) {
680
+ showCatBubble();
681
+ lastCatBubble = time;
682
+ }
683
+
684
+ if (typewriterIndex < typewriterTarget.length && time - lastTypewriter > TYPEWRITER_DELAY) {
685
+ typewriterText += typewriterTarget[typewriterIndex];
686
+ statusText.textContent = typewriterText;
687
+ typewriterIndex++;
688
+ lastTypewriter = time;
689
+ }
690
+
691
+ moveStar(time);
692
+ }
693
+
694
+ function normalizeState(s) {
695
+ if (!s) return 'idle';
696
+ if (s === 'working') return 'writing';
697
+ if (s === 'run' || s === 'running') return 'executing';
698
+ if (s === 'sync') return 'syncing';
699
+ if (s === 'research') return 'researching';
700
+ return s;
701
+ }
702
+
703
+ function fetchStatus() {
704
+ fetch('/status')
705
+ .then(response => response.json())
706
+ .then(data => {
707
+ const nextState = normalizeState(data.state);
708
+ const stateInfo = STATES[nextState] || STATES.idle;
709
+ const changed = (pendingDesiredState === null) && (nextState !== currentState);
710
+ const nextLine = '[' + stateInfo.name + '] ' + (data.detail || '...');
711
+ if (changed) {
712
+ typewriterTarget = nextLine;
713
+ typewriterText = '';
714
+ typewriterIndex = 0;
715
+
716
+ pendingDesiredState = null;
717
+ currentState = nextState;
718
+
719
+ if (nextState === 'idle') {
720
+ if (game.textures.exists('sofa_busy')) {
721
+ sofa.setTexture('sofa_busy');
722
+ sofa.anims.play('sofa_busy', true);
723
+ }
724
+ star.setVisible(false);
725
+ star.anims.stop();
726
+ if (window.starWorking) {
727
+ window.starWorking.setVisible(false);
728
+ window.starWorking.anims.stop();
729
+ }
730
+ } else if (nextState === 'error') {
731
+ sofa.anims.stop();
732
+ sofa.setTexture('sofa_idle');
733
+ star.setVisible(false);
734
+ star.anims.stop();
735
+ if (window.starWorking) {
736
+ window.starWorking.setVisible(false);
737
+ window.starWorking.anims.stop();
738
+ }
739
+ } else if (nextState === 'syncing') {
740
+ sofa.anims.stop();
741
+ sofa.setTexture('sofa_idle');
742
+ star.setVisible(false);
743
+ star.anims.stop();
744
+ if (window.starWorking) {
745
+ window.starWorking.setVisible(false);
746
+ window.starWorking.anims.stop();
747
+ }
748
+ } else {
749
+ sofa.anims.stop();
750
+ sofa.setTexture('sofa_idle');
751
+ star.setVisible(false);
752
+ star.anims.stop();
753
+ if (window.starWorking) {
754
+ window.starWorking.setVisible(true);
755
+ window.starWorking.anims.play('star_working', true);
756
+ }
757
+ }
758
+
759
+ if (serverroom) {
760
+ if (nextState === 'idle') {
761
+ serverroom.anims.stop();
762
+ serverroom.setFrame(0);
763
+ } else {
764
+ serverroom.anims.play('serverroom_on', true);
765
+ }
766
+ }
767
+
768
+ if (syncAnimSprite) {
769
+ if (nextState === 'syncing') {
770
+ if (!syncAnimSprite.anims.isPlaying || syncAnimSprite.anims.currentAnim?.key !== 'sync_anim') {
771
+ syncAnimSprite.anims.play('sync_anim', true);
772
+ }
773
+ } else {
774
+ if (syncAnimSprite.anims.isPlaying) syncAnimSprite.anims.stop();
775
+ syncAnimSprite.setFrame(0);
776
+ }
777
+ }
778
+ } else {
779
+ if (!typewriterTarget || typewriterTarget !== nextLine) {
780
+ typewriterTarget = nextLine;
781
+ typewriterText = '';
782
+ typewriterIndex = 0;
783
+ }
784
+ }
785
+ })
786
+ .catch(error => {
787
+ typewriterTarget = '连接失败,正在重试...';
788
+ typewriterText = '';
789
+ typewriterIndex = 0;
790
+ });
791
+ }
792
+
793
+ function moveStar(time) {
794
+ const effectiveState = pendingDesiredState || currentState;
795
+ const stateInfo = STATES[effectiveState] || STATES.idle;
796
+ const baseTarget = areas[stateInfo.area] || areas.breakroom;
797
+
798
+ const dx = targetX - star.x;
799
+ const dy = targetY - star.y;
800
+ const dist = Math.sqrt(dx * dx + dy * dy);
801
+ const speed = 1.4;
802
+ const wobble = Math.sin(time / 200) * 0.8;
803
+
804
+ if (dist > 3) {
805
+ star.x += (dx / dist) * speed;
806
+ star.y += (dy / dist) * speed;
807
+ star.setY(star.y + wobble);
808
+ isMoving = true;
809
+ } else {
810
+ if (waypoints && waypoints.length > 0) {
811
+ waypoints.shift();
812
+ if (waypoints.length > 0) {
813
+ targetX = waypoints[0].x;
814
+ targetY = waypoints[0].y;
815
+ isMoving = true;
816
+ } else {
817
+ if (pendingDesiredState !== null) {
818
+ isMoving = false;
819
+ currentState = pendingDesiredState;
820
+ pendingDesiredState = null;
821
+
822
+ if (currentState === 'idle') {
823
+ star.setVisible(false);
824
+ star.anims.stop();
825
+ if (window.starWorking) {
826
+ window.starWorking.setVisible(false);
827
+ window.starWorking.anims.stop();
828
+ }
829
+ } else {
830
+ star.setVisible(false);
831
+ star.anims.stop();
832
+ if (window.starWorking) {
833
+ window.starWorking.setVisible(true);
834
+ window.starWorking.anims.play('star_working', true);
835
+ }
836
+ }
837
+ }
838
+ }
839
+ } else {
840
+ if (pendingDesiredState !== null) {
841
+ isMoving = false;
842
+ currentState = pendingDesiredState;
843
+ pendingDesiredState = null;
844
+
845
+ if (currentState === 'idle') {
846
+ star.setVisible(false);
847
+ star.anims.stop();
848
+ if (window.starWorking) {
849
+ window.starWorking.setVisible(false);
850
+ window.starWorking.anims.stop();
851
+ }
852
+ if (game.textures.exists('sofa_busy')) {
853
+ sofa.setTexture('sofa_busy');
854
+ sofa.anims.play('sofa_busy', true);
855
+ }
856
+ } else {
857
+ star.setVisible(false);
858
+ star.anims.stop();
859
+ if (window.starWorking) {
860
+ window.starWorking.setVisible(true);
861
+ window.starWorking.anims.play('star_working', true);
862
+ }
863
+ sofa.anims.stop();
864
+ sofa.setTexture('sofa_idle');
865
+ }
866
+ }
867
+ }
868
+ }
869
+ }
870
+
871
+ function showBubble() {
872
+ if (bubble) { bubble.destroy(); bubble = null; }
873
+ const texts = BUBBLE_TEXTS[currentState] || BUBBLE_TEXTS.idle;
874
+ if (currentState === 'idle') return;
875
+
876
+ let anchorX = star.x;
877
+ let anchorY = star.y;
878
+ if (currentState === 'syncing' && syncAnimSprite && syncAnimSprite.visible) {
879
+ anchorX = syncAnimSprite.x;
880
+ anchorY = syncAnimSprite.y;
881
+ } else if (currentState === 'error' && window.errorBug && window.errorBug.visible) {
882
+ anchorX = window.errorBug.x;
883
+ anchorY = window.errorBug.y;
884
+ } else if (!star.visible && window.starWorking && window.starWorking.visible) {
885
+ anchorX = window.starWorking.x;
886
+ anchorY = window.starWorking.y;
887
+ }
888
+
889
+ const text = texts[Math.floor(Math.random() * texts.length)];
890
+ const bubbleY = anchorY - 70;
891
+ const bg = game.add.rectangle(anchorX, bubbleY, text.length * 10 + 20, 28, 0xffffff, 0.95);
892
+ bg.setStrokeStyle(2, 0x000000);
893
+ const txt = game.add.text(anchorX, bubbleY, text, { fontFamily: 'ArkPixel, monospace', fontSize: '12px', fill: '#000', align: 'center' }).setOrigin(0.5);
894
+ bubble = game.add.container(0, 0, [bg, txt]);
895
+ bubble.setDepth(1200);
896
+ setTimeout(() => { if (bubble) { bubble.destroy(); bubble = null; } }, 3000);
897
+ }
898
+
899
+ function showCatBubble() {
900
+ if (!window.catSprite) return;
901
+ if (window.catBubble) { window.catBubble.destroy(); window.catBubble = null; }
902
+ const texts = BUBBLE_TEXTS.cat || ['喵~', '咕噜咕噜…'];
903
+ const text = texts[Math.floor(Math.random() * texts.length)];
904
+ const anchorX = window.catSprite.x;
905
+ const anchorY = window.catSprite.y - 60;
906
+ const bg = game.add.rectangle(anchorX, anchorY, text.length * 10 + 20, 24, 0xfffbeb, 0.95);
907
+ bg.setStrokeStyle(2, 0xd4a574);
908
+ const txt = game.add.text(anchorX, anchorY, text, { fontFamily: 'ArkPixel, monospace', fontSize: '11px', fill: '#8b6914', align: 'center' }).setOrigin(0.5);
909
+ window.catBubble = game.add.container(0, 0, [bg, txt]);
910
+ window.catBubble.setDepth(2100);
911
+ setTimeout(() => { if (window.catBubble) { window.catBubble.destroy(); window.catBubble = null; } }, 4000);
912
+ }
913
+
914
+ function fetchAgents() {
915
+ fetch('/agents?t=' + Date.now(), { cache: 'no-store' })
916
+ .then(response => response.json())
917
+ .then(data => {
918
+ if (!Array.isArray(data)) return;
919
+ // 重置位置计数器
920
+ // 按区域分配不同位置索引,避免重叠
921
+ const areaSlots = { breakroom: 0, writing: 0, error: 0 };
922
+ for (let agent of data) {
923
+ const area = agent.area || 'breakroom';
924
+ agent._slotIndex = areaSlots[area] || 0;
925
+ areaSlots[area] = (areaSlots[area] || 0) + 1;
926
+ renderAgent(agent);
927
+ }
928
+ // 移除不再存在的 agent
929
+ const currentIds = new Set(data.map(a => a.agentId));
930
+ for (let id in agents) {
931
+ if (!currentIds.has(id)) {
932
+ if (agents[id]) {
933
+ agents[id].destroy();
934
+ delete agents[id];
935
+ }
936
+ }
937
+ }
938
+ })
939
+ .catch(error => {
940
+ console.error('拉取 agents 失败:', error);
941
+ });
942
+ }
943
+
944
+ function getAreaPosition(area, slotIndex) {
945
+ const positions = AREA_POSITIONS[area] || AREA_POSITIONS.breakroom;
946
+ const idx = (slotIndex || 0) % positions.length;
947
+ return positions[idx];
948
+ }
949
+
950
+ function renderAgent(agent) {
951
+ const agentId = agent.agentId;
952
+ const name = agent.name || 'Agent';
953
+ const area = agent.area || 'breakroom';
954
+ const authStatus = agent.authStatus || 'pending';
955
+ const isMain = !!agent.isMain;
956
+
957
+ // 获取这个 agent 在区域里的位置
958
+ const pos = getAreaPosition(area, agent._slotIndex || 0);
959
+ const baseX = pos.x;
960
+ const baseY = pos.y;
961
+
962
+ // 颜色
963
+ const bodyColor = AGENT_COLORS[agentId] || AGENT_COLORS.default;
964
+ const nameColor = NAME_TAG_COLORS[authStatus] || NAME_TAG_COLORS.default;
965
+
966
+ // 透明度(离线/待批准/拒绝时变半透明)
967
+ let alpha = 1;
968
+ if (authStatus === 'pending') alpha = 0.7;
969
+ if (authStatus === 'rejected') alpha = 0.4;
970
+ if (authStatus === 'offline') alpha = 0.5;
971
+
972
+ if (!agents[agentId]) {
973
+ // 新建 agent
974
+ const container = game.add.container(baseX, baseY);
975
+ container.setDepth(1200 + (isMain ? 100 : 0)); // 放到最顶层!
976
+
977
+ // 像素小人:用星星图标,更明显
978
+ const starIcon = game.add.text(0, 0, '⭐', {
979
+ fontFamily: 'ArkPixel, monospace',
980
+ fontSize: '32px'
981
+ }).setOrigin(0.5);
982
+ starIcon.name = 'starIcon';
983
+
984
+ // 名字标签(漂浮)
985
+ const nameTag = game.add.text(0, -36, name, {
986
+ fontFamily: 'ArkPixel, monospace',
987
+ fontSize: '14px',
988
+ fill: '#' + nameColor.toString(16).padStart(6, '0'),
989
+ stroke: '#000',
990
+ strokeThickness: 3,
991
+ backgroundColor: 'rgba(255,255,255,0.95)'
992
+ }).setOrigin(0.5);
993
+ nameTag.name = 'nameTag';
994
+
995
+ // 状态小点(绿色/黄色/红色)
996
+ let dotColor = 0x64748b;
997
+ if (authStatus === 'approved') dotColor = 0x22c55e;
998
+ if (authStatus === 'pending') dotColor = 0xf59e0b;
999
+ if (authStatus === 'rejected') dotColor = 0xef4444;
1000
+ if (authStatus === 'offline') dotColor = 0x94a3b8;
1001
+ const statusDot = game.add.circle(20, -20, 5, dotColor, alpha);
1002
+ statusDot.setStrokeStyle(2, 0x000000, alpha);
1003
+ statusDot.name = 'statusDot';
1004
+
1005
+ container.add([starIcon, statusDot, nameTag]);
1006
+ agents[agentId] = container;
1007
+ } else {
1008
+ // 更新 agent
1009
+ const container = agents[agentId];
1010
+ container.setPosition(baseX, baseY);
1011
+ container.setAlpha(alpha);
1012
+ container.setDepth(1200 + (isMain ? 100 : 0));
1013
+
1014
+ // 更新名字和颜色(如果变化)
1015
+ const nameTag = container.getAt(2);
1016
+ if (nameTag && nameTag.name === 'nameTag') {
1017
+ nameTag.setText(name);
1018
+ nameTag.setFill('#' + (NAME_TAG_COLORS[authStatus] || NAME_TAG_COLORS.default).toString(16).padStart(6, '0'));
1019
+ }
1020
+ // 更新状态点颜色
1021
+ const statusDot = container.getAt(1);
1022
+ if (statusDot && statusDot.name === 'statusDot') {
1023
+ let dotColor = 0x64748b;
1024
+ if (authStatus === 'approved') dotColor = 0x22c55e;
1025
+ if (authStatus === 'pending') dotColor = 0xf59e0b;
1026
+ if (authStatus === 'rejected') dotColor = 0xef4444;
1027
+ if (authStatus === 'offline') dotColor = 0x94a3b8;
1028
+ statusDot.fillColor = dotColor;
1029
+ }
1030
+ }
1031
+ }
1032
+
1033
+ // 启动游戏
1034
+ initGame();
frontend/index.html ADDED
The diff for this file is too large to render. See raw diff
 
frontend/layout.js ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Star Office UI - 布局与层级配置
2
+ // 所有坐标、depth、资源路径统一管理在这里
3
+ // 避免 magic numbers,降低改错风险
4
+
5
+ // 核心规则:
6
+ // - 透明资源(如办公桌)强制 .png,不透明优先 .webp
7
+ // - 层级:低 → sofa(10) → starWorking(900) → desk(1000) → flower(1100)
8
+
9
+ const LAYOUT = {
10
+ // === 游戏画布 ===
11
+ game: {
12
+ width: 1280,
13
+ height: 720
14
+ },
15
+
16
+ // === 各区域坐标 ===
17
+ areas: {
18
+ door: { x: 640, y: 550 },
19
+ writing: { x: 320, y: 360 },
20
+ researching: { x: 320, y: 360 },
21
+ error: { x: 1066, y: 180 },
22
+ breakroom: { x: 640, y: 360 }
23
+ },
24
+
25
+ // === 装饰与家具:坐标 + 原点 + depth ===
26
+ furniture: {
27
+ // 沙发
28
+ sofa: {
29
+ x: 670,
30
+ y: 144,
31
+ origin: { x: 0, y: 0 },
32
+ depth: 10
33
+ },
34
+
35
+ // 新办公桌(透明 PNG 强制)
36
+ desk: {
37
+ x: 218,
38
+ y: 417,
39
+ origin: { x: 0.5, y: 0.5 },
40
+ depth: 1000
41
+ },
42
+
43
+ // 桌上花盆
44
+ flower: {
45
+ x: 310,
46
+ y: 390,
47
+ origin: { x: 0.5, y: 0.5 },
48
+ depth: 1100,
49
+ scale: 0.8
50
+ },
51
+
52
+ // Star 在桌前工作(在 desk 下面)
53
+ starWorking: {
54
+ x: 217,
55
+ y: 333,
56
+ origin: { x: 0.5, y: 0.5 },
57
+ depth: 900,
58
+ scale: 1.32
59
+ },
60
+
61
+ // 植物们
62
+ plants: [
63
+ { x: 565, y: 178, depth: 5 },
64
+ { x: 230, y: 185, depth: 5 },
65
+ { x: 977, y: 496, depth: 5 }
66
+ ],
67
+
68
+ // 海报
69
+ poster: {
70
+ x: 252,
71
+ y: 66,
72
+ depth: 4
73
+ },
74
+
75
+ // 咖啡机
76
+ coffeeMachine: {
77
+ x: 659,
78
+ y: 397,
79
+ origin: { x: 0.5, y: 0.5 },
80
+ depth: 99
81
+ },
82
+
83
+ // 服务器区
84
+ serverroom: {
85
+ x: 1021,
86
+ y: 142,
87
+ origin: { x: 0.5, y: 0.5 },
88
+ depth: 2
89
+ },
90
+
91
+ // 错误 bug
92
+ errorBug: {
93
+ x: 1007,
94
+ y: 221,
95
+ origin: { x: 0.5, y: 0.5 },
96
+ depth: 50,
97
+ scale: 0.9,
98
+ pingPong: { leftX: 1007, rightX: 1111, speed: 0.6 }
99
+ },
100
+
101
+ // 同步动画
102
+ syncAnim: {
103
+ x: 1157,
104
+ y: 592,
105
+ origin: { x: 0.5, y: 0.5 },
106
+ depth: 40
107
+ },
108
+
109
+ // 小猫
110
+ cat: {
111
+ x: 94,
112
+ y: 557,
113
+ origin: { x: 0.5, y: 0.5 },
114
+ depth: 2000
115
+ }
116
+ },
117
+
118
+ // === 牌匾 ===
119
+ plaque: {
120
+ x: 640,
121
+ y: 720 - 36,
122
+ width: 420,
123
+ height: 44
124
+ },
125
+
126
+ // === 资源加载规则:哪些强制用 PNG(透明资源) ===
127
+ forcePng: {
128
+ desk_v2: true // 新办公桌必须透明,强制 PNG
129
+ },
130
+
131
+ // === 总资源数量(用于加载进度条) ===
132
+ totalAssets: 15
133
+ };
package.json CHANGED
@@ -1,5 +1,7 @@
1
  {
2
  "dependencies": {
3
- "ws": "^8.19.0"
 
 
4
  }
5
  }
 
1
  {
2
  "dependencies": {
3
+ "ws": "^8.19.0",
4
+ "express": "^4.21.0",
5
+ "http-proxy-middleware": "^3.0.0"
6
  }
7
  }
scripts/entrypoint.sh CHANGED
@@ -26,8 +26,9 @@ export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require /home/node/scripts
26
  # Enable Telegram API proxy (redirects fetch() to working mirror if needed)
27
  export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require /home/node/scripts/telegram-proxy.cjs"
28
 
29
- # Auto-fill gateway token in Control UI (redirects "/" to "/?token=GATEWAY_TOKEN")
30
- export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require /home/node/scripts/token-redirect.cjs"
 
31
 
32
  # ── Extensions symlink ──────────────────────────────────────────────────────
33
  SYMLINK_START=$(date +%s)
 
26
  # Enable Telegram API proxy (redirects fetch() to working mirror if needed)
27
  export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require /home/node/scripts/telegram-proxy.cjs"
28
 
29
+ # token-redirect.cjs is no longer needed the Express proxy (proxy.cjs)
30
+ # handles token injection for /admin and serves the animation at /
31
+ # export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require /home/node/scripts/token-redirect.cjs"
32
 
33
  # ── Extensions symlink ──────────────────────────────────────────────────────
34
  SYMLINK_START=$(date +%s)
scripts/proxy.cjs ADDED
@@ -0,0 +1,374 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * proxy.cjs — Express reverse proxy + state bridge for HuggingClaw
3
+ *
4
+ * Serves the Star-Office-UI animation at "/" and proxies
5
+ * OpenClaw's Control UI at "/admin/*" (port 7861).
6
+ * Also provides state bridge API and A2A pass-through.
7
+ */
8
+ 'use strict';
9
+
10
+ const express = require('express');
11
+ const { createProxyMiddleware } = require('http-proxy-middleware');
12
+ const http = require('http');
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+
16
+ const LISTEN_PORT = 7860;
17
+ const OPENCLAW_PORT = 7861;
18
+ const OPENCLAW_TARGET = `http://localhost:${OPENCLAW_PORT}`;
19
+ const GATEWAY_TOKEN = process.env.GATEWAY_TOKEN || 'huggingclaw';
20
+ const ROLE = process.env.HUGGINGCLAW_ROLE || 'primary';
21
+ const PEERS = (process.env.A2A_PEERS || '').split(',').filter(Boolean);
22
+ const AGENT_NAME = process.env.AGENT_NAME || 'Star';
23
+ const FRONTEND_DIR = path.resolve(__dirname, '..', 'frontend');
24
+ const STATE_FILE = '/tmp/openclaw-sync-state.json';
25
+ const LOG_FILE = path.join(process.env.HOME || '/home/node', '.openclaw', 'workspace', 'startup.log');
26
+
27
+ // ── State Bridge ──────────────────────────────────────────────────────────
28
+
29
+ let currentState = {
30
+ state: 'syncing',
31
+ detail: 'Starting up...',
32
+ progress: 0,
33
+ updated_at: new Date().toISOString()
34
+ };
35
+
36
+ let peerStates = {}; // url -> { state, detail, name, ... }
37
+ let openclawReady = false;
38
+
39
+ function readSyncState() {
40
+ try {
41
+ if (fs.existsSync(STATE_FILE)) {
42
+ const data = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
43
+ return data;
44
+ }
45
+ } catch (_) {}
46
+ return null;
47
+ }
48
+
49
+ function checkOpenClawHealth() {
50
+ return new Promise((resolve) => {
51
+ const req = http.get(`${OPENCLAW_TARGET}/`, { timeout: 2000 }, (res) => {
52
+ resolve(res.statusCode < 500);
53
+ });
54
+ req.on('error', () => resolve(false));
55
+ req.on('timeout', () => { req.destroy(); resolve(false); });
56
+ });
57
+ }
58
+
59
+ async function updateState() {
60
+ const syncState = readSyncState();
61
+
62
+ if (syncState && syncState.phase === 'syncing') {
63
+ currentState = {
64
+ state: 'syncing',
65
+ detail: syncState.detail || 'Syncing data...',
66
+ progress: syncState.progress || 0,
67
+ updated_at: new Date().toISOString()
68
+ };
69
+ return;
70
+ }
71
+
72
+ const healthy = await checkOpenClawHealth();
73
+ if (!healthy) {
74
+ if (!openclawReady) {
75
+ currentState = {
76
+ state: 'syncing',
77
+ detail: 'OpenClaw starting up...',
78
+ progress: 0,
79
+ updated_at: new Date().toISOString()
80
+ };
81
+ } else {
82
+ currentState = {
83
+ state: 'error',
84
+ detail: 'OpenClaw not responding',
85
+ progress: 0,
86
+ updated_at: new Date().toISOString()
87
+ };
88
+ }
89
+ return;
90
+ }
91
+
92
+ openclawReady = true;
93
+
94
+ // Try to detect activity from log file
95
+ try {
96
+ if (fs.existsSync(LOG_FILE)) {
97
+ const stat = fs.statSync(LOG_FILE);
98
+ const readSize = Math.min(stat.size, 2048);
99
+ const fd = fs.openSync(LOG_FILE, 'r');
100
+ const buf = Buffer.alloc(readSize);
101
+ fs.readSync(fd, buf, 0, readSize, Math.max(0, stat.size - readSize));
102
+ fs.closeSync(fd);
103
+ const tail = buf.toString('utf-8');
104
+ const lines = tail.split('\n').filter(Boolean);
105
+ const lastLine = lines[lines.length - 1] || '';
106
+ const secondAgo = Date.now() - 5000;
107
+
108
+ // Detect LLM call
109
+ if (lastLine.includes('completion') || lastLine.includes('LLM') || lastLine.includes('model')) {
110
+ currentState = { state: 'writing', detail: 'Calling LLM...', progress: 0, updated_at: new Date().toISOString() };
111
+ return;
112
+ }
113
+ // Detect agent execution
114
+ if (lastLine.includes('agent') || lastLine.includes('executing') || lastLine.includes('running')) {
115
+ currentState = { state: 'executing', detail: 'Agent processing...', progress: 0, updated_at: new Date().toISOString() };
116
+ return;
117
+ }
118
+ }
119
+ } catch (_) {}
120
+
121
+ // Default: idle
122
+ if (syncState && syncState.phase === 'idle') {
123
+ currentState = {
124
+ state: 'idle',
125
+ detail: 'Ready',
126
+ progress: 0,
127
+ updated_at: new Date().toISOString()
128
+ };
129
+ } else if (currentState.state === 'syncing') {
130
+ // Transition from syncing to idle once healthy
131
+ currentState = {
132
+ state: 'idle',
133
+ detail: 'Ready',
134
+ progress: 0,
135
+ updated_at: new Date().toISOString()
136
+ };
137
+ }
138
+ }
139
+
140
+ // Poll state every 2 seconds
141
+ setInterval(updateState, 2000);
142
+ updateState();
143
+
144
+ // ── Peer State Polling (primary only) ─────────────────────────────────────
145
+
146
+ async function fetchPeerStates() {
147
+ if (ROLE !== 'primary' || PEERS.length === 0) return;
148
+
149
+ for (const peerUrl of PEERS) {
150
+ try {
151
+ const url = `${peerUrl.replace(/\/$/, '')}/api/state`;
152
+ const controller = new AbortController();
153
+ const timeout = setTimeout(() => controller.abort(), 5000);
154
+ const res = await fetch(url, { signal: controller.signal, cache: 'no-store' });
155
+ clearTimeout(timeout);
156
+ if (res.ok) {
157
+ const data = await res.json();
158
+ peerStates[peerUrl] = {
159
+ ...data,
160
+ online: true,
161
+ peerUrl
162
+ };
163
+ } else {
164
+ peerStates[peerUrl] = { state: 'idle', detail: 'Unreachable', online: false, peerUrl };
165
+ }
166
+ } catch (_) {
167
+ peerStates[peerUrl] = { state: 'idle', detail: 'Offline', online: false, peerUrl };
168
+ }
169
+ }
170
+ }
171
+
172
+ if (ROLE === 'primary' && PEERS.length > 0) {
173
+ setInterval(fetchPeerStates, 5000);
174
+ fetchPeerStates();
175
+ }
176
+
177
+ // ── Express App ───────────────────────────────────────────────────────────
178
+
179
+ const app = express();
180
+ app.use(express.json());
181
+
182
+ // --- API routes (before proxy) ---
183
+
184
+ // State endpoint
185
+ app.get('/api/state', (req, res) => {
186
+ res.json(currentState);
187
+ });
188
+
189
+ // Compat: Star-Office-UI uses /status
190
+ app.get('/status', (req, res) => {
191
+ res.json(currentState);
192
+ });
193
+
194
+ // Agents/guests endpoint (for Star-Office-UI multi-agent rendering)
195
+ app.get('/agents', (req, res) => {
196
+ const agents = [];
197
+ let slotIndex = 0;
198
+
199
+ for (const [peerUrl, peerState] of Object.entries(peerStates)) {
200
+ // Derive agent name from peer URL
201
+ let name = 'Agent';
202
+ if (peerUrl.includes('adam')) name = 'Adam';
203
+ else if (peerUrl.includes('eve')) name = 'Eve';
204
+
205
+ const stateMap = {
206
+ idle: 'breakroom',
207
+ writing: 'writing',
208
+ researching: 'writing',
209
+ executing: 'writing',
210
+ syncing: 'writing',
211
+ error: 'error'
212
+ };
213
+
214
+ agents.push({
215
+ agentId: name.toLowerCase(),
216
+ name: name,
217
+ area: stateMap[peerState.state] || 'breakroom',
218
+ authStatus: peerState.online ? 'approved' : 'offline',
219
+ detail: peerState.detail || '',
220
+ state: peerState.state || 'idle',
221
+ _slotIndex: slotIndex++
222
+ });
223
+ }
224
+ res.json(agents);
225
+ });
226
+
227
+ app.get('/api/guests', (req, res) => {
228
+ // Alias for /agents
229
+ res.redirect('/agents');
230
+ });
231
+
232
+ // Stub endpoints that Star-Office-UI tries to call
233
+ app.get('/yesterday-memo', (req, res) => {
234
+ res.json({ success: false, memo: null });
235
+ });
236
+
237
+ app.post('/set_state', (req, res) => {
238
+ const { state, detail } = req.body || {};
239
+ if (state) {
240
+ currentState = {
241
+ state,
242
+ detail: detail || '',
243
+ progress: 0,
244
+ updated_at: new Date().toISOString()
245
+ };
246
+ // Also write to state file so it persists
247
+ try {
248
+ fs.writeFileSync(STATE_FILE, JSON.stringify({ phase: state, detail }));
249
+ } catch (_) {}
250
+ }
251
+ res.json({ ok: true });
252
+ });
253
+
254
+ // Asset-related stubs (drawer features we don't use)
255
+ app.get('/assets/auth/status', (req, res) => {
256
+ res.json({ authenticated: false });
257
+ });
258
+ app.get('/assets', (req, res) => {
259
+ res.json([]);
260
+ });
261
+
262
+ // --- Static files ---
263
+ app.use('/frontend', express.static(FRONTEND_DIR, {
264
+ maxAge: '1h',
265
+ setHeaders: (res, filePath) => {
266
+ if (filePath.endsWith('.webp') || filePath.endsWith('.png')) {
267
+ res.setHeader('Cache-Control', 'public, max-age=86400');
268
+ }
269
+ }
270
+ }));
271
+
272
+ // Serve /static as alias for /frontend (Star-Office-UI references /static/)
273
+ app.use('/static', express.static(FRONTEND_DIR, {
274
+ maxAge: '1h',
275
+ setHeaders: (res, filePath) => {
276
+ if (filePath.endsWith('.webp') || filePath.endsWith('.png')) {
277
+ res.setHeader('Cache-Control', 'public, max-age=86400');
278
+ }
279
+ }
280
+ }));
281
+
282
+ // --- Animation homepage ---
283
+ app.get('/', (req, res) => {
284
+ const indexPath = path.join(FRONTEND_DIR, 'index.html');
285
+ if (fs.existsSync(indexPath)) {
286
+ let html = fs.readFileSync(indexPath, 'utf-8');
287
+ // Replace version timestamps
288
+ html = html.replace(/\{\{VERSION_TIMESTAMP\}\}/g, Date.now().toString());
289
+ res.type('html').send(html);
290
+ } else {
291
+ res.status(503).send('Frontend not ready. Please wait...');
292
+ }
293
+ });
294
+
295
+ // --- Admin panel (reverse proxy to OpenClaw) ---
296
+
297
+ // Redirect /admin to /admin/ for consistent path handling
298
+ app.get('/admin', (req, res) => {
299
+ const tokenParam = req.query.token ? `?token=${req.query.token}` : `?token=${GATEWAY_TOKEN}`;
300
+ res.redirect(`/admin/${tokenParam}`);
301
+ });
302
+
303
+ // Create the proxy middleware
304
+ const openclawProxy = createProxyMiddleware({
305
+ target: OPENCLAW_TARGET,
306
+ changeOrigin: true,
307
+ pathRewrite: { '^/admin': '' },
308
+ ws: true,
309
+ on: {
310
+ proxyRes: (proxyRes, req, res) => {
311
+ // Inject <base href="/admin/"> for HTML responses
312
+ const contentType = proxyRes.headers['content-type'] || '';
313
+ if (contentType.includes('text/html')) {
314
+ const origWrite = res.write;
315
+ const origEnd = res.end;
316
+ let body = '';
317
+
318
+ res.write = function(chunk) {
319
+ body += chunk.toString();
320
+ return true;
321
+ };
322
+
323
+ res.end = function(chunk) {
324
+ if (chunk) body += chunk.toString();
325
+ // Inject base tag after <head>
326
+ body = body.replace(/<head([^>]*)>/i, '<head$1><base href="/admin/">');
327
+ // Remove content-length since we modified the body
328
+ delete proxyRes.headers['content-length'];
329
+ res.setHeader('content-length', Buffer.byteLength(body));
330
+ origWrite.call(res, body);
331
+ origEnd.call(res);
332
+ };
333
+ }
334
+ }
335
+ }
336
+ });
337
+
338
+ app.use('/admin', openclawProxy);
339
+
340
+ // --- A2A pass-through ---
341
+ app.use('/a2a', createProxyMiddleware({
342
+ target: OPENCLAW_TARGET,
343
+ changeOrigin: true,
344
+ ws: true
345
+ }));
346
+
347
+ app.use('/.well-known', createProxyMiddleware({
348
+ target: OPENCLAW_TARGET,
349
+ changeOrigin: true
350
+ }));
351
+
352
+ // ── Start server ──────────────────────────────────────────────────────────
353
+
354
+ const server = http.createServer(app);
355
+
356
+ // Handle WebSocket upgrades for /admin
357
+ server.on('upgrade', (req, socket, head) => {
358
+ if (req.url.startsWith('/admin')) {
359
+ openclawProxy.upgrade(req, socket, head);
360
+ } else if (req.url.startsWith('/a2a')) {
361
+ // A2A WebSocket if needed
362
+ }
363
+ });
364
+
365
+ server.listen(LISTEN_PORT, '0.0.0.0', () => {
366
+ console.log(`[proxy] HuggingClaw proxy listening on port ${LISTEN_PORT}`);
367
+ console.log(`[proxy] Role: ${ROLE}`);
368
+ console.log(`[proxy] Animation: http://localhost:${LISTEN_PORT}/`);
369
+ console.log(`[proxy] Admin: http://localhost:${LISTEN_PORT}/admin`);
370
+ console.log(`[proxy] OpenClaw backend: ${OPENCLAW_TARGET}`);
371
+ if (PEERS.length > 0) {
372
+ console.log(`[proxy] A2A peers: ${PEERS.join(', ')}`);
373
+ }
374
+ });
scripts/sync_hf.py CHANGED
@@ -286,6 +286,11 @@ class OpenClawFullSync:
286
  return
287
 
288
  print(f"[SYNC] ▶ Uploading ~/.openclaw → dataset {HF_REPO_ID}/{DATASET_PATH}/ ...")
 
 
 
 
 
289
 
290
  try:
291
  # Log what will be uploaded
@@ -322,6 +327,11 @@ class OpenClawFullSync:
322
  ],
323
  )
324
  print(f"[SYNC] ✓ Upload completed at {datetime.now().isoformat()}")
 
 
 
 
 
325
 
326
  # Verify
327
  try:
@@ -338,6 +348,11 @@ class OpenClawFullSync:
338
  except Exception as e:
339
  print(f"[SYNC] ✗ Upload failed: {e}")
340
  traceback.print_exc()
 
 
 
 
 
341
 
342
  # ── Config helpers ─────────────────────────────────────────────────
343
 
@@ -378,7 +393,7 @@ class OpenClawFullSync:
378
  with open(config_path, "w") as f:
379
  json.dump({
380
  "gateway": {
381
- "mode": "local", "bind": "lan", "port": 7860,
382
  "trustedProxies": ["0.0.0.0/0"],
383
  "controlUi": {
384
  "allowInsecureAuth": True,
@@ -436,7 +451,7 @@ class OpenClawFullSync:
436
  data["gateway"] = {
437
  "mode": "local",
438
  "bind": "lan",
439
- "port": 7860,
440
  "auth": {"token": GATEWAY_TOKEN},
441
  "trustedProxies": ["0.0.0.0/0"],
442
  "controlUi": {
@@ -490,9 +505,34 @@ class OpenClawFullSync:
490
  data.setdefault("models", {})["providers"] = providers
491
  data["agents"]["defaults"]["model"]["primary"] = OPENCLAW_DEFAULT_MODEL
492
 
493
- # Plugin whitelist (only load telegram + whatsapp to speed up startup)
494
  data.setdefault("plugins", {}).setdefault("entries", {})
495
- data["plugins"]["allow"] = ["telegram", "whatsapp"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
496
  if "telegram" not in data["plugins"]["entries"]:
497
  data["plugins"]["entries"]["telegram"] = {"enabled": True}
498
  elif isinstance(data["plugins"]["entries"]["telegram"], dict):
@@ -647,6 +687,42 @@ class OpenClawFullSync:
647
 
648
  # ── Main ────────────────────────────────────────────────────────────────────
649
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
650
  def main():
651
  try:
652
  t_main_start = time.time()
@@ -655,6 +731,9 @@ def main():
655
  sync = OpenClawFullSync()
656
  print(f"[TIMER] sync_hf init: {time.time() - t0:.1f}s")
657
 
 
 
 
658
  # 1. Restore
659
  t0 = time.time()
660
  sync.load_from_repo()
@@ -683,6 +762,12 @@ def main():
683
  process.wait(timeout=5)
684
  except subprocess.TimeoutExpired:
685
  process.kill()
 
 
 
 
 
 
686
  print("[SYNC] Final sync...")
687
  sync.save_to_repo()
688
  sys.exit(0)
@@ -695,12 +780,16 @@ def main():
695
  print("[SYNC] ERROR: Failed to start OpenClaw process. Exiting.")
696
  stop_event.set()
697
  t.join(timeout=5)
 
 
698
  sys.exit(1)
699
 
700
  exit_code = process.wait()
701
  print(f"[SYNC] OpenClaw exited with code {exit_code}")
702
  stop_event.set()
703
  t.join(timeout=10)
 
 
704
  print("[SYNC] Final sync...")
705
  sync.save_to_repo()
706
  sys.exit(exit_code)
 
286
  return
287
 
288
  print(f"[SYNC] ▶ Uploading ~/.openclaw → dataset {HF_REPO_ID}/{DATASET_PATH}/ ...")
289
+ # Notify state bridge
290
+ try:
291
+ Path("/tmp/openclaw-sync-state.json").write_text(json.dumps({"phase": "syncing", "detail": "Uploading data..."}))
292
+ except Exception:
293
+ pass
294
 
295
  try:
296
  # Log what will be uploaded
 
327
  ],
328
  )
329
  print(f"[SYNC] ✓ Upload completed at {datetime.now().isoformat()}")
330
+ # Notify state bridge
331
+ try:
332
+ Path("/tmp/openclaw-sync-state.json").write_text(json.dumps({"phase": "idle", "detail": "Ready"}))
333
+ except Exception:
334
+ pass
335
 
336
  # Verify
337
  try:
 
348
  except Exception as e:
349
  print(f"[SYNC] ✗ Upload failed: {e}")
350
  traceback.print_exc()
351
+ # Notify state bridge of error
352
+ try:
353
+ Path("/tmp/openclaw-sync-state.json").write_text(json.dumps({"phase": "idle", "detail": "Sync failed"}))
354
+ except Exception:
355
+ pass
356
 
357
  # ── Config helpers ─────────────────────────────────────────────────
358
 
 
393
  with open(config_path, "w") as f:
394
  json.dump({
395
  "gateway": {
396
+ "mode": "local", "bind": "lan", "port": 7861,
397
  "trustedProxies": ["0.0.0.0/0"],
398
  "controlUi": {
399
  "allowInsecureAuth": True,
 
451
  data["gateway"] = {
452
  "mode": "local",
453
  "bind": "lan",
454
+ "port": 7861,
455
  "auth": {"token": GATEWAY_TOKEN},
456
  "trustedProxies": ["0.0.0.0/0"],
457
  "controlUi": {
 
505
  data.setdefault("models", {})["providers"] = providers
506
  data["agents"]["defaults"]["model"]["primary"] = OPENCLAW_DEFAULT_MODEL
507
 
508
+ # Plugin whitelist
509
  data.setdefault("plugins", {}).setdefault("entries", {})
510
+ plugin_allow = ["telegram", "whatsapp"]
511
+
512
+ # ── A2A Gateway Plugin ──────────────────────────────────────────
513
+ a2a_peers_env = os.environ.get("A2A_PEERS", "")
514
+ if a2a_peers_env:
515
+ plugin_allow.append("a2a-gateway")
516
+ peers_list = [
517
+ {"url": f"{url.rstrip('/')}/.well-known/agent.json"}
518
+ for url in a2a_peers_env.split(",") if url.strip()
519
+ ]
520
+ a2a_token = os.environ.get("A2A_PEER_TOKEN", "")
521
+ a2a_config = {
522
+ "enabled": True,
523
+ "config": {
524
+ "server": {"port": 7861},
525
+ "peers": peers_list,
526
+ }
527
+ }
528
+ if a2a_token:
529
+ a2a_config["config"]["security"] = {"inbound": {"token": a2a_token}}
530
+ for p in peers_list:
531
+ p["auth"] = {"type": "bearer", "token": a2a_token}
532
+ data["plugins"]["entries"]["a2a-gateway"] = a2a_config
533
+ print(f"[SYNC] Set A2A gateway plugin with {len(peers_list)} peer(s)")
534
+
535
+ data["plugins"]["allow"] = plugin_allow
536
  if "telegram" not in data["plugins"]["entries"]:
537
  data["plugins"]["entries"]["telegram"] = {"enabled": True}
538
  elif isinstance(data["plugins"]["entries"]["telegram"], dict):
 
687
 
688
  # ── Main ────────────────────────────────────────────────────────────────────
689
 
690
+ def start_proxy():
691
+ """Start the Express proxy server (port 7860) before OpenClaw.
692
+ This ensures HF Spaces sees the container as ready immediately."""
693
+ proxy_script = Path(__file__).parent / "proxy.cjs"
694
+ if not proxy_script.exists():
695
+ print("[PROXY] WARNING: proxy.cjs not found, skipping proxy startup")
696
+ return None
697
+
698
+ print("[PROXY] Starting Express proxy on port 7860...")
699
+ env = os.environ.copy()
700
+ try:
701
+ proxy_proc = subprocess.Popen(
702
+ ["node", str(proxy_script)],
703
+ env=env,
704
+ stdout=subprocess.PIPE,
705
+ stderr=subprocess.STDOUT,
706
+ text=True,
707
+ bufsize=1
708
+ )
709
+
710
+ # Copy proxy output in background
711
+ def copy_proxy_output():
712
+ try:
713
+ for line in proxy_proc.stdout:
714
+ print(f"[PROXY] {line}", end='')
715
+ except Exception:
716
+ pass
717
+
718
+ threading.Thread(target=copy_proxy_output, daemon=True).start()
719
+ print(f"[PROXY] Proxy started with PID: {proxy_proc.pid}")
720
+ return proxy_proc
721
+ except Exception as e:
722
+ print(f"[PROXY] ERROR: Failed to start proxy: {e}")
723
+ return None
724
+
725
+
726
  def main():
727
  try:
728
  t_main_start = time.time()
 
731
  sync = OpenClawFullSync()
732
  print(f"[TIMER] sync_hf init: {time.time() - t0:.1f}s")
733
 
734
+ # 0. Start proxy first (so HF Spaces sees port 7860 ready)
735
+ proxy_process = start_proxy()
736
+
737
  # 1. Restore
738
  t0 = time.time()
739
  sync.load_from_repo()
 
762
  process.wait(timeout=5)
763
  except subprocess.TimeoutExpired:
764
  process.kill()
765
+ if proxy_process:
766
+ proxy_process.terminate()
767
+ try:
768
+ proxy_process.wait(timeout=3)
769
+ except subprocess.TimeoutExpired:
770
+ proxy_process.kill()
771
  print("[SYNC] Final sync...")
772
  sync.save_to_repo()
773
  sys.exit(0)
 
780
  print("[SYNC] ERROR: Failed to start OpenClaw process. Exiting.")
781
  stop_event.set()
782
  t.join(timeout=5)
783
+ if proxy_process:
784
+ proxy_process.terminate()
785
  sys.exit(1)
786
 
787
  exit_code = process.wait()
788
  print(f"[SYNC] OpenClaw exited with code {exit_code}")
789
  stop_event.set()
790
  t.join(timeout=10)
791
+ if proxy_process:
792
+ proxy_process.terminate()
793
  print("[SYNC] Final sync...")
794
  sync.save_to_repo()
795
  sys.exit(exit_code)