codebee 0.1.28 → 0.1.30

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.
@@ -20,6 +20,7 @@ import json
20
20
  import logging
21
21
  import os
22
22
  import re
23
+ import subprocess
23
24
  import threading
24
25
  import time
25
26
  from datetime import datetime, timedelta
@@ -708,3 +709,142 @@ def _test_reset():
708
709
  _STATE["groups_cache"] = []
709
710
  _STATE["last_scan"] = _STATE["next_scan"] = _STATE["last_error"] = ""
710
711
  globals()["_LOADED"] = True
712
+
713
+
714
+ # ---- 64 位 Python 自动扫描 + wechatauto-replica 一键安装(2026-09-21 用户诉求:
715
+ # 别让用户手填解释器路径、别让用户自己开 conda 装依赖)----
716
+
717
+ _PY_SCAN = {"scanning": False, "done": True, "results": []}
718
+ _PY_INSTALL = {"running": False, "ok": None, "python": "", "log": []}
719
+ _REPLICA_PKG = "wechatauto-replica"
720
+
721
+
722
+ def _candidate_pythons():
723
+ """收集本机候选解释器路径(去重、存在性过滤)。只列不判,位数/依赖交给探测。"""
724
+ import glob
725
+ cands = []
726
+
727
+ def _add(p):
728
+ p = str(p).strip().strip('"')
729
+ if p and os.path.isfile(p) and p.lower().endswith((".exe",)) is not False:
730
+ if p not in cands and "python" in os.path.basename(p).lower():
731
+ cands.append(p)
732
+
733
+ if os.name == "nt":
734
+ home = os.environ.get("USERPROFILE") or ""
735
+ patterns = [
736
+ os.path.join(home, "anaconda3", "envs", "*", "python.exe"),
737
+ os.path.join(home, "miniconda3", "envs", "*", "python.exe"),
738
+ os.path.join(home, ".conda", "envs", "*", "python.exe"),
739
+ os.path.join(os.environ.get("LOCALAPPDATA") or "", "Programs",
740
+ "Python", "*", "python.exe"),
741
+ r"C:\Python*\python.exe",
742
+ os.path.join(home, "AppData", "Local", "anaconda3", "python.exe"),
743
+ os.path.join(home, "anaconda3", "python.exe"),
744
+ os.path.join(home, "miniconda3", "python.exe"),
745
+ ]
746
+ for pat in patterns:
747
+ for p in glob.glob(pat):
748
+ _add(p)
749
+ try:
750
+ r = subprocess.run(["cmd", "/c", "where", "python"],
751
+ capture_output=True, timeout=15)
752
+ for ln in r.stdout.decode("utf-8", "replace").splitlines():
753
+ _add(ln)
754
+ except Exception:
755
+ pass
756
+ else:
757
+ import glob as _g
758
+ for pat in ("/usr/bin/python3*", "/usr/local/bin/python3*",
759
+ os.path.expanduser("~/.conda/envs/*/bin/python3"),
760
+ os.path.expanduser("~/miniconda3/envs/*/bin/python3"),
761
+ os.path.expanduser("~/anaconda3/envs/*/bin/python3")):
762
+ for p in _g.glob(pat):
763
+ _add(p)
764
+ return cands[:24]
765
+
766
+
767
+ def _probe_python(py):
768
+ """探测解释器:返回 {path, version, bits} 或 None(跑不动/不是 python)。"""
769
+ try:
770
+ r = subprocess.run(
771
+ [py, "-c", "import sys,struct;print('%d.%d'%sys.version_info[:2],"
772
+ "struct.calcsize('P')*8)"],
773
+ capture_output=True, timeout=20,
774
+ creationflags=CREATE_NO_WINDOW if os.name == "nt" else 0)
775
+ out = (r.stdout or b"").decode("utf-8", "replace").strip()
776
+ ver, bits = out.split()
777
+ return {"path": py, "version": ver, "bits": int(bits)}
778
+ except Exception:
779
+ return None
780
+
781
+
782
+ def _has_replica(py):
783
+ """该环境是否已装 wechatauto-replica(pip show 探测,失败视为未装)。"""
784
+ try:
785
+ r = subprocess.run([py, "-m", "pip", "show", _REPLICA_PKG],
786
+ capture_output=True, timeout=60,
787
+ creationflags=CREATE_NO_WINDOW if os.name == "nt" else 0)
788
+ return r.returncode == 0
789
+ except Exception:
790
+ return False
791
+
792
+
793
+ def scan_pythons():
794
+ """启动后台扫描(幂等:扫描中重复调用直接忽略)。返回当前状态。"""
795
+ if _PY_SCAN["scanning"]:
796
+ return dict(_PY_SCAN)
797
+
798
+ def _run():
799
+ _PY_SCAN.update(scanning=True, done=False, results=[])
800
+ found = []
801
+ for py in _candidate_pythons():
802
+ info = _probe_python(py)
803
+ if not info:
804
+ continue
805
+ info["has_replica"] = _has_replica(py)
806
+ found.append(info)
807
+ # 排序:64 位优先 → 已装 replica 优先 → 版本新优先 → 路径短优先
808
+ found.sort(key=lambda x: (x["bits"] < 64, not x["has_replica"],
809
+ x["version"], len(x["path"])), reverse=False)
810
+ found.sort(key=lambda x: (x["bits"] >= 64, x["has_replica"]))
811
+ _PY_SCAN.update(scanning=False, done=True, results=found)
812
+
813
+ threading.Thread(target=_run, name="wx-py-scan", daemon=True).start()
814
+ return dict(_PY_SCAN)
815
+
816
+
817
+ def pythons_status():
818
+ return dict(_PY_SCAN)
819
+
820
+
821
+ def install_replica(py):
822
+ """一键安装 wechatauto-replica 到指定解释器(后台 pip,状态可轮询)。"""
823
+ py = str(py or "").strip()
824
+ if not py or not os.path.isfile(py):
825
+ return {"ok": False, "error": "解释器路径无效"}
826
+ if _PY_INSTALL["running"]:
827
+ return {"ok": False, "error": "已有安装在跑,请等它结束"}
828
+
829
+ def _run():
830
+ _PY_INSTALL.update(running=True, ok=None, python=py, log=[])
831
+ try:
832
+ r = subprocess.run(
833
+ [py, "-m", "pip", "install", _REPLICA_PKG],
834
+ capture_output=True, timeout=900,
835
+ creationflags=CREATE_NO_WINDOW if os.name == "nt" else 0)
836
+ ok = r.returncode == 0
837
+ tail = ((r.stderr or b"").decode("utf-8", "replace")
838
+ or (r.stdout or b"").decode("utf-8", "replace"))[-1500:]
839
+ except Exception as e:
840
+ ok, tail = False, str(e)[:500]
841
+ _PY_INSTALL.update(running=False, ok=ok, log=tail.strip().splitlines()[-25:])
842
+ if ok:
843
+ scan_pythons() # 装完自动重扫,has_replica 标记即时生效
844
+
845
+ threading.Thread(target=_run, name="wx-replica-install", daemon=True).start()
846
+ return {"ok": True}
847
+
848
+
849
+ def install_status():
850
+ return dict(_PY_INSTALL)
package/app/main.py CHANGED
@@ -52,6 +52,7 @@ def _utf8_bytes(data):
52
52
  return data.decode("utf-8", "replace").encode("utf-8")
53
53
 
54
54
  PORT = 8765 # main() 启动时更新;/api/connect 组装扫码地址用
55
+ _BOOT_TS = time.time() # 服务进程启动时间戳:/api/pet_state 下发,蜜蜂据此识别「服务换人了」并让位
55
56
 
56
57
  # 写接口统一限制 JSON 请求体,避免误传文件或异常客户端把 worker 线程和
57
58
  # 内存拖垮。16 MiB 足够覆盖任务上下文、故事圣经和附件清单(附件本体走
@@ -283,7 +284,12 @@ class Handler(BaseHTTPRequestHandler):
283
284
  edays = max(1, min(3650, int((q.get("days") or ["90"])[0])))
284
285
  except ValueError:
285
286
  edays = 90
286
- return self._json(200, usage.estimate(task_type=ttype, days=edays))
287
+ mode = (q.get("mode") or ["auto"])[0][:16]
288
+ thinking = (q.get("thinking") or ["standard"])[0][:16]
289
+ rounds = (q.get("rounds") or [None])[0]
290
+ return self._json(200, usage.estimate(
291
+ task_type=ttype, days=edays, mode=mode,
292
+ thinking=thinking, rounds=rounds))
287
293
  if path == "/api/dispatch/replay":
288
294
  from core import dispatch_log
289
295
  q = parse_qs(urlparse(self.path).query)
@@ -312,6 +318,13 @@ class Handler(BaseHTTPRequestHandler):
312
318
  # 偏好记忆:上次创建任务的类型/参数(新建表单预填用)
313
319
  from core import prefs
314
320
  return self._json(200, {"prefs": prefs.load()})
321
+ if path == "/api/wxdigest/pythons":
322
+ # 64 位 Python 候选(蜜蜂坞微信直连配置):扫描后台跑,此端点轮询
323
+ from core import wxdigest
324
+ return self._json(200, wxdigest.pythons_status())
325
+ if path == "/api/wxdigest/replica/status":
326
+ from core import wxdigest
327
+ return self._json(200, wxdigest.install_status())
315
328
  if path == "/api/ports":
316
329
  # 端口占用诊断(借鉴 leftopen):谁在听、PID/进程/项目归属、
317
330
  # 是否仅本机。?port=N 只看单端口。只读,不碰任何进程。
@@ -1079,6 +1092,15 @@ class Handler(BaseHTTPRequestHandler):
1079
1092
  from core import wxdigest
1080
1093
  res = wxdigest.scan_now() # 含模型调用,同步做(ThreadingHTTPServer 不堵别的请求)
1081
1094
  return self._json(200, dict(res, ok=bool(res.get("ok"))))
1095
+ if path == "/api/wxdigest/pythons/scan":
1096
+ # 64 位 Python 自动扫描(后台线程,GET /api/wxdigest/pythons 轮询)
1097
+ from core import wxdigest
1098
+ return self._json(200, wxdigest.scan_pythons())
1099
+ if path == "/api/wxdigest/replica/install":
1100
+ # 一键安装 wechatauto-replica 到指定解释器(后台 pip,状态可轮询)
1101
+ from core import wxdigest
1102
+ res = wxdigest.install_replica(self._body().get("python"))
1103
+ return self._json(200 if res.get("ok") else 400, res)
1082
1104
  if path == "/api/wxdigest/seen":
1083
1105
  from core import wxdigest
1084
1106
  return self._json(200, {"ok": True, "view": wxdigest.seen_clear()})
@@ -1468,6 +1490,7 @@ class Handler(BaseHTTPRequestHandler):
1468
1490
  「刚完工/刚出事」。设置随响应回传,蜜蜂轮询到 pet_enabled=false 自行
1469
1491
  退出——关闭桌宠不需要专门的信号通道。
1470
1492
  """
1493
+ global _BOOT_TS
1471
1494
  st = settings.load()
1472
1495
  rows = []
1473
1496
  n_run = n_q = 0
@@ -1500,6 +1523,10 @@ class Handler(BaseHTTPRequestHandler):
1500
1523
  dg = {"unseen": 0, "latest": None}
1501
1524
  return self._json(200, {
1502
1525
  "ok": True, "ts": int(time.time()), "port": PORT,
1526
+ # boot:本服务进程的启动时间戳。蜜蜂记住首次见到的值,值变了说明
1527
+ # 服务换了个进程(升级重启)——旧蜜蜂自行退场让新服务 spawn 新蜜蜂
1528
+ # (旧形象常驻的根因:同端口轮询永远正常,旧进程从不自离)。
1529
+ "boot": _BOOT_TS,
1503
1530
  "settings": {"pet_enabled": bool(st.get("pet_enabled", True)),
1504
1531
  "pet_mode": str(st.get("pet_mode") or "always"),
1505
1532
  "pet_skin": str(st.get("pet_skin") or "plush")},
package/app/pet.py CHANGED
@@ -82,6 +82,8 @@ def parse_snapshot(raw):
82
82
  "error": str(t.get("error") or ""),
83
83
  })
84
84
  return {
85
+ # boot:服务进程启动标识(值变化=服务换人,本宠让位给新服务的蜜蜂)
86
+ "boot": raw.get("boot") or None,
85
87
  "settings": {
86
88
  "pet_enabled": bool(st.get("pet_enabled", True)),
87
89
  "pet_mode": str(st.get("pet_mode") or "always"),
@@ -166,6 +168,7 @@ def _short(text, limit=22):
166
168
  LANG = {
167
169
  "zh": {
168
170
  "open": "打开 CodeBee",
171
+ "digest": "群摘要",
169
172
  "mode": "显示模式",
170
173
  "always": "常驻显示",
171
174
  "tasks_only": "仅任务运行时出现",
@@ -195,6 +198,7 @@ LANG = {
195
198
  },
196
199
  "en": {
197
200
  "open": "Open CodeBee",
201
+ "digest": "Group Digests",
198
202
  "mode": "Display mode",
199
203
  "always": "Always visible",
200
204
  "tasks_only": "Only when tasks run",
@@ -309,7 +313,6 @@ KEY_RGB = (1, 2, 3)
309
313
  WIN_W, WIN_H = 170, 150 # 手绘回落模式的窗口尺寸
310
314
  SPRITE_DIR = Path(__file__).resolve().parent
311
315
  SPRITE_DISP_H = 112 # 精灵显示高度(宽等比)
312
- PROG_H = 16 # 底部进度条预留高度(有进度时才显示)
313
316
 
314
317
 
315
318
  def _pid_alive(pid):
@@ -371,6 +374,9 @@ _BROWSER_EXES = {
371
374
  }
372
375
 
373
376
 
377
+ petWindow = None
378
+
379
+
374
380
  def _window_process_name(hwnd):
375
381
  """取顶层窗口所属进程的可执行文件名(小写);失败返回空串。"""
376
382
  try:
@@ -400,10 +406,8 @@ def _window_process_name(hwnd):
400
406
  return ""
401
407
 
402
408
 
403
- def _focus_codebee_window(ignore_hwnd=None):
404
- """Windows:已打开过 CodeBee 浏览器页时,把那个窗口找出来还原并置前,
405
- 避免每点一次桌宠就新开一个浏览器窗口。返回 True 表示已找到并尝试置前;
406
- False 表示没有现成窗口,调用方应回退 webbrowser.open 新开。"""
409
+ def _find_codebee_window(port, ignore_hwnd=None):
410
+ """查找当前端口对应的 CodeBee 浏览器顶层窗口,找不到返回 None。"""
407
411
  try:
408
412
  import ctypes
409
413
  from ctypes import wintypes
@@ -422,22 +426,55 @@ def _focus_codebee_window(ignore_hwnd=None):
422
426
  return True
423
427
  buf = ctypes.create_unicode_buffer(length + 1)
424
428
  user32.GetWindowTextW(hwnd, buf, length + 1)
425
- if "CodeBee" in buf.value:
429
+ title = buf.value
430
+ # 页面启动时把端口写入 document.title。只匹配当前服务端口,避免
431
+ # 把标题里碰巧含 CodeBee 的文档/仓库页或另一套本地实例抢到前台。
432
+ if "CodeBee" in title and ("[%s]" % int(port)) in title:
426
433
  found.append(hwnd)
427
434
  return True
428
435
 
429
436
  user32.EnumWindows(EnumWindowsProc(_cb), 0)
430
437
  if not found:
438
+ return None
439
+ return found[0]
440
+ except Exception:
441
+ return None
442
+
443
+
444
+ def _show_pet_window(port, ignore_hwnd=None):
445
+ """复用桌宠拉起的 CodeBee 浏览器窗口;窗口已关闭时返回 False。"""
446
+ global petWindow
447
+ try:
448
+ if os.name != "nt":
449
+ return False
450
+ import ctypes
451
+ user32 = ctypes.windll.user32
452
+ if petWindow and user32.IsWindow(petWindow):
453
+ if user32.IsIconic(petWindow):
454
+ user32.ShowWindow(petWindow, 9) # SW_RESTORE
455
+ user32.SetForegroundWindow(petWindow)
456
+ return True
457
+ petWindow = None
458
+ hwnd = _find_codebee_window(port, ignore_hwnd)
459
+ if not hwnd:
431
460
  return False
432
- target = found[0]
433
- if user32.IsIconic(target):
434
- user32.ShowWindow(target, 9) # SW_RESTORE:最小化先还原
435
- user32.SetForegroundWindow(target)
461
+ petWindow = hwnd
462
+ if user32.IsIconic(hwnd):
463
+ user32.ShowWindow(hwnd, 9)
464
+ user32.SetForegroundWindow(hwnd)
436
465
  return True
437
466
  except Exception:
438
467
  return False
439
468
 
440
469
 
470
+ def openPetWindow(port, ignore_hwnd=None, url=None):
471
+ """打开 CodeBee 网页:Windows 先置前已打开的窗口(用户拍板「已打开就不重复
472
+ 开」),找不到才用默认浏览器新开。url 可带锚点(如 #bee=1 直达蜜蜂坞)。"""
473
+ if os.name == "nt" and _show_pet_window(port, ignore_hwnd):
474
+ return
475
+ webbrowser.open(url or ("http://127.0.0.1:%d" % port))
476
+
477
+
441
478
  class PetApp:
442
479
  """蜜蜂窗口:蜜蜂本体(精灵/手绘)+ 状态机动画 + 悬停任务清单 + 右键菜单。"""
443
480
 
@@ -485,6 +522,7 @@ class PetApp:
485
522
  # 运行态
486
523
  self.snap = None
487
524
  self.miss = 0
525
+ self.boot = None # 首次见到的服务 boot 标识;变化=服务换进程,本宠让位
488
526
  self.prev_active = set()
489
527
  self.mood_kind, self.mood_until = "", 0.0
490
528
  self.last_busy = time.time()
@@ -592,7 +630,26 @@ class PetApp:
592
630
  try:
593
631
  from PIL import Image, ImageEnhance, ImageTk
594
632
  except Exception:
595
- return None
633
+ # 无 Pillow:用 Tk 原生 PhotoImage 直接贴 PNG(Tk 8.6+ 原生解码,
634
+ # 零依赖)——此前直接回落手绘蜂,普通用户 npm 装完没有 Pillow,
635
+ # 看到的永远是手绘丑蜂(同事「还是老的丑蜜蜂」的真凶)。
636
+ # 限制:无平滑缩放/旋转,动画帧退化为静态贴图 + 位移点缀。
637
+ fname = SKINS.get(skin, SKINS[DEFAULT_SKIN])[0]
638
+ try:
639
+ img = self.tk.PhotoImage(file=str(SPRITE_DIR / fname))
640
+ except Exception:
641
+ return None
642
+ h0 = max(1, img.height())
643
+ disp_h = max(60, int(SPRITE_DISP_H * SIZES.get(self.size_key, 1.0)))
644
+ scale = disp_h / h0
645
+ if scale < 0.9:
646
+ k = max(1, round(1 / scale))
647
+ img = img.subsample(k, k)
648
+ elif scale > 1.1:
649
+ k = max(1, round(scale))
650
+ img = img.zoom(k, k)
651
+ return {"alert": [img], "sleep": [img], "dead": [img],
652
+ "work": [img], "cheer": [img]}
596
653
  fname = SKINS.get(skin, SKINS[DEFAULT_SKIN])[0]
597
654
  try:
598
655
  base0 = Image.open(SPRITE_DIR / fname).convert("RGBA")
@@ -638,18 +695,19 @@ class PetApp:
638
695
  def _size_for(self, frames):
639
696
  fw = max(f.width() for lst in frames.values() for f in lst)
640
697
  fh = max(f.height() for lst in frames.values() for f in lst)
641
- return int(fw) + 8, int(fh) + PROG_H + 4
698
+ return int(fw) + 8, int(fh) + 4
642
699
 
643
700
  # ---- 蜜蜂绘制(精灵模式:贴图 + 状态点缀画件)----
644
- # 用户拍板去掉:右上徽章、左侧速度线、底部影子(真机截图圈删)——
645
- # 窗口贴着蜂体开,点缀只留睡觉 Zzz / 庆祝星光 / 底部进度条。
701
+ # 用户拍板去掉:右上徽章、左侧速度线、底部影子(真机截图圈删)、
702
+ # 底部任务进度条(2026-09-21 真机截图圈删)——
703
+ # 窗口贴着蜂体开,点缀只留睡觉 Zzz / 庆祝星光。
646
704
  def _build_sprite(self):
647
705
  cv = self.cv
648
- cx, cy = self.win_w // 2, (self.win_h - PROG_H) // 2 + 2
706
+ cx, cy = self.win_w // 2, self.win_h // 2 + 2
649
707
  self._spr_c = (cx, cy)
650
708
  self.spr = cv.create_image(cx, cy, image=self.frames["alert"][0],
651
709
  anchor="center")
652
- hw, hh = self.win_w / 2, (self.win_h - PROG_H) / 2
710
+ hw, hh = self.win_w / 2, self.win_h / 2
653
711
  self.zzz = [cv.create_text(cx + hw - 12, cy - hh + 14, text="z",
654
712
  fill="#8A93A6",
655
713
  font=("Segoe UI", 11, "italic bold")),
@@ -671,15 +729,6 @@ class PetApp:
671
729
  for grp in (self.zzz, self.spark):
672
730
  for i in grp:
673
731
  cv.itemconfigure(i, state="hidden")
674
- # 底部进度条(工作且有步骤进度才出现)
675
- self.pbar_bg = cv.create_rectangle(cx - 34, self.win_h - 7,
676
- cx + 34, self.win_h - 2,
677
- fill="#202A36", outline="#3A4654")
678
- self.pbar_fg = cv.create_rectangle(cx - 33, self.win_h - 6,
679
- cx - 32, self.win_h - 3,
680
- fill="#7ED07E", outline="")
681
- cv.itemconfigure(self.pbar_bg, state="hidden")
682
- cv.itemconfigure(self.pbar_fg, state="hidden")
683
732
  self.ox = self.oy = 0.0
684
733
  self._t0 = time.time()
685
734
 
@@ -867,14 +916,6 @@ class PetApp:
867
916
  self._palette = {i: cv.itemcget(i, "fill")
868
917
  for i in (self.body, self.belly)}
869
918
 
870
- # —— 贴地件:进度条(用户拍板去掉影子)——
871
- self.pbar_bg = cv.create_rectangle(46, 139, 128, 147, fill="#202A36",
872
- outline="#3A4654")
873
- self.pbar_fg = cv.create_rectangle(47, 140, 48, 146, fill="#7ED07E",
874
- outline="")
875
- cv.itemconfigure(self.pbar_bg, state="hidden")
876
- cv.itemconfigure(self.pbar_fg, state="hidden")
877
-
878
919
  # 动画游标
879
920
  self.ox = self.oy = 0.0
880
921
  self._t0 = time.time()
@@ -923,26 +964,9 @@ class PetApp:
923
964
  cv.itemconfigure(self.body, fill=self._palette[self.body])
924
965
  cv.itemconfigure(self.belly, fill=self._palette[self.belly])
925
966
  cv.itemconfigure(self.gloss, state="normal")
926
- # —— 共同:Zzz / 星光 / 进度条(徽章与速度线已按用户要求移除)——
967
+ # —— 共同:Zzz / 星光(徽章、速度线、进度条均已按用户要求移除)——
927
968
  self._show(self.zzz, False)
928
969
  self._show(self.spark, False)
929
- prog = []
930
- if st == "work" and self.snap:
931
- prog = [t for t in self.snap["tasks"]
932
- if t["run_status"] == "running" and t["steps_total"]]
933
- if prog:
934
- done = sum(t["steps_done"] for t in prog)
935
- total = sum(t["steps_total"] for t in prog)
936
- ratio = max(0.0, min(1.0, done / float(total)))
937
- x0 = (self.win_w // 2) - 34
938
- cv.itemconfigure(self.pbar_bg, state="normal")
939
- cv.itemconfigure(self.pbar_fg, state="normal")
940
- cv.coords(self.pbar_bg, x0, self.win_h - 7, x0 + 68,
941
- self.win_h - 2)
942
- cv.coords(self.pbar_fg, x0 + 1, self.win_h - 6,
943
- x0 + 1 + 66 * ratio, self.win_h - 3)
944
- else:
945
- self._show([self.pbar_bg, self.pbar_fg], False)
946
970
 
947
971
  def _post_settings(self, body):
948
972
  """合并并串行发送设置,保证快速连点时最后一次选择最终生效。"""
@@ -1029,6 +1053,15 @@ class PetApp:
1029
1053
  else:
1030
1054
  self.miss = 0
1031
1055
  if snap is not None:
1056
+ # 服务换人检测(升级重启同端口场景):boot 与首次见到的不一致,
1057
+ # 说明响应来自新进程——本进程是旧代码的遗老,立即让位,新服务的
1058
+ # 看护会 spawn 带新形象的新蜜蜂(旧形象常驻的根因修复)。
1059
+ boot = snap.get("boot")
1060
+ if boot:
1061
+ if self.boot is None:
1062
+ self.boot = boot
1063
+ elif boot != self.boot:
1064
+ return self._bye()
1032
1065
  self._reconcile_desired_settings(snap, confirmed_at_poll_start)
1033
1066
  if not snap["settings"]["pet_enabled"]:
1034
1067
  return self._bye()
@@ -1330,6 +1363,7 @@ class PetApp:
1330
1363
  self._lang_var.set(self.lang)
1331
1364
  m = self.tk.Menu(self.root, tearoff=0)
1332
1365
  m.add_command(label=self._L("open"), command=self._open_ui)
1366
+ m.add_command(label=self._L("digest"), command=self._open_digest)
1333
1367
  m.add_separator()
1334
1368
  m.add_command(label="◎ " + self._L("mode"), state="disabled")
1335
1369
  m.add_radiobutton(label=" " + self._L("always"),
@@ -1373,17 +1407,20 @@ class PetApp:
1373
1407
  self.lang = lang
1374
1408
  self._save_cfg(lang=lang)
1375
1409
 
1376
- def _open_ui(self):
1410
+ def _open_ui(self, hash_part=""):
1377
1411
  try:
1378
- url = "http://127.0.0.1:%d" % self.port
1379
- # 已开过就不新开:Windows 上先找现有 CodeBee 浏览器窗口置前,
1380
- # 找不到(没开/标签不在前台)才回退 webbrowser.open 新开。
1381
- if os.name == "nt" and _focus_codebee_window(self.root.winfo_id()):
1382
- return
1383
- webbrowser.open(url)
1412
+ if hash_part:
1413
+ openPetWindow(self.port, self.root.winfo_id(),
1414
+ url="http://127.0.0.1:%d/%s" % (self.port, hash_part))
1415
+ else:
1416
+ openPetWindow(self.port, self.root.winfo_id())
1384
1417
  except Exception:
1385
1418
  pass
1386
1419
 
1420
+ def _open_digest(self):
1421
+ """群摘要:打开网页并自动展开蜜蜂坞面板(样式与网页端统一)。"""
1422
+ self._open_ui("#bee=1")
1423
+
1387
1424
  def _bind_input(self):
1388
1425
  # 菜单变量须先于 _menu 存在
1389
1426
  self._mode_var = self.tk.StringVar(value="always")