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.
- package/CHANGELOG.md +37 -0
- package/README.md +4 -3
- package/app/browser-final-1440.png +0 -0
- package/app/browser-final-390.png +0 -0
- package/app/browser-google-fallback.png +0 -0
- package/app/browser-google-test.png +0 -0
- package/app/browser-workbench-1440.png +0 -0
- package/app/browser-workbench-390.png +0 -0
- package/app/browser-workspace-google.png +0 -0
- package/app/browser-workspace-smoke.png +0 -0
- package/app/browser-workspace.png +0 -0
- package/app/core/audit.py +30 -0
- package/app/core/automation.py +107 -12
- package/app/core/benchstore.py +95 -0
- package/app/core/bookmeta.py +9 -4
- package/app/core/builtin_agent.py +472 -33
- package/app/core/catalog.py +7 -1
- package/app/core/dispatch.py +20 -3
- package/app/core/error_codes.py +11 -2
- package/app/core/evalbench.py +720 -0
- package/app/core/flows.py +228 -3
- package/app/core/jobs.py +22 -1
- package/app/core/manager.py +21 -0
- package/app/core/market_remote.py +10 -0
- package/app/core/mcp_client.py +287 -0
- package/app/core/modelhub.py +185 -2
- package/app/core/notify.py +118 -23
- package/app/core/novel_quality.py +189 -0
- package/app/core/paths.py +4 -0
- package/app/core/pipeline.py +426 -28
- package/app/core/planner.py +243 -22
- package/app/core/plugins.py +525 -0
- package/app/core/publish/browser.py +129 -13
- package/app/core/publish/fanqie.py +6 -0
- package/app/core/publish/flow.py +154 -5
- package/app/core/publish/flows-fanqie-calibrated.json +11 -7
- package/app/core/publish/ledger.py +29 -3
- package/app/core/publish/manager.py +134 -2
- package/app/core/publish/qimao.py +5 -0
- package/app/core/remotesync.py +121 -0
- package/app/core/runner.py +80 -5
- package/app/core/settings.py +39 -2
- package/app/core/settings_schema.py +8 -0
- package/app/core/store.py +30 -8
- package/app/core/usage.py +187 -14
- package/app/core/zentao.py +153 -4
- package/app/current-browser.png +0 -0
- package/app/main.py +279 -15
- package/app/mcp_server.py +175 -0
- package/app/pet.py +82 -21
- package/app/plugins/code-review-helper/.codex-plugin/plugin.json +18 -0
- package/app/plugins/code-review-helper/skills/review.md +16 -0
- package/app/ui/app.js +1380 -73
- package/app/ui/i18n.js +248 -6
- package/app/ui/index.html +277 -13
- package/app/ui/style.css +786 -0
- 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
|
-
|
|
518
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
|
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(
|
|
2788
|
+
"tasks": store.list_tasks(archived=False),
|
|
2562
2789
|
# 归档任务默认隐藏;目标/上下文可能很大,只有查看或恢复时再按需读取。
|
|
2563
|
-
"archived_tasks": [_state_task_summary(t) for t in store.list_tasks(
|
|
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()
|