codebee 0.1.18 → 0.1.19
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 +15 -0
- package/README.md +4 -3
- package/app/core/automation.py +19 -0
- package/app/core/backup.py +470 -0
- package/app/core/bookmeta.py +4 -1
- package/app/core/builtin_agent.py +33 -1
- package/app/core/cleanup.py +303 -0
- package/app/core/flows.py +1 -1
- package/app/core/jobs.py +22 -4
- package/app/core/knowledge.py +26 -4
- package/app/core/manager.py +25 -1
- package/app/core/market.py +14 -2
- package/app/core/modelhub.py +16 -0
- package/app/core/pipeline.py +50 -8
- package/app/core/planner.py +22 -4
- package/app/core/publish/manager.py +80 -3
- package/app/core/runner.py +127 -7
- package/app/core/selfupdate.py +80 -38
- package/app/core/settings.py +32 -1
- package/app/core/skill_scan.py +86 -0
- package/app/core/store.py +62 -8
- package/app/core/wxdigest.py +710 -0
- package/app/core/zentao.py +516 -50
- package/app/main.py +263 -1
- package/app/pet.py +1323 -0
- package/app/pet_bee.png +0 -0
- package/app/pet_bee_robot.png +0 -0
- package/app/pick_dialog.py +34 -2
- package/app/ui/app.js +11063 -10389
- package/app/ui/i18n.js +169 -9
- package/app/ui/index.html +132 -2
- package/app/ui/style.css +156 -2
- package/package.json +1 -1
package/app/core/pipeline.py
CHANGED
|
@@ -310,7 +310,8 @@ def _wait_gate(run_id, ev):
|
|
|
310
310
|
return
|
|
311
311
|
if ev is not None and ev.is_set():
|
|
312
312
|
return
|
|
313
|
-
|
|
313
|
+
# ev.wait 睡等:取消置位即刻醒来,不必耗满 1s 轮询间隔
|
|
314
|
+
(ev.wait(1.0) if ev is not None else time.sleep(1.0))
|
|
314
315
|
|
|
315
316
|
|
|
316
317
|
def _binding_dead_msg(agent):
|
|
@@ -632,6 +633,7 @@ __GOAL__
|
|
|
632
633
|
|
|
633
634
|
## 本步指令
|
|
634
635
|
__SUBTASK__
|
|
636
|
+
__FILES__
|
|
635
637
|
|
|
636
638
|
## 背景与上下文
|
|
637
639
|
__CONTEXT__
|
|
@@ -693,6 +695,16 @@ def _verify_hint(task):
|
|
|
693
695
|
return ""
|
|
694
696
|
|
|
695
697
|
|
|
698
|
+
def _files_hint(sub):
|
|
699
|
+
"""计划锚定文件清单(OpenSpec explore 借鉴):计划里带了 files 就明示改动面,
|
|
700
|
+
让实现者知道该动哪些文件、不必全盘摸索。无 files 时返回空串(旧计划兼容)。"""
|
|
701
|
+
files = (sub or {}).get("files")
|
|
702
|
+
if not isinstance(files, list) or not files:
|
|
703
|
+
return ""
|
|
704
|
+
return "\n- 本步涉及文件(计划已锚定,先读再改):" + "、".join(
|
|
705
|
+
"`%s`" % str(f) for f in files[:10])
|
|
706
|
+
|
|
707
|
+
|
|
696
708
|
def _run_verify(run_id, task, workdir, ev):
|
|
697
709
|
"""确定性验证。返回 (verify_pass, ran)。"""
|
|
698
710
|
if not task.get("verify_command"):
|
|
@@ -803,6 +815,7 @@ def _code_bestof(run, task, impl, difficulty, ev):
|
|
|
803
815
|
prompt = (_RUN_CONSTITUTION + CODE_IMPL_PROMPT
|
|
804
816
|
.replace("__GOAL__", task["goal"])
|
|
805
817
|
.replace("__SUBTASK__", sub["detail"] if sub["detail"] else sub["title"])
|
|
818
|
+
.replace("__FILES__", _files_hint(sub))
|
|
806
819
|
.replace("__CONTEXT__", task.get("context") or "(无)")
|
|
807
820
|
.replace("__VERIFY_HINT__", _verify_hint(task)))
|
|
808
821
|
res = _run_step(run_id, "race%d-impl" % k, agt_b, prompt, wt_path,
|
|
@@ -953,6 +966,7 @@ def _run_code(run, task, agents, ev, stats, mode):
|
|
|
953
966
|
.replace("__GOAL__", task["goal"])
|
|
954
967
|
.replace("__SUBTASK__",
|
|
955
968
|
sub["detail"] if sub["detail"] else sub["title"])
|
|
969
|
+
.replace("__FILES__", _files_hint(sub))
|
|
956
970
|
.replace("__CONTEXT__", task.get("context") or "(无)")
|
|
957
971
|
.replace("__VERIFY_HINT__", _verify_hint(task))) + prog
|
|
958
972
|
role = "implement" if len(subtasks) == 1 else "implement-%d/%d" % (i + 1, len(subtasks))
|
|
@@ -1421,7 +1435,8 @@ RESEARCH_APPENDIX = """
|
|
|
1421
1435
|
单源信息要标注「仅单一来源」。
|
|
1422
1436
|
- 引用来源在文中用行内链接或脚注标明(域名即可,不编造 URL)。
|
|
1423
1437
|
- 区分「事实」与「观点」:数据/时间/版本号给来源,预测/评价标明是分析。
|
|
1424
|
-
-
|
|
1438
|
+
- 结构硬性要求:报告第一段必须是「**核心结论**」三行以内的要点摘要(结论先行),
|
|
1439
|
+
之后才展开分层论证 → 风险与局限(说明哪些结论证据不足)。"""
|
|
1425
1440
|
|
|
1426
1441
|
NOVEL_REVISE_PROMPT = """你是一名专业作者。请根据下方汇总评审意见修订稿件文件:`__FILE__`(直接写入该文件)。文件必须以 UTF-8 编码保存(PowerShell 写文件显式加 -Encoding UTF8,禁止依赖默认编码)。
|
|
1427
1442
|
|
|
@@ -1647,6 +1662,10 @@ def _critique_json(res, dims):
|
|
|
1647
1662
|
if isinstance(gj, dict) and isinstance(gj.get("scores"), dict) and gj.get("scores"):
|
|
1648
1663
|
return gj
|
|
1649
1664
|
prose = runner.scores_from_prose(text, dims)
|
|
1665
|
+
if not prose:
|
|
1666
|
+
# 第四道网(BAML 借鉴):维度名没命中时按「X:N 分」模式泛化抓取——
|
|
1667
|
+
# 自定义 rubric 改了维度措辞而模型用了自己的说法时仍能救回
|
|
1668
|
+
prose = runner.extract_scores_from_text(text)
|
|
1650
1669
|
if prose:
|
|
1651
1670
|
return {"scores": prose, "issues": [], "summary": text[:400]}
|
|
1652
1671
|
return {"scores": {}, "issues": [],
|
|
@@ -2016,9 +2035,12 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
2016
2035
|
use_prompt = prompt
|
|
2017
2036
|
for draft_attempt in range(3):
|
|
2018
2037
|
if draft_attempt:
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2038
|
+
# 30s / 60s 退避;ev.wait 睡等可被取消即刻唤醒
|
|
2039
|
+
if ev is not None:
|
|
2040
|
+
if ev.wait(30 * draft_attempt):
|
|
2041
|
+
break
|
|
2042
|
+
else:
|
|
2043
|
+
time.sleep(30 * draft_attempt)
|
|
2022
2044
|
if draft_attempt and len(prompt) > 12000 and sk_block and sk_block in prompt:
|
|
2023
2045
|
# 长提示词在容量受限通道(讯飞托管 35B 等)上会挂起/秒拒
|
|
2024
2046
|
# ——分层降级:经验库→4K、模块库按模块边界、圣经按二级标题
|
|
@@ -2103,9 +2125,12 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
2103
2125
|
for race_round in range(2):
|
|
2104
2126
|
# 全变体失败(网关突发限流)→ 60s 退避重赛一轮,别一章判死
|
|
2105
2127
|
if race_round:
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2128
|
+
# ev.wait 睡等可被取消即刻唤醒
|
|
2129
|
+
if ev is not None:
|
|
2130
|
+
if ev.wait(60):
|
|
2131
|
+
break
|
|
2132
|
+
else:
|
|
2133
|
+
time.sleep(60)
|
|
2109
2134
|
for kk in range(n_variants):
|
|
2110
2135
|
# 清上一轮残稿:防陈旧半成品被本轮评分误认成新成品
|
|
2111
2136
|
try:
|
|
@@ -3008,11 +3033,28 @@ def _write_task_evidence(run_id, task, workdir, summary_lines):
|
|
|
3008
3033
|
for ln in summary_lines:
|
|
3009
3034
|
f.write("- %s\n" % str(ln)[:300])
|
|
3010
3035
|
f.write("\n")
|
|
3036
|
+
_archive_stamp(run_id, task, workdir, summary_lines)
|
|
3011
3037
|
return path
|
|
3012
3038
|
except Exception:
|
|
3013
3039
|
return ""
|
|
3014
3040
|
|
|
3015
3041
|
|
|
3042
|
+
def _archive_stamp(run_id, task, workdir, summary_lines):
|
|
3043
|
+
"""归档戳(借鉴 OpenSpec archive):evidence 落盘后向 spec.md 尾部追加
|
|
3044
|
+
「已完成」快照行——spec 从「意图」升格为「意图+交付记录」的活档案。
|
|
3045
|
+
失败静默。"""
|
|
3046
|
+
try:
|
|
3047
|
+
spec = os.path.join(workdir, ".codebee", "spec.md")
|
|
3048
|
+
if not os.path.isfile(spec):
|
|
3049
|
+
return
|
|
3050
|
+
with open(spec, "a", encoding="utf-8") as f:
|
|
3051
|
+
f.write("\n---\n**✅ 已交付** · %s · run %s\n%s\n" % (
|
|
3052
|
+
_now(), run_id,
|
|
3053
|
+
"\n".join("- %s" % str(ln)[:160] for ln in summary_lines[:5])))
|
|
3054
|
+
except Exception:
|
|
3055
|
+
return
|
|
3056
|
+
|
|
3057
|
+
|
|
3016
3058
|
def _evidence_lines_from_run(run_id, task):
|
|
3017
3059
|
"""从 run 步骤与 verdict 提取证据行(确定性事实,不抄模型输出)。"""
|
|
3018
3060
|
run = store.get_run(run_id) or {}
|
package/app/core/planner.py
CHANGED
|
@@ -12,7 +12,7 @@ import os
|
|
|
12
12
|
import re
|
|
13
13
|
import time
|
|
14
14
|
|
|
15
|
-
from . import modelhub, runner, skills, usage
|
|
15
|
+
from . import knowledge, modelhub, runner, skills, usage
|
|
16
16
|
|
|
17
17
|
MAX_SUBTASKS = 4
|
|
18
18
|
DEFAULT_OUTLINE_TIMEOUT = 900 # 8 章大纲 + 经验包注入是重生成任务,300s 实测不够
|
|
@@ -116,10 +116,16 @@ def _log_usage(source, role, task, res, agent=None, tool="", model="", provider=
|
|
|
116
116
|
|
|
117
117
|
CODE_PLAN_PROMPT = """你是技术负责人。请把下面的开发目标拆解为 __N__ 个以内、按顺序执行的子任务,
|
|
118
118
|
并判定任务难度。只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
|
|
119
|
-
{"difficulty": "easy 或 hard", "subtasks": [{"title": "简短标题", "detail": "具体要做什么,给执行工程师的直接指令"}]}
|
|
119
|
+
{"difficulty": "easy 或 hard", "subtasks": [{"title": "简短标题", "detail": "具体要做什么,给执行工程师的直接指令", "files": ["涉及的文件路径"]}]}
|
|
120
120
|
难度判定:常规增删改查/小函数/格式调整 = easy;跨模块改动/架构调整/复杂算法/安全相关 = hard。
|
|
121
121
|
子任务粒度要可独立验证;最后一个子任务必须包含整体联调/收尾。
|
|
122
122
|
|
|
123
|
+
## 先探索再计划(重要,借鉴 OpenSpec explore)
|
|
124
|
+
拆解之前先用你的读文件/搜索工具**实际查看工作目录**,找到目标相关的真实文件与函数,再据此拆解。
|
|
125
|
+
detail 必须点名真实存在的文件路径;files 列出该子任务会改动的文件(新建的写目标路径)。
|
|
126
|
+
**没探索过就不要凭想象编路径**——计划的可执行性取决于它对真实代码库的贴合度。
|
|
127
|
+
若工作目录为空(全新项目),files 写计划新建的文件路径。
|
|
128
|
+
|
|
123
129
|
## 开发目标
|
|
124
130
|
__GOAL__
|
|
125
131
|
|
|
@@ -274,6 +280,9 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
|
|
|
274
280
|
wpc = int(serial.get("words_per_chapter") or 2500)
|
|
275
281
|
start = int(serial.get("start_chapter") or 1)
|
|
276
282
|
sk_block, _ = skills.block_for(task)
|
|
283
|
+
kb_block = knowledge.block_for(task)
|
|
284
|
+
if kb_block:
|
|
285
|
+
sk_block = (sk_block + "\n\n" + kb_block) if sk_block else kb_block
|
|
277
286
|
prev_title = ""
|
|
278
287
|
if start > 1:
|
|
279
288
|
prev_lines, done, prev_title, prev_tail = _prev_serial_story(task)
|
|
@@ -367,7 +376,10 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
|
|
|
367
376
|
|
|
368
377
|
|
|
369
378
|
def _norm_subtasks(data):
|
|
370
|
-
"""规范化 LLM 计划输出;不合规返回 None。
|
|
379
|
+
"""规范化 LLM 计划输出;不合规返回 None。
|
|
380
|
+
|
|
381
|
+
files 字段(OpenSpec explore 借鉴):计划锚定的真实文件清单,透传给执行步
|
|
382
|
+
让实现者知道改动面(缺失/非法时留空,向后兼容旧计划)。"""
|
|
371
383
|
if not isinstance(data, dict):
|
|
372
384
|
return None
|
|
373
385
|
subs = data.get("subtasks")
|
|
@@ -381,7 +393,13 @@ def _norm_subtasks(data):
|
|
|
381
393
|
detail = str(s.get("detail") or "").strip()
|
|
382
394
|
if not title:
|
|
383
395
|
continue
|
|
384
|
-
|
|
396
|
+
step = {"title": title[:60], "detail": detail[:1500]}
|
|
397
|
+
files = s.get("files")
|
|
398
|
+
if isinstance(files, list):
|
|
399
|
+
clean = [str(f).strip()[:200] for f in files if str(f).strip()][:10]
|
|
400
|
+
if clean:
|
|
401
|
+
step["files"] = clean
|
|
402
|
+
steps.append(step)
|
|
385
403
|
return steps or None
|
|
386
404
|
|
|
387
405
|
|
|
@@ -86,16 +86,56 @@ def view():
|
|
|
86
86
|
|
|
87
87
|
|
|
88
88
|
def recover_orphans():
|
|
89
|
-
"""启动收尸:waiting_login / busy 的线程随进程重启死掉,统一改判 error。
|
|
89
|
+
"""启动收尸:waiting_login / busy 的线程随进程重启死掉,统一改判 error。
|
|
90
|
+
|
|
91
|
+
升级自愈:旧版「等扫码窗口一关就冤判超时」留下的 error(error 文案带
|
|
92
|
+
「等待登录超时」)排队后台复核——profile 登录态还在的直接翻 connected,
|
|
93
|
+
别让升级完还挂着旧冤案;attach 不到活实例就维持原样(用户点重连时
|
|
94
|
+
新终审逻辑自会兜住)。"""
|
|
90
95
|
_load()
|
|
91
96
|
n = 0
|
|
97
|
+
stale = []
|
|
92
98
|
for plat in PLATFORMS:
|
|
93
|
-
|
|
99
|
+
s = _st(plat)
|
|
100
|
+
if s.get("status") in ("waiting_login", "busy"):
|
|
94
101
|
_set(plat, status="error", error="上次操作随服务重启中断,请重试")
|
|
95
102
|
n += 1
|
|
103
|
+
elif s.get("status") == "error" and "等待登录超时" in (s.get("error") or ""):
|
|
104
|
+
stale.append(plat)
|
|
105
|
+
if stale:
|
|
106
|
+
threading.Thread(target=_recheck_stale_errors, args=(stale,),
|
|
107
|
+
daemon=True, name="pub-stale-recheck").start()
|
|
96
108
|
return n
|
|
97
109
|
|
|
98
110
|
|
|
111
|
+
def _recheck_stale_errors(plats):
|
|
112
|
+
"""旧版假超时的后台复核:只 attach 还活着的浏览器实例(绝不 launch——
|
|
113
|
+
升级重启时静默弹窗口吓人),登录态复核过了翻 connected;否则不动。"""
|
|
114
|
+
for plat in plats:
|
|
115
|
+
if _st(plat).get("status") != "error":
|
|
116
|
+
continue # 用户已先行操作(重连/断开),别覆盖
|
|
117
|
+
port = _st(plat).get("port") or 0
|
|
118
|
+
if not port:
|
|
119
|
+
continue
|
|
120
|
+
try:
|
|
121
|
+
b = Browser.attach(int(port))
|
|
122
|
+
except Exception:
|
|
123
|
+
continue # 实例已死:不 launch,维持原错误
|
|
124
|
+
with LOCK:
|
|
125
|
+
_browsers[plat] = b
|
|
126
|
+
page = b.first_page(create=False)
|
|
127
|
+
if page is None:
|
|
128
|
+
continue
|
|
129
|
+
try:
|
|
130
|
+
ok, _u = _check_login(plat, page)
|
|
131
|
+
except Exception:
|
|
132
|
+
continue
|
|
133
|
+
if ok and _st(plat).get("status") == "error":
|
|
134
|
+
_set(plat, status="connected", error="",
|
|
135
|
+
last_login=time.strftime("%m-%d %H:%M"))
|
|
136
|
+
ledger.record(plat, "connect", ok=True)
|
|
137
|
+
|
|
138
|
+
|
|
99
139
|
# ---------------------------------------------------------------- 浏览器会话
|
|
100
140
|
def _profiles_base():
|
|
101
141
|
"""profile 存放根:用户主目录 ~/.codebee/publish_profiles(仓库外)。
|
|
@@ -245,13 +285,31 @@ def connect(plat):
|
|
|
245
285
|
except (BrowserError, Exception):
|
|
246
286
|
pass # 页面被用户关掉等:继续等到超时
|
|
247
287
|
time.sleep(5)
|
|
248
|
-
|
|
288
|
+
_finalize_login_wait(plat)
|
|
249
289
|
|
|
250
290
|
threading.Thread(target=wait_login, daemon=True,
|
|
251
291
|
name="pub-login-%s" % plat).start()
|
|
252
292
|
return True, ""
|
|
253
293
|
|
|
254
294
|
|
|
295
|
+
def _finalize_login_wait(plat):
|
|
296
|
+
"""等扫码超时后的终审:用户可能已在本窗口登录后把窗口关了(profile
|
|
297
|
+
里登录态还在),attach-or-launch 重拉页面再复核一次,别急着冤判
|
|
298
|
+
超时——「明明登录着却报超时」是发布卡死感的最大来源。复核也过不了
|
|
299
|
+
才落超时错误。返回 True 表示复核通过(已判 connected)。"""
|
|
300
|
+
try:
|
|
301
|
+
_b, page = _open_page(plat)
|
|
302
|
+
ok, _u = _check_login(plat, page)
|
|
303
|
+
except Exception:
|
|
304
|
+
ok = False
|
|
305
|
+
if ok:
|
|
306
|
+
_set(plat, status="connected", last_login=time.strftime("%m-%d %H:%M"))
|
|
307
|
+
ledger.record(plat, "connect", ok=True)
|
|
308
|
+
else:
|
|
309
|
+
_set(plat, status="error", error="等待登录超时(15 分钟),请重新点连接")
|
|
310
|
+
return ok
|
|
311
|
+
|
|
312
|
+
|
|
255
313
|
def disconnect(plat):
|
|
256
314
|
b = _browsers.pop(plat, None)
|
|
257
315
|
if b:
|
|
@@ -357,6 +415,25 @@ def create_book_async(task_id, plat, auto_submit=False):
|
|
|
357
415
|
return True, ""
|
|
358
416
|
|
|
359
417
|
|
|
418
|
+
# ---------------------------------------------------------------- 动作:登记已有作品
|
|
419
|
+
def register_book(task_id, plat, title, book_id=""):
|
|
420
|
+
"""人工登记平台已有作品:建书流程没走通、或用户纯手工在平台上建的
|
|
421
|
+
书,补进台账让卡片翻到「已建书·发一章」,避免再点「创建作品」造出
|
|
422
|
+
重复书。只动本地台账不碰浏览器;发章按书名找书,title 必填,
|
|
423
|
+
book_id 选填(有直达 URL 时填)。已登记会覆盖更新(兼纠错口)。"""
|
|
424
|
+
from .. import store
|
|
425
|
+
if plat not in PLATFORMS:
|
|
426
|
+
return False, "未知平台"
|
|
427
|
+
if not store.get_task(task_id):
|
|
428
|
+
return False, "任务不存在"
|
|
429
|
+
title = (title or "").strip()
|
|
430
|
+
if not title:
|
|
431
|
+
return False, "作品名必填(发章按作品名在平台找书)"
|
|
432
|
+
ledger.save_book(task_id, plat, {"book_id": str(book_id or "").strip(),
|
|
433
|
+
"title": title[:120]})
|
|
434
|
+
return True, ""
|
|
435
|
+
|
|
436
|
+
|
|
360
437
|
def _with_tag_steps(steps, groups, values, mod=None):
|
|
361
438
|
"""标签走数据驱动:清单进 values["_tags"]([组名, 标签] 对),由 flow 的
|
|
362
439
|
"tags" 步骤按组切换点选。组显示名映射来自**对应平台模块**的
|
package/app/core/runner.py
CHANGED
|
@@ -163,8 +163,26 @@ def _pipe_reader(stream, chunks, log_fh, stamp=None):
|
|
|
163
163
|
pass
|
|
164
164
|
|
|
165
165
|
|
|
166
|
+
def _decode_line(blob):
|
|
167
|
+
"""单行解码:UTF-8 → GBK → replace(与 decode_output 同一编码纪律)。"""
|
|
168
|
+
try:
|
|
169
|
+
return blob.decode("utf-8")
|
|
170
|
+
except UnicodeDecodeError:
|
|
171
|
+
pass
|
|
172
|
+
try:
|
|
173
|
+
return blob.decode("gbk")
|
|
174
|
+
except UnicodeDecodeError:
|
|
175
|
+
return blob.decode("utf-8", "replace")
|
|
176
|
+
|
|
177
|
+
|
|
166
178
|
def decode_output(data):
|
|
167
|
-
"""子进程输出解码:UTF-8 严格解码失败时回退 GBK(中文 Windows 控制台)。
|
|
179
|
+
"""子进程输出解码:UTF-8 严格解码失败时回退 GBK(中文 Windows 控制台)。
|
|
180
|
+
|
|
181
|
+
整块 UTF-8 与整块 GBK 都失败时逐行兜底——日志文件是混合编码(我方 UTF-8
|
|
182
|
+
审计头 + CLI 自家 GBK 输出),整块回退会把能读的部分一起牺牲:要么头部中文
|
|
183
|
+
变乱码,要么整篇落进 replace 分支铺成 U+FFFD 墙(2026-09-20 aider 日志实证,
|
|
184
|
+
详情页卡片显示成一片 ◆)。逐行解码两边都保住(GBK/UTF-8 的多字节序列都不
|
|
185
|
+
含 0x0A,按行切不会切坏字符)。"""
|
|
168
186
|
if not data:
|
|
169
187
|
return ""
|
|
170
188
|
try:
|
|
@@ -174,7 +192,8 @@ def decode_output(data):
|
|
|
174
192
|
try:
|
|
175
193
|
return data.decode("gbk")
|
|
176
194
|
except UnicodeDecodeError:
|
|
177
|
-
|
|
195
|
+
pass
|
|
196
|
+
return "\n".join(_decode_line(ln) for ln in data.split(b"\n"))
|
|
178
197
|
|
|
179
198
|
|
|
180
199
|
def read_text_any_enc(path):
|
|
@@ -201,6 +220,64 @@ def tail_decoded(data, tail):
|
|
|
201
220
|
return decode_output(chunk[i:])
|
|
202
221
|
|
|
203
222
|
|
|
223
|
+
# 终端控制序列:CSI(颜色/光标/清行,`\x1b[91m`)、OSC(窗口标题)、其余两字符转义
|
|
224
|
+
_ANSI_RE = re.compile(
|
|
225
|
+
r"\x1b\[[0-9;?]*[ -/]*[@-~]"
|
|
226
|
+
r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?"
|
|
227
|
+
r"|\x1b[@-Z\\-_]"
|
|
228
|
+
)
|
|
229
|
+
# 被切片/前端截断掉 ESC 的「裸序列」残尾(`[91m`、`[0m`):只在行首止血,
|
|
230
|
+
# 正文里合法的 [数字+字母](如 [1m] 引用)不动——后面紧跟 `]` 的不算残尾
|
|
231
|
+
_ANSI_HEAD_RE = re.compile(r"^\[[0-9;?]{1,6}[A-Za-z](?!\])")
|
|
232
|
+
# CLI 拿来做进度条/画框/旋转动画的图元,成串出现时人读不出任何信息
|
|
233
|
+
_NOISE_RUN_RE = re.compile(r"([\u2500-\u259f\u25a0-\u25ff\u2800-\u28ff])\1{7,}")
|
|
234
|
+
# 解不出来的字节(replace 兜底)成串即「乱码墙」——多数等宽字体把 U+FFFD 画成
|
|
235
|
+
# 带问号的菱形,一屏看起来就是 ◆◆◆◆
|
|
236
|
+
_FFFD_RUN_RE = re.compile(r"\ufffd{3,}")
|
|
237
|
+
_CTRL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def strip_ansi(text):
|
|
241
|
+
"""剥掉 ANSI 转义序列(颜色/光标/进度),并清掉行首被截断的裸序列残尾。"""
|
|
242
|
+
if not text:
|
|
243
|
+
return ""
|
|
244
|
+
out = _ANSI_RE.sub("", text)
|
|
245
|
+
# 残尾可能连着几个(`[91m[0mError:`),一次替换只够消掉最前面那个
|
|
246
|
+
while True:
|
|
247
|
+
nxt = "\n".join(_ANSI_HEAD_RE.sub("", ln) for ln in out.split("\n"))
|
|
248
|
+
if nxt == out:
|
|
249
|
+
return out
|
|
250
|
+
out = nxt
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def clean_cli_text(text):
|
|
254
|
+
"""CLI 原始输出 → 人类可读文本(只用于错误摘要/日志展示,不碰模型正文)。
|
|
255
|
+
|
|
256
|
+
终端噪声四种,都是实测踩过的:
|
|
257
|
+
1. ANSI 转义:`\\x1b[91m\\x1b[1mError:` 在浏览器里渲染成 `[91m[1mError:`;
|
|
258
|
+
2. 裸 `\\r` 覆写:进度条/旋转动画反复回退改写同一行,原样展示会叠成一片;
|
|
259
|
+
`\\r\\n` 是行结束符不是覆写,先归一化再按覆写取「该行最终形态」;
|
|
260
|
+
3. 画线/进度图元成串(`────…`、`████…`)与 U+FFFD 乱码墙(编码不可解);
|
|
261
|
+
4. 剩余控制字符。
|
|
262
|
+
"""
|
|
263
|
+
if not text:
|
|
264
|
+
return ""
|
|
265
|
+
out = strip_ansi(text).replace("\r\n", "\n")
|
|
266
|
+
if "\r" in out:
|
|
267
|
+
lines = []
|
|
268
|
+
for ln in out.split("\n"):
|
|
269
|
+
if "\r" in ln:
|
|
270
|
+
parts = ln.split("\r")
|
|
271
|
+
ln = next((p for p in reversed(parts) if p.strip()), parts[-1])
|
|
272
|
+
lines.append(ln)
|
|
273
|
+
out = "\n".join(lines)
|
|
274
|
+
out = _CTRL_RE.sub("", out)
|
|
275
|
+
out = _FFFD_RUN_RE.sub(
|
|
276
|
+
lambda m: "…(%d 个字符无法解码:CLI 输出不是 UTF-8/GBK)" % len(m.group(0)), out)
|
|
277
|
+
out = _NOISE_RUN_RE.sub(lambda m: m.group(1) * 3 + "…", out)
|
|
278
|
+
return re.sub(r"\n{4,}", "\n\n\n", out)
|
|
279
|
+
|
|
280
|
+
|
|
204
281
|
_TS_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}T[\d:.,]+Z?\s*")
|
|
205
282
|
|
|
206
283
|
|
|
@@ -600,7 +677,11 @@ _TRANSIENT = ("503", "502", "529", "429", "no available channel", "temporarily",
|
|
|
600
677
|
# 「非瞬态不降级」,导致跨厂商链上健康的后继模型从未被尝试。
|
|
601
678
|
"unknown model", "1211", "connection error", "econnrefused",
|
|
602
679
|
"connection aborted", "initialize", "reset by peer",
|
|
603
|
-
"channel is closed", "no route to host"
|
|
680
|
+
"channel is closed", "no route to host",
|
|
681
|
+
# 2026-09-20 连载评审实测:codex 网关断流(stream disconnected)
|
|
682
|
+
# 与 opencode 服务端 500(Unexpected server error)都是「重试/换将
|
|
683
|
+
# 就可能活」的瞬态病,旧表判成终态导致整链早死
|
|
684
|
+
"stream disconnected", "unexpected server error")
|
|
604
685
|
|
|
605
686
|
|
|
606
687
|
def _transient_error(err):
|
|
@@ -809,8 +890,21 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None, workdir=
|
|
|
809
890
|
stdin_text = prompt
|
|
810
891
|
elif kind == "aider":
|
|
811
892
|
argv = resolve_command(agent["command"]) + [
|
|
812
|
-
"--yes-always", "--no-auto-commits", "--no-check-update",
|
|
893
|
+
"--yes-always", "--no-auto-commits", "--no-check-update",
|
|
894
|
+
# 网关自定义模型名(glm-5.1 等)litellm 全都不认识,警告页+建议列表
|
|
895
|
+
# 纯属刷屏(2026-09-20 实测占满步骤日志头部)
|
|
896
|
+
"--no-show-model-warnings", "--message", prompt]
|
|
813
897
|
if model:
|
|
898
|
+
# litellm 靠 provider 前缀路由,裸模型名直接报
|
|
899
|
+
# "LLM Provider NOT provided"。前缀按本条尝试实际注入的端点协议定:
|
|
900
|
+
# ANTHROPIC_BASE_URL=anthropic 面(Bigmodel 等)、OPENAI_API_BASE=
|
|
901
|
+
# openai 兼容面;都没有=CLI 本机默认场景,模型名保持用户原样
|
|
902
|
+
envd = agent.get("env") or {}
|
|
903
|
+
if "/" not in model:
|
|
904
|
+
if envd.get("ANTHROPIC_BASE_URL"):
|
|
905
|
+
model = "anthropic/" + model
|
|
906
|
+
elif envd.get("OPENAI_API_BASE"):
|
|
907
|
+
model = "openai/" + model
|
|
814
908
|
argv += ["--model", model]
|
|
815
909
|
else: # generic:模板把 {prompt}/{session} 嵌进参数(注意 cmd 行长度限制)
|
|
816
910
|
tmpl = agent.get("argv_template") or ["-p", "{prompt}"]
|
|
@@ -968,7 +1062,9 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
968
1062
|
# stderr 与 stdout 都要进错误串:codex 把 "Reading prompt from
|
|
969
1063
|
# stdin..." 打在 stderr,真正的配额/限流错误全在 stdout 的 JSONL
|
|
970
1064
|
# 里——只取其一会让 _quota_error/_transient_error 判空。
|
|
971
|
-
|
|
1065
|
+
# 先洗后切:切片会割断 ANSI 序列,留下 `[91m` 这种裸残尾直接进 UI
|
|
1066
|
+
tail = clean_cli_text(
|
|
1067
|
+
((res["stderr"] or "") + "\n" + (res["stdout"] or ""))[-4000:]).strip()[-600:]
|
|
972
1068
|
head = ("输出停滞 %ss(stall timed out,疑似卡死已提前终止)" % stall_t
|
|
973
1069
|
if res.get("stalled")
|
|
974
1070
|
else "超时" if res["timed_out"]
|
|
@@ -1025,7 +1121,7 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1025
1121
|
out["error"] = "codex: %s" % fm
|
|
1026
1122
|
out["error_code"] = ErrorCode.VENDOR_ERROR
|
|
1027
1123
|
elif not out["text"]: # 事件流解析失败时退化为取 stdout 尾部
|
|
1028
|
-
out["text"] = res["stdout"][-2000:]
|
|
1124
|
+
out["text"] = clean_cli_text(res["stdout"][-2000:])
|
|
1029
1125
|
if require_tools and out["ok"] and _codex_work_events(res["stdout"]) == 0:
|
|
1030
1126
|
# 实现步空转闸:exit 0 + 有话但零动手 → 判拒绝(fatal 语义正确,
|
|
1031
1127
|
# 不把谎报的「已改完」静默传给下游;auto 模式换将逻辑按 ok 触发,
|
|
@@ -1060,7 +1156,10 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1060
1156
|
out["error_code"] = _classify_failure(
|
|
1061
1157
|
res, kind="claude", parsed=parsed, attempt_done=True)
|
|
1062
1158
|
else:
|
|
1063
|
-
|
|
1159
|
+
# generic(aider/opencode/kimi…)的 stdout 就是终端转录:ANSI 颜色、
|
|
1160
|
+
# \r 进度条、画线框全在里面。不清洗的话它同时污染两处——步骤摘要
|
|
1161
|
+
# (详情页卡片显示成 ◆/─ 墙)与评审 JSON 解析(转义混进正文)
|
|
1162
|
+
out["text"] = clean_cli_text(res["stdout"]).strip()
|
|
1064
1163
|
if not out["text"]:
|
|
1065
1164
|
out["error_code"] = _classify_failure(res, kind=kind, empty_output=True)
|
|
1066
1165
|
break
|
|
@@ -1162,6 +1261,27 @@ def as_scores(gj):
|
|
|
1162
1261
|
return gj if isinstance(gj, dict) else None
|
|
1163
1262
|
|
|
1164
1263
|
|
|
1264
|
+
def extract_scores_from_text(text):
|
|
1265
|
+
"""评审解析第四道网(借鉴 BAML 的宽容提取):模型把分数写成散文键值对
|
|
1266
|
+
完全不出 JSON 时(2026-09-18 真实案例:kimi 正文提分),从文本直接抓
|
|
1267
|
+
「维度:N 分」模式。返回 scores dict 或 {}。"""
|
|
1268
|
+
if not text:
|
|
1269
|
+
return {}
|
|
1270
|
+
scores = {}
|
|
1271
|
+
for m in re.finditer(
|
|
1272
|
+
r"[\u4e00-\u9fa5A-Za-z][\u4e00-\u9fa5A-Za-z0-9 ]{0,11}"
|
|
1273
|
+
r"[::]\s*(\d{1,2}(?:\.\d)?)\s*分?", text):
|
|
1274
|
+
dim = m.group(0).rsplit(":", 1)[0].rsplit(":", 1)[0].strip()
|
|
1275
|
+
try:
|
|
1276
|
+
val = float(m.group(1))
|
|
1277
|
+
except ValueError:
|
|
1278
|
+
continue
|
|
1279
|
+
if not dim or dim in scores or not (1.0 <= val <= 10.0):
|
|
1280
|
+
continue
|
|
1281
|
+
scores[dim] = round(val, 1)
|
|
1282
|
+
return scores if len(scores) >= 3 else {}
|
|
1283
|
+
|
|
1284
|
+
|
|
1165
1285
|
def scores_from_prose(text, dims):
|
|
1166
1286
|
"""评审解析第三道网:agentic CLI 有时把 JSON 写进文件、stdout 只留中文
|
|
1167
1287
|
总结(「情节 8 / 人物 8 / …」)。JSON 全灭后按维度名从正文提分;
|
package/app/core/selfupdate.py
CHANGED
|
@@ -18,9 +18,9 @@ SSE 可看进度)。npm 替换的是包目录文件,当前进程已加载进
|
|
|
18
18
|
"""
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
|
-
import json
|
|
22
|
-
import logging
|
|
23
|
-
import os
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
24
|
import re
|
|
25
25
|
import socket
|
|
26
26
|
import subprocess
|
|
@@ -29,11 +29,11 @@ import threading
|
|
|
29
29
|
import time
|
|
30
30
|
from pathlib import Path
|
|
31
31
|
|
|
32
|
-
from . import paths, runner
|
|
33
|
-
|
|
34
|
-
log = logging.getLogger(__name__)
|
|
35
|
-
|
|
36
|
-
_PKG_NAME = "codebee" # npm 发布名;必须与 package.json 的 name 一致(单测断言)
|
|
32
|
+
from . import paths, runner
|
|
33
|
+
|
|
34
|
+
log = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
_PKG_NAME = "codebee" # npm 发布名;必须与 package.json 的 name 一致(单测断言)
|
|
37
37
|
_UPDATE_TTL = 600 # 查新结果缓存(秒)
|
|
38
38
|
_LOCK = threading.Lock()
|
|
39
39
|
_CHECK_CACHE = {"ts": 0.0, "result": None}
|
|
@@ -161,46 +161,88 @@ def check(force=False):
|
|
|
161
161
|
return out
|
|
162
162
|
|
|
163
163
|
|
|
164
|
-
def apply_upgrade():
|
|
164
|
+
def apply_upgrade():
|
|
165
165
|
"""发起升级:建 mgmt run 异步跑 npm install -g @latest。返回 {run_id} 或 {error}。"""
|
|
166
166
|
if install_mode() != "npm":
|
|
167
167
|
return {"error": "当前安装方式不支持自动升级(见版本页说明)"}
|
|
168
168
|
from . import store, jobs
|
|
169
|
-
try:
|
|
170
|
-
run = store.create_run(
|
|
171
|
-
"mgmt", "升级 CodeBee 本体(npm install -g %s@latest)" % _PKG_NAME,
|
|
172
|
-
entry_id="__self__", op="selfupgrade")
|
|
173
|
-
except Exception:
|
|
174
|
-
log.exception("selfupdate: 创建升级运行记录失败")
|
|
175
|
-
return {"error": "升级任务创建失败,请稍后重试"}
|
|
176
|
-
try:
|
|
177
|
-
jobs.enqueue({"kind": "selfupgrade", "run_id": run["id"]})
|
|
178
|
-
except Exception:
|
|
179
|
-
# The run is already durable when enqueue fails. Close it explicitly so
|
|
180
|
-
# the upgrade panel cannot remain in a misleading queued state.
|
|
181
|
-
log.exception("selfupdate: 升级任务入队失败 run=%s", run["id"])
|
|
182
|
-
try:
|
|
183
|
-
store.update_run(run["id"], status="failed",
|
|
184
|
-
error="升级任务入队失败,请稍后重试",
|
|
185
|
-
ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
|
|
186
|
-
except Exception:
|
|
187
|
-
log.exception("selfupdate: 升级运行失败收口失败 run=%s", run["id"])
|
|
188
|
-
return {"error": "升级任务入队失败,请稍后重试", "run_id": run["id"]}
|
|
189
|
-
return {"run_id": run["id"]}
|
|
169
|
+
try:
|
|
170
|
+
run = store.create_run(
|
|
171
|
+
"mgmt", "升级 CodeBee 本体(npm install -g %s@latest)" % _PKG_NAME,
|
|
172
|
+
entry_id="__self__", op="selfupgrade")
|
|
173
|
+
except Exception:
|
|
174
|
+
log.exception("selfupdate: 创建升级运行记录失败")
|
|
175
|
+
return {"error": "升级任务创建失败,请稍后重试"}
|
|
176
|
+
try:
|
|
177
|
+
jobs.enqueue({"kind": "selfupgrade", "run_id": run["id"]})
|
|
178
|
+
except Exception:
|
|
179
|
+
# The run is already durable when enqueue fails. Close it explicitly so
|
|
180
|
+
# the upgrade panel cannot remain in a misleading queued state.
|
|
181
|
+
log.exception("selfupdate: 升级任务入队失败 run=%s", run["id"])
|
|
182
|
+
try:
|
|
183
|
+
store.update_run(run["id"], status="failed",
|
|
184
|
+
error="升级任务入队失败,请稍后重试",
|
|
185
|
+
ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
|
|
186
|
+
except Exception:
|
|
187
|
+
log.exception("selfupdate: 升级运行失败收口失败 run=%s", run["id"])
|
|
188
|
+
return {"error": "升级任务入队失败,请稍后重试", "run_id": run["id"]}
|
|
189
|
+
return {"run_id": run["id"]}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
_LOCKED_RE = re.compile(r"\b(EBUSY|EPERM)\b")
|
|
193
|
+
_RETRY_DELAYS = (5, 15) # 目录被占用时自动重试前的等待秒数(暂时性占用多在此窗口内释放)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _locked_error(res):
|
|
197
|
+
"""npm 失败输出是否为「包目录被占用」类(EBUSY/EPERM)——值得等一等重试。"""
|
|
198
|
+
blob = ((res or {}).get("stderr") or "") + ((res or {}).get("stdout") or "")
|
|
199
|
+
return bool(_LOCKED_RE.search(blob[-4000:]))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _log_note(log_path, text):
|
|
203
|
+
"""向步骤日志追加一行进度说明(run_process 以 append 模式写同一文件)。"""
|
|
204
|
+
if not log_path:
|
|
205
|
+
return
|
|
206
|
+
try:
|
|
207
|
+
with open(log_path, "ab") as fh:
|
|
208
|
+
fh.write(("\n===== %s =====\n" % text).encode("utf-8", "replace"))
|
|
209
|
+
except Exception:
|
|
210
|
+
pass
|
|
190
211
|
|
|
191
212
|
|
|
192
213
|
def run_upgrade(run_id, log_path):
|
|
193
|
-
"""worker 线程里执行升级命令(run/step 生命周期由 jobs 层管)。
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
214
|
+
"""worker 线程里执行升级命令(run/step 生命周期由 jobs 层管)。
|
|
215
|
+
|
|
216
|
+
包目录被其他进程占用(EBUSY/EPERM:打开包目录的资源管理器/终端窗口、
|
|
217
|
+
杀毒或索引扫描)是升级失败的最常见原因,且多为暂时性——自动重试
|
|
218
|
+
_RETRY_DELAYS 轮,仍败则给人话结论(原始 npm 输出在步骤日志里可查)。"""
|
|
219
|
+
res = {}
|
|
220
|
+
for attempt, delay in enumerate((0,) + _RETRY_DELAYS):
|
|
221
|
+
if delay:
|
|
222
|
+
_log_note(log_path, "目录被占用(EBUSY/EPERM),%d 秒后自动重试(第 %d/%d 次)"
|
|
223
|
+
% (delay, attempt, len(_RETRY_DELAYS)))
|
|
224
|
+
time.sleep(delay)
|
|
225
|
+
res = runner.run_process(
|
|
226
|
+
argv=_npm_argv("install", "-g", _PKG_NAME + "@latest"),
|
|
227
|
+
# Windows 上 npm 换版本靠把包目录整体改名(codebee → .codebee-xxx);
|
|
228
|
+
# cwd 若落在本包内,目录被自身进程占用,rename 必报 EBUSY——钉在包外
|
|
229
|
+
cwd=str(Path.home()), timeout=900, log_path=log_path)
|
|
230
|
+
if res["ok"] or not _locked_error(res):
|
|
231
|
+
break
|
|
199
232
|
if res["ok"]:
|
|
200
233
|
with _LOCK: # 装完即过期查新缓存,重启后自然拿到新版本
|
|
201
234
|
_CHECK_CACHE["result"] = None
|
|
202
|
-
|
|
203
|
-
|
|
235
|
+
return {"ok": True, "exit_code": res["exit_code"], "error": ""}
|
|
236
|
+
stderr = res["stderr"] or ""
|
|
237
|
+
if _locked_error(res):
|
|
238
|
+
err = ("升级失败:codebee 安装目录被其他程序占用(已自动重试 %d 次未恢复)。"
|
|
239
|
+
"常见占用:打开包目录的资源管理器窗口/终端、杀毒或索引扫描。"
|
|
240
|
+
"请关闭相关窗口后回版本页重试;仍不行可退出 CodeBee 后手动执行 "
|
|
241
|
+
"npm install -g %s@latest。" % (len(_RETRY_DELAYS), _PKG_NAME))
|
|
242
|
+
else:
|
|
243
|
+
# npm 的进度条/颜色转义与中文 Windows 的 GBK 输出都进过这里,先洗再用
|
|
244
|
+
err = runner.clean_cli_text(stderr)[-800:] or "退出码 %s" % res["exit_code"]
|
|
245
|
+
return {"ok": False, "exit_code": res["exit_code"], "error": err}
|
|
204
246
|
|
|
205
247
|
|
|
206
248
|
def _port_free(port):
|