codebee 0.1.29 → 0.1.31

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/store.py CHANGED
@@ -104,12 +104,12 @@ def create_task(payload):
104
104
  if not wd.is_dir():
105
105
  raise ValueError("工作目录不存在: %s" % workdir)
106
106
  mode = payload.get("mode")
107
- if mode not in ("auto", "manual"):
108
- mode = "manual" if payload.get("implementer") else "auto"
107
+ if mode not in ("auto", "fast", "expert", "manual"):
108
+ mode = "manual" if payload.get("implementer") else "auto"
109
109
  difficulty = payload.get("difficulty")
110
110
  if difficulty not in ("auto", "easy", "hard", "default"):
111
111
  difficulty = "auto"
112
- task = {
112
+ task = {
113
113
  "id": _new_id("t"), "type": flow["id"], "engine": flow["engine"],
114
114
  "title": title, "goal": goal,
115
115
  "context": _text(payload.get("context"), "context"),
@@ -119,8 +119,16 @@ def create_task(payload):
119
119
  "implementer": payload.get("implementer") or "",
120
120
  "attachments": [],
121
121
  "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
122
- "status": "created",
123
- }
122
+ "status": "created",
123
+ }
124
+ thinking = str(payload.get("thinking") or "standard").strip().lower()
125
+ task["thinking"] = thinking if thinking in ("auto", "low", "standard", "high") else "auto"
126
+ if flow["engine"] == "direct":
127
+ task["direct_provider_id"] = _text(payload.get("direct_provider_id"),
128
+ "direct_provider_id")[:80]
129
+ task["direct_model"] = _text(payload.get("direct_model"), "direct_model")[:160]
130
+ if task["direct_model"] and not task["direct_provider_id"]:
131
+ raise ValueError("指定对话模型时必须同时指定厂商")
124
132
  # 代码版本:仅当引用合法才固化(流水线执行前据此检出任务分支)
125
133
  from . import gitmod
126
134
  git_rev = _text(payload.get("git_rev"), "git_rev")
@@ -133,8 +141,8 @@ def create_task(payload):
133
141
  task["git_rev"] = git_rev
134
142
  if flow["engine"] == "code":
135
143
  task["verify_command"] = _text(payload.get("verify_command"), "verify_command")
136
- elif flow["engine"] == "direct":
137
- pass # 直连任务:无验证命令也无评审参数,目标+附件即全部输入
144
+ elif flow["engine"] == "direct":
145
+ pass # 直连任务:无验证命令也无评审参数,目标+附件即全部输入
138
146
  else:
139
147
  ms = _text(payload.get("manuscript") or flow.get("manuscript") or "manuscript.md",
140
148
  "manuscript")
@@ -478,7 +486,7 @@ def load_all():
478
486
 
479
487
  # ---------------------------------------------------------------- 运行(含管理操作)
480
488
 
481
- def create_run(kind, title, task_id=None, entry_id=None, op=None):
489
+ def create_run(kind, title, task_id=None, entry_id=None, op=None):
482
490
  run = {
483
491
  "id": _new_id("r" if kind == "orchestration" else "m"),
484
492
  "kind": kind, # orchestration | mgmt
@@ -488,8 +496,21 @@ def create_run(kind, title, task_id=None, entry_id=None, op=None):
488
496
  "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
489
497
  "started_at": None, "ended_at": None,
490
498
  "cost_usd": 0.0, "tokens": 0, "error": "",
491
- "verdict": None, "summary": "",
492
- }
499
+ "verdict": None, "summary": "",
500
+ }
501
+ if kind == "orchestration" and task_id:
502
+ task = get_task(task_id) or {}
503
+ try:
504
+ from . import usage
505
+ eta = usage.estimate(
506
+ task_type=task.get("type") or "", mode=task.get("mode") or "auto",
507
+ thinking=task.get("thinking") or "standard",
508
+ rounds=task.get("rounds"))
509
+ run["estimated_duration_s"] = eta.get("estimated_duration_s")
510
+ run["estimated_p90_s"] = eta.get("p90_duration_s")
511
+ run["estimate_source"] = eta.get("duration_source")
512
+ except Exception:
513
+ pass
493
514
  rdir = paths.RUNS_DIR / run["id"]
494
515
  (rdir / "steps").mkdir(parents=True, exist_ok=True)
495
516
  with LOCK:
@@ -814,12 +835,36 @@ def run_workdir(run_id):
814
835
  _SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", ".idea", ".vscode"}
815
836
 
816
837
  # 构建产物/依赖缓存目录:里面的文件是工具链再生成的,不是任务成品
817
- _BUILD_DIRS = {
838
+ _BUILD_DIRS = {
818
839
  "target", "build", "dist", "out", "bin", "obj", # 通用构建输出
819
840
  "surefire", "failsafe-reports", "test-output", "reports", # 测试/报告输出
820
841
  ".next", ".nuxt", ".output", ".gradle", ".gradle-home", # 前端/Gradle
821
842
  "__MACOSX",
822
- }
843
+ }
844
+
845
+ # CLI 自身的历史/评审中间件不属于用户成果。它们通常落在工作目录根部,
846
+ # 只按隐藏目录过滤会漏掉 Aider 的 .aider.* 与流程生成的 *_review.json。
847
+ _PROCESS_ARTIFACT_NAMES = {
848
+ ".aider.chat.history.md", ".aider.input.history", ".aider.input.history.md",
849
+ ".aider.tags.cache.v4", ".aider.tags.cache.v3", ".aider.conf.yml",
850
+ }
851
+ _PROCESS_ARTIFACT_SUFFIXES = ("_review.json", ".review.json")
852
+ _PROCESS_ARTIFACT_PREFIXES = ("tutti_prompt_", "_tutti_prompt_")
853
+
854
+
855
+ def _is_process_artifact(rel_name):
856
+ """判断工作目录文件是否为 CLI/评审过程产物,而非可交付成果。"""
857
+ name = str(rel_name or "").replace("\\", "/")
858
+ base = name.rsplit("/", 1)[-1].lower()
859
+ if base in _PROCESS_ARTIFACT_NAMES:
860
+ return True
861
+ if base.startswith(".") and base.startswith(".aider"):
862
+ return True
863
+ if base.endswith(_PROCESS_ARTIFACT_SUFFIXES):
864
+ return True
865
+ if base.startswith(_PROCESS_ARTIFACT_PREFIXES):
866
+ return True
867
+ return False
823
868
 
824
869
 
825
870
  def task_first_start(task_id, fallback=""):
@@ -880,7 +925,10 @@ def run_artifacts(run_id, limit=200):
880
925
  continue
881
926
  if st.st_mtime < t0:
882
927
  continue
883
- files.append({"name": str(p.relative_to(root)).replace("\\", "/"),
928
+ rel_name = str(p.relative_to(root)).replace("\\", "/")
929
+ if _is_process_artifact(rel_name):
930
+ continue
931
+ files.append({"name": rel_name,
884
932
  "size": st.st_size, "mtime": int(st.st_mtime)})
885
933
  if len(files) >= 800: # 防超大目录拖垮接口;截断后再排序取最新
886
934
  break
@@ -69,6 +69,9 @@ def compile_task(task):
69
69
  "dimension": dimension,
70
70
  "difficulty": difficulty,
71
71
  "difficulty_reason": difficulty_reason,
72
+ "thinking": (str(raw.get("thinking") or "standard").lower()
73
+ if str(raw.get("thinking") or "standard").lower()
74
+ in ("auto", "low", "standard", "high") else "auto"),
72
75
  "capabilities": _capabilities(raw, dimension, engine),
73
76
  "deliverable": deliverable,
74
77
  "quality_dimensions": rubric,
@@ -97,26 +100,38 @@ def code_workflow(task, difficulty, plan=None, mode="auto", planned=True):
97
100
  rec = (plan or {}).get("workflow") if isinstance(plan, Mapping) else None
98
101
  rec = rec if isinstance(rec, Mapping) else {}
99
102
  has_verify = bool(str(task.get("verify_command") or "").strip())
100
- easy_auto = mode == "auto" and difficulty == "easy"
103
+ risk_text = " ".join(str(task.get(k) or "") for k in ("title", "goal", "context")).lower()
104
+ high_risk = bool(any(k in risk_text for k in (
105
+ "安全", "权限", "鉴权", "认证", "加密", "密钥", "注入", "迁移", "数据库",
106
+ "并发", "竞态", "支付", "架构", "公共接口", "public api", "公共 api",
107
+ "breaking change", "security", "permission", "auth", "migration",
108
+ "concurrency", "architecture")))
109
+ fast_mode = mode == "fast"
110
+ expert_mode = mode == "expert"
111
+ easy_auto = (mode == "auto" and difficulty == "easy") or fast_mode
101
112
 
102
113
  review_required = True
103
114
  max_repairs = 2
104
115
  allow_switch = True
105
116
  reason = "复杂或手动任务使用完整质量门禁"
106
117
  if easy_auto:
107
- review_required = not has_verify
118
+ review_required = high_risk or not has_verify
108
119
  max_repairs = 1
109
120
  allow_switch = False
110
121
  reason = ("低风险任务以确定性验证代替模型评审"
111
122
  if has_verify else "低风险任务无验证命令,保留一次模型评审")
112
123
  if isinstance(rec.get("review_required"), bool):
113
- review_required = rec["review_required"] or not has_verify
124
+ review_required = high_risk or rec["review_required"] or not has_verify
114
125
  try:
115
126
  if "max_repair_rounds" in rec:
116
- max_repairs = max(0, min(1, int(rec["max_repair_rounds"])))
127
+ max_repairs = max(1 if not has_verify else 0,
128
+ min(1, int(rec["max_repair_rounds"])))
117
129
  except (TypeError, ValueError):
118
130
  pass
119
- elif mode == "auto" and difficulty == "hard":
131
+ elif expert_mode or (mode == "auto" and difficulty == "hard"):
132
+ review_required = True
133
+ max_repairs = 2
134
+ allow_switch = True
120
135
  try:
121
136
  if "max_repair_rounds" in rec:
122
137
  max_repairs = max(1, min(2, int(rec["max_repair_rounds"])))
@@ -148,6 +163,13 @@ def content_workflow(task, difficulty, plan=None, mode="auto"):
148
163
  light_types = {"email", "weekly_report", "translation"}
149
164
  deep_types = {"novel", "research", "tech_proposal"}
150
165
 
166
+ if mode == "fast":
167
+ return {"outline": False, "reviewers": 1, "review_rounds": 1,
168
+ "reason": "快速模式省略独立大纲,仅保留一次快速评审"}
169
+ if mode == "expert":
170
+ return {"outline": True, "reviewers": 2,
171
+ "review_rounds": min(requested_rounds, 3),
172
+ "reason": "专家模式启用完整大纲、多评审与修订门禁"}
151
173
  if mode != "auto":
152
174
  return {"outline": True, "reviewers": 0,
153
175
  "review_rounds": requested_rounds,
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()})