codebee 0.1.48 → 0.1.50
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 +30 -0
- package/README.md +4 -150
- package/app/core/aiflavor.py +94 -2
- package/app/core/automation.py +55 -2
- package/app/core/builtin_agent.py +450 -32
- package/app/core/knowledge.py +432 -405
- package/app/core/manager.py +76 -3
- package/app/core/modelhub.py +53 -5
- package/app/core/pipeline.py +162 -88
- package/app/core/planner.py +45 -0
- package/app/core/preview.py +293 -0
- package/app/core/runner.py +336 -144
- package/app/core/settings.py +7 -1
- package/app/core/store.py +78 -7
- package/app/core/volumes.py +7 -5
- package/app/core/zentao.py +63 -13
- package/app/main.py +66 -1
- package/app/ui/app.js +741 -167
- package/app/ui/i18n.js +64 -31
- package/app/ui/index.html +57 -12
- package/app/ui/style.css +230 -33
- package/package.json +1 -1
package/app/core/manager.py
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
"""
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import hashlib
|
|
9
10
|
import json
|
|
10
11
|
import os
|
|
11
12
|
import re
|
|
@@ -15,6 +16,7 @@ import signal
|
|
|
15
16
|
import socket
|
|
16
17
|
import subprocess
|
|
17
18
|
import sys
|
|
19
|
+
import tempfile
|
|
18
20
|
import threading
|
|
19
21
|
import time
|
|
20
22
|
import webbrowser
|
|
@@ -46,6 +48,31 @@ def _expand(p):
|
|
|
46
48
|
return os.path.abspath(os.path.expanduser(os.path.expandvars(p)))
|
|
47
49
|
|
|
48
50
|
|
|
51
|
+
def _under(path, root):
|
|
52
|
+
try:
|
|
53
|
+
return os.path.commonpath([os.path.realpath(path), root]) == root
|
|
54
|
+
except ValueError:
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def tmp_data_no_home_write():
|
|
59
|
+
"""测试实例防毒闸:TUTTI_DATA 指向系统临时目录、且进程主目录仍是真实
|
|
60
|
+
主目录时,一切对真实 CLI 配置的写入都应拒绝。
|
|
61
|
+
|
|
62
|
+
一次性测试服务的 models.json 里全是 a.test/sk-test 夹具,忘设假 HOME 的
|
|
63
|
+
测试一旦触发 launch/自愈/autobind 同步,就会把夹具毒进真实
|
|
64
|
+
~/.claude/settings.json(2026-09-22 实案:claude 步骤全体 ENOTFOUND,
|
|
65
|
+
下一次测试又覆盖回来,形成「越测越坏」循环)。已设假 HOME 的测试不受
|
|
66
|
+
影响——假 HOME 本身就在临时目录下。
|
|
67
|
+
"""
|
|
68
|
+
td = (os.environ.get("TUTTI_DATA") or "").strip()
|
|
69
|
+
if not td:
|
|
70
|
+
return False
|
|
71
|
+
tmp = tempfile.gettempdir()
|
|
72
|
+
home = os.path.abspath(os.path.expanduser("~"))
|
|
73
|
+
return _under(td, tmp) and not _under(home, tmp)
|
|
74
|
+
|
|
75
|
+
|
|
49
76
|
def _safe_config_path(raw):
|
|
50
77
|
"""展开并校验配置路径:必须在用户主目录内(防穿越)。"""
|
|
51
78
|
if not raw:
|
|
@@ -521,6 +548,9 @@ def read_model(entry):
|
|
|
521
548
|
def write_model(entry, model):
|
|
522
549
|
"""写入默认模型(改动前自动备份 .bak)。支持 toml-line / toml-section /
|
|
523
550
|
json / json-path / jsonc / yaml-line。"""
|
|
551
|
+
if tmp_data_no_home_write():
|
|
552
|
+
return {"ok": False,
|
|
553
|
+
"error": "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"}
|
|
524
554
|
path = _config_path(entry)
|
|
525
555
|
cfg = entry.get("config") or {}
|
|
526
556
|
fmt = cfg.get("format")
|
|
@@ -750,6 +780,8 @@ def _sync_dsh_settings(entry, model, base_url):
|
|
|
750
780
|
端点不同步不行——settings 优先级高于 env,密钥会发给旧端点;
|
|
751
781
|
models 列表不同步不行——dsh web 的模型下拉只列它,缺条目就选不中。
|
|
752
782
|
返回错误串或 None。"""
|
|
783
|
+
if tmp_data_no_home_write():
|
|
784
|
+
return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
|
|
753
785
|
path = _config_path(entry)
|
|
754
786
|
if not path:
|
|
755
787
|
return "dsh 配置路径无效"
|
|
@@ -891,6 +923,8 @@ def _sync_codex_settings(entry, model, cp):
|
|
|
891
923
|
_codex_provider_args 同构,但落 config 文件。返回错误串或 None。"""
|
|
892
924
|
if _is_dead_endpoint(cp.get("base_url")):
|
|
893
925
|
return "供应商端点 %r 是测试/保留地址,拒绝写入 config.toml" % (cp.get("base_url"),)
|
|
926
|
+
if tmp_data_no_home_write():
|
|
927
|
+
return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
|
|
894
928
|
path = _config_path(entry)
|
|
895
929
|
if not path:
|
|
896
930
|
return "codex 配置路径无效"
|
|
@@ -1120,6 +1154,8 @@ def _sync_settings_env(path, updates, remove_keys=()):
|
|
|
1120
1154
|
"""把键值对写进目标 settings.json 的 env 段(claude/qwen 等同构:交互 TUI
|
|
1121
1155
|
启动时把该段合并进进程环境)。只动 env 相关键,其余内容保留;坏 JSON 中止
|
|
1122
1156
|
不覆盖;改动前 .bak。返回错误串或 None。path 必须已过主目录围栏校验。"""
|
|
1157
|
+
if tmp_data_no_home_write():
|
|
1158
|
+
return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
|
|
1123
1159
|
try:
|
|
1124
1160
|
if os.path.isfile(path):
|
|
1125
1161
|
text = _read_text(path)
|
|
@@ -1213,6 +1249,8 @@ def _sync_opencode_settings(entry, model, prov):
|
|
|
1213
1249
|
没 key)。写入所有已存在的候选文件(避免新文件遮蔽旧文件的读取优先级),
|
|
1214
1250
|
全不存在时建 catalog 登记的那个。纯 JSON 走整体读改写;带注释的 JSONC 走
|
|
1215
1251
|
文本级就地 patch(保留注释)。返回错误串或 None。"""
|
|
1252
|
+
if tmp_data_no_home_write():
|
|
1253
|
+
return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
|
|
1216
1254
|
npm = "@ai-sdk/anthropic" if prov.get("protocol") == "anthropic" else "@ai-sdk/openai-compatible"
|
|
1217
1255
|
base = prov.get("base_url") or ""
|
|
1218
1256
|
if prov.get("protocol") == "anthropic" and not base.rstrip("/").endswith("/v1"):
|
|
@@ -1323,7 +1361,9 @@ def _sync_kimi_settings(entry, model, prov):
|
|
|
1323
1361
|
yolo/defaultPermissionMode(camelCase)+ [providers.<id>](type/apiKey/
|
|
1324
1362
|
baseUrl)+ [models.<别名>](provider/model/maxContextSize 必填)。无人值守
|
|
1325
1363
|
要 yolo——否则工具调用逐个要审批,headless 全被拒。文本级托管块(标记注释
|
|
1326
|
-
|
|
1364
|
+
之间)幂等重写,用户自有内容保留在外。返回错误串或 None。"""
|
|
1365
|
+
if tmp_data_no_home_write():
|
|
1366
|
+
return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
|
|
1327
1367
|
import re as _re
|
|
1328
1368
|
top_keys = ("defaultProvider", "defaultModel", "yolo", "defaultPermissionMode")
|
|
1329
1369
|
targets = [os.path.abspath(os.path.expanduser("~/.kimi-code/config.toml"))]
|
|
@@ -1357,10 +1397,23 @@ def _sync_kimi_settings(entry, model, prov):
|
|
|
1357
1397
|
return ";".join(errs) or None
|
|
1358
1398
|
|
|
1359
1399
|
|
|
1400
|
+
def _claude_sync_enabled():
|
|
1401
|
+
"""「直写 claude 配置」开关(settings 的 claude_config_sync,默认开)。
|
|
1402
|
+
关闭 = ~/.claude/settings.json 归用户手动管理(cc-switch 等),打开/运行前
|
|
1403
|
+
防线一律不落盘;编排步骤经 runner 一次性 --settings 注入绑定链,不受影响。"""
|
|
1404
|
+
try:
|
|
1405
|
+
from . import settings
|
|
1406
|
+
return settings.load().get("claude_config_sync", True)
|
|
1407
|
+
except Exception:
|
|
1408
|
+
return True
|
|
1409
|
+
|
|
1410
|
+
|
|
1360
1411
|
def sync_cli_config_now(agent_id):
|
|
1361
1412
|
"""运行期自愈:CLI 本体没配置(如 kimi「No model configured」)→ 立即把
|
|
1362
1413
|
绑定注入其自家配置,换将/下轮即可用。返回给日志的备注(空=无事发生)。"""
|
|
1363
1414
|
try:
|
|
1415
|
+
if agent_id == "claude-code" and not _claude_sync_enabled():
|
|
1416
|
+
return "按设置跳过直写 ~/.claude 配置(编排步骤经一次性 --settings 注入,不受影响)"
|
|
1364
1417
|
from . import modelhub
|
|
1365
1418
|
entry = next((a for a in catalog.load() if a.get("id") == agent_id), None)
|
|
1366
1419
|
if not entry:
|
|
@@ -1397,6 +1450,8 @@ def _sync_agent_injection(entry, binding):
|
|
|
1397
1450
|
spec = _AGENT_INJECTORS.get(entry["id"])
|
|
1398
1451
|
if spec:
|
|
1399
1452
|
protocols, injector = spec
|
|
1453
|
+
if entry["id"] == "claude-code" and not _claude_sync_enabled():
|
|
1454
|
+
return "按设置跳过直写 ~/.claude 配置(交互 TUI 保持手动管理;编排步骤经一次性 --settings 注入不受影响)"
|
|
1400
1455
|
pick, note = modelhub.launch_pick(entry["id"], protocols)
|
|
1401
1456
|
if pick:
|
|
1402
1457
|
prov = pick["provider"]
|
|
@@ -1466,7 +1521,19 @@ def _sync_launch_model(entry, binding):
|
|
|
1466
1521
|
# (draft-c12 等),CLI 身份在 kind 里;目录型智能体的 id 就是条目 id。
|
|
1467
1522
|
_KIND_ENTRY = {"codex": "codex-cli", "claude": "claude-code",
|
|
1468
1523
|
"qwen": "qwencode", "opencode": "opencode", "aider": "aider"}
|
|
1469
|
-
_RUNTIME_SYNC = {"fps": {}, "lock": threading.Lock()}
|
|
1524
|
+
_RUNTIME_SYNC = {"fps": {}, "files": {}, "lock": threading.Lock()}
|
|
1525
|
+
|
|
1526
|
+
|
|
1527
|
+
def _runtime_cfg_hash(entry):
|
|
1528
|
+
"""CLI 自家配置文件的内容指纹;文件不存在返回 None。"""
|
|
1529
|
+
try:
|
|
1530
|
+
p = _safe_config_path((entry.get("config") or {}).get("path"))
|
|
1531
|
+
if p and os.path.isfile(p):
|
|
1532
|
+
with open(p, "rb") as fh:
|
|
1533
|
+
return hashlib.sha256(fh.read()).hexdigest()
|
|
1534
|
+
except Exception:
|
|
1535
|
+
pass
|
|
1536
|
+
return None
|
|
1470
1537
|
|
|
1471
1538
|
|
|
1472
1539
|
def sync_runtime_config(agent):
|
|
@@ -1509,10 +1576,16 @@ def sync_runtime_config(agent):
|
|
|
1509
1576
|
fp = (model, repr(sorted(env.items())),
|
|
1510
1577
|
repr(binding.get("codex_provider") or "")[:300])
|
|
1511
1578
|
with _RUNTIME_SYNC["lock"]:
|
|
1512
|
-
if _RUNTIME_SYNC["fps"].get(entry["id"]) == fp
|
|
1579
|
+
if (_RUNTIME_SYNC["fps"].get(entry["id"]) == fp
|
|
1580
|
+
and _RUNTIME_SYNC["files"].get(entry["id"]) == _runtime_cfg_hash(entry)):
|
|
1513
1581
|
return
|
|
1582
|
+
# 绑定没变≠文件没变:指纹命中但文件被外部改写(测试毒写/手工编辑
|
|
1583
|
+
# 托管段)时必须按绑定重写——2026-09-22 kimi 实案:测试夹具毒入
|
|
1584
|
+
# ~/.kimi-code/config.toml(x.example 不可解析),换将步骤因指纹
|
|
1585
|
+
# 命中跳过重写,kimi 带毒配置静默退出码 1
|
|
1514
1586
|
_sync_launch_model(entry, binding)
|
|
1515
1587
|
_RUNTIME_SYNC["fps"][entry["id"]] = fp
|
|
1588
|
+
_RUNTIME_SYNC["files"][entry["id"]] = _runtime_cfg_hash(entry)
|
|
1516
1589
|
except Exception:
|
|
1517
1590
|
pass
|
|
1518
1591
|
|
package/app/core/modelhub.py
CHANGED
|
@@ -448,6 +448,30 @@ def _drop_model_refs(data, prov, name):
|
|
|
448
448
|
_sync_chain_refs(b)
|
|
449
449
|
|
|
450
450
|
|
|
451
|
+
def _prune_binding_chains(data, prov_ids=(), pairs=()):
|
|
452
|
+
"""停用即出调度:把指向已停用供应商/模型的条目从所有绑定链里剔除。
|
|
453
|
+
|
|
454
|
+
与删除的清引用(_drop_model_refs / providers_op delete)不同,停用可逆,
|
|
455
|
+
只摘链上死条目,不动主供应商 provider_id 与厂商默认模型——重新启用后
|
|
456
|
+
原样恢复。链上剔除不回填:调度页所见即所绑,不靠运行时降级默默跳过
|
|
457
|
+
占位死条目(2026-09-22 用户拍板:禁用就直接去掉,自动就行)。
|
|
458
|
+
"""
|
|
459
|
+
ids = {i for i in (prov_ids or ()) if i}
|
|
460
|
+
prs = {(p, m) for p, m in (pairs or ()) if p and m}
|
|
461
|
+
if not ids and not prs:
|
|
462
|
+
return
|
|
463
|
+
for b in (data.get("bindings") or {}).values():
|
|
464
|
+
chain = b.get("chain")
|
|
465
|
+
if not chain:
|
|
466
|
+
continue
|
|
467
|
+
kept = [c for c in chain
|
|
468
|
+
if (c.get("provider_id") or "") not in ids
|
|
469
|
+
and (c.get("provider_id") or "", c.get("model") or "") not in prs]
|
|
470
|
+
if len(kept) != len(chain):
|
|
471
|
+
b["chain"] = kept
|
|
472
|
+
_sync_chain_refs(b)
|
|
473
|
+
|
|
474
|
+
|
|
451
475
|
_MODEL_OPS = ("enable", "disable", "delete", "restore")
|
|
452
476
|
|
|
453
477
|
|
|
@@ -475,6 +499,7 @@ def _apply_model_op(data, prov, models, name, op):
|
|
|
475
499
|
if not target.get("enabled", True):
|
|
476
500
|
return False, ""
|
|
477
501
|
target["enabled"] = False
|
|
502
|
+
_prune_binding_chains(data, pairs=[(prov.get("id"), name)])
|
|
478
503
|
elif op == "delete":
|
|
479
504
|
if target.get("hidden"):
|
|
480
505
|
return False, ""
|
|
@@ -494,6 +519,8 @@ def model_ops(provider_id, names, op):
|
|
|
494
519
|
|
|
495
520
|
先整体校验再落盘:任一名不存在、或 enable/disable 选中了已删除的模型,
|
|
496
521
|
整个请求都不生效,避免只改一半。全部改动合并为一次写盘。
|
|
522
|
+
disable 即出调度:命中的 (供应商, 模型) 条目自动从所有绑定链剔除,
|
|
523
|
+
不回填;delete 走 _drop_model_refs 连默认模型引用一起清。
|
|
497
524
|
restore 与可见模型混选时,可见项直接跳过(不算失败),便于「恢复所选」。
|
|
498
525
|
"""
|
|
499
526
|
names = list(dict.fromkeys(n for n in (names or []) if n))
|
|
@@ -656,9 +683,13 @@ def provider_view():
|
|
|
656
683
|
_KEY_COOLDOWN_S = 30 * 60 # 欠费类失败后的冷却时长
|
|
657
684
|
MAX_PROVIDER_KEYS = 8 # 单厂商 KEY 上限
|
|
658
685
|
MAX_CHAIN_ATTEMPTS = 8 # 链展开后的尝试上限(模型 × KEY)
|
|
659
|
-
#
|
|
686
|
+
# 欠费/配额/限流类失败:换 KEY 有意义(同厂商另一账号=另一份额度与并发桶),
|
|
687
|
+
# 与瞬态网络错误分开记。429 限流文案一并纳入——只记错不冷却会让每个新步骤都
|
|
688
|
+
# 从超限的首选 KEY 重新烧起(2026-09-22 首选超限不切备用实案)。
|
|
660
689
|
_QUOTA_HINTS = ("insufficient", "quota", "balance", "credit", "billing", "arrears",
|
|
661
|
-
"payment required", "402", "欠费", "余额", "额度", "exceeded"
|
|
690
|
+
"payment required", "402", "欠费", "余额", "额度", "exceeded",
|
|
691
|
+
"too many requests", "rate limit", "rate_limit", "http 429",
|
|
692
|
+
"并发", "超过限")
|
|
662
693
|
|
|
663
694
|
|
|
664
695
|
def _quota_error(err):
|
|
@@ -938,8 +969,9 @@ def upsert_provider(entry):
|
|
|
938
969
|
def providers_op(ids, op):
|
|
939
970
|
"""批量供应商操作(enable | disable | delete | duplicate)。返回 (改动数, 错误)。
|
|
940
971
|
|
|
941
|
-
|
|
942
|
-
|
|
972
|
+
停用即出调度:绑定链里它的条目自动剔除(_prune_binding_chains),配置与
|
|
973
|
+
模型列表保留,随时可再启用;已剔除的链条目不回填。运行时解析本来就会
|
|
974
|
+
跳过停用供应商,这里把落盘链一并清掉,调度页所见即所绑。
|
|
943
975
|
duplicate 复制一条(同地址同密钥的第二个账号/新网关),副本不带绑定引用。
|
|
944
976
|
"""
|
|
945
977
|
ids = list(dict.fromkeys(i for i in (ids or []) if i))
|
|
@@ -998,6 +1030,8 @@ def providers_op(ids, op):
|
|
|
998
1030
|
p["enabled"] = want
|
|
999
1031
|
changed += 1
|
|
1000
1032
|
if changed:
|
|
1033
|
+
if not want:
|
|
1034
|
+
_prune_binding_chains(data, prov_ids=ids)
|
|
1001
1035
|
# 启用置顶:本次选中的启用者排最前,其余启用跟随,停用的殿后;
|
|
1002
1036
|
# 排序稳定,各块内部保持原有顺序。落盘重排,列表页看到的就是它。
|
|
1003
1037
|
sel = set(ids)
|
|
@@ -2298,11 +2332,13 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
|
|
|
2298
2332
|
entries.append(_chain_entry_env(prov, model, target=agent_kind_or_id,
|
|
2299
2333
|
endpoint=ep, key=kk["key"],
|
|
2300
2334
|
key_id=kk.get("id") or "", provider_id=pid))
|
|
2335
|
+
mk = (chain_keys or [{}])[0] # 回退模型沿用首选可用 KEY:失败也要能记账冷却
|
|
2301
2336
|
for n in fallbacks:
|
|
2302
2337
|
if len(entries) >= MAX_CHAIN_ATTEMPTS:
|
|
2303
2338
|
break
|
|
2304
2339
|
entries.append(_chain_entry_env(prov, n, target=agent_kind_or_id,
|
|
2305
|
-
endpoint=ep,
|
|
2340
|
+
endpoint=ep, key=mk.get("key") or "",
|
|
2341
|
+
key_id=mk.get("id") or "", provider_id=pid))
|
|
2306
2342
|
head = entries[0]
|
|
2307
2343
|
out = {"model": model, "env": head["env"], "provider": prov,
|
|
2308
2344
|
"model_fallbacks": fallbacks,
|
|
@@ -2406,6 +2442,18 @@ def launch_pick(agent_id, protocols):
|
|
|
2406
2442
|
protocols = tuple(p for p in (protocols or ()) if p)
|
|
2407
2443
|
b = bindings().get(agent_id) or {}
|
|
2408
2444
|
chain = _binding_chain(b)
|
|
2445
|
+
if not chain and str(b.get("provider_id") or "").strip():
|
|
2446
|
+
# 只锁定了供应商、没排链也没选模型的绑定:与 resolve_binding 的无链分支
|
|
2447
|
+
# 同语义补一条,打开前才有凭据可注入。否则这类绑定永远「未指定供应商」,
|
|
2448
|
+
# CLI 自家配置里的残留(a.test 占位案)等不到自愈覆盖——2026-09-22
|
|
2449
|
+
# claude ENOTFOUND 四连败实测,settings.json 毒端点靠它长期存活。
|
|
2450
|
+
_pid = str(b.get("provider_id")).strip()
|
|
2451
|
+
_prov = {p.get("id"): p for p in providers()}.get(_pid)
|
|
2452
|
+
_model = str(b.get("model") or "").strip()
|
|
2453
|
+
if _prov and not _model:
|
|
2454
|
+
_names = [m["name"] for m in _enabled_models(_prov)]
|
|
2455
|
+
_model = _prov.get("model") or (_names[0] if _names else "")
|
|
2456
|
+
chain = [{"provider_id": _pid, "model": _model}]
|
|
2409
2457
|
provs = {p.get("id"): p for p in providers()}
|
|
2410
2458
|
disabled, mismatch = [], False
|
|
2411
2459
|
for item in chain:
|
package/app/core/pipeline.py
CHANGED
|
@@ -419,7 +419,25 @@ def _run_builtin_step(run_id, role, bi, prompt, workdir, ev, note="", images=Non
|
|
|
419
419
|
def _log(line):
|
|
420
420
|
lines.append(str(line))
|
|
421
421
|
|
|
422
|
-
|
|
422
|
+
# 思考过程可视化(2026-09-22 用户诉求):模型的思维链/正文/工具活动边收边
|
|
423
|
+
# 落进步骤记录(store.stream_step 内部节流),对话时间线 2s 轮询即能读到
|
|
424
|
+
# 「它正在想什么」——此前整轮只有三点打字动画。
|
|
425
|
+
live = {"thinking": "", "text": ""}
|
|
426
|
+
|
|
427
|
+
def _on_reason(chunk):
|
|
428
|
+
live["thinking"] += chunk or ""
|
|
429
|
+
store.stream_step(run_id, step["n"], thinking=live["thinking"])
|
|
430
|
+
|
|
431
|
+
def _on_stream(acc):
|
|
432
|
+
live["text"] = acc or ""
|
|
433
|
+
store.stream_step(run_id, step["n"], text=live["text"])
|
|
434
|
+
|
|
435
|
+
def _on_activity(line):
|
|
436
|
+
store.stream_step(run_id, step["n"], activity=str(line or "")[:200])
|
|
437
|
+
|
|
438
|
+
res = builtin_agent.run(bi, prompt, workdir, cancel_event=ev, log=_log, images=images,
|
|
439
|
+
on_reason=_on_reason, on_stream=_on_stream,
|
|
440
|
+
on_activity=_on_activity)
|
|
423
441
|
if followups and res.get("ok"):
|
|
424
442
|
clean, fups = _parse_followups(res.get("text") or "")
|
|
425
443
|
if fups:
|
|
@@ -427,6 +445,10 @@ def _run_builtin_step(run_id, role, bi, prompt, workdir, ev, note="", images=Non
|
|
|
427
445
|
res["followups"] = fups
|
|
428
446
|
if log_abs:
|
|
429
447
|
try:
|
|
448
|
+
# 思考过程同时进步骤日志(日志抽屉与对话气泡同源,便于事后复盘)
|
|
449
|
+
think = (res.get("reasoning") or "").strip()
|
|
450
|
+
if think:
|
|
451
|
+
lines.append("[思考过程]\n" + think)
|
|
430
452
|
log_abs.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
431
453
|
except Exception:
|
|
432
454
|
pass
|
|
@@ -550,7 +572,9 @@ def _finish_step_result(run_id, step, res, role, agent, start):
|
|
|
550
572
|
# 智能体的最终回答(runner 已从 JSONL 事件流里抽出 agent_message)。
|
|
551
573
|
# 对话视图直读这个;日志文件是全量事件流,塞进气泡就成了「看日志」。
|
|
552
574
|
output=(res.get("text") or ""),
|
|
553
|
-
followups=res.get("followups")
|
|
575
|
+
followups=res.get("followups"),
|
|
576
|
+
# 思考过程(内置智能体流式抓取):落进步骤记录,对话气泡折叠展示
|
|
577
|
+
thinking=res.get("reasoning") or None)
|
|
554
578
|
# 错误台账:失败/超时各记一条结构化记录(遥测与诊断包的数据源)。
|
|
555
579
|
# 用户主动取消不入账——那不是产品问题;detail 只存脱敏后的失败摘录。
|
|
556
580
|
if status in ("failed", "timeout"):
|
|
@@ -1807,45 +1831,11 @@ def _plot_modules(workdir):
|
|
|
1807
1831
|
"鼓励化用,不要照抄原句)\n\n" + txt)
|
|
1808
1832
|
|
|
1809
1833
|
|
|
1810
|
-
def _chain_volume_names(task):
|
|
1811
|
-
"""沿 serial.continues 链收集各批次大纲里的卷名/卷弧光(旧 → 新,后者覆盖)。
|
|
1812
|
-
续写批次合并成书时要给全书(含此前各批)的卷都写上标题,那些卷的命名记在
|
|
1813
|
-
更早的批次任务上,本批 run 的 outline 里没有。失败静默返回 {}。"""
|
|
1814
|
-
from . import store as _store
|
|
1815
|
-
names, seen, cur = {}, set(), task
|
|
1816
|
-
chain = []
|
|
1817
|
-
while cur and len(chain) < 20:
|
|
1818
|
-
tid = str(cur.get("id") or "")
|
|
1819
|
-
if not tid or tid in seen:
|
|
1820
|
-
break
|
|
1821
|
-
seen.add(tid)
|
|
1822
|
-
chain.append(cur)
|
|
1823
|
-
nxt = str((cur.get("serial") or {}).get("continues") or "")
|
|
1824
|
-
cur = _store.get_task(nxt) if nxt else None
|
|
1825
|
-
for t in reversed(chain): # 旧 → 新:新批次的命名覆盖旧批次
|
|
1826
|
-
try:
|
|
1827
|
-
for r in _store.task_runs(t["id"]):
|
|
1828
|
-
o = r.get("outline") or {}
|
|
1829
|
-
if isinstance(o.get("volumes"), dict):
|
|
1830
|
-
names.update(o["volumes"])
|
|
1831
|
-
except Exception:
|
|
1832
|
-
continue
|
|
1833
|
-
return names
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
1834
|
def _book_volume_plan(task, outline, upto):
|
|
1837
|
-
"""
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
per = volumes.norm_per(serial.get("volume_chapters"))
|
|
1842
|
-
plan = volumes.build_plan(serial.get("volumes"), per, upto=upto)
|
|
1843
|
-
if not plan:
|
|
1844
|
-
return []
|
|
1845
|
-
named = dict(_chain_volume_names(task))
|
|
1846
|
-
if isinstance(outline, dict):
|
|
1847
|
-
named.update(outline.get("volumes") or {})
|
|
1848
|
-
return volumes.merge_titles(plan, named)
|
|
1835
|
+
"""本书的卷规划表(卷边界+卷名+卷弧光)。实现见 planner.book_volume_plan
|
|
1836
|
+
——放在 planner 是因为它要沿 serial.continues 链读历史 run 的卷名,属
|
|
1837
|
+
「大纲/命名」职责;此处保留薄封装便于连载流程阅读。"""
|
|
1838
|
+
return planner.book_volume_plan(task, outline, upto)
|
|
1849
1839
|
|
|
1850
1840
|
|
|
1851
1841
|
LEDGER_FILE = os.path.join(".codebee", "resource-ledger.md")
|
|
@@ -2194,6 +2184,9 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
2194
2184
|
kb = knowledge.block_for(task)
|
|
2195
2185
|
if kb:
|
|
2196
2186
|
tpl = tpl.replace("## 待评审稿件", "%s\n\n## 待评审稿件" % kb, 1)
|
|
2187
|
+
# 确定性检测(AI 味/叙事架构/节奏)此前只挂在单稿件评审上,连载逐章
|
|
2188
|
+
# 从未拿到——逐章节奏与钩子恰恰最需要这条参考线(命中才追加,不扣分)
|
|
2189
|
+
tpl = aiflavor.inject_into_prompt(tpl, text, task.get("type"))
|
|
2197
2190
|
return tpl.replace("__DIMKEYS__", dimkey).replace(
|
|
2198
2191
|
"__MANUSCRIPT__", text or "(稿件为空!)")
|
|
2199
2192
|
|
|
@@ -2354,50 +2347,92 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
2354
2347
|
|
|
2355
2348
|
def run_critique(text, rnd, note_extra="", critic_sids=None, event_check=""):
|
|
2356
2349
|
"""一轮多维评审:返回 (cj_by_agent, scored)。变体赛马与主循环共用。
|
|
2357
|
-
event_check:本章大纲核对块(逐项目标审稿),随评审下发并回收标记块。
|
|
2350
|
+
event_check:本章大纲核对块(逐项目标审稿),随评审下发并回收标记块。
|
|
2351
|
+
|
|
2352
|
+
多评审并发跑(2026-09-22):评审占运行总时长的六成以上,而各评审之间
|
|
2353
|
+
互不依赖(同一份稿件、各自视角),串行等待纯属浪费——每位评审的墙上
|
|
2354
|
+
时间直接叠加。与「同章多稿赛马」「best-of 候选」同一套线程范式(那两处
|
|
2355
|
+
早已多线程调用 _run_step)。共享结构的写入全部收敛到 join 之后按
|
|
2356
|
+
critics 原顺序合并,保证与串行版逐字节同结果(ev_check 取第一份非空、
|
|
2357
|
+
issues/ledger 的先后顺序都不变),只在耗时上取并行收益。"""
|
|
2358
2358
|
cj_map, sids = {}, dict(critic_sids or {})
|
|
2359
2359
|
scored = 0 # 真正给出分数的评审数;失败/不可解析不得当成 0 分计入
|
|
2360
|
-
|
|
2361
|
-
|
|
2360
|
+
role = "critique-c%d" % i
|
|
2361
|
+
results = {}
|
|
2362
|
+
# 绑定解析提前到主线程:bind_agent 会把链首同步进 CLI 自家配置(写盘),
|
|
2363
|
+
# 多线程同时调它有写盘竞争。解析结果随线程参数传入,线程内只做调用。
|
|
2364
|
+
bound = [modelhub.bind_agent(a, difficulty) for a in critics]
|
|
2365
|
+
|
|
2366
|
+
def _critique_one(idx, agent):
|
|
2367
|
+
"""单评审执行体(线程内只做调用与解析,不改共享结构)。
|
|
2368
|
+
|
|
2369
|
+
异常不吞:记进 results 由主线程按原顺序重抛——串行版的
|
|
2370
|
+
Cancelled/管道异常语义(上抛打断本 run)必须原样保留。"""
|
|
2371
|
+
try:
|
|
2372
|
+
_critique_one_inner(idx, agent)
|
|
2373
|
+
except BaseException as exc: # noqa: BLE001(含 Cancelled)
|
|
2374
|
+
results[idx] = {"error": exc}
|
|
2375
|
+
|
|
2376
|
+
def _critique_one_inner(idx, agent):
|
|
2362
2377
|
if agent.get("mode") == "mock":
|
|
2363
2378
|
step, log_abs = store.add_step(run_id, role, agent["id"], agent.get("label"))
|
|
2364
2379
|
time.sleep(0.15)
|
|
2365
2380
|
cj = mocks.critique(agent["id"], rnd, dims, threshold_ch)
|
|
2366
|
-
scored += 1
|
|
2367
2381
|
store.finish_step(run_id, step["n"], "done",
|
|
2368
2382
|
summary="均分 %.1f:%s" % (
|
|
2369
2383
|
sum(cj["scores"].values()) / max(1, len(dims)),
|
|
2370
2384
|
cj["summary"]),
|
|
2371
2385
|
duration_s=0.15)
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
#
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
if evl and not ev_check_lines[0]:
|
|
2392
|
-
ev_check_lines[0] = evl
|
|
2393
|
-
ledger_lines.extend(_parse_tagged_lines(res.get("text"), "ledger"))
|
|
2386
|
+
results[idx] = {"agent": agent, "cj": cj, "evl": [],
|
|
2387
|
+
"ledger": [], "sid": None}
|
|
2388
|
+
return
|
|
2389
|
+
lens = _critic_lens(critics, agent)
|
|
2390
|
+
res = _run_step(run_id, role, bound[idx],
|
|
2391
|
+
crit_prompt_for(
|
|
2392
|
+
text,
|
|
2393
|
+
note=("小说第 %d 章" % i) + (
|
|
2394
|
+
"|你的专属评审视角:%s(其他评审会覆盖其余视角,"
|
|
2395
|
+
"请深挖你的镜头,但所有维度仍需打分)" % lens)
|
|
2396
|
+
if lens else "",
|
|
2397
|
+
event_check=event_check) + note_extra,
|
|
2398
|
+
workdir, readonly=True, ev=ev,
|
|
2399
|
+
resume=sids.get(agent["id"]))
|
|
2400
|
+
results[idx] = {
|
|
2401
|
+
"agent": agent, "cj": _critique_json(res, dims),
|
|
2402
|
+
# 逐项目标审稿 + 资源账本:标记块先各存各的,join 后按序合并
|
|
2403
|
+
"evl": _parse_tagged_lines(res.get("text"), "event_check"),
|
|
2404
|
+
"ledger": _parse_tagged_lines(res.get("text"), "ledger"),
|
|
2394
2405
|
# §07 T1.1:记录该评审的会话 id(第 2 轮复用)
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2406
|
+
"sid": _resume_sid(agent, res.get("sid")),
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
threads = []
|
|
2410
|
+
for idx, agent in enumerate(critics):
|
|
2411
|
+
th = threading.Thread(target=_critique_one, args=(idx, agent),
|
|
2412
|
+
name="crit-%s-c%d-%d" % (run_id, i, idx), daemon=True)
|
|
2413
|
+
threads.append(th)
|
|
2414
|
+
th.start()
|
|
2415
|
+
for th in threads:
|
|
2416
|
+
th.join(3000)
|
|
2417
|
+
_check_cancel(ev)
|
|
2418
|
+
|
|
2419
|
+
# join 后按原顺序合并(确定性:与串行版结果一致)
|
|
2420
|
+
for idx in range(len(critics)):
|
|
2421
|
+
r = results.get(idx)
|
|
2422
|
+
if r is None:
|
|
2423
|
+
continue # 该评审线程超时未回,按「没出分」处理
|
|
2424
|
+
if "error" in r:
|
|
2425
|
+
raise r["error"] # 还原串行版的异常上抛语义
|
|
2426
|
+
agent, cj = r["agent"], r["cj"]
|
|
2427
|
+
if cj.get("scores"):
|
|
2428
|
+
scored += 1
|
|
2429
|
+
if r["evl"] and not ev_check_lines[0]:
|
|
2430
|
+
ev_check_lines[0] = r["evl"]
|
|
2431
|
+
ledger_lines.extend(r["ledger"])
|
|
2432
|
+
if r["sid"]:
|
|
2433
|
+
sids[agent["id"]] = r["sid"]
|
|
2398
2434
|
cj_map[agent["id"]] = cj
|
|
2399
2435
|
issues_all.extend({"chapter": i, **it} for it in (cj.get("issues") or [])[:6])
|
|
2400
|
-
_check_cancel(ev)
|
|
2401
2436
|
|
|
2402
2437
|
# 评审者级 fallback:名单内评审全挂(网关抖动/CLI 故障)时,
|
|
2403
2438
|
# 从其它已启用真实智能体补位至多 2 个(排除 mock 与已试过的),
|
|
@@ -2512,6 +2547,8 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
2512
2547
|
chapter_reason = route.get("author", "")
|
|
2513
2548
|
last_attempt_impl = impl
|
|
2514
2549
|
last_attempt_reason = chapter_reason
|
|
2550
|
+
prev_prompt = None # 上一次实际下发的提示词
|
|
2551
|
+
prev_timed_out = False # 上一次是否「超时/停滞」收场
|
|
2515
2552
|
for draft_attempt in range(3):
|
|
2516
2553
|
if draft_attempt:
|
|
2517
2554
|
# 30s / 60s 退避;ev.wait 睡等可被取消即刻唤醒
|
|
@@ -2527,11 +2564,20 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
2527
2564
|
# 30KB 对讯飞必挂)
|
|
2528
2565
|
sk2, bible2, _note = _shrink_context_block(sk_block, bible, budget=12000)
|
|
2529
2566
|
use_prompt = prompt.replace(sk_block, sk2).replace(bible, bible2)
|
|
2567
|
+
# 超时重试必须有新变量才值得做(2026-09-22):提示词与上次逐字节
|
|
2568
|
+
# 相同、上次又是超时/停滞收场时,重试只是把同一个超时再烧一遍
|
|
2569
|
+
# (2400s × N)。此时直接跳出交替换将——换 CLI/模型才是新机会。
|
|
2570
|
+
# 注意保留「缩上下文重试」:提示词真的变小了(缩块生效)就照试,
|
|
2571
|
+
# 那是针对「提示词过大挂起」的有效降级。
|
|
2572
|
+
if prev_timed_out and use_prompt == prev_prompt:
|
|
2573
|
+
break
|
|
2530
2574
|
res = _run_step(run_id, "draft-c%d" % i, modelhub.bind_agent(impl, difficulty), use_prompt,
|
|
2531
2575
|
step_wd, readonly=False, ev=ev, timeout=2400,
|
|
2532
2576
|
resume=resume_ctx["session"] if resume_ctx else None,
|
|
2533
2577
|
images=_task_images(task, workdir),
|
|
2534
2578
|
note=("起草重试 %d/2(网关限流退避)" % draft_attempt) if draft_attempt else "")
|
|
2579
|
+
prev_prompt = use_prompt
|
|
2580
|
+
prev_timed_out = bool((res.get("raw") or {}).get("timed_out"))
|
|
2535
2581
|
good, txt = _chapter_state()
|
|
2536
2582
|
if good:
|
|
2537
2583
|
break
|
|
@@ -2852,9 +2898,23 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
2852
2898
|
global_issues = []
|
|
2853
2899
|
|
|
2854
2900
|
def run_global_round(agent_list):
|
|
2855
|
-
"""一轮全局评审:返回 (出分评审数, 按维累计分)。失败/不可解析不得当成低分计入。
|
|
2901
|
+
"""一轮全局评审:返回 (出分评审数, 按维累计分)。失败/不可解析不得当成低分计入。
|
|
2902
|
+
|
|
2903
|
+
多评审并发(2026-09-22,同章级评审并发):各自读同一份全书文本、互不依赖,
|
|
2904
|
+
串行只是把等待时间叠起来。共享结构仍在 join 后按原顺序合并,结果与串行一致。"""
|
|
2856
2905
|
gmeans_acc, scored = {}, 0
|
|
2857
|
-
|
|
2906
|
+
results = {}
|
|
2907
|
+
# 绑定解析提前到主线程(线程内不做会写盘的 bind_agent)
|
|
2908
|
+
bound = [modelhub.bind_agent(a, difficulty) for a in agent_list]
|
|
2909
|
+
|
|
2910
|
+
def _global_one(idx, agent):
|
|
2911
|
+
"""异常不吞:记进 results 由主线程按原顺序重抛(保留串行语义)。"""
|
|
2912
|
+
try:
|
|
2913
|
+
_global_one_inner(idx, agent)
|
|
2914
|
+
except BaseException as exc: # noqa: BLE001(含 Cancelled)
|
|
2915
|
+
results[idx] = {"error": exc}
|
|
2916
|
+
|
|
2917
|
+
def _global_one_inner(idx, agent):
|
|
2858
2918
|
if agent.get("mode") == "mock":
|
|
2859
2919
|
step, _ = store.add_step(run_id, "global-critique", agent["id"],
|
|
2860
2920
|
agent.get("label"))
|
|
@@ -2863,25 +2923,41 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
2863
2923
|
"issues": [], "summary": "(mock)全书结构完整,达到可签约水平"}
|
|
2864
2924
|
store.finish_step(run_id, step["n"], "done", summary="均分 8.0:(mock)全书达标",
|
|
2865
2925
|
duration_s=0.15)
|
|
2926
|
+
results[idx] = gj
|
|
2927
|
+
return
|
|
2928
|
+
gtpl = SERIAL_GLOBAL_PROMPT
|
|
2929
|
+
if bible:
|
|
2930
|
+
gtpl = gtpl.replace("## 全书目标", bible + "\n\n## 全书目标", 1)
|
|
2931
|
+
res = _run_step(run_id, "global-critique", bound[idx],
|
|
2932
|
+
(gtpl.replace("__DIMKEYS__", dimkey)
|
|
2933
|
+
.replace("__GOAL__", task["goal"])
|
|
2934
|
+
.replace("__MANUSCRIPT__", full_text[:60000])),
|
|
2935
|
+
workdir, readonly=True, ev=ev, timeout=2400)
|
|
2936
|
+
results[idx] = _critique_json(res, dims)
|
|
2937
|
+
|
|
2938
|
+
threads = []
|
|
2939
|
+
for idx, agent in enumerate(agent_list):
|
|
2940
|
+
th = threading.Thread(target=_global_one, args=(idx, agent),
|
|
2941
|
+
name="gcrit-%s-%d" % (run_id, idx), daemon=True)
|
|
2942
|
+
threads.append(th)
|
|
2943
|
+
th.start()
|
|
2944
|
+
for th in threads:
|
|
2945
|
+
th.join(3000)
|
|
2946
|
+
_check_cancel(ev)
|
|
2947
|
+
|
|
2948
|
+
for idx in range(len(agent_list)):
|
|
2949
|
+
gj = results.get(idx)
|
|
2950
|
+
if gj is None:
|
|
2951
|
+
continue
|
|
2952
|
+
if "error" in gj:
|
|
2953
|
+
raise gj["error"] # 还原串行版的异常上抛语义
|
|
2954
|
+
if gj.get("scores"):
|
|
2866
2955
|
scored += 1
|
|
2867
|
-
else:
|
|
2868
|
-
gtpl = SERIAL_GLOBAL_PROMPT
|
|
2869
|
-
if bible:
|
|
2870
|
-
gtpl = gtpl.replace("## 全书目标", bible + "\n\n## 全书目标", 1)
|
|
2871
|
-
res = _run_step(run_id, "global-critique", modelhub.bind_agent(agent, difficulty),
|
|
2872
|
-
(gtpl.replace("__DIMKEYS__", dimkey)
|
|
2873
|
-
.replace("__GOAL__", task["goal"])
|
|
2874
|
-
.replace("__MANUSCRIPT__", full_text[:60000])),
|
|
2875
|
-
workdir, readonly=True, ev=ev, timeout=2400)
|
|
2876
|
-
gj = _critique_json(res, dims)
|
|
2877
|
-
if gj.get("scores"):
|
|
2878
|
-
scored += 1
|
|
2879
2956
|
global_issues.extend({"chapter": "全书", **it} for it in (gj.get("issues") or [])[:8])
|
|
2880
2957
|
for d in dims:
|
|
2881
2958
|
v = gj.get("scores", {}).get(d)
|
|
2882
2959
|
if v is not None:
|
|
2883
2960
|
gmeans_acc.setdefault(d, []).append(float(v))
|
|
2884
|
-
_check_cancel(ev)
|
|
2885
2961
|
return scored, gmeans_acc
|
|
2886
2962
|
|
|
2887
2963
|
gscored, gmeans_acc = run_global_round(critics)
|
|
@@ -3376,9 +3452,7 @@ def _run_content_review(run, task, agents, ev, stats, mode):
|
|
|
3376
3452
|
crit_prompt += "\n\n## 原始任务背景与附件参考\n" + task["context"]
|
|
3377
3453
|
# AI 味确定性检测(借鉴 oh-story 去AI味):客观参考线随评审下发,
|
|
3378
3454
|
# 命中才追加——评审官结合上下文判断是否真问题,脚本不直接扣分
|
|
3379
|
-
|
|
3380
|
-
if _aiflavor_line:
|
|
3381
|
-
crit_prompt += "\n\n## 确定性检测结果(供评审参考)\n" + _aiflavor_line
|
|
3455
|
+
crit_prompt = aiflavor.inject_into_prompt(crit_prompt, manuscript, task.get("type"))
|
|
3382
3456
|
for agent in critics:
|
|
3383
3457
|
role = "critique-r%d" % r
|
|
3384
3458
|
if agent.get("mode") == "mock":
|