codebee 0.1.18 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app/pet.py ADDED
@@ -0,0 +1,1323 @@
1
+ # -*- coding: utf-8 -*-
2
+ """桌面蜜蜂(桌宠):CodeBee 服务的一只常驻小蜜蜂,浮在桌面角落实时汇报蜂群动态。
3
+
4
+ 参考 Codex Pets 的产品形态(/pet 唤起、随 agent 状态变化),但形象固定为
5
+ CodeBee 蜜蜂。实现要点:
6
+
7
+ - 独立进程:Tk 必须独占自家进程主线程(pick_dialog.py 同款哲学),由 main.py
8
+ 的看护线程按设置拉起;轮询 /api/pet_state 取任务近况,服务关了蜜蜂自己离开。
9
+ - 两种渲染:优先「精灵模式」——app/pet_bee.png(tools/_gen_pet_sprite.py 从
10
+ 用户提供的手绘毛绒蜜蜂生成)+ Pillow 运行时做动画帧(工作=左右倾斜摆、
11
+ 庆祝=原地旋转、睡觉=调暗降饱和、失联=灰化);机器没有 Pillow 或素材缺失
12
+ 时回落「手绘模式」——canvas 矢量卡通蜜蜂(零依赖兜底,npm 分发用户可能
13
+ 装不了 PIL)。
14
+ - 状态机:sleep(打盹)/ work(振翅)/ cheer(翻滚庆祝)/ alert(警示抖动),
15
+ 由「上一轮活跃任务集合 vs 本轮结果」的差分推出,不需要后端记事件流。
16
+ - 悬停任务清单走指针轮询而不是 Enter/Leave:-transparentcolor 的透明像素在
17
+ Windows 上是点击穿透的,Tk 收不到那片区域的进入/离开事件(蜜蜂还在动,
18
+ 事件时序更乱);每 250ms 查一次指针是否落在窗口包围盒内,稳定且把整个
19
+ 窗口都变成热区。
20
+ - 设置真源是 data/settings.json(pet_enabled / pet_mode),蜜蜂轮询到关闭指令
21
+ 自行退出;「关闭桌宠」菜单就是写这个设置,看护线程因此不会把它复活。
22
+
23
+ 用法:python app/pet.py --port 8765 --data-dir <data目录>
24
+ 诊断:python app/pet.py --port 8765 --once (拉一次状态打印 JSON,不开窗口)
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import http.client
30
+ import json
31
+ import math
32
+ import os
33
+ import random
34
+ import sys
35
+ import time
36
+ import webbrowser
37
+ from pathlib import Path
38
+
39
+ # --------------------------------------------------------------- 纯逻辑(可测试)
40
+
41
+ ACTIVE_STATUSES = ("queued", "running")
42
+ BAD_STATUSES = ("failed", "cancelled")
43
+ POLL_MS = 4000 # 轮询间隔
44
+ POLL_TIMEOUT = 2.5 # 单次拉取超时(秒)
45
+ MAX_MISS = 15 # 连续拉不到服务 N 次后自离(约 1 分钟,防孤儿常驻)
46
+ MOOD_HOLD_S = 12.0 # cheer/alert 表情最低持续
47
+ IDLE_HIDE_S = 90.0 # tasks_only 模式:空闲多久后隐身
48
+ HOVER_MS = 250 # 指针轮询周期
49
+ HOVER_DELAY_MS = 350 # 悬停多久后才弹任务清单(扫过不弹)
50
+
51
+
52
+ def parse_snapshot(raw):
53
+ """把 /api/pet_state 响应裁成蜜蜂关心的最小集。字段缺失一律兜底,不抛。"""
54
+ raw = raw if isinstance(raw, dict) else {}
55
+ st = raw.get("settings") or {}
56
+ tasks = []
57
+ for t in (raw.get("tasks") or [])[:300]:
58
+ if not isinstance(t, dict):
59
+ continue
60
+ tasks.append({
61
+ "id": str(t.get("id") or ""),
62
+ "title": str(t.get("title") or t.get("id") or ""),
63
+ "run_status": str(t.get("run_status") or ""),
64
+ "steps_done": int(t.get("steps_done") or 0),
65
+ "steps_total": int(t.get("steps_total") or 0),
66
+ "step_current": str(t.get("step_current") or ""),
67
+ "error": str(t.get("error") or ""),
68
+ })
69
+ return {
70
+ "settings": {
71
+ "pet_enabled": bool(st.get("pet_enabled", True)),
72
+ "pet_mode": str(st.get("pet_mode") or "always"),
73
+ "pet_skin": str(st.get("pet_skin") or DEFAULT_SKIN),
74
+ },
75
+ "workers": {
76
+ "running": int((raw.get("workers") or {}).get("running") or 0),
77
+ "queued": int((raw.get("workers") or {}).get("queued") or 0),
78
+ },
79
+ "tasks": tasks,
80
+ "digest": {
81
+ "unseen": int((raw.get("digest") or {}).get("unseen") or 0),
82
+ "latest": (raw.get("digest") or {}).get("latest")
83
+ if isinstance((raw.get("digest") or {}).get("latest"), dict)
84
+ else None,
85
+ },
86
+ }
87
+
88
+
89
+ def derive_state(prev_active_ids, tasks):
90
+ """差分推导蜜蜂状态。
91
+
92
+ prev_active_ids:上一轮快照里 queued/running 的任务 id 集合;tasks:本轮
93
+ 任务列表。返回 (state, info):state ∈ sleep/work/cheer/alert;info 是
94
+ {"active": [...], "good": [...], "bad": [...]}——本轮活跃、刚完工、刚出事
95
+ 的任务(气泡文案用)。全部完成的判定只看上一轮活跃集合,天然不受历史
96
+ 失败任务污染(三年前的失败不会让蜜蜂永远举牌子)。
97
+ """
98
+ info = {"active": [], "good": [], "bad": []}
99
+ for t in tasks:
100
+ if t["run_status"] in ACTIVE_STATUSES:
101
+ info["active"].append(t)
102
+ if info["active"]:
103
+ return "work", info
104
+ if prev_active_ids:
105
+ by_id = {t["id"]: t for t in tasks}
106
+ for tid in prev_active_ids:
107
+ t = by_id.get(tid)
108
+ if t is None or t["run_status"] not in BAD_STATUSES:
109
+ info["good"].append(t or {"id": tid, "title": tid})
110
+ else:
111
+ info["bad"].append(t)
112
+ if info["bad"]:
113
+ return "alert", info
114
+ if info["good"]:
115
+ return "cheer", info
116
+ return "sleep", info
117
+
118
+
119
+ def tooltip_lines(snap, lang="zh"):
120
+ """悬停清单:未读摘要 + 进行中 + 排队,最多 6 行。
121
+
122
+ 只列「还在跑 / 待跑」的任务——历史失败项会刷满清单(截图实测十几条
123
+ ✘ 把面板撑成一堵墙),失败已由警示气泡点名,这里不再重复。
124
+ """
125
+ L = LANG.get(lang, LANG["zh"])
126
+ rows = []
127
+ dg = snap.get("digest") or {}
128
+ n_dg = int(dg.get("unseen") or 0)
129
+ if n_dg > 0:
130
+ rows.append(L["digest_tip"] % n_dg)
131
+ for t in snap["tasks"]:
132
+ if t["run_status"] == "running":
133
+ prog = ""
134
+ if t["steps_total"]:
135
+ prog = " %d/%d" % (t["steps_done"], t["steps_total"])
136
+ step = (" · " + t["step_current"]) if t["step_current"] else ""
137
+ rows.append("● %s%s%s" % (_short(t["title"]), step, prog))
138
+ for t in snap["tasks"]:
139
+ if t["run_status"] == "queued":
140
+ rows.append("○ %s(%s)" % (_short(t["title"]), L["queued"]))
141
+ return rows[:6] or [L["all_clear"]]
142
+
143
+
144
+ def _short(text, limit=22):
145
+ """任务标题截断:标题常带长路径/长句,不截会把清单撑成一行一句。"""
146
+ s = str(text or "").replace("\n", " ").strip()
147
+ return s if len(s) <= limit else s[:limit] + "…"
148
+
149
+
150
+ LANG = {
151
+ "zh": {
152
+ "open": "打开 CodeBee",
153
+ "mode": "显示模式",
154
+ "always": "常驻显示",
155
+ "tasks_only": "仅任务运行时出现",
156
+ "skin": "形象",
157
+ "size": "大小",
158
+ "small": "小",
159
+ "mid": "中",
160
+ "big": "大",
161
+ "reset_pos": "回到初始位置",
162
+ "quiet": "让TA安静一会(30 分钟)",
163
+ "hide": "先藏起来(30 分钟)",
164
+ "quiet_ack": "好吧,俺安静会儿…",
165
+ "lang": "语言",
166
+ "close": "关闭桌宠(设置里可重开)",
167
+ "all_clear": "蜂群闲着,都在打盹…",
168
+ "title": "蜂群动态",
169
+ "n_running": "%d 个进行中",
170
+ "queued": "排队中",
171
+ "cheer": "🎉 %d 个任务完工!",
172
+ "alert": "⚠️ 「%s」出岔子了,点我看看",
173
+ "bye": "蜜蜂回巢啦",
174
+ "chatter": ["蜂群今天也很努力哦", "点点俺,去蜂巢瞧瞧",
175
+ "代码写完记得眨眨眼~", "俺在监工,谁都不许摸鱼",
176
+ "你今天比昨天好看(蜂言蜂语)"],
177
+ "digest": "🍯 「%s」有新群摘要:\n%s\n(点我细看)",
178
+ "digest_tip": "🍯 %d 条群摘要未读,点我细看",
179
+ },
180
+ "en": {
181
+ "open": "Open CodeBee",
182
+ "mode": "Display mode",
183
+ "always": "Always visible",
184
+ "tasks_only": "Only when tasks run",
185
+ "skin": "Look",
186
+ "size": "Size",
187
+ "small": "S",
188
+ "mid": "M",
189
+ "big": "L",
190
+ "reset_pos": "Reset position",
191
+ "quiet": "Quiet for 30 min",
192
+ "hide": "Hide for 30 min",
193
+ "quiet_ack": "Fine, I'll zip it…",
194
+ "lang": "Language",
195
+ "close": "Close the bee (re-enable in Settings)",
196
+ "all_clear": "Hive is resting…",
197
+ "title": "Hive activity",
198
+ "n_running": "%d running",
199
+ "queued": "queued",
200
+ "cheer": "🎉 %d task(s) done!",
201
+ "alert": "⚠️ \"%s\" ran into trouble",
202
+ "bye": "Back to the hive",
203
+ "chatter": ["The hive is buzzing hard today", "Click me — tour the hive",
204
+ "Blink once when the code compiles", "No slacking on my watch",
205
+ "You look better than yesterday (bee talk)"],
206
+ "digest": "🍯 New digest for \"%s\":\n%s\n(click to read)",
207
+ "digest_tip": "🍯 %d unread digest(s), click to read",
208
+ },
209
+ }
210
+
211
+ # 预置形象:key → (精灵文件, 显示名)。两个名字一律用原文(品牌/形象名不翻译)。
212
+ SKINS = {
213
+ "plush": ("pet_bee.png", "毛绒蜜蜂"),
214
+ "robot": ("pet_bee_robot.png", "机械蜜蜂"),
215
+ }
216
+ DEFAULT_SKIN = "plush"
217
+ # 大小三档(显示高度倍率),对齐竞品宠物的「小/中/大」
218
+ SIZES = {"small": 0.82, "mid": 1.0, "big": 1.24}
219
+ DEFAULT_SIZE = "mid"
220
+ QUIET_S = 30 * 60 # 「让TA安静一会」:静音气泡时长
221
+ HIDE_S = 30 * 60 # 「先藏起来」:隐身时长(任务忙完也等满,藏就藏彻底)
222
+
223
+
224
+ def digest_bubble_text(digest, lang="zh"):
225
+ """新群摘要气泡文案(纯函数,测试直接 import):群名 + 摘要首行预览。"""
226
+ latest = (digest or {}).get("latest") or {}
227
+ group = str(latest.get("group") or "?")
228
+ first = ""
229
+ for ln in str(latest.get("text") or "").splitlines():
230
+ ln = ln.strip().lstrip("-*·• ").strip()
231
+ if ln:
232
+ first = ln
233
+ break
234
+ if len(first) > 60:
235
+ first = first[:60] + "…"
236
+ return LANG.get(lang, LANG["zh"])["digest"] % (group, first or "…")
237
+
238
+
239
+ def digest_alert(prev_seen, digest):
240
+ """未读摘要是否该报一次(纯函数):返回 (新的 prev_seen, 是否提醒)。
241
+
242
+ 只增不减地比较会漏报:用户在网页点开面板会把 unseen 清零,之后新摘要
243
+ 从 1 起涨,永远小于历史峰值 → 再也不提醒。故未读归零时把基准一起归零。
244
+ 轮询回调里抛异常会中断整个 tick,故脏载荷一律当 0 处理。
245
+ """
246
+ try:
247
+ un = int((digest or {}).get("unseen") or 0)
248
+ except (TypeError, ValueError):
249
+ un = 0
250
+ try:
251
+ prev = int(prev_seen or 0)
252
+ except (TypeError, ValueError):
253
+ prev = 0
254
+ if un <= 0:
255
+ return 0, False
256
+ if un > prev:
257
+ return un, True
258
+ return prev, False
259
+
260
+
261
+ def _http_json(port, path, body=None):
262
+ """本机 API 调用:GET 拉 /api/pet_state,POST 改设置(127.0.0.1 免令牌)。
263
+
264
+ 主机是字面量、端口是 argparse 的 int、路径只来自本文件里两处固定字面量
265
+ ——没有可被外部输入摆布的 URL(Mimosa SSRF 规则的同款写法)。
266
+ """
267
+ conn = http.client.HTTPConnection("127.0.0.1", int(port),
268
+ timeout=POLL_TIMEOUT)
269
+ try:
270
+ payload = None
271
+ headers = {}
272
+ method = "GET"
273
+ if body is not None:
274
+ payload = json.dumps(body).encode("utf-8")
275
+ headers["Content-Type"] = "application/json"
276
+ method = "POST"
277
+ conn.request(method, path, body=payload, headers=headers)
278
+ resp = conn.getresponse()
279
+ return json.loads(resp.read().decode("utf-8"))
280
+ finally:
281
+ conn.close()
282
+
283
+
284
+ # --------------------------------------------------------------- Tk 渲染
285
+
286
+ # 透明键色:窗口里凡是这个颜色都变透明(Windows -transparentcolor)。
287
+ # 用罕见深色,蜜蜂配色不会撞上。
288
+ KEY = "#010203"
289
+ KEY_RGB = (1, 2, 3)
290
+ WIN_W, WIN_H = 170, 150 # 手绘回落模式的窗口尺寸
291
+ SPRITE_DIR = Path(__file__).resolve().parent
292
+ SPRITE_DISP_H = 112 # 精灵显示高度(宽等比)
293
+ PROG_H = 16 # 底部进度条预留高度(有进度时才显示)
294
+
295
+
296
+ def _pid_alive(pid):
297
+ """跨平台 PID 存活探测:Windows 走 OpenProcess,POSIX 用 kill 0 探针。"""
298
+ if not pid:
299
+ return False
300
+ if os.name == "nt":
301
+ try:
302
+ import ctypes
303
+ SYNCHRONIZE = 0x00100000
304
+ h = ctypes.windll.kernel32.OpenProcess(SYNCHRONIZE, 0, int(pid))
305
+ if h:
306
+ ctypes.windll.kernel32.CloseHandle(h)
307
+ return True
308
+ return False
309
+ except Exception:
310
+ return True # 探不动就宁可保守:当作活着,避免双开
311
+ try:
312
+ os.kill(int(pid), 0)
313
+ return True
314
+ except Exception:
315
+ return False
316
+
317
+
318
+ def global_lock_path():
319
+ """全机唯一单实例锁(用户拍板:桌面上只允许一只蜜蜂)。
320
+
321
+ 之前锁按数据目录分,两个服务实例(不同 TUTTI_DATA,比如开发仓 + npm 装
322
+ 的正式版,或测试冒烟)会各养一只,桌面上出现多只蜜蜂。放系统临时目录
323
+ 才是真正的全机闸:同用户任意进程都看得到它。
324
+ """
325
+ import tempfile
326
+ return Path(tempfile.gettempdir()) / "codebee-pet.lock"
327
+
328
+
329
+ def global_lock_held():
330
+ """别的蜜蜂还活着吗(锁 60s 内有心跳且 PID 存活)。看护线程用它决定
331
+ 要不要拉起,避免拉起即退出的空转。"""
332
+ lock = global_lock_path()
333
+ try:
334
+ if lock.exists() and time.time() - lock.stat().st_mtime < 60:
335
+ pid = int(json.loads(lock.read_text(encoding="utf-8")
336
+ or "{}").get("pid") or 0)
337
+ return _pid_alive(pid)
338
+ except Exception:
339
+ pass
340
+ return False
341
+
342
+
343
+ class PetApp:
344
+ """蜜蜂窗口:蜜蜂本体(精灵/手绘)+ 状态机动画 + 悬停任务清单 + 右键菜单。"""
345
+
346
+ def __init__(self, port, data_dir, lang="zh"):
347
+ self.port = port
348
+ self.data_dir = Path(data_dir)
349
+ self.lang = lang if lang in LANG else "zh"
350
+ self.cfg_file = self.data_dir / "pet.json"
351
+ cfg = self._load_cfg()
352
+
353
+ import tkinter as tk
354
+ self.tk = tk
355
+ self.root = tk.Tk()
356
+ self.root.title("CodeBee")
357
+ self.root.overrideredirect(True) # 无边框:桌宠不要标题栏和任务栏
358
+ self.root.attributes("-topmost", True)
359
+ if os.name == "nt":
360
+ try:
361
+ self.root.attributes("-transparentcolor", KEY)
362
+ except Exception:
363
+ pass
364
+ self.root.configure(bg=KEY)
365
+
366
+ # 形象:本地 cfg 优先(换形象要立刻见效,不等服务端轮询),缺省 plush
367
+ self.skin = str(cfg.get("skin") or DEFAULT_SKIN)
368
+ if self.skin not in SKINS:
369
+ self.skin = DEFAULT_SKIN
370
+ # 大小:小/中/大(显示高度倍率)
371
+ self.size_key = str(cfg.get("size") or DEFAULT_SIZE)
372
+ if self.size_key not in SIZES:
373
+ self.size_key = DEFAULT_SIZE
374
+
375
+ # 精灵帧要在 root 建好之后再做(ImageTk.PhotoImage 依赖 Tk 解释器)
376
+ self.frames = self._load_frames(self.skin)
377
+ if self.frames:
378
+ self.win_w, self.win_h = self._size_for(self.frames)
379
+ else:
380
+ self.win_w, self.win_h = WIN_W, WIN_H
381
+
382
+ self.cv = tk.Canvas(self.root, width=self.win_w, height=self.win_h,
383
+ bg=KEY, highlightthickness=0, bd=0)
384
+ self.cv.pack()
385
+ self.spr = None
386
+
387
+ # 运行态
388
+ self.snap = None
389
+ self.miss = 0
390
+ self.prev_active = set()
391
+ self.mood_kind, self.mood_until = "", 0.0
392
+ self.last_busy = time.time()
393
+ self.state = "sleep"
394
+ self.dead_visual = False
395
+ self.hidden = False
396
+ self._flap_a = True
397
+ self._lock_cnt = 0
398
+ self._bubble = None
399
+ self._bubble_until = 0.0
400
+ self._tip = None
401
+ self._hover = False
402
+ self._tip_pending = None
403
+ self.quiet_until = 0.0 # 「安静一会」:静音气泡截止
404
+ self.hide_until = 0.0 # 「先藏起来」:隐身截止
405
+ self.hop_until = 0.0 # 冒气泡时的蹦跶动作截止
406
+ self.next_chatter = time.time() + random.uniform(90, 240)
407
+ self.seen_digests = 0 # 本轮已通知过的摘要未读数(涨了才报,不重复轰炸)
408
+
409
+ if self.frames:
410
+ self._build_sprite()
411
+ else:
412
+ self._build_canvas_bee()
413
+ self._restore_pos(cfg)
414
+ self._bind_input()
415
+ self._apply_state("sleep", force=True)
416
+ self.root.after(200, self._tick)
417
+ self.root.after(100, self._animate)
418
+ self.root.after(HOVER_MS, self._hover_tick)
419
+
420
+ # ---- 配置(位置+语言,蜜蜂私有,不进 settings.json)----
421
+ def _load_cfg(self):
422
+ try:
423
+ data = json.loads(self.cfg_file.read_text(encoding="utf-8"))
424
+ return data if isinstance(data, dict) else {}
425
+ except Exception:
426
+ return {}
427
+
428
+ def _save_cfg(self, **kw):
429
+ cfg = self._load_cfg()
430
+ cfg.update(kw)
431
+ try:
432
+ self.cfg_file.parent.mkdir(parents=True, exist_ok=True)
433
+ self.cfg_file.write_text(json.dumps(cfg, ensure_ascii=False),
434
+ encoding="utf-8")
435
+ except Exception:
436
+ pass
437
+
438
+ def _restore_pos(self, cfg):
439
+ x, y = cfg.get("x"), cfg.get("y")
440
+ sw = self.root.winfo_screenwidth()
441
+ sh = self.root.winfo_screenheight()
442
+ if not (isinstance(x, int) and isinstance(y, int)):
443
+ x, y = sw - self.win_w - 18, sh - self.win_h - 64 # 右下角,托盘上方
444
+ x = max(0, min(int(x), sw - self.win_w))
445
+ y = max(0, min(int(y), sh - self.win_h - 40)) # 底部留给任务栏
446
+ self.root.geometry("+%d+%d" % (x, y))
447
+
448
+ def _clamp_pos(self):
449
+ sw = self.root.winfo_screenwidth()
450
+ sh = self.root.winfo_screenheight()
451
+ x = max(0, min(self.root.winfo_x(), sw - self.win_w))
452
+ y = max(0, min(self.root.winfo_y(), sh - self.win_h - 40))
453
+ self.root.geometry("+%d+%d" % (x, y))
454
+
455
+ # ---- 单实例锁:全机唯一(见 global_lock_path),心跳 60s,摸过就退让 ----
456
+ def _acquire_lock(self):
457
+ lock = global_lock_path()
458
+ if global_lock_held():
459
+ return False
460
+ try:
461
+ lock.write_text(json.dumps({"pid": os.getpid(),
462
+ "ts": time.time()}), encoding="utf-8")
463
+ except Exception:
464
+ pass
465
+ return True
466
+
467
+ def _touch_lock(self):
468
+ self._lock_cnt += 1
469
+ if self._lock_cnt % 8: # 约每 30s 心跳一次
470
+ return
471
+ try:
472
+ global_lock_path().write_text(
473
+ json.dumps({"pid": os.getpid(), "ts": time.time()}),
474
+ encoding="utf-8")
475
+ except Exception:
476
+ pass
477
+
478
+ # ---- 精灵模式:app/pet_bee.png + Pillow 运行时动画帧 ----
479
+ def _load_frames(self, skin):
480
+ """做各状态帧;Pillow/素材缺失回 None(手绘回落),绝不抛。
481
+
482
+ 所有帧都 rotate(expand=False) 在底图画布上转——帧尺寸全一致,窗口
483
+ 能贴着蜂体开(庆祝摆动 ±20° 也裁不到蜂身)。庆祝不转整圈:转圈要
484
+ expand 放画布,窗口会比蜂体大一圈,点缀画件全悬空(真机截图翻车)。
485
+ """
486
+ try:
487
+ from PIL import Image, ImageEnhance, ImageTk
488
+ except Exception:
489
+ return None
490
+ fname = SKINS.get(skin, SKINS[DEFAULT_SKIN])[0]
491
+ try:
492
+ base0 = Image.open(SPRITE_DIR / fname).convert("RGBA")
493
+ w0, h0 = base0.size
494
+ disp_h = max(60, int(SPRITE_DISP_H * SIZES.get(self.size_key, 1.0)))
495
+ disp_w = max(1, round(w0 * disp_h / h0))
496
+ base = base0.resize((disp_w, disp_h), Image.LANCZOS)
497
+
498
+ def flat(img):
499
+ """合成到键色底:羽化边缘自然过渡,键色即透明,无需真 alpha。"""
500
+ bg = Image.new("RGBA", img.size, KEY_RGB + (255,))
501
+ bg.alpha_composite(img)
502
+ return bg.convert("RGB")
503
+
504
+ def bake(img):
505
+ """RGB → PhotoImage;近键色噪点(旋转/缩放的 AA 碎点)归键。"""
506
+ px = img.load()
507
+ w, h = img.size
508
+ for yy in range(h):
509
+ for xx in range(w):
510
+ r, g, b = px[xx, yy]
511
+ if abs(r - 1) + abs(g - 2) + abs(b - 3) < 12:
512
+ px[xx, yy] = KEY_RGB
513
+ return ImageTk.PhotoImage(img)
514
+
515
+ rot = lambda a: flat(base.rotate(a, resample=Image.BICUBIC))
516
+ frames = {
517
+ "alert": [bake(flat(base))],
518
+ "sleep": [bake(ImageEnhance.Brightness(
519
+ ImageEnhance.Color(flat(base)).enhance(0.7)
520
+ ).enhance(0.55))],
521
+ "dead": [bake(ImageEnhance.Brightness(
522
+ flat(base).convert("L").convert("RGB")).enhance(0.6))],
523
+ "work": [bake(rot(a)) for a in (-8, -4, 0, 4, 8)],
524
+ # 欢腾摇摆:左倾-回正-右倾-回正,像跳舞不像旋转木马
525
+ "cheer": [bake(rot(a)) for a in (0, 10, 20, 10, 0, -10,
526
+ -20, -10)],
527
+ }
528
+ return frames
529
+ except Exception:
530
+ return None
531
+
532
+ def _size_for(self, frames):
533
+ fw = max(f.width() for lst in frames.values() for f in lst)
534
+ fh = max(f.height() for lst in frames.values() for f in lst)
535
+ return int(fw) + 8, int(fh) + PROG_H + 4
536
+
537
+ # ---- 蜜蜂绘制(精灵模式:贴图 + 状态点缀画件)----
538
+ # 用户拍板去掉:右上徽章、左侧速度线、底部影子(真机截图圈删)——
539
+ # 窗口贴着蜂体开,点缀只留睡觉 Zzz / 庆祝星光 / 底部进度条。
540
+ def _build_sprite(self):
541
+ cv = self.cv
542
+ cx, cy = self.win_w // 2, (self.win_h - PROG_H) // 2 + 2
543
+ self._spr_c = (cx, cy)
544
+ self.spr = cv.create_image(cx, cy, image=self.frames["alert"][0],
545
+ anchor="center")
546
+ hw, hh = self.win_w / 2, (self.win_h - PROG_H) / 2
547
+ self.zzz = [cv.create_text(cx + hw - 12, cy - hh + 14, text="z",
548
+ fill="#8A93A6",
549
+ font=("Segoe UI", 11, "italic bold")),
550
+ cv.create_text(cx + hw - 4, cy - hh + 4, text="Z",
551
+ fill="#A5AEC0",
552
+ font=("Segoe UI", 13, "italic bold"))]
553
+ self.zzz_base = [cv.coords(z) for z in self.zzz]
554
+
555
+ def star(x, y):
556
+ return (x, y - 7, x + 2, y - 2, x + 7, y, x + 2, y + 2,
557
+ x, y + 7, x - 2, y + 2, x - 7, y, x - 2, y - 2)
558
+
559
+ self.spark = [cv.create_polygon(star(cx - hw + 8, cy - hh + 12),
560
+ fill="#FFD54D", outline="",
561
+ smooth=True),
562
+ cv.create_polygon(star(cx + hw - 10, cy + hh - 10),
563
+ fill="#FFE08A", outline="",
564
+ smooth=True)]
565
+ for grp in (self.zzz, self.spark):
566
+ for i in grp:
567
+ cv.itemconfigure(i, state="hidden")
568
+ # 底部进度条(工作且有步骤进度才出现)
569
+ self.pbar_bg = cv.create_rectangle(cx - 34, self.win_h - 7,
570
+ cx + 34, self.win_h - 2,
571
+ fill="#202A36", outline="#3A4654")
572
+ self.pbar_fg = cv.create_rectangle(cx - 33, self.win_h - 6,
573
+ cx - 32, self.win_h - 3,
574
+ fill="#7ED07E", outline="")
575
+ cv.itemconfigure(self.pbar_bg, state="hidden")
576
+ cv.itemconfigure(self.pbar_fg, state="hidden")
577
+ self.ox = self.oy = 0.0
578
+ self._t0 = time.time()
579
+
580
+ def _set_skin(self, skin):
581
+ """换形象:本地 cfg 立即生效 + 落 settings(重启用),画布原位重建。"""
582
+ if skin not in SKINS or skin == self.skin:
583
+ return
584
+ self.skin = skin
585
+ self._save_cfg(skin=skin)
586
+ try:
587
+ _http_json(self.port, "/api/settings", body={"pet_skin": skin})
588
+ except Exception:
589
+ pass
590
+ self._rebuild_sprite()
591
+
592
+ def _rebuild_sprite(self):
593
+ frames = self._load_frames(self.skin)
594
+ if frames is None:
595
+ return # 换皮肤缺素材:保持现状,别把蜜蜂变没了
596
+ self.frames = frames
597
+ self.win_w, self.win_h = self._size_for(frames)
598
+ self.cv.delete("all")
599
+ self.cv.configure(width=self.win_w, height=self.win_h)
600
+ self._build_sprite()
601
+ self._apply_state(self.state, force=True)
602
+ self._clamp_pos()
603
+
604
+ def _set_size(self, key):
605
+ """大小三档:重建帧+窗口(显示高度乘系数),大小记本地 cfg。"""
606
+ if key not in SIZES or key == self.size_key:
607
+ return
608
+ self.size_key = key
609
+ self._save_cfg(size=key)
610
+ self._rebuild_sprite()
611
+
612
+ def _reset_pos(self):
613
+ """回到默认的右下角初始位置(对齐竞品「回到初始位置」)。"""
614
+ self.root.geometry("+%d+%d"
615
+ % (self.root.winfo_screenwidth() - self.win_w - 18,
616
+ self.root.winfo_screenheight() - self.win_h - 64))
617
+ self._save_cfg(x=self.root.winfo_x(), y=self.root.winfo_y())
618
+
619
+ def _be_quiet(self):
620
+ """安静 30 分钟:不冒气泡不碎碎念(时限内一概闭嘴;这条确认语本身
621
+ 走 force 放行)。悬停清单不受影响,该看任务还是能看。"""
622
+ self._show_bubble(self._L("quiet_ack"), secs=4.0, force=True)
623
+ self.quiet_until = time.time() + QUIET_S
624
+
625
+ def _hide_awhile(self):
626
+ """先藏起来 30 分钟:彻底隐身(时限内任务再忙也不现身),到点自己回来。"""
627
+ self.hide_until = time.time() + HIDE_S
628
+ self._tip_hide()
629
+ self.hidden = True
630
+ try:
631
+ self.root.withdraw()
632
+ except Exception:
633
+ pass
634
+
635
+ # ---- 蜜蜂绘制(手绘回落:canvas 矢量卡通)----
636
+ def _build_canvas_bee(self):
637
+ cv = self.cv
638
+ self.bee = []
639
+
640
+ def add(item_id):
641
+ self.bee.append(item_id)
642
+ return item_id
643
+
644
+ # —— 翅膀(三套:振翅上/下帧 + 睡觉收翅;画在身体后面)——
645
+ WING_A, WING_B, WING_EDGE = "#D9EAFF", "#E8F2FF", "#9FB9D8"
646
+ self.wing_up = [add(cv.create_oval(54, 22, 92, 62, fill=WING_A,
647
+ outline=WING_EDGE, width=2,
648
+ stipple="gray50")),
649
+ add(cv.create_oval(84, 26, 124, 66, fill=WING_B,
650
+ outline=WING_EDGE, width=2,
651
+ stipple="gray50"))]
652
+ self.wing_down = [add(cv.create_oval(48, 40, 90, 72, fill=WING_A,
653
+ outline=WING_EDGE, width=2,
654
+ stipple="gray50")),
655
+ add(cv.create_oval(86, 42, 128, 74, fill=WING_B,
656
+ outline=WING_EDGE, width=2,
657
+ stipple="gray50"))]
658
+ self.wing_rest = [add(cv.create_oval(62, 46, 88, 62, fill="#C4D6EA",
659
+ outline=WING_EDGE, width=1,
660
+ stipple="gray50")),
661
+ add(cv.create_oval(90, 46, 116, 62, fill="#D2E0EE",
662
+ outline=WING_EDGE, width=1,
663
+ stipple="gray50"))]
664
+
665
+ # —— 身体(基色 → 肚底暗色 → 环纹 → 顶部高光 → 外轮廓收边盖住接缝)——
666
+ self.leg1 = add(cv.create_line(68, 112, 63, 126, fill="#6B4A16",
667
+ width=3, capstyle="round"))
668
+ self.leg2 = add(cv.create_line(100, 112, 97, 126, fill="#6B4A16",
669
+ width=3, capstyle="round"))
670
+ self.stinger = add(cv.create_polygon(40, 82, 22, 88, 40, 94,
671
+ fill="#6B4A16", outline="",
672
+ smooth=True))
673
+ self.body = add(cv.create_oval(36, 58, 136, 118, fill="#FFD75E",
674
+ outline=""))
675
+ self.belly = add(cv.create_oval(40, 86, 132, 116, fill="#F0B93C",
676
+ outline=""))
677
+ self.stripe1 = add(cv.create_oval(58, 61, 76, 115, fill="#4A3220",
678
+ outline=""))
679
+ self.stripe2 = add(cv.create_oval(84, 61, 102, 115, fill="#4A3220",
680
+ outline=""))
681
+ self.gloss = add(cv.create_oval(44, 62, 92, 80, fill="#FFE79A",
682
+ outline="", stipple="gray50"))
683
+ self.body_edge = add(cv.create_oval(36, 58, 136, 118, fill="",
684
+ outline="#6B4A16", width=2))
685
+
686
+ # —— 触角(画在头前面但接头藏进头圆,曲线比直线柔)——
687
+ add(cv.create_line(120, 60, 114, 44, 110, 36, fill="#3A2C25",
688
+ width=2, smooth=True, capstyle="round"))
689
+ add(cv.create_line(138, 60, 146, 44, 150, 36, fill="#3A2C25",
690
+ width=2, smooth=True, capstyle="round"))
691
+ add(cv.create_oval(104, 30, 116, 42, fill="#3A2C25", outline=""))
692
+ add(cv.create_oval(144, 30, 156, 42, fill="#3A2C25", outline=""))
693
+
694
+ # —— 头 + 脸 ——
695
+ self.head = add(cv.create_oval(106, 54, 154, 102, fill="#3A2C25",
696
+ outline="#241A14", width=2))
697
+ self.head_gloss = add(cv.create_oval(112, 58, 130, 70, fill="#5A463C",
698
+ outline="", stipple="gray50"))
699
+ # 睁眼(大眼 + 每只双高光)
700
+ self.eye_open = [
701
+ add(cv.create_oval(112, 66, 130, 90, fill="#FFFFFF", outline="")),
702
+ add(cv.create_oval(116, 72, 128, 88, fill="#241A14", outline="")),
703
+ add(cv.create_oval(118, 74, 123, 79, fill="#FFFFFF", outline="")),
704
+ add(cv.create_oval(125, 83, 127, 86, fill="#FFFFFF", outline="")),
705
+ add(cv.create_oval(132, 66, 150, 90, fill="#FFFFFF", outline="")),
706
+ add(cv.create_oval(136, 72, 148, 88, fill="#241A14", outline="")),
707
+ add(cv.create_oval(138, 74, 143, 79, fill="#FFFFFF", outline="")),
708
+ add(cv.create_oval(145, 83, 147, 86, fill="#FFFFFF", outline="")),
709
+ ]
710
+ # 闭眼(睡觉 ⌣)/ 弯弯眼(开心 ∩)
711
+ self.eye_closed = [
712
+ add(cv.create_arc(112, 72, 130, 86, start=180, extent=180,
713
+ style="arc", outline="#E8D8C8", width=2)),
714
+ add(cv.create_arc(132, 72, 150, 86, start=180, extent=180,
715
+ style="arc", outline="#E8D8C8", width=2)),
716
+ ]
717
+ self.eye_happy = [
718
+ add(cv.create_arc(112, 72, 130, 86, start=0, extent=180,
719
+ style="arc", outline="#E8D8C8", width=2)),
720
+ add(cv.create_arc(132, 72, 150, 86, start=0, extent=180,
721
+ style="arc", outline="#E8D8C8", width=2)),
722
+ ]
723
+ # 腮红 + 嘴(微笑 / 惊呼小圆嘴)
724
+ self.blush = [
725
+ add(cv.create_oval(106, 90, 120, 98, fill="#E88A7D", outline="",
726
+ stipple="gray50")),
727
+ add(cv.create_oval(142, 90, 156, 98, fill="#E88A7D", outline="",
728
+ stipple="gray50")),
729
+ ]
730
+ self.smile = add(cv.create_arc(124, 86, 138, 96, start=200, extent=140,
731
+ style="arc", outline="#E8D8C8",
732
+ width=2))
733
+ self.mouth_o = add(cv.create_oval(127, 88, 135, 96, fill="#241A14",
734
+ outline=""))
735
+
736
+ # —— 状态点缀(用户拍板去掉徽章/速度线/影子,只留 Zzz + 星光)——
737
+ self.zzz = [add(cv.create_text(124, 48, text="z", fill="#B9C2D0",
738
+ font=("Segoe UI", 11, "italic bold"))),
739
+ add(cv.create_text(138, 36, text="Z", fill="#CBD4E0",
740
+ font=("Segoe UI", 13, "italic bold"))),
741
+ add(cv.create_text(151, 26, text="z", fill="#DDE3EC",
742
+ font=("Segoe UI", 10, "italic bold")))]
743
+ self.zzz_base = [cv.coords(z) for z in self.zzz]
744
+
745
+ def star(x, y):
746
+ return (x, y - 7, x + 2, y - 2, x + 7, y, x + 2, y + 2,
747
+ x, y + 7, x - 2, y + 2, x - 7, y, x - 2, y - 2)
748
+
749
+ self.spark = [add(cv.create_polygon(star(24, 40), fill="#FFE08A",
750
+ outline="", smooth=True)),
751
+ add(cv.create_polygon(star(40, 104), fill="#FFD54D",
752
+ outline="", smooth=True)),
753
+ add(cv.create_polygon(star(150, 108), fill="#FFE08A",
754
+ outline="", smooth=True)),
755
+ add(cv.create_polygon(star(160, 60), fill="#FFF3C4",
756
+ outline="", smooth=True))]
757
+
758
+ for grp in (self.wing_up, self.wing_down, self.eye_closed,
759
+ self.eye_happy, [self.mouth_o], self.zzz,
760
+ self.spark):
761
+ for i in grp:
762
+ cv.itemconfigure(i, state="hidden")
763
+ # 失联灰化要按原色还原,先把基色记账
764
+ self._palette = {i: cv.itemcget(i, "fill")
765
+ for i in (self.body, self.belly)}
766
+
767
+ # —— 贴地件:进度条(用户拍板去掉影子)——
768
+ self.pbar_bg = cv.create_rectangle(46, 139, 128, 147, fill="#202A36",
769
+ outline="#3A4654")
770
+ self.pbar_fg = cv.create_rectangle(47, 140, 48, 146, fill="#7ED07E",
771
+ outline="")
772
+ cv.itemconfigure(self.pbar_bg, state="hidden")
773
+ cv.itemconfigure(self.pbar_fg, state="hidden")
774
+
775
+ # 动画游标
776
+ self.ox = self.oy = 0.0
777
+ self._t0 = time.time()
778
+
779
+ def _move_bee(self, dx, dy):
780
+ if not (dx or dy):
781
+ return
782
+ if self.frames:
783
+ self.cv.move(self.spr, dx, dy) # 精灵:整张贴图平移
784
+ else:
785
+ for i in self.bee:
786
+ self.cv.move(i, dx, dy)
787
+ self.ox += dx
788
+ self.oy += dy
789
+
790
+ def _show(self, ids, on):
791
+ for i in ids:
792
+ self.cv.itemconfigure(i, state=("normal" if on else "hidden"))
793
+
794
+ # ---- 状态套用:精灵选帧交给 _animate;这里切点缀画件 ----
795
+ def _apply_state(self, st, force=False):
796
+ if st == self.state and not force:
797
+ return
798
+ self.state = st
799
+ cv = self.cv
800
+ if not self.frames:
801
+ # —— 手绘模式专属:翅膀 / 眼睛 / 嘴 / 失联灰化 ——
802
+ flying = st in ("work", "cheer")
803
+ self._show(self.wing_rest, st in ("sleep", "dead"))
804
+ if not flying:
805
+ self._show(self.wing_up, False)
806
+ self._show(self.wing_down, st == "alert")
807
+ self._show(self.eye_open, st in ("work", "alert", "dead"))
808
+ self._show(self.eye_closed, st in ("sleep", "dead"))
809
+ self._show(self.eye_happy, st == "cheer")
810
+ cv.itemconfigure(self.smile,
811
+ state=("hidden" if st == "alert" else "normal"))
812
+ self._show([self.mouth_o], st == "alert")
813
+ if st == "dead" and not self.dead_visual:
814
+ self.dead_visual = True
815
+ cv.itemconfigure(self.body, fill="#B9B9B9")
816
+ cv.itemconfigure(self.belly, fill="#A6A6A6")
817
+ cv.itemconfigure(self.gloss, state="hidden")
818
+ elif st != "dead" and self.dead_visual:
819
+ self.dead_visual = False
820
+ cv.itemconfigure(self.body, fill=self._palette[self.body])
821
+ cv.itemconfigure(self.belly, fill=self._palette[self.belly])
822
+ cv.itemconfigure(self.gloss, state="normal")
823
+ # —— 共同:Zzz / 星光 / 进度条(徽章与速度线已按用户要求移除)——
824
+ self._show(self.zzz, False)
825
+ self._show(self.spark, False)
826
+ prog = []
827
+ if st == "work" and self.snap:
828
+ prog = [t for t in self.snap["tasks"]
829
+ if t["run_status"] == "running" and t["steps_total"]]
830
+ if prog:
831
+ done = sum(t["steps_done"] for t in prog)
832
+ total = sum(t["steps_total"] for t in prog)
833
+ ratio = max(0.0, min(1.0, done / float(total)))
834
+ x0 = (self.win_w // 2) - 34
835
+ cv.itemconfigure(self.pbar_bg, state="normal")
836
+ cv.itemconfigure(self.pbar_fg, state="normal")
837
+ cv.coords(self.pbar_bg, x0, self.win_h - 7, x0 + 68,
838
+ self.win_h - 2)
839
+ cv.coords(self.pbar_fg, x0 + 1, self.win_h - 6,
840
+ x0 + 1 + 66 * ratio, self.win_h - 3)
841
+ else:
842
+ self._show([self.pbar_bg, self.pbar_fg], False)
843
+
844
+ # ---- 数据轮询(4s 一拍)----
845
+ def _tick(self):
846
+ self._touch_lock()
847
+ snap = None
848
+ try:
849
+ snap = parse_snapshot(_http_json(self.port, "/api/pet_state"))
850
+ self.miss = 0
851
+ except Exception:
852
+ self.miss += 1
853
+ if snap is not None:
854
+ if not snap["settings"]["pet_enabled"]:
855
+ return self._bye()
856
+ self.snap = snap
857
+ # 设置页换形象:服务端设置为准,原位换肤(菜单换肤走 _set_skin 落同一处)
858
+ sk = str(snap["settings"].get("pet_skin") or "")
859
+ if sk in SKINS and sk != self.skin:
860
+ self.skin = sk
861
+ self._save_cfg(skin=sk)
862
+ self._rebuild_sprite()
863
+ raw, info = derive_state(self.prev_active, snap["tasks"])
864
+ self.prev_active = {t["id"] for t in snap["tasks"]
865
+ if t["run_status"] in ACTIVE_STATUSES}
866
+ now = time.time()
867
+ if raw in ("cheer", "alert"):
868
+ self.mood_kind, self.mood_until = raw, now + MOOD_HOLD_S
869
+ self._bubble(raw, info)
870
+ self.last_busy = now
871
+ elif raw == "work":
872
+ self.last_busy = now
873
+ st = raw if raw != "sleep" else (
874
+ (self.mood_kind or "sleep") if now < self.mood_until
875
+ else "sleep")
876
+ # 群摘要喂食:未读数涨了才报一次(气泡 9s),睡觉被打断就欢腾一下;
877
+ # tasks_only 模式下把报摘要当作「忙」,隐身中的蜜蜂会因此现身。
878
+ dg = snap.get("digest") or {}
879
+ self.seen_digests, notify = digest_alert(self.seen_digests, dg)
880
+ if notify:
881
+ self._show_bubble(digest_bubble_text(dg, self.lang), secs=9.0)
882
+ self.last_busy = now
883
+ if st == "sleep":
884
+ self.mood_kind, self.mood_until = "cheer", now + MOOD_HOLD_S
885
+ st = "cheer"
886
+ snap["active_ids"] = list(self.prev_active)
887
+ self._apply_state(st)
888
+ self._sync_visibility(st)
889
+ if self._tip is not None and self._hover:
890
+ self._tip_update()
891
+ # 待机台词:睡觉且不害羞(未静音/未藏起)时偶尔冒一句,像有性格
892
+ now = time.time()
893
+ if (st == "sleep" and not self.hidden
894
+ and now > self.next_chatter
895
+ and now >= self.quiet_until):
896
+ self._show_bubble(self._L(random.choice(LANG[self.lang]
897
+ ["chatter"])),
898
+ secs=6.0)
899
+ self.next_chatter = max(self.next_chatter,
900
+ now + random.uniform(240, 540))
901
+ if self.miss >= MAX_MISS:
902
+ return self._bye()
903
+ if self.miss >= 2:
904
+ self._apply_state("dead")
905
+ self._sync_visibility("dead")
906
+ self.root.after(POLL_MS, self._tick)
907
+
908
+ def _sync_visibility(self, st):
909
+ """tasks_only 模式:空闲超过阈值隐身,忙起来立刻回来。"""
910
+ mode = "always"
911
+ if self.snap:
912
+ mode = self.snap["settings"]["pet_mode"]
913
+ want_visible = (mode != "tasks_only" or st not in ("sleep", "dead") or
914
+ time.time() - self.last_busy < IDLE_HIDE_S)
915
+ if time.time() < self.hide_until:
916
+ want_visible = False # 「先藏起来」:时限内隐身,忙也叫不应
917
+ if want_visible and self.hidden:
918
+ self.hidden = False
919
+ try:
920
+ self.root.deiconify()
921
+ self._clamp_pos()
922
+ except Exception:
923
+ pass
924
+ elif not want_visible and not self.hidden:
925
+ self.hidden = True
926
+ try:
927
+ self.root.withdraw()
928
+ self._tip_hide()
929
+ except Exception:
930
+ pass
931
+
932
+ # ---- 动画(100ms 一帧)----
933
+ def _animate(self):
934
+ t = time.time() - self._t0
935
+ if self._press: # 拖动中冻结姿态:窗口跟手优先,别让蜂在框里乱飘
936
+ self.root.after(100, self._animate)
937
+ return
938
+ st = self.state
939
+ if st == "work":
940
+ tx, ty = math.sin(t * 2.1) * 4.0, math.sin(t * 4.2) * 5.0
941
+ elif st == "cheer":
942
+ # 摇摆舞 + 小跳:蹦跶比原地晃欢腾得多
943
+ tx = math.sin(t * 6.0) * 8.0
944
+ ty = -abs(math.sin(t * 5.0)) * 10.0
945
+ elif st == "alert":
946
+ tx = 3.0 if int(t * 5) % 2 else -3.0
947
+ ty = 0.0
948
+ elif st == "dead":
949
+ tx, ty = 0.0, 0.0
950
+ else: # sleep:坐得低一点,呼吸式微沉浮
951
+ tx, ty = 0.0, 3.0 + math.sin(t * 1.2) * 1.5
952
+ # 冒气泡时蹦一下(所有状态通用的活性小动作)
953
+ now = time.time()
954
+ if now < self.hop_until:
955
+ k = (self.hop_until - now) / 0.7
956
+ ty -= abs(math.sin(k * math.pi)) * 9.0
957
+ if self.frames:
958
+ # —— 精灵选帧:单帧状态直接用;work 高频摆=振翅悬浮;
959
+ # sleep 每 18s 借工作帧慢慢伸个懒腰,坐着也有活物感 ——
960
+ if st == "work":
961
+ wl = self.frames["work"]
962
+ k = int(t * 10) % (2 * (len(wl) - 1))
963
+ img = wl[k if k < len(wl) else 2 * (len(wl) - 1) - k]
964
+ elif st == "cheer":
965
+ cl = self.frames["cheer"]
966
+ img = cl[int(t * 8) % len(cl)]
967
+ elif st == "sleep":
968
+ img = self.frames["sleep"][0]
969
+ cyc = t % 18.0
970
+ if cyc < 2.4:
971
+ wl = self.frames["work"]
972
+ img = wl[int(round(abs(math.sin(cyc / 2.4 * math.pi))
973
+ * (len(wl) - 1)))]
974
+ else:
975
+ img = self.frames[st][0]
976
+ if self.spr is not None:
977
+ self.cv.itemconfigure(self.spr, image=img)
978
+ else:
979
+ if st in ("work", "cheer"):
980
+ self._flap()
981
+ self._move_bee(tx - self.ox, ty - self.oy)
982
+ if st == "sleep":
983
+ n = len(self.zzz)
984
+ phase = int(t * 1.6) % (n + 1)
985
+ for i, z in enumerate(self.zzz):
986
+ self.cv.itemconfigure(z, state=("normal" if i < phase
987
+ else "hidden"))
988
+ rise = (t * 8) % 10
989
+ for k, z in enumerate(self.zzz):
990
+ self.cv.coords(z, self.zzz_base[k][0],
991
+ self.zzz_base[k][1] - rise * 0.4)
992
+ if st == "cheer":
993
+ on = int(t * 6) % 2
994
+ for i, s in enumerate(self.spark):
995
+ self.cv.itemconfigure(s, state=("normal" if (i % 2) == on
996
+ else "hidden"))
997
+ self.root.after(100, self._animate)
998
+
999
+ def _flap(self):
1000
+ self._flap_a = not self._flap_a
1001
+ self._show(self.wing_up, self._flap_a)
1002
+ self._show(self.wing_down, not self._flap_a)
1003
+
1004
+ # ---- 悬停任务清单(指针轮询:透明像素点击穿透,Enter/Leave 收不全)----
1005
+ def _hover_tick(self):
1006
+ try:
1007
+ px, py = self.root.winfo_pointerx(), self.root.winfo_pointery()
1008
+ wx, wy = self.root.winfo_x(), self.root.winfo_y()
1009
+ inside = (not self.hidden and
1010
+ wx <= px < wx + self.win_w and
1011
+ wy <= py < wy + self.win_h)
1012
+ except Exception:
1013
+ inside = False
1014
+ if inside and not self._hover:
1015
+ self._hover = True
1016
+ self._tip_pending = self.root.after(HOVER_DELAY_MS, self._tip_show)
1017
+ elif not inside and self._hover:
1018
+ self._hover = False
1019
+ if self._tip_pending:
1020
+ try:
1021
+ self.root.after_cancel(self._tip_pending)
1022
+ except Exception:
1023
+ pass
1024
+ self._tip_pending = None
1025
+ self._tip_hide()
1026
+ self.root.after(HOVER_MS, self._hover_tick)
1027
+
1028
+ def _tip_show(self):
1029
+ self._tip_pending = None
1030
+ if not self._hover:
1031
+ return
1032
+ if self._tip is None:
1033
+ tk = self.tk
1034
+ tip = tk.Toplevel(self.root)
1035
+ tip.overrideredirect(True)
1036
+ try:
1037
+ tip.attributes("-topmost", True)
1038
+ tip.configure(bg="#1B2430")
1039
+ except Exception:
1040
+ pass
1041
+ self._tip_head = tk.Label(tip, text=self._tip_header(),
1042
+ bg="#1B2430", fg="#9FB9D8",
1043
+ font=("Microsoft YaHei UI", 8))
1044
+ self._tip_head.pack(anchor="w", padx=10, pady=(6, 0))
1045
+ self._tip_body = tk.Label(tip, text="", bg="#1B2430",
1046
+ fg="#ECF2F8",
1047
+ font=("Microsoft YaHei UI", 9),
1048
+ justify="left", wraplength=280)
1049
+ self._tip_body.pack(anchor="w", padx=10, pady=(2, 8))
1050
+ self._tip = tip
1051
+ self._tip_update()
1052
+ try:
1053
+ self._tip.deiconify()
1054
+ self._tip.lift()
1055
+ except Exception:
1056
+ pass
1057
+
1058
+ def _tip_header(self):
1059
+ n = 0
1060
+ if self.snap:
1061
+ n = sum(1 for t in self.snap["tasks"]
1062
+ if t["run_status"] in ACTIVE_STATUSES)
1063
+ return (self._L("title") + " · " + self._L("n_running") % n) if n \
1064
+ else self._L("title")
1065
+
1066
+ def _tip_update(self):
1067
+ if self._tip is None:
1068
+ return
1069
+ body = "\n".join(tooltip_lines(self.snap or {"tasks": []}, self.lang))
1070
+ try:
1071
+ self._tip_head.configure(text=self._tip_header())
1072
+ self._tip_body.configure(text=body)
1073
+ self._tip_place()
1074
+ except Exception:
1075
+ pass
1076
+
1077
+ def _tip_place(self):
1078
+ try:
1079
+ self._tip.update_idletasks()
1080
+ tw, th = self._tip.winfo_width(), self._tip.winfo_height()
1081
+ wx, wy = self.root.winfo_x(), self.root.winfo_y()
1082
+ sw = self.root.winfo_screenwidth()
1083
+ x = wx + self.win_w + 8
1084
+ if x + tw > sw - 4:
1085
+ x = max(4, wx - tw - 8)
1086
+ y = max(4, min(wy + 2, self.root.winfo_screenheight() - th - 40))
1087
+ self._tip.geometry("+%d+%d" % (x, y))
1088
+ except Exception:
1089
+ pass
1090
+
1091
+ def _tip_hide(self):
1092
+ self._destroy_win("_tip")
1093
+
1094
+ # ---- 气泡 / 菜单 ----
1095
+ def _bubble(self, kind, info):
1096
+ if kind == "cheer":
1097
+ text = self._L("cheer") % max(1, len(info["good"]))
1098
+ else:
1099
+ bad = info["bad"][0] if info["bad"] else {"title": "?"}
1100
+ text = self._L("alert") % bad.get("title", "?")
1101
+ self._show_bubble(text)
1102
+
1103
+ def _show_bubble(self, text, secs=5.0, force=False):
1104
+ if not force and time.time() < self.quiet_until:
1105
+ return # 「安静一会」期间不碎碎念(系统确认语用 force 放行)
1106
+ self._destroy_win("_bubble")
1107
+ self.hop_until = time.time() + 0.7 # 冒泡蹦一下,像在说话
1108
+ tk = self.tk
1109
+ bub = tk.Toplevel(self.root)
1110
+ bub.overrideredirect(True)
1111
+ try:
1112
+ bub.attributes("-topmost", True)
1113
+ except Exception:
1114
+ pass
1115
+ tk.Label(bub, text=text, bg="#FFF8E1", fg="#4A3200",
1116
+ bd=1, relief="solid",
1117
+ font=("Microsoft YaHei UI", 9), justify="left",
1118
+ wraplength=230, padx=8, pady=5).pack()
1119
+ x = self.root.winfo_x() + self.win_w - 40
1120
+ y = max(0, self.root.winfo_y() - 8)
1121
+ bub.update_idletasks()
1122
+ if x + bub.winfo_width() > self.root.winfo_screenwidth():
1123
+ x = self.root.winfo_screenwidth() - bub.winfo_width() - 8
1124
+ bub.geometry("+%d+%d" % (x, y))
1125
+ self._bubble = bub
1126
+ self._bubble_until = time.time() + secs
1127
+ self.root.after(int(secs * 1000), self._hide_bubble)
1128
+
1129
+ def _hide_bubble(self):
1130
+ if time.time() < self._bubble_until:
1131
+ self.root.after(500, self._hide_bubble)
1132
+ return
1133
+ self._destroy_win("_bubble")
1134
+
1135
+ def _destroy_win(self, attr):
1136
+ try:
1137
+ win = getattr(self, attr, None)
1138
+ if win is not None:
1139
+ win.destroy()
1140
+ except Exception:
1141
+ pass
1142
+ setattr(self, attr, None)
1143
+
1144
+ def _menu(self):
1145
+ mode = "always"
1146
+ if self.snap:
1147
+ mode = self.snap["settings"]["pet_mode"]
1148
+ self._mode_var.set(mode)
1149
+ self._skin_var.set(self.skin)
1150
+ self._size_var.set(self.size_key)
1151
+ self._lang_var.set(self.lang)
1152
+ m = self.tk.Menu(self.root, tearoff=0)
1153
+ m.add_command(label=self._L("open"), command=self._open_ui)
1154
+ m.add_separator()
1155
+ m.add_command(label="◎ " + self._L("mode"), state="disabled")
1156
+ m.add_radiobutton(label=" " + self._L("always"),
1157
+ command=lambda: self._set_mode("always"),
1158
+ variable=self._mode_var, value="always")
1159
+ m.add_radiobutton(label=" " + self._L("tasks_only"),
1160
+ command=lambda: self._set_mode("tasks_only"),
1161
+ variable=self._mode_var, value="tasks_only")
1162
+ m.add_separator()
1163
+ m.add_command(label="◎ " + self._L("skin"), state="disabled")
1164
+ for key, (_, name) in SKINS.items():
1165
+ m.add_radiobutton(label=" " + name,
1166
+ command=lambda k=key: self._set_skin(k),
1167
+ variable=self._skin_var, value=key)
1168
+ m.add_command(label="◎ " + self._L("size"), state="disabled")
1169
+ for key in ("small", "mid", "big"):
1170
+ m.add_radiobutton(label=" " + self._L(key),
1171
+ command=lambda k=key: self._set_size(k),
1172
+ variable=self._size_var, value=key)
1173
+ m.add_separator()
1174
+ m.add_command(label=self._L("reset_pos"), command=self._reset_pos)
1175
+ m.add_command(label=self._L("quiet"), command=self._be_quiet)
1176
+ m.add_command(label=self._L("hide"), command=self._hide_awhile)
1177
+ m.add_separator()
1178
+ m.add_command(label="◎ " + self._L("lang"), state="disabled")
1179
+ m.add_radiobutton(label=" 中文",
1180
+ command=lambda: self._set_lang("zh"),
1181
+ variable=self._lang_var, value="zh")
1182
+ m.add_radiobutton(label=" English",
1183
+ command=lambda: self._set_lang("en"),
1184
+ variable=self._lang_var, value="en")
1185
+ m.add_separator()
1186
+ m.add_command(label=self._L("close"), command=self._bye)
1187
+ return m
1188
+
1189
+ def _set_mode(self, mode):
1190
+ try:
1191
+ _http_json(self.port, "/api/settings", body={"pet_mode": mode})
1192
+ except Exception:
1193
+ pass
1194
+
1195
+ def _set_lang(self, lang):
1196
+ self.lang = lang
1197
+ self._save_cfg(lang=lang)
1198
+
1199
+ def _open_ui(self):
1200
+ try:
1201
+ webbrowser.open("http://127.0.0.1:%d" % self.port)
1202
+ except Exception:
1203
+ pass
1204
+
1205
+ def _bind_input(self):
1206
+ # 菜单变量须先于 _menu 存在
1207
+ self._mode_var = self.tk.StringVar(value="always")
1208
+ self._skin_var = self.tk.StringVar(value=self.skin)
1209
+ self._size_var = self.tk.StringVar(value=self.size_key)
1210
+ self._lang_var = self.tk.StringVar(value=self.lang)
1211
+ cv = self.cv
1212
+ self._press = None
1213
+ self._moved = False
1214
+ cv.bind("<ButtonPress-1>", self._on_press)
1215
+ cv.bind("<B1-Motion>", self._on_motion)
1216
+ cv.bind("<ButtonRelease-1>", self._on_release)
1217
+ cv.bind("<Button-3>", lambda e: self._menu().tk_popup(e.x_root,
1218
+ e.y_root))
1219
+ self.root.protocol("WM_DELETE_WINDOW", self._bye)
1220
+
1221
+ def _on_press(self, e):
1222
+ self._press = e
1223
+ self._moved = False
1224
+ try:
1225
+ self.root.scan_mark(e.x_root, e.y_root) # Tk 内置 C 级拖动,跟手
1226
+ except Exception:
1227
+ pass
1228
+
1229
+ def _on_motion(self, e):
1230
+ if not self._press:
1231
+ return
1232
+ if (abs(e.x - self._press.x) + abs(e.y - self._press.y) > 4):
1233
+ self._moved = True
1234
+ # 逐事件 geometry() 会把透明层窗拖成PPT(每次回调都走 Python+重排);
1235
+ # scan_dragto 在 C 里完成同样的移动,丝滑
1236
+ try:
1237
+ self.root.scan_dragto(e.x_root, e.y_root, 1)
1238
+ except Exception:
1239
+ pass
1240
+
1241
+ def _on_release(self, e):
1242
+ moved, self._moved = self._moved, False
1243
+ self._press = None
1244
+ if moved:
1245
+ self._clamp_pos()
1246
+ self._save_cfg(x=self.root.winfo_x(), y=self.root.winfo_y())
1247
+ else:
1248
+ self._open_ui()
1249
+
1250
+ def _bye(self):
1251
+ try:
1252
+ self._save_cfg(x=self.root.winfo_x(), y=self.root.winfo_y())
1253
+ except Exception:
1254
+ pass
1255
+ try:
1256
+ global_lock_path().unlink()
1257
+ except Exception:
1258
+ pass
1259
+ try:
1260
+ self.root.destroy()
1261
+ except Exception:
1262
+ pass
1263
+
1264
+ def _L(self, key):
1265
+ return LANG[self.lang][key]
1266
+
1267
+
1268
+ def main():
1269
+ ap = argparse.ArgumentParser(description="CodeBee 桌面蜜蜂")
1270
+ ap.add_argument("--port", type=int, default=8765)
1271
+ ap.add_argument("--data-dir", default=os.environ.get("TUTTI_DATA")
1272
+ or str(Path.home() / ".codebee"))
1273
+ ap.add_argument("--lang", default="zh", choices=("zh", "en"))
1274
+ ap.add_argument("--once", action="store_true",
1275
+ help="拉一次状态推导蜜蜂状态并打印 JSON,不开窗口(诊断/测试)")
1276
+ args = ap.parse_args()
1277
+
1278
+ try:
1279
+ sys.stdout.reconfigure(encoding="utf-8") # py3.7+;重复设无害
1280
+ except Exception:
1281
+ pass
1282
+
1283
+ if args.once:
1284
+ out = {"ok": False, "state": "sleep", "port": args.port}
1285
+ try:
1286
+ snap = parse_snapshot(_http_json(args.port, "/api/pet_state"))
1287
+ st, info = derive_state(
1288
+ {t["id"] for t in snap["tasks"]
1289
+ if t["run_status"] in ACTIVE_STATUSES}, snap["tasks"])
1290
+ out.update({"ok": True, "state": st,
1291
+ "workers": snap["workers"],
1292
+ "settings": snap["settings"],
1293
+ "digest": snap.get("digest") or {},
1294
+ "active": len(info["active"]),
1295
+ "good": len(info["good"]),
1296
+ "bad": len(info["bad"])})
1297
+ except Exception as e:
1298
+ out["error"] = str(e)
1299
+ print(json.dumps(out, ensure_ascii=False))
1300
+ return
1301
+
1302
+ try:
1303
+ import tkinter as tk # noqa: F401 (提前探,缺了给人话而不是堆栈)
1304
+ except Exception:
1305
+ print("[CodeBee] 这台机器的 Python 没有 tkinter,桌面蜜蜂养不了"
1306
+ "(其余功能不受影响)")
1307
+ return 3
1308
+
1309
+ try:
1310
+ from ctypes import windll
1311
+ windll.shcore.SetProcessDpiAwareness(1) # 高分屏不发糊(失败无所谓)
1312
+ except Exception:
1313
+ pass
1314
+
1315
+ app = PetApp(args.port, args.data_dir, lang=args.lang)
1316
+ if not app._acquire_lock():
1317
+ print("[CodeBee] 已有一只蜜蜂在岗,不多养一只")
1318
+ return 0
1319
+ app.root.mainloop()
1320
+
1321
+
1322
+ if __name__ == "__main__":
1323
+ sys.exit(main())