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/CHANGELOG.md +24 -0
- package/README.md +24 -2
- package/app/core/builtin_agent.py +38 -2
- package/app/core/jobs.py +11 -0
- package/app/core/modelhub.py +17 -5
- package/app/core/pipeline.py +58 -12
- package/app/core/planner.py +34 -10
- package/app/core/repeat_guard.py +3 -3
- 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 +27 -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 +1564 -1535
- package/app/ui/app.js +249 -58
- package/app/ui/i18n.js +36 -0
- package/app/ui/index.html +1172 -1165
- package/app/ui/style.css +44 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
package/app/core/task_compile.py
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
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()})
|