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/core/flows.py
CHANGED
|
@@ -9,10 +9,12 @@
|
|
|
9
9
|
"""
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
+
import base64
|
|
12
13
|
import hashlib
|
|
13
14
|
import json
|
|
14
15
|
import re
|
|
15
16
|
import threading
|
|
17
|
+
import time
|
|
16
18
|
|
|
17
19
|
from . import paths, volumes
|
|
18
20
|
|
|
@@ -50,7 +52,7 @@ BUILTIN_FLOWS = [
|
|
|
50
52
|
"threshold": 7.0, "rounds": 2,
|
|
51
53
|
"serial": {"chapters": 8, "words_per_chapter": 2500},
|
|
52
54
|
"goal_hint": "题材/受众/卖点 + 总字数(例:2 万字都市女频,可签约平台)",
|
|
53
|
-
"note": "大纲 → 逐章起草 →
|
|
55
|
+
"note": "大纲 → 逐章起草 → 签约质量门禁(开篇/推进/文风/情感/节奏)→ 全局一致性评审 → 合并(可断点续跑)"},
|
|
54
56
|
{"id": "article", "name": "自媒体文章", "icon": "i-news", "engine": "review", "builtin": True,
|
|
55
57
|
"manuscript": "article.md",
|
|
56
58
|
"rubric": ["选题与标题", "开头吸引力", "结构节奏", "平台适配", "传播性"],
|
|
@@ -296,11 +298,13 @@ def flow_drift(task):
|
|
|
296
298
|
return {"flow": cur.get("id") or "", "pinned": pinned, "current": current}
|
|
297
299
|
|
|
298
300
|
|
|
299
|
-
def upsert_flow(payload):
|
|
301
|
+
def upsert_flow(payload, source="manual"):
|
|
300
302
|
"""新增/更新流程。返回 (flow, 错误)。
|
|
301
303
|
|
|
302
304
|
- 预置流程 id:写入 overrides(可编辑、可恢复默认);
|
|
303
|
-
- 新 id:创建自定义流程——**只需 name + engine
|
|
305
|
+
- 新 id:创建自定义流程——**只需 name + engine**,其余字段留空走引擎默认;
|
|
306
|
+
- source:覆盖成因,进版本历史(manual=手动改 / import=分享码导入 /
|
|
307
|
+
restore=恢复操作顶下旧版);语义与名字都相同则不产生历史噪音。
|
|
304
308
|
"""
|
|
305
309
|
fid = str(payload.get("id") or "").strip()
|
|
306
310
|
name = str(payload.get("name") or "").strip()
|
|
@@ -319,6 +323,7 @@ def upsert_flow(payload):
|
|
|
319
323
|
ov.pop("id", None)
|
|
320
324
|
ov["engine"] = base["engine"] # 引擎不可改(避免语义错乱)
|
|
321
325
|
merged = _apply_overrides(base, ov)
|
|
326
|
+
_push_history(fid, merged, source)
|
|
322
327
|
with _LOCK:
|
|
323
328
|
data = _read()
|
|
324
329
|
ovs = data.get("overrides") if isinstance(data.get("overrides"), dict) else {}
|
|
@@ -362,6 +367,7 @@ def upsert_flow(payload):
|
|
|
362
367
|
elif engine == "code":
|
|
363
368
|
flow["verify_command"] = str(payload.get("verify_command") or "")[:200]
|
|
364
369
|
# direct:无流程参数(目标+附件即全部输入)
|
|
370
|
+
_push_history(fid, flow, source)
|
|
365
371
|
with _LOCK:
|
|
366
372
|
data = _read()
|
|
367
373
|
flows = data.get("flows") if isinstance(data.get("flows"), list) else []
|
|
@@ -399,3 +405,222 @@ def reset_flow(flow_id):
|
|
|
399
405
|
data["overrides"] = ovs
|
|
400
406
|
_write(data)
|
|
401
407
|
return None
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
# ---- 流程分享码(参考 Dify 模板中心 / n8n workflow 模板的可分发思路)----
|
|
411
|
+
# 把「流程」变成可分发资产:导出为一段可粘贴的分享码,对方在任务类型管理里
|
|
412
|
+
# 粘贴导入即装。码体 = 前缀 + urlsafe base64(JSON),无签名——分享码本来就
|
|
413
|
+
# 是明文可读的信封,导入时过 upsert_flow 全套规范化,坏数据进不来。
|
|
414
|
+
|
|
415
|
+
_SHARE_PREFIX = "CBFLOW1."
|
|
416
|
+
_SHARE_KIND = "codebee-flow"
|
|
417
|
+
# 导出字段白名单:与 _EDITABLE 对齐 + 身份/展示字段。builtin 标记不导出
|
|
418
|
+
# (对端是否预置由对端的 BUILTIN_FLOWS 决定,不能跨机携带)。
|
|
419
|
+
_SHARE_FIELDS = ("id", "name", "engine", "icon", "goal_hint", "note",
|
|
420
|
+
"manuscript", "rubric", "threshold", "rounds", "best_of",
|
|
421
|
+
"serial", "draft_prompt", "critique_prompt", "verify_command")
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def export_flow_code(flow_id):
|
|
425
|
+
"""导出流程为分享码。返回 (code, 错误)。"""
|
|
426
|
+
f = get_flow(str(flow_id or "").strip())
|
|
427
|
+
if not f:
|
|
428
|
+
return None, "流程不存在"
|
|
429
|
+
payload = {"kind": _SHARE_KIND, "v": 1,
|
|
430
|
+
"flow": {k: f.get(k) for k in _SHARE_FIELDS if k in f}}
|
|
431
|
+
blob = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
432
|
+
return _SHARE_PREFIX + base64.urlsafe_b64encode(blob.encode("utf-8")).decode("ascii"), None
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _decode_share_code(code):
|
|
436
|
+
"""分享码 → (flow_in, 错误)。解码与校验共用(preview / import 两步走)。"""
|
|
437
|
+
if not isinstance(code, str) or not code.strip().startswith(_SHARE_PREFIX):
|
|
438
|
+
return None, "不是有效的 CodeBee 流程分享码(应以 %s 开头)" % _SHARE_PREFIX
|
|
439
|
+
raw = code.strip()[len(_SHARE_PREFIX):].strip()
|
|
440
|
+
try:
|
|
441
|
+
blob = base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4))
|
|
442
|
+
payload = json.loads(blob.decode("utf-8"))
|
|
443
|
+
except Exception:
|
|
444
|
+
return None, "分享码解析失败(可能被截断或篡改)"
|
|
445
|
+
if not isinstance(payload, dict) or payload.get("kind") != _SHARE_KIND:
|
|
446
|
+
return None, "分享码内容不是 CodeBee 流程"
|
|
447
|
+
flow_in = payload.get("flow")
|
|
448
|
+
if not isinstance(flow_in, dict):
|
|
449
|
+
return None, "分享码缺少流程定义"
|
|
450
|
+
if not str(flow_in.get("id") or "").strip():
|
|
451
|
+
return None, "分享码缺少流程 ID"
|
|
452
|
+
return flow_in, None
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def preview_flow_code(code):
|
|
456
|
+
"""分享码两步导入的第一步:解析并预览,不落盘。
|
|
457
|
+
|
|
458
|
+
返回 (预览, 错误):预览带流程定义摘要 + 与现有同 ID 流程的冲突提示。"""
|
|
459
|
+
flow_in, err = _decode_share_code(code)
|
|
460
|
+
if err:
|
|
461
|
+
return None, err
|
|
462
|
+
fid = str(flow_in.get("id") or "").strip()
|
|
463
|
+
existed = get_flow(fid)
|
|
464
|
+
preview = {"id": fid,
|
|
465
|
+
"name": str(flow_in.get("name") or "")[:20],
|
|
466
|
+
"engine": str(flow_in.get("engine") or "review"),
|
|
467
|
+
"rubric": flow_in.get("rubric") if isinstance(flow_in.get("rubric"), list) else [],
|
|
468
|
+
"threshold": flow_in.get("threshold"),
|
|
469
|
+
"rounds": flow_in.get("rounds"),
|
|
470
|
+
"has_prompts": bool(str(flow_in.get("draft_prompt") or "").strip()
|
|
471
|
+
or str(flow_in.get("critique_prompt") or "").strip()),
|
|
472
|
+
"serial": bool(isinstance(flow_in.get("serial"), dict)
|
|
473
|
+
and flow_in.get("serial", {}).get("chapters")),
|
|
474
|
+
"exists": bool(existed),
|
|
475
|
+
"existing_name": (existed or {}).get("name") or "",
|
|
476
|
+
"builtin": bool(existed and existed.get("builtin"))}
|
|
477
|
+
return preview, None
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def import_flow_code(code):
|
|
481
|
+
"""导入分享码。返回 (result, 错误);result = {id, name, status, builtin}。
|
|
482
|
+
|
|
483
|
+
status:created=新建自定义流程;updated=覆盖已有定义(预置流程写
|
|
484
|
+
overrides,可「恢复默认」回滚);noop=内容与现状完全一致。UI 走
|
|
485
|
+
preview_flow_code 预览确认后再进这里。"""
|
|
486
|
+
flow_in, err = _decode_share_code(code)
|
|
487
|
+
if err:
|
|
488
|
+
return None, err
|
|
489
|
+
fid = str(flow_in.get("id") or "").strip()
|
|
490
|
+
existed = get_flow(fid)
|
|
491
|
+
existed_digest = flow_digest(existed) if existed else ""
|
|
492
|
+
flow, err = upsert_flow(flow_in, source="import")
|
|
493
|
+
if err:
|
|
494
|
+
return None, err
|
|
495
|
+
if existed and flow_digest(flow) == existed_digest \
|
|
496
|
+
and flow.get("name") == existed.get("name"):
|
|
497
|
+
status = "noop"
|
|
498
|
+
else:
|
|
499
|
+
status = "updated" if existed else "created"
|
|
500
|
+
return {"id": fid, "name": flow.get("name"), "status": status,
|
|
501
|
+
"builtin": bool(existed and existed.get("builtin"))}, None
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
# ---- 版本历史(Langfuse prompt management 借鉴):提示词/参数改动可回滚 ----
|
|
505
|
+
# 覆盖前的当前版自动快照进 data/flows_history.json(每流程 ≤_HISTORY_MAX 条),
|
|
506
|
+
# 恢复=把历史版重新 upsert 回来;runs 已按 flow_revision(digest 前 16)对账,
|
|
507
|
+
# 每版顺手给出实测战绩——「哪版提示词好用」不再靠感觉。
|
|
508
|
+
|
|
509
|
+
_HISTORY_MAX = 10 # 每流程保留的历史版本数(新的顶旧的)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _history_read():
|
|
513
|
+
try:
|
|
514
|
+
data = json.loads((paths.DATA_DIR / "flows_history.json").read_text(encoding="utf-8"))
|
|
515
|
+
return data if isinstance(data, dict) else {}
|
|
516
|
+
except Exception:
|
|
517
|
+
return {}
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _history_write(data):
|
|
521
|
+
paths.DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
522
|
+
tmp = paths.DATA_DIR / "flows_history.json.tmp"
|
|
523
|
+
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
524
|
+
tmp.replace(paths.DATA_DIR / "flows_history.json")
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _push_history(fid, new_flow, source):
|
|
528
|
+
"""把「即将被覆盖的当前版」存进历史;语义与名字都相同则跳过(不产噪音)。"""
|
|
529
|
+
try:
|
|
530
|
+
cur = get_flow(fid)
|
|
531
|
+
if not cur:
|
|
532
|
+
return # 首次创建没有旧版可存
|
|
533
|
+
if flow_digest(cur) == flow_digest(new_flow) \
|
|
534
|
+
and cur.get("name") == new_flow.get("name"):
|
|
535
|
+
return
|
|
536
|
+
with _LOCK:
|
|
537
|
+
data = _history_read()
|
|
538
|
+
versions = data.get(fid) if isinstance(data.get(fid), list) else []
|
|
539
|
+
versions.insert(0, {"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
540
|
+
"source": str(source or "manual")[:16],
|
|
541
|
+
"flow": {k: cur.get(k) for k in _SHARE_FIELDS if k in cur}})
|
|
542
|
+
data[fid] = versions[:_HISTORY_MAX]
|
|
543
|
+
_history_write(data)
|
|
544
|
+
except Exception:
|
|
545
|
+
pass # 历史是增强,永远不挡保存
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
_RUNS_PROVIDER = None # main.py 启动注入 store.list_runs;flows(L0) 不反向
|
|
549
|
+
# 依赖 store(L1)——静态图不成环(同 set_run_estimator 模式)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def set_runs_provider(fn):
|
|
553
|
+
"""注入 run 遍历器(应用入口传 store.list_runs);未注入时版本无战绩。"""
|
|
554
|
+
global _RUNS_PROVIDER
|
|
555
|
+
_RUNS_PROVIDER = fn if callable(fn) else None
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _flow_stats(digest16):
|
|
559
|
+
"""该流程修订的实测战绩:任务数/通过率/均分(按 run 钉的 flow_revision 对账)。
|
|
560
|
+
|
|
561
|
+
均分分母只算有评分的 run;无记录返回 None。"""
|
|
562
|
+
try:
|
|
563
|
+
if not _RUNS_PROVIDER:
|
|
564
|
+
return None
|
|
565
|
+
runs = wins = scored_n = 0
|
|
566
|
+
total = 0.0
|
|
567
|
+
for run in (_RUNS_PROVIDER(limit=None) or []):
|
|
568
|
+
if not isinstance(run, dict):
|
|
569
|
+
continue
|
|
570
|
+
rev = str(((run.get("execution_snapshot") or {}).get("flow_revision")) or "")
|
|
571
|
+
if rev[:16] != digest16:
|
|
572
|
+
continue
|
|
573
|
+
runs += 1
|
|
574
|
+
v = run.get("verdict") or {}
|
|
575
|
+
if v.get("publishable") is True:
|
|
576
|
+
wins += 1
|
|
577
|
+
if isinstance(v.get("overall"), (int, float)):
|
|
578
|
+
total += float(v["overall"])
|
|
579
|
+
scored_n += 1
|
|
580
|
+
if not runs:
|
|
581
|
+
return None
|
|
582
|
+
return {"runs": runs, "pass_rate": round(wins / runs, 2),
|
|
583
|
+
"avg_overall": round(total / scored_n, 1) if scored_n else None}
|
|
584
|
+
except Exception:
|
|
585
|
+
return None
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def flow_versions(flow_id):
|
|
589
|
+
"""给 UI 的版本清单:当前版在前,历史版新→旧;每版带 digest 与实测战绩。"""
|
|
590
|
+
fid = str(flow_id or "").strip()
|
|
591
|
+
out = []
|
|
592
|
+
cur = get_flow(fid)
|
|
593
|
+
if cur:
|
|
594
|
+
d = flow_digest(cur)[:16]
|
|
595
|
+
out.append({"current": True, "ts": "", "source": "current", "name": cur.get("name"),
|
|
596
|
+
"digest": d, "stats": _flow_stats(d)})
|
|
597
|
+
for snap in _history_read().get(fid) or []:
|
|
598
|
+
d = flow_digest(snap.get("flow") or {})[:16]
|
|
599
|
+
out.append({"current": False, "ts": str(snap.get("ts") or ""),
|
|
600
|
+
"source": str(snap.get("source") or "manual"),
|
|
601
|
+
"name": (snap.get("flow") or {}).get("name"),
|
|
602
|
+
"digest": d, "stats": _flow_stats(d)})
|
|
603
|
+
return out
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def restore_flow_version(flow_id, ts):
|
|
607
|
+
"""恢复历史版本。返回 (flow, 错误)。
|
|
608
|
+
|
|
609
|
+
恢复动作本身也会把「被顶下来的当前版」存进历史(source=restore),
|
|
610
|
+
恢复错了还能再恢复回去;目标条目先从历史中消费掉再做 upsert——
|
|
611
|
+
ts 是秒级精度,顺序反了同秒快照会被误删。"""
|
|
612
|
+
fid = str(flow_id or "").strip()
|
|
613
|
+
ts = str(ts or "")
|
|
614
|
+
with _LOCK:
|
|
615
|
+
data = _history_read()
|
|
616
|
+
versions = data.get(fid) if isinstance(data.get(fid), list) else []
|
|
617
|
+
snap = next((s for s in versions if s.get("ts") == ts), None)
|
|
618
|
+
if snap:
|
|
619
|
+
data[fid] = [s for s in versions if s.get("ts") != ts]
|
|
620
|
+
_history_write(data)
|
|
621
|
+
if not snap:
|
|
622
|
+
return None, "历史版本不存在(可能已被消费)"
|
|
623
|
+
flow, err = upsert_flow(dict(snap.get("flow") or {}), source="restore")
|
|
624
|
+
if err:
|
|
625
|
+
return None, err
|
|
626
|
+
return flow, None
|
package/app/core/jobs.py
CHANGED
|
@@ -538,7 +538,11 @@ def cancel_event_for(run_id):
|
|
|
538
538
|
return ev
|
|
539
539
|
|
|
540
540
|
|
|
541
|
-
|
|
541
|
+
# 连载任务自动续跑上限(超时/中断后自动接着写,无需人工)。3 是 2026-09-25
|
|
542
|
+
# 戍边骑奴 9-20·续2 案的上限:20 章连载 3 轮烧完次数后卡死在「任务总时限已到」
|
|
543
|
+
# 等人工,而每轮都在稳定进章——止损已由零进度两轮同因止损兜底,上限只作
|
|
544
|
+
# 最后保险(真烧钱还有日/月花费硬顶闸),10 轮足够 20 章慢链天跑完。
|
|
545
|
+
AUTO_RESUME_MAX = 10
|
|
542
546
|
AUTO_RESUME_DELAY_S = 300 # 自动续跑延迟入队秒数:网关限流/欠费窗口通常分钟级,
|
|
543
547
|
# 立即重排会撞在同一堵墙上把续跑次数烧光(2026-09-17 七猫实测)
|
|
544
548
|
|
|
@@ -589,6 +593,8 @@ def _maybe_auto_resume(run_id):
|
|
|
589
593
|
终态写明死因,不再烧剩余的退避次数。超时例外:错误签名恒为同一句
|
|
590
594
|
「任务总时限已到」,但每轮都在落章——本轮比上轮多过审了章就不算
|
|
591
595
|
「同因无效重试」,续跑交给次数上限兜底;两轮一章未进则照常止损。
|
|
596
|
+
上限烧完时(超时且有进章)把已过审进度与断点续跑指引写进错误行,
|
|
597
|
+
别让执行结果卡读起来像白跑。
|
|
592
598
|
"""
|
|
593
599
|
try:
|
|
594
600
|
from . import store
|
|
@@ -601,6 +607,21 @@ def _maybe_auto_resume(run_id):
|
|
|
601
607
|
if run.get("cancelled_by_user") or run.get("status") == "cancelled":
|
|
602
608
|
return False # 用户主动取消的运行绝不自动续跑
|
|
603
609
|
if int(run.get("auto_resumes") or 0) >= AUTO_RESUME_MAX:
|
|
610
|
+
# 上限烧完不再续跑。但「任务总时限已到」+本轮有进章时,错误行必须
|
|
611
|
+
# 带上进度与续跑方式——不然执行结果卡只写「任务总时限已到」,读起来
|
|
612
|
+
# 像白跑(2026-09-25 续2 案:用户看到 64/64 步+超时以为续跑没生效)。
|
|
613
|
+
if run.get("status") == "timeout" and _serial_progress(run) > 0:
|
|
614
|
+
try:
|
|
615
|
+
total = int((task.get("serial") or {}).get("chapters") or 0)
|
|
616
|
+
where = ("/" + str(total) + " 章") if total > 0 else " 章"
|
|
617
|
+
store.update_run(
|
|
618
|
+
run_id,
|
|
619
|
+
error=(run.get("error") or "")
|
|
620
|
+
+ "|自动续跑次数已用完,已过审 "
|
|
621
|
+
+ str(_serial_progress(run)) + where
|
|
622
|
+
+ ";点重试将从断点继续,已过审章不会重写")
|
|
623
|
+
except Exception:
|
|
624
|
+
pass
|
|
604
625
|
return False
|
|
605
626
|
prev_id = run.get("auto_resumed_from")
|
|
606
627
|
if prev_id:
|
package/app/core/manager.py
CHANGED
|
@@ -2089,6 +2089,27 @@ def check_updates_async(force=False):
|
|
|
2089
2089
|
return len(entries)
|
|
2090
2090
|
|
|
2091
2091
|
|
|
2092
|
+
def upgrade_all_targets():
|
|
2093
|
+
"""「一键升级全部」的目标条目,按目录顺序返回。
|
|
2094
|
+
|
|
2095
|
+
只收同时满足:已安装 + 配了 upgrade 命令(op=upgrade 硬依赖该字段,
|
|
2096
|
+
没配的跟单条一样不出「升级」按钮)+ 更新检查不是「已是最新」。
|
|
2097
|
+
检查结果为 unknown(还没查)/ unsupported(渠道查不了)的也升级——
|
|
2098
|
+
npm/pip 的升级命令幂等,宁可空跑一次也不漏升;唯一跳过「已是最新」,
|
|
2099
|
+
防无谓重装(重装易撞 EBUSY 文件锁)。
|
|
2100
|
+
"""
|
|
2101
|
+
detected = detect_all()
|
|
2102
|
+
out = []
|
|
2103
|
+
for e in catalog.load():
|
|
2104
|
+
det = (detected or {}).get(e["id"]) or {}
|
|
2105
|
+
if not det.get("installed") or not e.get("upgrade"):
|
|
2106
|
+
continue
|
|
2107
|
+
if update_info(e).get("status") == "current":
|
|
2108
|
+
continue
|
|
2109
|
+
out.append(e)
|
|
2110
|
+
return out
|
|
2111
|
+
|
|
2112
|
+
|
|
2092
2113
|
def catalog_view():
|
|
2093
2114
|
"""管理页数据:catalog + 检测 + 版本 + 模型 + 编排启用状态。"""
|
|
2094
2115
|
from . import registry
|
|
@@ -1039,6 +1039,15 @@ def preview_remote(entry_id):
|
|
|
1039
1039
|
_PREVIEW_CACHE[token] = {"entry_id": entry_id, "files": files,
|
|
1040
1040
|
"stripped": stripped, "ts": time.time()}
|
|
1041
1041
|
file_list = sorted(files.items(), key=lambda kv: kv[0])
|
|
1042
|
+
# 内容安检(ClawHavoc 投毒案借鉴):对全部技能文本跑危险模式扫描,
|
|
1043
|
+
# 风险随预览返回——UI 高风险红字确认;发现列表给行号可定位。
|
|
1044
|
+
try:
|
|
1045
|
+
from . import skill_scan
|
|
1046
|
+
risk_findings = skill_scan.scan_text(
|
|
1047
|
+
"\n".join(str(v) for v in files.values()))
|
|
1048
|
+
risk_label = skill_scan.risk_label(risk_findings)
|
|
1049
|
+
except Exception:
|
|
1050
|
+
risk_findings, risk_label = [], ""
|
|
1042
1051
|
return {
|
|
1043
1052
|
"token": token, "id": entry_id, "title": entry["title"],
|
|
1044
1053
|
"source": entry["source_name"], "version": entry["version"],
|
|
@@ -1048,6 +1057,7 @@ def preview_remote(entry_id):
|
|
|
1048
1057
|
"total_chars": sum(len(v) for v in files.values()),
|
|
1049
1058
|
"file_list": [{"path": k, "chars": len(v)} for k, v in file_list[:60]],
|
|
1050
1059
|
"stripped": sorted(stripped)[:40], "stripped_total": len(stripped),
|
|
1060
|
+
"risk_label": risk_label, "risk_findings": risk_findings[:8],
|
|
1051
1061
|
"expires_in": _PREVIEW_TTL,
|
|
1052
1062
|
}, None
|
|
1053
1063
|
except ValueError as e:
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""内置智能体的 MCP 客户端(Model Context Protocol,stdio 形态)。
|
|
3
|
+
|
|
4
|
+
零依赖:JSON-RPC 2.0 走子进程 stdin/stdout(按行分帧——MCP stdio 传输约定)。
|
|
5
|
+
每次工具调用重新拉起服务器进程:没有长驻会话要管,冷启动成本对内置智能体
|
|
6
|
+
的低频工具循环可接受;进程用完即关(finally 杀树),绝不留孤儿。
|
|
7
|
+
|
|
8
|
+
配置(settings.mcp_servers,JSON 数组字符串,≤4 个服务器):
|
|
9
|
+
[{"name": "fs", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-fs", "C:/x"],
|
|
10
|
+
"env": {"KEY": "v"}}]
|
|
11
|
+
name 用于工具名前缀(mcp__<name>__<tool>),必须 [a-z0-9_-]。
|
|
12
|
+
|
|
13
|
+
安全口径:服务器命令是用户在本机自己配置的(与内置智能体 run_command 同一
|
|
14
|
+
自主权级别);本模块只做传输与解析,不校验工具语义。工具清单按服务器缓存
|
|
15
|
+
(TTL 300s)——每次对话请求重建 tools 列表时不能每次都冷启动全量服务器。
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import subprocess
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
|
|
26
|
+
_LOCK = threading.RLock()
|
|
27
|
+
_TOOLS_CACHE = {} # server_key → (ts, tools)
|
|
28
|
+
_TOOLS_TTL = 300.0
|
|
29
|
+
|
|
30
|
+
MAX_SERVERS = 4
|
|
31
|
+
MAX_MCP_TOOLS = 24 # 全部服务器合计注入内置智能体的工具数上限
|
|
32
|
+
_NAME_RE = re.compile(r"^[a-z0-9_-]{1,24}$")
|
|
33
|
+
_INIT_TIMEOUT = 15.0
|
|
34
|
+
_CALL_TIMEOUT = 60.0
|
|
35
|
+
_CALL_TIMEOUT_MAX = 300.0
|
|
36
|
+
|
|
37
|
+
_CLIENT_INFO = {"name": "CodeBee", "version": "1.0"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def invalidate_tools_cache():
|
|
41
|
+
"""服务器配置变化后丢弃已发现工具(插件启停等场景调用)。"""
|
|
42
|
+
with _LOCK:
|
|
43
|
+
_TOOLS_CACHE.clear()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_servers(text):
|
|
47
|
+
"""settings.mcp_servers 文本 → 校验后的服务器配置列表。返回 (servers, 错误)。"""
|
|
48
|
+
raw = str(text or "").strip()
|
|
49
|
+
if not raw:
|
|
50
|
+
return [], None
|
|
51
|
+
try:
|
|
52
|
+
arr = json.loads(raw)
|
|
53
|
+
except Exception:
|
|
54
|
+
return None, "mcp_servers 不是合法 JSON"
|
|
55
|
+
if not isinstance(arr, list):
|
|
56
|
+
return None, "mcp_servers 必须是数组"
|
|
57
|
+
if len(arr) > MAX_SERVERS:
|
|
58
|
+
return None, "最多 %d 个 MCP 服务器" % MAX_SERVERS
|
|
59
|
+
out = []
|
|
60
|
+
for item in arr:
|
|
61
|
+
if not isinstance(item, dict):
|
|
62
|
+
return None, "MCP 服务器配置必须是对象"
|
|
63
|
+
name = str(item.get("name") or "").strip()
|
|
64
|
+
command = str(item.get("command") or "").strip()
|
|
65
|
+
if not _NAME_RE.match(name):
|
|
66
|
+
return None, "服务器名 %r 只能是小写字母/数字/-/_(≤24 位)" % name[:24]
|
|
67
|
+
if not command:
|
|
68
|
+
return None, "服务器 %s 缺 command" % name
|
|
69
|
+
args = item.get("args") if isinstance(item.get("args"), list) else []
|
|
70
|
+
args = [str(a) for a in args][:16]
|
|
71
|
+
env = item.get("env") if isinstance(item.get("env"), dict) else {}
|
|
72
|
+
env = {str(k): str(v) for k, v in list(env.items())[:16]}
|
|
73
|
+
out.append({"name": name, "command": command, "args": args, "env": env})
|
|
74
|
+
return out, None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class _Session:
|
|
78
|
+
"""一次性的 MCP 服务器会话:spawn → initialize → (list/call)→ 杀树。"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, server):
|
|
81
|
+
self.server = server
|
|
82
|
+
self.proc = None
|
|
83
|
+
self.lines = None # reader 线程产出的行队列
|
|
84
|
+
self.err = None
|
|
85
|
+
|
|
86
|
+
def _pump_err(self):
|
|
87
|
+
try:
|
|
88
|
+
for _ in self.proc.stderr:
|
|
89
|
+
pass
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
def _pump_out(self):
|
|
94
|
+
try:
|
|
95
|
+
for raw in self.proc.stdout:
|
|
96
|
+
self.lines.put(raw.decode("utf-8", errors="replace").rstrip("\r\n"))
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
finally:
|
|
100
|
+
self.lines.put(None) # EOF 哨兵
|
|
101
|
+
|
|
102
|
+
def __enter__(self):
|
|
103
|
+
try:
|
|
104
|
+
self.proc = subprocess.Popen(
|
|
105
|
+
[self.server["command"]] + list(self.server.get("args") or []),
|
|
106
|
+
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
107
|
+
stderr=subprocess.PIPE, text=False,
|
|
108
|
+
env={**os.environ, **(self.server.get("env") or {})})
|
|
109
|
+
except Exception as e:
|
|
110
|
+
raise RuntimeError("MCP 服务器启动失败: %s" % e)
|
|
111
|
+
import queue as _queue
|
|
112
|
+
self.lines = _queue.Queue()
|
|
113
|
+
threading.Thread(target=self._pump_out, daemon=True).start()
|
|
114
|
+
threading.Thread(target=self._pump_err, daemon=True).start()
|
|
115
|
+
self._request("initialize", {
|
|
116
|
+
"protocolVersion": "2024-11-05", "capabilities": {},
|
|
117
|
+
"clientInfo": _CLIENT_INFO}, timeout=_INIT_TIMEOUT)
|
|
118
|
+
self._notify("notifications/initialized")
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
def __exit__(self, *exc):
|
|
122
|
+
try:
|
|
123
|
+
if self.proc and self.proc.stdin:
|
|
124
|
+
try:
|
|
125
|
+
self.proc.stdin.close()
|
|
126
|
+
except Exception:
|
|
127
|
+
pass
|
|
128
|
+
finally:
|
|
129
|
+
try:
|
|
130
|
+
if self.proc:
|
|
131
|
+
self.proc.kill()
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
def _send(self, obj):
|
|
137
|
+
data = json.dumps(obj, ensure_ascii=False).encode("utf-8") + b"\n"
|
|
138
|
+
self.proc.stdin.write(data)
|
|
139
|
+
self.proc.stdin.flush()
|
|
140
|
+
|
|
141
|
+
def _notify(self, method, params=None):
|
|
142
|
+
self._send({"jsonrpc": "2.0", "method": method, "params": params or {}})
|
|
143
|
+
|
|
144
|
+
def _request(self, method, params, timeout):
|
|
145
|
+
"""单请求单响应;忽略通知与未知 id 的服务端请求。"""
|
|
146
|
+
rid = id({})
|
|
147
|
+
self._send({"jsonrpc": "2.0", "id": 1 if method == "initialize" else rid,
|
|
148
|
+
"method": method, "params": params or {}})
|
|
149
|
+
deadline = time.time() + max(1.0, timeout)
|
|
150
|
+
while True:
|
|
151
|
+
remain = deadline - time.time()
|
|
152
|
+
if remain <= 0:
|
|
153
|
+
raise RuntimeError("%s 超时(%.0fs)" % (method, timeout))
|
|
154
|
+
try:
|
|
155
|
+
line = self.lines.get(timeout=remain)
|
|
156
|
+
except Exception:
|
|
157
|
+
raise RuntimeError("%s 超时(%.0fs)" % (method, timeout))
|
|
158
|
+
if line is None:
|
|
159
|
+
raise RuntimeError("MCP 服务器提前退出")
|
|
160
|
+
line = line.strip()
|
|
161
|
+
if not line.startswith("{"):
|
|
162
|
+
continue
|
|
163
|
+
try:
|
|
164
|
+
msg = json.loads(line)
|
|
165
|
+
except Exception:
|
|
166
|
+
continue
|
|
167
|
+
if not isinstance(msg, dict) or msg.get("id") is None:
|
|
168
|
+
continue # 通知/服务端请求:不匹配本调用
|
|
169
|
+
if msg.get("id") != (1 if method == "initialize" else rid):
|
|
170
|
+
continue
|
|
171
|
+
if msg.get("error"):
|
|
172
|
+
raise RuntimeError("MCP %s 失败: %s" % (
|
|
173
|
+
method, json.dumps(msg["error"], ensure_ascii=False)[:200]))
|
|
174
|
+
return msg.get("result") or {}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def list_tools(server):
|
|
178
|
+
"""发现服务器工具。返回 {"ok", "tools":[{"name","description","input_schema"}], "error"}。"""
|
|
179
|
+
try:
|
|
180
|
+
with _Session(server) as sess:
|
|
181
|
+
res = sess._request("tools/list", {}, timeout=_INIT_TIMEOUT)
|
|
182
|
+
tools = []
|
|
183
|
+
for t in (res.get("tools") or [])[:16]:
|
|
184
|
+
if isinstance(t, dict) and isinstance(t.get("name"), str) and t["name"]:
|
|
185
|
+
tools.append({"name": t["name"],
|
|
186
|
+
"description": str(t.get("description") or "")[:400],
|
|
187
|
+
"input_schema": t.get("input_schema")
|
|
188
|
+
if isinstance(t.get("input_schema"), dict) else
|
|
189
|
+
{"type": "object", "properties": {}}})
|
|
190
|
+
return {"ok": True, "tools": tools, "error": ""}
|
|
191
|
+
except Exception as e:
|
|
192
|
+
return {"ok": False, "tools": [], "error": str(e)[:200]}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def call_tool(server, tool_name, arguments, timeout_s=_CALL_TIMEOUT):
|
|
196
|
+
"""调用一个工具。返回 {"ok", "text", "error", "is_error"}。"""
|
|
197
|
+
try:
|
|
198
|
+
timeout = min(_CALL_TIMEOUT_MAX, max(5.0, float(timeout_s or _CALL_TIMEOUT)))
|
|
199
|
+
with _Session(server) as sess:
|
|
200
|
+
res = sess._request("tools/call",
|
|
201
|
+
{"name": tool_name,
|
|
202
|
+
"arguments": arguments if isinstance(arguments, dict) else {}},
|
|
203
|
+
timeout=timeout)
|
|
204
|
+
parts = []
|
|
205
|
+
for c in res.get("content") or []:
|
|
206
|
+
if isinstance(c, dict) and c.get("type") == "text" and c.get("text"):
|
|
207
|
+
parts.append(str(c["text"]))
|
|
208
|
+
text = "\n".join(parts)[:20000]
|
|
209
|
+
is_err = bool(res.get("isError"))
|
|
210
|
+
return {"ok": bool(text) and not is_err, "text": text,
|
|
211
|
+
"error": "" if not is_err else (text or "工具返回 isError"),
|
|
212
|
+
"is_error": is_err}
|
|
213
|
+
except Exception as e:
|
|
214
|
+
return {"ok": False, "text": "", "error": str(e)[:200], "is_error": False}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ---- 面向 builtin_agent 的合并视图(带缓存) ----
|
|
218
|
+
|
|
219
|
+
def tool_specs_cached(force=False):
|
|
220
|
+
"""全部服务器的工具合并清单:[{server, name(原名), full_name, description,
|
|
221
|
+
input_schema}]。TTL 内走缓存;单个服务器失败跳过(error 挂在条目外不打断)。"""
|
|
222
|
+
with _LOCK:
|
|
223
|
+
if not force:
|
|
224
|
+
hit = True
|
|
225
|
+
for key, (ts, _t) in _TOOLS_CACHE.items():
|
|
226
|
+
if time.time() - ts > _TOOLS_TTL:
|
|
227
|
+
hit = False
|
|
228
|
+
break
|
|
229
|
+
if hit and _TOOLS_CACHE:
|
|
230
|
+
out = []
|
|
231
|
+
for _key, (_ts, tools) in _TOOLS_CACHE.items():
|
|
232
|
+
out.extend(tools)
|
|
233
|
+
return out[:MAX_MCP_TOOLS]
|
|
234
|
+
servers, err = parse_servers(_settings_text())
|
|
235
|
+
if err or not servers:
|
|
236
|
+
return []
|
|
237
|
+
out = []
|
|
238
|
+
with _LOCK:
|
|
239
|
+
_TOOLS_CACHE.clear()
|
|
240
|
+
for srv in servers:
|
|
241
|
+
res = list_tools(srv)
|
|
242
|
+
if not res.get("ok"):
|
|
243
|
+
continue
|
|
244
|
+
specs = []
|
|
245
|
+
for t in res["tools"]:
|
|
246
|
+
specs.append({"server": srv["name"], "name": t["name"],
|
|
247
|
+
"full_name": "mcp__%s__%s" % (srv["name"], t["name"]),
|
|
248
|
+
"description": t["description"],
|
|
249
|
+
"input_schema": t["input_schema"]})
|
|
250
|
+
with _LOCK:
|
|
251
|
+
_TOOLS_CACHE[srv["name"]] = (time.time(), specs)
|
|
252
|
+
out.extend(specs)
|
|
253
|
+
if len(out) >= MAX_MCP_TOOLS:
|
|
254
|
+
break
|
|
255
|
+
return out[:MAX_MCP_TOOLS]
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def dispatch_full_name(full_name, arguments, timeout_s=_CALL_TIMEOUT):
|
|
259
|
+
"""mcp__<server>__<tool> → 找服务器配置并调用。返回 {"ok","text","error"}。"""
|
|
260
|
+
m = re.match(r"^mcp__([a-z0-9_-]{1,24})__(.+)$", str(full_name or ""))
|
|
261
|
+
if not m:
|
|
262
|
+
return {"ok": False, "text": "", "error": "非法 MCP 工具名"}
|
|
263
|
+
sname, tool = m.group(1), m.group(2)
|
|
264
|
+
servers, err = parse_servers(_settings_text())
|
|
265
|
+
if err:
|
|
266
|
+
return {"ok": False, "text": "", "error": err}
|
|
267
|
+
srv = next((s for s in servers if s["name"] == sname), None)
|
|
268
|
+
if not srv:
|
|
269
|
+
return {"ok": False, "text": "", "error": "MCP 服务器 %s 未配置" % sname}
|
|
270
|
+
return call_tool(srv, tool, arguments, timeout_s=timeout_s)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
_SETTINGS_TEXT = None # main.py 启动注入(读 settings.mcp_servers)——本模块
|
|
274
|
+
# 不直接依赖 settings(L0),静态图保持干净(同 set_run_estimator 模式)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def set_settings_text(fn):
|
|
278
|
+
"""注入 MCP 服务器清单读取器;未注入时工具清单为空(MCP 功能关闭)。"""
|
|
279
|
+
global _SETTINGS_TEXT
|
|
280
|
+
_SETTINGS_TEXT = fn if callable(fn) else None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _settings_text():
|
|
284
|
+
try:
|
|
285
|
+
return str(_SETTINGS_TEXT() or "") if _SETTINGS_TEXT else ""
|
|
286
|
+
except Exception:
|
|
287
|
+
return ""
|