codebee 0.1.69 → 0.1.70

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +4 -3
  3. package/app/browser-final-1440.png +0 -0
  4. package/app/browser-final-390.png +0 -0
  5. package/app/browser-google-fallback.png +0 -0
  6. package/app/browser-google-test.png +0 -0
  7. package/app/browser-workbench-1440.png +0 -0
  8. package/app/browser-workbench-390.png +0 -0
  9. package/app/browser-workspace-google.png +0 -0
  10. package/app/browser-workspace-smoke.png +0 -0
  11. package/app/browser-workspace.png +0 -0
  12. package/app/core/audit.py +30 -0
  13. package/app/core/automation.py +107 -12
  14. package/app/core/benchstore.py +95 -0
  15. package/app/core/bookmeta.py +9 -4
  16. package/app/core/builtin_agent.py +472 -33
  17. package/app/core/catalog.py +7 -1
  18. package/app/core/dispatch.py +20 -3
  19. package/app/core/error_codes.py +11 -2
  20. package/app/core/evalbench.py +720 -0
  21. package/app/core/flows.py +228 -3
  22. package/app/core/jobs.py +22 -1
  23. package/app/core/manager.py +21 -0
  24. package/app/core/market_remote.py +10 -0
  25. package/app/core/mcp_client.py +287 -0
  26. package/app/core/modelhub.py +185 -2
  27. package/app/core/notify.py +118 -23
  28. package/app/core/novel_quality.py +189 -0
  29. package/app/core/paths.py +4 -0
  30. package/app/core/pipeline.py +426 -28
  31. package/app/core/planner.py +243 -22
  32. package/app/core/plugins.py +525 -0
  33. package/app/core/publish/browser.py +129 -13
  34. package/app/core/publish/fanqie.py +6 -0
  35. package/app/core/publish/flow.py +154 -5
  36. package/app/core/publish/flows-fanqie-calibrated.json +11 -7
  37. package/app/core/publish/ledger.py +29 -3
  38. package/app/core/publish/manager.py +134 -2
  39. package/app/core/publish/qimao.py +5 -0
  40. package/app/core/remotesync.py +121 -0
  41. package/app/core/runner.py +80 -5
  42. package/app/core/settings.py +39 -2
  43. package/app/core/settings_schema.py +8 -0
  44. package/app/core/store.py +30 -8
  45. package/app/core/usage.py +187 -14
  46. package/app/core/zentao.py +153 -4
  47. package/app/current-browser.png +0 -0
  48. package/app/main.py +279 -15
  49. package/app/mcp_server.py +175 -0
  50. package/app/pet.py +82 -21
  51. package/app/plugins/code-review-helper/.codex-plugin/plugin.json +18 -0
  52. package/app/plugins/code-review-helper/skills/review.md +16 -0
  53. package/app/ui/app.js +1380 -73
  54. package/app/ui/i18n.js +248 -6
  55. package/app/ui/index.html +277 -13
  56. package/app/ui/style.css +786 -0
  57. package/package.json +2 -1
package/app/main.py CHANGED
@@ -34,6 +34,7 @@ if os.environ.get("TUTTI_ISOLATE_HOME", "").strip() == "1":
34
34
  from core import automation, board, catalog, flows, jobs, manager, market, market_remote, preview, registry, remote, settings, store
35
35
  from core import paths
36
36
  from core import health
37
+ from core import usage as usage_ledger
37
38
  import pick_dialog
38
39
  import pet
39
40
 
@@ -267,6 +268,10 @@ class Handler(BaseHTTPRequestHandler):
267
268
  return self._json(200, {"sources": modelhub.sources()})
268
269
  if path == "/api/flows":
269
270
  return self._json(200, {"flows": flows.list_flows()})
271
+ if path == "/api/flows/versions":
272
+ qs = parse_qs(urlparse(self.path).query)
273
+ fid = (qs.get("id") or [""])[0]
274
+ return self._json(200, {"versions": flows.flow_versions(fid)})
270
275
  if path == "/api/skills":
271
276
  from core import skills
272
277
  return self._json(200, skills.view())
@@ -288,6 +293,9 @@ class Handler(BaseHTTPRequestHandler):
288
293
  if path == "/api/orchestrator":
289
294
  from core import modelhub
290
295
  return self._json(200, {"orchestrator": modelhub.orchestrator_view()})
296
+ if path == "/api/evalbench":
297
+ from core import evalbench
298
+ return self._json(200, evalbench.state())
291
299
  if path == "/api/catalog":
292
300
  return self._json(200, {"catalog": manager.catalog_view(),
293
301
  "checking": manager.updates_checking()})
@@ -362,6 +370,19 @@ class Handler(BaseHTTPRequestHandler):
362
370
  "Content-Disposition":
363
371
  'attachment; filename="codebee-diag-%s.zip"'
364
372
  % time.strftime("%Y%m%d-%H%M%S")})
373
+ if path == "/api/usage/export":
374
+ # 台账明细导出 CSV(UTF-8 BOM,Excel 直开;行数封顶防炸)
375
+ from core import usage as _usage
376
+ try:
377
+ days = max(0, min(3650, int((parse_qs(urlparse(self.path).query)
378
+ .get("days") or ["30"])[0])))
379
+ except (TypeError, ValueError):
380
+ days = 30
381
+ csv_bytes = _usage.export_csv(days=days)
382
+ return self._send(200, csv_bytes, ctype="text/csv; charset=utf-8",
383
+ headers={"Content-Disposition":
384
+ 'attachment; filename="codebee-usage-%s.csv"'
385
+ % time.strftime("%Y%m%d-%H%M%S")})
365
386
  if path == "/api/diagnostics/issue-summary":
366
387
  # 一键反馈 Issue 的预填摘要(标题+正文,全程脱敏,用户亲手提交)
367
388
  from core import telemetry
@@ -514,8 +535,12 @@ class Handler(BaseHTTPRequestHandler):
514
535
  if m:
515
536
  from core.publish import manager as pub
516
537
  from core.publish import ledger as pub_ledger
517
- return self._json(200, {"history": pub.history(task_id=m.group(1), limit=50),
518
- "books": pub_ledger.load_books().get(m.group(1)) or {}})
538
+ tid = m.group(1)
539
+ books = pub_ledger.load_books().get(tid) or {}
540
+ return self._json(200, {"history": pub.history(task_id=tid, limit=50),
541
+ "books": books,
542
+ "published": {p: pub.published_local(tid, p)
543
+ for p in books}})
519
544
  m = re.match(r"^/api/publish/task/([^/]+)/pending$", path)
520
545
  if m:
521
546
  # 自动发布视图:待发清单统计 + 护栏状态 + 批量发布进度
@@ -665,7 +690,8 @@ class Handler(BaseHTTPRequestHandler):
665
690
  # /api/hooks/run 用自己的令牌鉴权(外部脚本没有设备控制权握手)。
666
691
  if path in ("/api/health/op", "/api/models/provider-op", "/api/models/model-op",
667
692
  "/api/models/test-provider", "/api/models/test-model",
668
- "/api/models/probe-wire", "/api/models/key-op", "/api/hooks/run"):
693
+ "/api/models/probe-wire", "/api/models/key-op", "/api/hooks/run",
694
+ "/api/notify/test", "/api/models/set-price", "/api/models/model-caps"):
669
695
  pass # 落到下方各自路由
670
696
  else:
671
697
  deny = self._deny_control()
@@ -690,6 +716,50 @@ class Handler(BaseHTTPRequestHandler):
690
716
  return self._json(200, {"ok": ok, "output": out})
691
717
  if path == "/api/tasks/clarify":
692
718
  return self._api_task_clarify()
719
+ if path == "/api/notify/test":
720
+ # 设置页「发送测试」:向全部已配置推送通道发一条测试消息
721
+ from core import notify
722
+ res = notify.push_text_ex(
723
+ "🔔 CodeBee 测试推送\n收到这条消息说明推送通道连通正常(任务收尾会推同样格式的结果摘要)。")
724
+ if not res:
725
+ return self._json(400, {"error": "未配置任何推送通道(群 Webhook / Bark / ntfy / Server酱 / Telegram)"})
726
+ return self._json(200, {"ok": any(res.values()), "channels": res})
727
+ if path == "/api/evalbench/run":
728
+ # 评测基准台:样题 × 候选模型实跑 + 编排者裁判打分(后台线程)
729
+ from core import evalbench
730
+ body = self._body() or {}
731
+ info, err = evalbench.start(body.get("candidates"),
732
+ sample_ids=body.get("samples"))
733
+ if err:
734
+ return self._json(409 if "在跑" in err else 400, {"error": err})
735
+ return self._json(200, dict(ok=True, **info))
736
+ if path == "/api/evalbench/cancel":
737
+ from core import evalbench
738
+ return self._json(200, {"ok": evalbench.cancel()})
739
+ if path == "/api/evalbench/sample-op":
740
+ # 自定义样题增删:{op:add|delete, sample:{...}|sid}
741
+ from core import evalbench
742
+ body = self._body() or {}
743
+ res, err = evalbench.sample_op(body.get("op") or "",
744
+ sample=body.get("sample"),
745
+ sid=body.get("sid") or "")
746
+ if err:
747
+ return self._json(400, {"error": err})
748
+ return self._json(200, {"ok": True, "sample": res})
749
+ if path == "/api/evalbench/sample-export":
750
+ from core import evalbench
751
+ body = self._body() or {}
752
+ code, err = evalbench.export_sample_code(body.get("id") or "")
753
+ if err:
754
+ return self._json(400, {"error": err})
755
+ return self._json(200, {"ok": True, "code": code})
756
+ if path == "/api/evalbench/sample-import":
757
+ from core import evalbench
758
+ body = self._body() or {}
759
+ res, err = evalbench.import_sample_code(body.get("code"))
760
+ if err:
761
+ return self._json(400, {"error": err})
762
+ return self._json(200, dict(ok=True, **res))
693
763
  if path == "/api/hooks/run":
694
764
  return self._api_hook_run()
695
765
  if path == "/api/health/op":
@@ -811,7 +881,10 @@ class Handler(BaseHTTPRequestHandler):
811
881
  _t = store.get_task(m.group(1)) or {}
812
882
  return self._json(200, {"ok": True, "rev": _t.get("rev")})
813
883
  else:
814
- ok, err = store.delete_task(m.group(1))
884
+ # actor 记进删除审计(store 落台账);本地回环标 local 更可读
885
+ ip = self.client_address[0] if self.client_address else ""
886
+ ok, err = store.delete_task(
887
+ m.group(1), actor="local" if ip in ("127.0.0.1", "::1") else ip)
815
888
  return self._json(400, {"error": err}) if not ok else self._json(200, {"ok": True})
816
889
  m = re.match(r"^/api/tasks/([^/]+)/bible$", path)
817
890
  if m:
@@ -839,6 +912,35 @@ class Handler(BaseHTTPRequestHandler):
839
912
  m = re.match(r"^/api/publish/task/([^/]+)/publish-all$", path)
840
913
  if m:
841
914
  return self._api_publish_auto(m.group(1))
915
+ m = re.match(r"^/api/publish/task/([^/]+)/sync-published$", path)
916
+ if m:
917
+ # 已发章数校准:数章节管理页的实况写回登记(后台线程)。
918
+ # manual=true(用户点「校准」)允许 attach-or-launch;自动触发
919
+ # 只 attach 在跑实例,绝不因打开页面弹出新浏览器窗口。
920
+ from core.publish import manager as pub
921
+ from core import store
922
+ from core.publish import ledger as pub_ledger
923
+ tid = m.group(1)
924
+ if not store.get_task(tid):
925
+ return self._json(404, {"error": "任务不存在"})
926
+ body = self._body() or {}
927
+ plat = str(body.get("platform") or "").strip()
928
+ books = pub_ledger.load_books().get(tid) or {}
929
+ if plat and plat not in books:
930
+ return self._json(400, {"error": "该平台未登记作品"})
931
+ plats = [plat] if plat else list(books)
932
+ if not plats:
933
+ return self._json(400, {"error": "该任务没有已登记作品"})
934
+ manual = bool(body.get("manual"))
935
+ started, skipped = [], {}
936
+ for p in plats:
937
+ ok, err = pub.sync_published_async(tid, p, manual=manual)
938
+ if ok:
939
+ started.append(p)
940
+ else:
941
+ skipped[p] = err
942
+ return self._json(200, {"ok": True, "started": started,
943
+ "skipped": skipped})
842
944
  m = re.match(r"^/api/tasks/([^/]+)/(git-merge|git-discard)$", path)
843
945
  if m:
844
946
  return self._api_git_verdict(m.group(1), m.group(2))
@@ -900,6 +1002,30 @@ class Handler(BaseHTTPRequestHandler):
900
1002
  n = manager.check_updates_async(force=force)
901
1003
  return self._json(200, {"ok": True, "count": n,
902
1004
  "checking": manager.updates_checking()})
1005
+ if path == "/api/catalog/upgrade-all":
1006
+ # 一键升级:把所有可升级条目逐个送进 mgmt 队列(选条见
1007
+ # manager.upgrade_all_targets)。同条目去重闸与单条升级共用——
1008
+ # 忙着的条目跳过并在响应里点名,前端 toast 汇报
1009
+ started, busy, failed = [], [], []
1010
+ for e in manager.upgrade_all_targets():
1011
+ if store.active_mgmt_run(e["id"]):
1012
+ busy.append(e.get("name", e["id"]))
1013
+ continue
1014
+ run = store.create_run("mgmt", "升级 %s" % e.get("name", e["id"]),
1015
+ entry_id=e["id"], op="upgrade")
1016
+ queued, qerr = self._enqueue_run(
1017
+ run["id"], None,
1018
+ {"kind": "mgmt", "run_id": run["id"], "entry_id": e["id"], "op": "upgrade"})
1019
+ if not queued:
1020
+ # 入队失败必须落终态:queued 僵尸会永久堵住去重闸
1021
+ store.update_run(run["id"], status="failed", error=qerr or "enqueue 失败",
1022
+ ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
1023
+ failed.append(e.get("name", e["id"]))
1024
+ continue
1025
+ started.append({"id": e["id"], "name": e.get("name", e["id"]),
1026
+ "run_id": run["id"]})
1027
+ return self._json(200, {"ok": True, "started": started,
1028
+ "busy": busy, "failed": failed})
903
1029
  if path == "/api/selfupdate/apply":
904
1030
  from core import selfupdate
905
1031
  try:
@@ -1005,6 +1131,15 @@ class Handler(BaseHTTPRequestHandler):
1005
1131
  body.get("name") or "",
1006
1132
  body.get("image_in"))
1007
1133
  return self._json(400, {"error": err}) if err else self._json(200, {"ok": True})
1134
+ if path == "/api/models/set-price":
1135
+ # 手标模型单价(¥/百万 tokens):台账记账与花费预算按此折算
1136
+ from core import modelhub
1137
+ body = self._body()
1138
+ err = modelhub.set_model_price(body.get("provider_id") or "",
1139
+ body.get("name") or "",
1140
+ body.get("price_in"),
1141
+ body.get("price_out"))
1142
+ return self._json(400, {"error": err}) if err else self._json(200, {"ok": True})
1008
1143
  if path == "/api/models/test-provider":
1009
1144
  from core import modelhub
1010
1145
  return self._json(200, modelhub.test_provider(self._body().get("id") or ""))
@@ -1037,6 +1172,33 @@ class Handler(BaseHTTPRequestHandler):
1037
1172
  if err:
1038
1173
  return self._json(400, {"error": err})
1039
1174
  return self._json(200, {"ok": True, "flow": flow})
1175
+ if path == "/api/flows/restore":
1176
+ # 版本历史恢复:把历史版重新 upsert 回来(当前版先入史,可再回滚)
1177
+ body = self._body() or {}
1178
+ flow, err = flows.restore_flow_version(body.get("id") or "",
1179
+ body.get("ts") or "")
1180
+ if err:
1181
+ return self._json(400, {"error": err})
1182
+ return self._json(200, {"ok": True, "flow": flow})
1183
+ if path == "/api/flows/export":
1184
+ # 流程分享码:导出一段可粘贴的码,对端「导入分享码」一键安装
1185
+ body = self._body() or {}
1186
+ code, err = flows.export_flow_code(body.get("id") or "")
1187
+ if err:
1188
+ return self._json(400, {"error": err})
1189
+ return self._json(200, {"ok": True, "code": code,
1190
+ "name": (flows.get_flow(body.get("id") or "") or {}).get("name")})
1191
+ if path == "/api/flows/preview":
1192
+ # 分享码两步导入第一步:解析预览,不落盘
1193
+ res, err = flows.preview_flow_code((self._body() or {}).get("code"))
1194
+ if err:
1195
+ return self._json(400, {"error": err})
1196
+ return self._json(200, dict(ok=True, **res))
1197
+ if path == "/api/flows/import":
1198
+ res, err = flows.import_flow_code((self._body() or {}).get("code"))
1199
+ if err:
1200
+ return self._json(400, {"error": err})
1201
+ return self._json(200, dict(ok=True, **res))
1040
1202
  if path == "/api/skills/lesson-op":
1041
1203
  from core import skills
1042
1204
  body = self._body()
@@ -1295,7 +1457,31 @@ class Handler(BaseHTTPRequestHandler):
1295
1457
  except Exception as e:
1296
1458
  log.exception("backup: 导出失败")
1297
1459
  return self._json(500, {"error": "导出失败:%s" % e})
1460
+ # 远程备份通道:启用即自动推送(失败不挡导出,结果并进响应)
1461
+ try:
1462
+ from core import remotesync
1463
+ info["remote"] = remotesync.push_remote(info.get("path") or "")
1464
+ except Exception:
1465
+ info["remote"] = {"ok": False, "error": "推送异常"}
1298
1466
  return self._json(200, {"ok": True, **info})
1467
+ if path == "/api/data/test-remote":
1468
+ # 远程备份连通性测试:PUT 一个探针文件
1469
+ from core import remotesync
1470
+ return self._json(200, {"ok": True, **remotesync.test_remote()})
1471
+ if path == "/api/data/remote-list":
1472
+ # 远程备份包列举(WebDAV PROPFIND)
1473
+ from core import remotesync
1474
+ names, err = remotesync.list_remote()
1475
+ if err:
1476
+ return self._json(400, {"error": err})
1477
+ return self._json(200, {"ok": True, "files": names})
1478
+ if path == "/api/data/remote-pull":
1479
+ # 拉远程备份包到 imports 目录(之后走既有导入预览/应用向导)
1480
+ from core import remotesync
1481
+ p, err = remotesync.pull_remote((self._body() or {}).get("name"))
1482
+ if err:
1483
+ return self._json(400, {"error": err})
1484
+ return self._json(200, {"ok": True, "path": p})
1299
1485
  if path == "/api/data/import":
1300
1486
  # op=inspect 预览(不落盘);op=apply 应用(merge/replace + 路径重映射)
1301
1487
  from core import backup
@@ -1337,6 +1523,16 @@ class Handler(BaseHTTPRequestHandler):
1337
1523
  from core import zentao
1338
1524
  res = zentao.scan_now() # 网络操作同步做(ThreadingHTTPServer 不堵别的请求)
1339
1525
  return self._json(200, dict(res, ok=bool(res.get("ok"))))
1526
+ if path == "/api/zentao/claims/archive":
1527
+ from core import zentao
1528
+ body = self._body() or {}
1529
+ try:
1530
+ c = zentao.archive_claim(body.get("bug_id"),
1531
+ bool(body.get("archived")),
1532
+ str(body.get("reason") or ""))
1533
+ except zentao.ZenError as e:
1534
+ return self._json(404, {"error": str(e)})
1535
+ return self._json(200, {"ok": True, "claim": c})
1340
1536
  if path == "/api/zentao/modules":
1341
1537
  from core import zentao
1342
1538
  pid = (self._body() or {}).get("product")
@@ -1997,7 +2193,7 @@ class Handler(BaseHTTPRequestHandler):
1997
2193
  return self._json(200, {"ok": True, "message": msg})
1998
2194
 
1999
2195
  def _api_run_timeline(self, run_id):
2000
- """直连对话视图的时间线:目标 + 用户消息 + 各步最终回答按序合并成气泡流。
2196
+ """任务对话工作区时间线:目标 + 用户消息 + 各步最终回答按序合并成气泡流。
2001
2197
 
2002
2198
  形状:[任务目标] → [用户消息] → [assistant 输出] → [用户追问] → …。
2003
2199
  步骤正文只认 output/summary(干净回答),供详情页「对话」分区直读。
@@ -2007,14 +2203,17 @@ class Handler(BaseHTTPRequestHandler):
2007
2203
  回放单个 run 会把历史轮的回答全丢掉(2026-09-17 实测:一发消息,
2008
2204
  之前智能体输出从时间线消失)。继承的消息是同 id 副本,跨 run 按
2009
2205
  (id, 时间, 文本) 去重;每个 run 内先消息后步骤(消息总是先于本轮
2010
- 回答送达)。非 direct 任务保持单 run 步骤流(视图未启用)。"""
2206
+ 回答送达)。没有任务归属的管理运行保持单 run 步骤流。"""
2011
2207
  run = store.get_run(run_id)
2012
2208
  if not run:
2013
2209
  return self._json(404, {"error": "not found"})
2014
2210
  task = store.get_task(run.get("task_id") or "") if run.get("task_id") else None
2015
2211
  engine = (task or {}).get("engine") or ""
2016
2212
  items = []
2017
- if engine == "direct":
2213
+ # 对话工作区按任务回放,而不是按某个引擎特判:编排类任务也会
2214
+ # 产生多轮运行(重试、连载续跑、用户反馈),用户需要看到完整的
2215
+ # 智能体输出历史。没有任务归属的管理运行仍只展示自己这一轮。
2216
+ if task:
2018
2217
  runs = list(reversed(store.task_runs(run["task_id"]))) # 旧→新
2019
2218
  else:
2020
2219
  runs = [run]
@@ -2022,7 +2221,7 @@ class Handler(BaseHTTPRequestHandler):
2022
2221
  # 附件归一成 _attachments/ 相对路径:前端展示仍取文件名(attName 剥目录),
2023
2222
  # 但「点击查看」需要相对路径走 /api/runs/<id>/file 通道——只下发文件名的话
2024
2223
  # 点开永远 404。时间取任务创建时刻(对话的开场是任务本身,不是某一轮续跑的起始时间)
2025
- if task and engine == "direct":
2224
+ if task:
2026
2225
  from core import attachments as _att
2027
2226
  atts = [p for p in (_att.norm_rel(a) for a in (task.get("attachments") or [])) if p]
2028
2227
  items.append({
@@ -2070,6 +2269,7 @@ class Handler(BaseHTTPRequestHandler):
2070
2269
  "role": s.get("role") or "",
2071
2270
  "n": s.get("n"),
2072
2271
  "status": s.get("status") or "",
2272
+ "partial": bool(s.get("partial")),
2073
2273
  "text": body,
2074
2274
  "note": s.get("note") or "",
2075
2275
  "run": run_id_of_step,
@@ -2130,13 +2330,23 @@ class Handler(BaseHTTPRequestHandler):
2130
2330
  成没成、跑多久、谁执行的、产出了哪些文件。模型最后一轮回答可能只是
2131
2331
  寒暄/追问(用户反馈:输入"1"跑完 55 秒只见一句"消息可能发错了"),
2132
2332
  执行结果不能依赖模型自觉交代,由产品明示。"""
2133
- if engine != "direct" or not latest:
2333
+ # 对话工作区现在覆盖所有有任务归属的运行(编排、直连、连载等)。
2334
+ # `engine` 仅保留给调用方展示/兼容,不能再把编排任务的结果卡过滤掉。
2335
+ # 没有关联任务的管理运行仍在 _api_run_timeline 中走孤立运行路径,
2336
+ # 不会误显示可续聊的结果卡。
2337
+ if not latest:
2134
2338
  return None
2135
2339
  st = latest.get("status") or ""
2136
2340
  if st not in ("done", "failed", "cancelled", "timeout"):
2137
2341
  return None
2138
2342
  verdict = latest.get("verdict") or {}
2139
2343
  route = latest.get("route") or {}
2344
+ # 非直连编排路径可能只在步骤收尾记录 partial,而没有统一的
2345
+ # run-level verdict。结果卡必须保留这个事实,不能把已执行一部分
2346
+ # 的运行显示成完整成功。
2347
+ partial = bool(verdict.get("partial")) or any(
2348
+ isinstance(step, dict) and bool(step.get("partial"))
2349
+ for step in (latest.get("steps") or []))
2140
2350
 
2141
2351
  def _seconds(value):
2142
2352
  try:
@@ -2183,7 +2393,10 @@ class Handler(BaseHTTPRequestHandler):
2183
2393
  wd, files = "", []
2184
2394
  return {
2185
2395
  "status": st,
2186
- "error": (latest.get("error") or "") if st in ("failed", "timeout") else "",
2396
+ "partial": partial,
2397
+ "error": ((latest.get("error") or "") if st in ("failed", "timeout")
2398
+ else "部分完成:已执行部分操作,但最终总结未完整生成"
2399
+ if partial else ""),
2187
2400
  "executor": route.get("implementer") or verdict.get("impl") or "",
2188
2401
  "turns": verdict.get("turns") or 0,
2189
2402
  "duration_s": duration_s,
@@ -2205,8 +2418,8 @@ class Handler(BaseHTTPRequestHandler):
2205
2418
  return self._json(404, {"error": "not found"})
2206
2419
  task = store.get_task(run.get("task_id") or "") if run.get("task_id") else None
2207
2420
  is_serial = bool(task and task.get("serial"))
2208
- if not task or (task.get("engine") != "direct" and not is_serial):
2209
- return self._json(400, {"error": "该任务不是直连任务,请用「下达指令」"})
2421
+ if not task:
2422
+ return self._json(400, {"error": "运行没有关联任务,无法进入对话工作区"})
2210
2423
  if (run.get("status") or "") in ("queued", "running"):
2211
2424
  return self._json(400, {"error": "运行中:消息会随下一步自动送达,无需追话"})
2212
2425
  body = self._body() or {}
@@ -2293,6 +2506,7 @@ class Handler(BaseHTTPRequestHandler):
2293
2506
  pass
2294
2507
  cid = self._client_id()
2295
2508
  ver = store.state_version()
2509
+ uver = usage_ledger.usage_version()
2296
2510
  sent_ctrl = None
2297
2511
  last_send = 0.0
2298
2512
  try:
@@ -2308,8 +2522,9 @@ class Handler(BaseHTTPRequestHandler):
2308
2522
  last_send = time.time()
2309
2523
  while True:
2310
2524
  ver = store.wait_state_change(ver, 2.0)
2525
+ uver_now = usage_ledger.usage_version()
2311
2526
  ctrl = remote.control_view(cid)
2312
- if ver == store.state_version() and ctrl == sent_ctrl:
2527
+ if ver == store.state_version() and ctrl == sent_ctrl and uver_now == uver:
2313
2528
  # 空闲心跳:真发出去的字节让中间层不掐空闲流,也让半死
2314
2529
  # 连接在下一次写时尽早报错(注释行,EventSource 不派发事件)
2315
2530
  if time.time() - last_send >= SSE_PING_S:
@@ -2317,6 +2532,18 @@ class Handler(BaseHTTPRequestHandler):
2317
2532
  self.wfile.flush()
2318
2533
  last_send = time.time()
2319
2534
  continue # 超时醒来且无变化
2535
+ if uver_now != uver:
2536
+ # 用量条实时化:台账有新记账(哪怕状态没变,如内置智能体
2537
+ # 同一工具循环内多次模型调用)只发一个几十字节的 usage
2538
+ # 事件,前端据此刷新左下角用量条,不构建/推送全量状态。
2539
+ # 2s 醒来才查一次版本号:两次醒来之间落多少条记录都合并
2540
+ # 成一个事件,推送频率天然有上界。
2541
+ self.wfile.write(("event: usage\ndata: " +
2542
+ json.dumps({"v": uver_now}) + "\n\n").encode("utf-8"))
2543
+ uver = uver_now
2544
+ last_send = time.time()
2545
+ if ver == store.state_version() and ctrl == sent_ctrl:
2546
+ continue # 只需要补 usage 事件,状态与控制权都没变
2320
2547
  payload = json.dumps(_state_payload(cid, ver), ensure_ascii=False)
2321
2548
  self.wfile.write(("data: " + payload + "\n\n").encode("utf-8"))
2322
2549
  self.wfile.flush()
@@ -2558,9 +2785,9 @@ def _state_payload(client_id="", ver=None):
2558
2785
  return {
2559
2786
  "v": ver,
2560
2787
  "agents": agents,
2561
- "tasks": store.list_tasks(30, archived=False),
2788
+ "tasks": store.list_tasks(archived=False),
2562
2789
  # 归档任务默认隐藏;目标/上下文可能很大,只有查看或恢复时再按需读取。
2563
- "archived_tasks": [_state_task_summary(t) for t in store.list_tasks(30, archived=True)],
2790
+ "archived_tasks": [_state_task_summary(t) for t in store.list_tasks(archived=True)],
2564
2791
  # 每个任务的最近一次运行(不受 runs 窗口限制):侧栏靠它展示各任务真实近况
2565
2792
  "task_latest": {tid: _state_run_summary(run) for tid, run in latest.items()},
2566
2793
  # 每个任务的运行次数/步骤总数(全量):侧栏「查看全部」的计数来源
@@ -2751,6 +2978,43 @@ def main():
2751
2978
  from core import usage
2752
2979
  store.set_run_estimator(usage.estimate)
2753
2980
  store.set_run_auditor(usage.audit_run) # 终态把预估与真实用量逐条对账
2981
+ flows.set_runs_provider(store.list_runs) # 版本战绩对账用 run 遍历器(防 flows→store 静态环)
2982
+ from core import evalbench as _evalbench
2983
+ automation.register_tick_hook(_evalbench.fire_due) # 评测台定时回归挂自动化 tick
2984
+ from core import notify as _notify
2985
+ usage_ledger.set_alert_push(_notify.push_text) # 花费预警推送注入(防 usage→notify 静态环)
2986
+ from core import mcp_client as _mcp
2987
+ def _mcp_servers_text():
2988
+ """settings 清单 + 已启用插件的 MCP 服务器合成(plugin 名隔离防撞名)。"""
2989
+ import json as _json
2990
+ try:
2991
+ raw = str(settings.load().get("mcp_servers") or "")
2992
+ configured = _json.loads(raw) if raw.strip() else []
2993
+ except Exception:
2994
+ return raw or ""
2995
+ if not isinstance(configured, list):
2996
+ return raw or ""
2997
+ try:
2998
+ from core import plugins as _plugins
2999
+ plugin_servers = _plugins.active_mcp_servers()
3000
+ except Exception:
3001
+ plugin_servers = [] # 坏插件不能连累用户显式配置的服务器
3002
+ import hashlib as _hashlib
3003
+ import re as _re
3004
+ for item in plugin_servers or []:
3005
+ if not isinstance(item, dict):
3006
+ continue
3007
+ plugin_id = str(item.pop("plugin_id", "plugin"))
3008
+ server_name = str(item.get("name") or "server")
3009
+ digest = _hashlib.sha256(
3010
+ (plugin_id + ":" + server_name).encode("utf-8")).hexdigest()[:6]
3011
+ safe = _re.sub(r"[^a-z0-9_-]", "-", plugin_id.lower())
3012
+ item["name"] = ("plg_" + safe[:11] + "_" + digest)[:24]
3013
+ if not any(x.get("name") == item["name"] for x in configured
3014
+ if isinstance(x, dict)):
3015
+ configured.append(item)
3016
+ return _json.dumps(configured, ensure_ascii=False)
3017
+ _mcp.set_settings_text(_mcp_servers_text) # MCP 清单注入(防 mcp_client→settings 边)
2754
3018
  _step("正在回填用量台账…")
2755
3019
  n_bf = usage.backfill_from_runs() # 历史运行 token 回填台账(幂等,仅补缺失步骤)
2756
3020
  if n_bf:
@@ -0,0 +1,175 @@
1
+ # -*- coding: utf-8 -*-
2
+ """CodeBee 的 MCP 服务器形态:其他 AI 客户端(Claude Code / ZCode / Cursor)
3
+ 把这里当工具服务器挂载,即可直接创建任务、查状态、看用量与评测榜单——
4
+ CodeBee 从编排工具变成可以被别的智能体调度的生态节点。
5
+
6
+ stdio JSON-RPC 2.0(按行分帧,与 core/mcp_client 同一传输约定)。启动:
7
+ python -X utf8 <CodeBee目录>/app/mcp_server.py
8
+ 客户端配置示例(Claude Code / 兼容客户端):
9
+ {"mcpServers": {"codebee": {"command": "python",
10
+ "args": ["-X", "utf8", "<CodeBee目录>/app/mcp_server.py"]}}}
11
+ 仅本机 stdio,无网络监听,凭据不经过此通道;create_task 会真实调度智能体
12
+ 执行(与界面上建任务同一条链)。
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import sys
18
+
19
+ PROTOCOL_VERSION = "2024-11-05"
20
+ SERVER_INFO = {"name": "codebee", "version": "1.0"}
21
+
22
+ TOOLS = [
23
+ {"name": "create_task", "description": "创建并启动一个 CodeBee 编排任务(真实调度智能体执行,与界面建任务同链)。返回 task_id 与 run_id。",
24
+ "inputSchema": {"type": "object", "properties": {
25
+ "goal": {"type": "string", "description": "任务目标(一句话说清要做什么)"},
26
+ "type": {"type": "string", "description": "任务类型 id(如 code/novel/doc/direct),默认 doc"},
27
+ "workdir": {"type": "string", "description": "工作目录绝对路径;留空用 CodeBee 默认保存路径"},
28
+ "title": {"type": "string", "description": "任务标题;留空取目标前 30 字"}},
29
+ "required": ["goal"]}},
30
+ {"name": "get_status", "description": "查任务与最近一次运行的状态:进度、当前步骤、评分、错误。",
31
+ "inputSchema": {"type": "object", "properties": {
32
+ "task_id": {"type": "string", "description": "任务 ID"}}, "required": ["task_id"]}},
33
+ {"name": "list_recent", "description": "列出最近的运行(id/标题/状态/时间)。",
34
+ "inputSchema": {"type": "object", "properties": {
35
+ "limit": {"type": "integer", "description": "条数,默认 10,最大 30"}}}},
36
+ {"name": "usage_summary", "description": "用量台账汇总:今日/本月花费(¥)与 token 总量。",
37
+ "inputSchema": {"type": "object", "properties": {}}},
38
+ {"name": "bench_leaderboard", "description": "模型评测基准台能力榜(综合分 Top N)。",
39
+ "inputSchema": {"type": "object", "properties": {
40
+ "top": {"type": "integer", "description": "取前 N,默认 5"}}}},
41
+ ]
42
+
43
+
44
+ def _enqueue(job):
45
+ """thin wrapper:便于单测 mock(真链会拉起 worker 执行)。"""
46
+ from core import jobs
47
+ jobs.enqueue(job)
48
+
49
+
50
+ def _tool_create_task(args):
51
+ from core import settings as settings_mod, store
52
+ goal = str(args.get("goal") or "").strip()
53
+ if not goal:
54
+ return {"isError": True, "content": "goal 不能为空"}
55
+ payload = {"type": str(args.get("type") or "doc").strip()[:40] or "doc",
56
+ "title": (str(args.get("title") or "").strip() or goal[:30])[:40],
57
+ "goal": goal[:4000],
58
+ "workdir": str(args.get("workdir") or "").strip()
59
+ or settings_mod.default_workdir()}
60
+ task = store.create_task(payload)
61
+ run = store.create_run("orchestration", task["title"], task_id=task["id"])
62
+ store.update_task_status(task["id"], "queued")
63
+ _enqueue({"kind": "orchestration", "run_id": run["id"], "task_id": task["id"]})
64
+ return {"content": "已创建并排队:task_id=%s run_id=%s(可用 get_status 查进度)"
65
+ % (task["id"], run["id"])}
66
+
67
+
68
+ def _tool_get_status(args):
69
+ from core import store
70
+ task = store.get_task(str(args.get("task_id") or "").strip())
71
+ if not task:
72
+ return {"isError": True, "content": "任务不存在"}
73
+ lines = ["任务:%s(%s)" % (task.get("title"), task.get("status"))]
74
+ run = store.latest_run_by_task().get(task["id"])
75
+ if run:
76
+ lines.append("最近运行:%s(%s)" % (run.get("id"), run.get("status")))
77
+ steps = [s for s in (run.get("steps") or []) if isinstance(s, dict)]
78
+ if steps:
79
+ cur = steps[-1]
80
+ lines.append("步骤 %d/%d:%s(%s)" % (len(steps), run.get("total_steps") or len(steps),
81
+ cur.get("title"), cur.get("status")))
82
+ v = run.get("verdict") or {}
83
+ if v.get("overall") is not None:
84
+ lines.append("评分 %.1f(%s)" % (float(v["overall"]),
85
+ "达标" if v.get("publishable") else "未达标"))
86
+ if run.get("error"):
87
+ lines.append("错误:%s" % str(run["error"])[:200])
88
+ if task.get("git_state") == "isolated":
89
+ lines.append("⚠ 任务分支待裁决(合并/丢弃)")
90
+ return {"content": "\n".join(lines)}
91
+
92
+
93
+ def _tool_list_recent(args):
94
+ from core import store
95
+ try:
96
+ limit = max(1, min(30, int(args.get("limit") or 10)))
97
+ except (TypeError, ValueError):
98
+ limit = 10
99
+ rows = [{"id": r.get("id"), "title": r.get("title"), "status": r.get("status"),
100
+ "ts": r.get("started_at") or ""} for r in store.list_runs(limit=limit)]
101
+ return {"content": json.dumps(rows, ensure_ascii=False, indent=1)}
102
+
103
+
104
+ def _tool_usage_summary(args):
105
+ from core import usage
106
+ today, month = usage.cost_snapshot()
107
+ tokens = sum(int(r.get("total") or 0) for r in usage._iter_records(31))
108
+ return {"content": "今日花费 ¥%.2f\n本月花费 ¥%.2f\n近 31 天 tokens:%d(未标定单价的调用不计钱)"
109
+ % (today, month, tokens)}
110
+
111
+
112
+ def _tool_bench(args):
113
+ from core import evalbench
114
+ try:
115
+ top = max(1, min(10, int(args.get("top") or 5)))
116
+ except (TypeError, ValueError):
117
+ top = 5
118
+ board = evalbench.state().get("leaderboard") or []
119
+ rows = [{"rank": r["rank"], "model": r["model"], "overall": r["overall"],
120
+ "provider": r.get("provider_name")} for r in board[:top]]
121
+ return {"content": json.dumps(rows, ensure_ascii=False, indent=1)}
122
+
123
+
124
+ _TOOL_IMPL = {"create_task": _tool_create_task, "get_status": _tool_get_status,
125
+ "list_recent": _tool_list_recent, "usage_summary": _tool_usage_summary,
126
+ "bench_leaderboard": _tool_bench}
127
+
128
+
129
+ def handle_request(msg):
130
+ """处理一条客户端请求,返回响应 dict(通知返回 None)。"""
131
+ if not isinstance(msg, dict) or msg.get("method") is None:
132
+ return None
133
+ rid = msg.get("id")
134
+ method = str(msg["method"])
135
+ if method == "initialize":
136
+ return {"jsonrpc": "2.0", "id": rid, "result": {
137
+ "protocolVersion": PROTOCOL_VERSION, "capabilities": {"tools": {}},
138
+ "serverInfo": SERVER_INFO}}
139
+ if method.startswith("notifications/"):
140
+ return None
141
+ if method == "tools/list":
142
+ return {"jsonrpc": "2.0", "id": rid, "result": {"tools": TOOLS}}
143
+ if method == "tools/call":
144
+ params = msg.get("params") or {}
145
+ name = str(params.get("name") or "")
146
+ fn = _TOOL_IMPL.get(name)
147
+ if fn is None:
148
+ return {"jsonrpc": "2.0", "id": rid, "result": {
149
+ "isError": True, "content": "未知工具: %s" % name}}
150
+ try:
151
+ out = fn(params.get("arguments") or {})
152
+ except Exception as e:
153
+ out = {"isError": True, "content": "工具执行失败: %s" % e}
154
+ return {"jsonrpc": "2.0", "id": rid, "result": out}
155
+ return {"jsonrpc": "2.0", "id": rid,
156
+ "error": {"code": -32601, "message": "no such method: %s" % method}}
157
+
158
+
159
+ def main():
160
+ for raw in sys.stdin:
161
+ line = raw.strip()
162
+ if not line.startswith("{"):
163
+ continue
164
+ try:
165
+ msg = json.loads(line)
166
+ except Exception:
167
+ continue
168
+ resp = handle_request(msg)
169
+ if resp is not None:
170
+ sys.stdout.write(json.dumps(resp, ensure_ascii=False) + "\n")
171
+ sys.stdout.flush()
172
+
173
+
174
+ if __name__ == "__main__":
175
+ main()