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/CHANGELOG.md +13 -0
- package/README.md +16 -2
- package/app/core/builtin_agent.py +36 -2
- package/app/core/jobs.py +11 -0
- package/app/core/modelhub.py +6 -0
- package/app/core/pipeline.py +54 -10
- package/app/core/runner.py +64 -14
- package/app/core/selfupdate.py +14 -5
- package/app/core/store.py +61 -13
- package/app/core/task_compile.py +26 -5
- package/app/core/usage.py +55 -14
- package/app/core/wxdigest.py +140 -0
- package/app/main.py +22 -1
- package/app/pet.py +49 -20
- package/app/ui/app.js +176 -1
- package/app/ui/i18n.js +30 -0
- package/app/ui/index.html +22 -2
- package/app/ui/style.css +31 -0
- package/package.json +1 -1
package/app/core/usage.py
CHANGED
|
@@ -655,11 +655,19 @@ def summary(days=30, recent_limit=30):
|
|
|
655
655
|
}
|
|
656
656
|
|
|
657
657
|
|
|
658
|
-
|
|
659
|
-
"""
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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,
|
|
675
|
-
|
|
676
|
-
g["
|
|
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
|
-
|
|
682
|
-
|
|
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
|
-
|
|
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
|
}
|
package/app/core/wxdigest.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
408
|
-
"""
|
|
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
|
-
|
|
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
|
|
436
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")
|