codebee 0.1.29 → 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.
package/app/core/usage.py CHANGED
@@ -655,11 +655,19 @@ def summary(days=30, recent_limit=30):
655
655
  }
656
656
 
657
657
 
658
- def estimate(task_type="", days=90):
659
- """同类任务开跑前成本预估(借鉴 omnigent 的 pre-run estimate;数据源就是本台账)。
660
-
661
- 同一 run 的多条步骤记录加总为一个样本,优先用成功 run,给中位数/平均/P90。
662
- task_type 为空=全类型。无样本时 samples=0,前端不展示。"""
658
+ _DURATION_BASELINES = {
659
+ "direct": 90, "rank_scan": 240, "email": 180, "weekly_report": 240,
660
+ "translation": 240, "speech": 360, "video_script": 360, "article": 480,
661
+ "doc": 480, "resume": 420, "code": 600, "tech_proposal": 720,
662
+ "research": 900, "novel": 720, "serial_novel": 2400,
663
+ }
664
+
665
+
666
+ def estimate(task_type="", days=90, mode="auto", thinking="standard", rounds=None):
667
+ """同类任务开跑前成本与耗时预估(数据源为本地用量台账)。
668
+
669
+ 同一 run 的多条步骤记录加总为一个样本,优先用成功 run,给中位数/平均/P90。
670
+ 无历史样本时使用流程级保守基线;模式与思考程度只做透明倍率修正。"""
663
671
  span = max(1, min(3650, int(days or 90)))
664
672
  with LOCK:
665
673
  records = _iter_records(span)
@@ -671,24 +679,53 @@ def estimate(task_type="", days=90):
671
679
  rid = str(r.get("run_id") or "")
672
680
  if not rid:
673
681
  continue
674
- g = runs.setdefault(rid, {"tokens": 0, "cost": 0.0, "ok": False})
675
- g["tokens"] += _num(r, "total")
676
- g["cost"] += _parse_float(r.get("cost_usd"))
682
+ g = runs.setdefault(rid, {"tokens": 0, "cost": 0.0,
683
+ "duration": 0.0, "ok": False})
684
+ g["tokens"] += _num(r, "total")
685
+ g["cost"] += _parse_float(r.get("cost_usd"))
686
+ g["duration"] += max(0.0, _parse_float(r.get("duration_s")))
677
687
  if r.get("ok"):
678
688
  g["ok"] = True
679
689
  ok_runs = [g for g in runs.values() if g["ok"]]
680
690
  basis = ok_runs or list(runs.values())
681
- if not basis:
682
- return {"task_type": task_type or "", "days": span, "samples": 0}
691
+ mode_factor = {"fast": 0.60, "auto": 1.0, "expert": 1.65,
692
+ "manual": 1.0}.get(str(mode or "auto"), 1.0)
693
+ thinking_factor = {"low": 0.78, "standard": 1.0, "high": 1.45,
694
+ "auto": 1.0}.get(str(thinking or "standard"), 1.0)
695
+ factor = mode_factor * thinking_factor
696
+ try:
697
+ if rounds is not None and int(rounds) > 2:
698
+ factor *= 1.0 + min(3, int(rounds) - 2) * 0.18
699
+ except (TypeError, ValueError):
700
+ pass
701
+ if not basis:
702
+ baseline = _DURATION_BASELINES.get(task_type, 480)
703
+ median_s = max(30, int(baseline * factor))
704
+ return {"task_type": task_type or "", "days": span, "samples": 0,
705
+ "estimated_duration_s": median_s,
706
+ "p90_duration_s": max(median_s + 30, int(median_s * 1.7)),
707
+ "duration_source": "baseline"}
683
708
  toks = sorted(g["tokens"] for g in basis)
684
- costs = sorted(g["cost"] for g in basis)
709
+ costs = sorted(g["cost"] for g in basis)
710
+ durations = sorted(g["duration"] for g in basis if g["duration"] > 0)
685
711
  n = len(toks)
686
712
  mid = n // 2
687
713
  med = toks[mid] if n % 2 else (toks[mid - 1] + toks[mid]) / 2.0
688
714
  med_c = costs[mid] if n % 2 else (costs[mid - 1] + costs[mid]) / 2.0
689
715
  import datetime
690
- import math
691
- return {
716
+ import math
717
+ if durations:
718
+ dn = len(durations)
719
+ dmid = dn // 2
720
+ dmed = (durations[dmid] if dn % 2
721
+ else (durations[dmid - 1] + durations[dmid]) / 2.0)
722
+ dp90 = durations[max(0, min(dn - 1, math.ceil(0.9 * dn) - 1))]
723
+ duration_source = "history"
724
+ else:
725
+ dmed = _DURATION_BASELINES.get(task_type, 480)
726
+ dp90 = dmed * 1.7
727
+ duration_source = "baseline"
728
+ return {
692
729
  "task_type": task_type or "",
693
730
  "days": span,
694
731
  "samples": n,
@@ -697,6 +734,10 @@ def estimate(task_type="", days=90):
697
734
  "median_tokens": int(med),
698
735
  "p90_tokens": toks[max(0, min(n - 1, math.ceil(0.9 * n) - 1))],
699
736
  "avg_cost_usd": round(sum(costs) / n, 4),
700
- "median_cost_usd": round(med_c, 4),
737
+ "median_cost_usd": round(med_c, 4),
738
+ "estimated_duration_s": max(30, int(dmed * factor)),
739
+ "p90_duration_s": max(60, int(dp90 * factor)),
740
+ "duration_samples": len(durations),
741
+ "duration_source": duration_source,
701
742
  "since": (datetime.date.today() - datetime.timedelta(days=span - 1)).isoformat(),
702
743
  }
@@ -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
@@ -284,7 +284,12 @@ class Handler(BaseHTTPRequestHandler):
284
284
  edays = max(1, min(3650, int((q.get("days") or ["90"])[0])))
285
285
  except ValueError:
286
286
  edays = 90
287
- 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))
288
293
  if path == "/api/dispatch/replay":
289
294
  from core import dispatch_log
290
295
  q = parse_qs(urlparse(self.path).query)
@@ -313,6 +318,13 @@ class Handler(BaseHTTPRequestHandler):
313
318
  # 偏好记忆:上次创建任务的类型/参数(新建表单预填用)
314
319
  from core import prefs
315
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())
316
328
  if path == "/api/ports":
317
329
  # 端口占用诊断(借鉴 leftopen):谁在听、PID/进程/项目归属、
318
330
  # 是否仅本机。?port=N 只看单端口。只读,不碰任何进程。
@@ -1080,6 +1092,15 @@ class Handler(BaseHTTPRequestHandler):
1080
1092
  from core import wxdigest
1081
1093
  res = wxdigest.scan_now() # 含模型调用,同步做(ThreadingHTTPServer 不堵别的请求)
1082
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)
1083
1104
  if path == "/api/wxdigest/seen":
1084
1105
  from core import wxdigest
1085
1106
  return self._json(200, {"ok": True, "view": wxdigest.seen_clear()})
package/app/pet.py CHANGED
@@ -168,6 +168,7 @@ def _short(text, limit=22):
168
168
  LANG = {
169
169
  "zh": {
170
170
  "open": "打开 CodeBee",
171
+ "digest": "群摘要",
171
172
  "mode": "显示模式",
172
173
  "always": "常驻显示",
173
174
  "tasks_only": "仅任务运行时出现",
@@ -197,6 +198,7 @@ LANG = {
197
198
  },
198
199
  "en": {
199
200
  "open": "Open CodeBee",
201
+ "digest": "Group Digests",
200
202
  "mode": "Display mode",
201
203
  "always": "Always visible",
202
204
  "tasks_only": "Only when tasks run",
@@ -404,10 +406,8 @@ def _window_process_name(hwnd):
404
406
  return ""
405
407
 
406
408
 
407
- def _focus_codebee_window(ignore_hwnd=None):
408
- """Windows:已打开过 CodeBee 浏览器页时,把那个窗口找出来还原并置前,
409
- 避免每点一次桌宠就新开一个浏览器窗口。返回 True 表示已找到并尝试置前;
410
- False 表示没有现成窗口,调用方应回退 webbrowser.open 新开。"""
409
+ def _find_codebee_window(port, ignore_hwnd=None):
410
+ """查找当前端口对应的 CodeBee 浏览器顶层窗口,找不到返回 None。"""
411
411
  try:
412
412
  import ctypes
413
413
  from ctypes import wintypes
@@ -426,23 +426,22 @@ def _focus_codebee_window(ignore_hwnd=None):
426
426
  return True
427
427
  buf = ctypes.create_unicode_buffer(length + 1)
428
428
  user32.GetWindowTextW(hwnd, buf, length + 1)
429
- 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:
430
433
  found.append(hwnd)
431
434
  return True
432
435
 
433
436
  user32.EnumWindows(EnumWindowsProc(_cb), 0)
434
437
  if not found:
435
- return False
436
- target = found[0]
437
- if user32.IsIconic(target):
438
- user32.ShowWindow(target, 9) # SW_RESTORE:最小化先还原
439
- user32.SetForegroundWindow(target)
440
- return True
438
+ return None
439
+ return found[0]
441
440
  except Exception:
442
- return False
441
+ return None
443
442
 
444
443
 
445
- def _show_pet_window(ignore_hwnd=None):
444
+ def _show_pet_window(port, ignore_hwnd=None):
446
445
  """复用桌宠拉起的 CodeBee 浏览器窗口;窗口已关闭时返回 False。"""
447
446
  global petWindow
448
447
  try:
@@ -456,7 +455,7 @@ def _show_pet_window(ignore_hwnd=None):
456
455
  user32.SetForegroundWindow(petWindow)
457
456
  return True
458
457
  petWindow = None
459
- hwnd = _find_codebee_window(ignore_hwnd)
458
+ hwnd = _find_codebee_window(port, ignore_hwnd)
460
459
  if not hwnd:
461
460
  return False
462
461
  petWindow = hwnd
@@ -468,10 +467,12 @@ def _show_pet_window(ignore_hwnd=None):
468
467
  return False
469
468
 
470
469
 
471
- def openPetWindow(port, ignore_hwnd=None):
472
- if os.name == "nt" and _show_pet_window(ignore_hwnd):
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):
473
474
  return
474
- webbrowser.open("http://127.0.0.1:%d" % port)
475
+ webbrowser.open(url or ("http://127.0.0.1:%d" % port))
475
476
 
476
477
 
477
478
  class PetApp:
@@ -629,7 +630,26 @@ class PetApp:
629
630
  try:
630
631
  from PIL import Image, ImageEnhance, ImageTk
631
632
  except Exception:
632
- 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]}
633
653
  fname = SKINS.get(skin, SKINS[DEFAULT_SKIN])[0]
634
654
  try:
635
655
  base0 = Image.open(SPRITE_DIR / fname).convert("RGBA")
@@ -1343,6 +1363,7 @@ class PetApp:
1343
1363
  self._lang_var.set(self.lang)
1344
1364
  m = self.tk.Menu(self.root, tearoff=0)
1345
1365
  m.add_command(label=self._L("open"), command=self._open_ui)
1366
+ m.add_command(label=self._L("digest"), command=self._open_digest)
1346
1367
  m.add_separator()
1347
1368
  m.add_command(label="◎ " + self._L("mode"), state="disabled")
1348
1369
  m.add_radiobutton(label=" " + self._L("always"),
@@ -1386,12 +1407,20 @@ class PetApp:
1386
1407
  self.lang = lang
1387
1408
  self._save_cfg(lang=lang)
1388
1409
 
1389
- def _open_ui(self):
1410
+ def _open_ui(self, hash_part=""):
1390
1411
  try:
1391
- openPetWindow(self.port, self.root.winfo_id())
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())
1392
1417
  except Exception:
1393
1418
  pass
1394
1419
 
1420
+ def _open_digest(self):
1421
+ """群摘要:打开网页并自动展开蜜蜂坞面板(样式与网页端统一)。"""
1422
+ self._open_ui("#bee=1")
1423
+
1395
1424
  def _bind_input(self):
1396
1425
  # 菜单变量须先于 _menu 存在
1397
1426
  self._mode_var = self.tk.StringVar(value="always")