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/settings.py
CHANGED
|
@@ -25,7 +25,11 @@ DEFAULTS = {"max_concurrent_jobs": 12, "default_workdir": "", "hooks_token": "",
|
|
|
25
25
|
"telemetry_errors": True, "publish_daily_cap": 10,
|
|
26
26
|
"publish_fail_streak": 3, "notify_webhook": "", "notify_base_url": "",
|
|
27
27
|
"pet_enabled": True, "pet_mode": "always", "pet_skin": "plush",
|
|
28
|
-
"cleanup_enabled": True, "cleanup_retention_days": 14
|
|
28
|
+
"cleanup_enabled": True, "cleanup_retention_days": 14,
|
|
29
|
+
# claude_config_sync:打开/运行前防线是否直写 ~/.claude/settings.json。
|
|
30
|
+
# 关闭后交互 claude 配置归用户手动管理(cc-switch 等),编排步骤经
|
|
31
|
+
# runner 一次性 --settings 注入绑定链,路由不受影响(manager 读取)
|
|
32
|
+
"claude_config_sync": True}
|
|
29
33
|
# 桌宠形象白名单(与 app/pet.py 的 SKINS 对齐;这里不 import pet 模块,避免
|
|
30
34
|
# core 反向依赖 app 根目录脚本)
|
|
31
35
|
PET_SKINS = ("plush", "robot")
|
|
@@ -127,6 +131,8 @@ def save(patch):
|
|
|
127
131
|
cur["pet_skin"] = ps
|
|
128
132
|
if "cleanup_enabled" in patch:
|
|
129
133
|
cur["cleanup_enabled"] = bool(patch.get("cleanup_enabled"))
|
|
134
|
+
if "claude_config_sync" in patch:
|
|
135
|
+
cur["claude_config_sync"] = bool(patch.get("claude_config_sync"))
|
|
130
136
|
if "cleanup_retention_days" in patch:
|
|
131
137
|
try:
|
|
132
138
|
cur["cleanup_retention_days"] = int(patch.get("cleanup_retention_days"))
|
package/app/core/store.py
CHANGED
|
@@ -24,6 +24,12 @@ LOCK = threading.RLock()
|
|
|
24
24
|
_TASKS = {}
|
|
25
25
|
_RUNS = {}
|
|
26
26
|
|
|
27
|
+
# 流式实时落盘的节流表:(run_id, 步骤号) -> 上次落盘时刻。步骤收尾即清,
|
|
28
|
+
# 长跑也不会无限增长(finish_step 里调 clear_stream_state)。
|
|
29
|
+
_STREAM_LOCK = threading.Lock()
|
|
30
|
+
_STREAM_TS = {}
|
|
31
|
+
LIVE_TEXT_MAX = 20000 # 单步骤实时思考/正文的保留上限(与 builtin_agent 同口径)
|
|
32
|
+
|
|
27
33
|
# ---------------------------------------------------------------- 状态版本(SSE 事件驱动)
|
|
28
34
|
# 任何落盘写都算状态变化:SSE 连接等版本号变化才构建/推送全量状态,
|
|
29
35
|
# 空闲时连接零开销(此前每连接每 0.8s 盲构建全量 payload,多端并发会放大成假死)
|
|
@@ -218,11 +224,10 @@ def create_task(payload):
|
|
|
218
224
|
# 不会因分批而错位。
|
|
219
225
|
vol_spec = volumes.norm_spec(serial.get("volumes"))
|
|
220
226
|
if not vol_spec:
|
|
221
|
-
#
|
|
222
|
-
#
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
or volumes.parse_spec_text(serial.get("volumes_text")) \
|
|
227
|
+
# 否则从文字里识别:表单「指定卷结构」框 > 任务目标 > 上下文。
|
|
228
|
+
# 保守识别——认不出返回空,绝不把普通文字误判成分卷。
|
|
229
|
+
vol_spec = volumes.parse_spec_text(serial.get("volumes_text")) \
|
|
230
|
+
or volumes.parse_spec_text(goal) \
|
|
226
231
|
or volumes.parse_spec_text(task.get("context") or "")
|
|
227
232
|
if vol_spec:
|
|
228
233
|
s["volumes"] = vol_spec
|
|
@@ -548,6 +553,9 @@ def create_run(kind, title, task_id=None, entry_id=None, op=None):
|
|
|
548
553
|
_RUNS.pop(run["id"], None)
|
|
549
554
|
raise
|
|
550
555
|
_RUNS[run["id"]] = run
|
|
556
|
+
# 新 run(排队/起跑)必须唤醒 SSE:否则侧栏 task_latest 感知不到
|
|
557
|
+
# 「最新一次运行」已易主,字形/时间停在上一轮(2026-09-22 侧栏假在跑案)
|
|
558
|
+
bump_state()
|
|
551
559
|
return run
|
|
552
560
|
|
|
553
561
|
|
|
@@ -738,7 +746,13 @@ def update_run(run_id, expected_status=None, **fields):
|
|
|
738
746
|
"error": str(run.get("error") or "")[:300]}}).start()
|
|
739
747
|
except Exception:
|
|
740
748
|
pass
|
|
741
|
-
|
|
749
|
+
# 每次成功写入都唤醒 SSE:run 的状态/步骤变化从前只能靠无关操作
|
|
750
|
+
# (自动化落盘、删记录)顺带广播,空闲时侧栏 task_latest 会冻在
|
|
751
|
+
# 「在跑」直到下一次无关 bump(2026-09-22 侧栏假在跑案)。
|
|
752
|
+
# 调用都是步骤级边界,不会形成推送风暴;CAS 拒绝/无此 run 的早退
|
|
753
|
+
# 路径不动版本号。
|
|
754
|
+
bump_state()
|
|
755
|
+
return run
|
|
742
756
|
|
|
743
757
|
|
|
744
758
|
def run_dir(run_id):
|
|
@@ -1525,9 +1539,58 @@ def add_step(run_id, role, agent_id, agent_label, note=""):
|
|
|
1525
1539
|
return step, log_abs
|
|
1526
1540
|
|
|
1527
1541
|
|
|
1542
|
+
def stream_step(run_id, n, thinking=None, text=None, activity=None, min_secs=0.6):
|
|
1543
|
+
"""步骤运行中实时落一段「思考过程/正文/活动」——对话时间线边跑边打印。
|
|
1544
|
+
|
|
1545
|
+
直连对话此前整轮只有三点打字动画(32 秒黑箱,用户 2026-09-22 反馈);
|
|
1546
|
+
模型的思维链与正文在流式回调里到达,这里节流落盘(默认 0.6s 一次),
|
|
1547
|
+
前端 2s 轮询一次读到的就是「它正在想什么」。
|
|
1548
|
+
|
|
1549
|
+
节流按步骤记忆上次落盘时刻;收尾时 finish_step 会写入最终值并清掉
|
|
1550
|
+
live 标记。返回是否真的落盘(调用方可据此决定要不要做下一步)。
|
|
1551
|
+
"""
|
|
1552
|
+
key = (run_id, int(n))
|
|
1553
|
+
now = time.time()
|
|
1554
|
+
with _STREAM_LOCK:
|
|
1555
|
+
last = _STREAM_TS.get(key, 0)
|
|
1556
|
+
if min_secs and now - last < min_secs:
|
|
1557
|
+
return False
|
|
1558
|
+
_STREAM_TS[key] = now
|
|
1559
|
+
with LOCK:
|
|
1560
|
+
run = _RUNS.get(run_id)
|
|
1561
|
+
if not run:
|
|
1562
|
+
return False
|
|
1563
|
+
for s in run["steps"]:
|
|
1564
|
+
if s["n"] != n:
|
|
1565
|
+
continue
|
|
1566
|
+
if thinking is not None:
|
|
1567
|
+
s["thinking"] = str(thinking)[-LIVE_TEXT_MAX:]
|
|
1568
|
+
if text is not None:
|
|
1569
|
+
s["stream"] = str(text)[-LIVE_TEXT_MAX:]
|
|
1570
|
+
if activity:
|
|
1571
|
+
acts = list(s.get("activity") or [])
|
|
1572
|
+
if not acts or acts[-1] != activity:
|
|
1573
|
+
acts.append(str(activity)[:200])
|
|
1574
|
+
s["activity"] = acts[-8:]
|
|
1575
|
+
s["live"] = int(s.get("live") or 0) + 1 # 前端重绘签名(内容变即刷新)
|
|
1576
|
+
_save_json(paths.RUNS_DIR / run_id / "run.json", run)
|
|
1577
|
+
return True
|
|
1578
|
+
return False
|
|
1579
|
+
|
|
1580
|
+
|
|
1581
|
+
def clear_stream_state(run_id=None, n=None):
|
|
1582
|
+
"""清掉流式节流状态(步骤收尾/测试收场用),避免字典随长跑无限增长。"""
|
|
1583
|
+
with _STREAM_LOCK:
|
|
1584
|
+
if run_id is None:
|
|
1585
|
+
_STREAM_TS.clear()
|
|
1586
|
+
return
|
|
1587
|
+
for key in [k for k in _STREAM_TS if k[0] == run_id and (n is None or k[1] == n)]:
|
|
1588
|
+
_STREAM_TS.pop(key, None)
|
|
1589
|
+
|
|
1590
|
+
|
|
1528
1591
|
def finish_step(run_id, n, status, summary="", exit_code=None,
|
|
1529
1592
|
cost_usd=0.0, tokens=0.0, duration_s=None, model=None, output=None,
|
|
1530
|
-
followups=None):
|
|
1593
|
+
followups=None, thinking=None):
|
|
1531
1594
|
with LOCK:
|
|
1532
1595
|
run = _RUNS.get(run_id)
|
|
1533
1596
|
if not run:
|
|
@@ -1550,12 +1613,20 @@ def finish_step(run_id, n, status, summary="", exit_code=None,
|
|
|
1550
1613
|
# output 是智能体正文(对话气泡直读):只剥 ANSI,不做噪声折叠
|
|
1551
1614
|
# ——正文里的装饰性长串是作者写的,不能替它省略
|
|
1552
1615
|
s["output"] = runner.strip_ansi(str(output))[:6000]
|
|
1616
|
+
if thinking is not None:
|
|
1617
|
+
s["thinking"] = runner.strip_ansi(str(thinking))[:LIVE_TEXT_MAX]
|
|
1553
1618
|
if followups:
|
|
1554
1619
|
s["followups"] = list(followups)[:3]
|
|
1620
|
+
# 收尾即清运行中态:stream/activity/live 只是过程快照,留着会让
|
|
1621
|
+
# 前端把「已结束」的步骤仍当实时流渲染(也白占 run.json 体积)
|
|
1622
|
+
s.pop("stream", None)
|
|
1623
|
+
s.pop("activity", None)
|
|
1624
|
+
s.pop("live", None)
|
|
1555
1625
|
break
|
|
1556
1626
|
run["cost_usd"] = round(run.get("cost_usd", 0.0) + cost_usd, 4)
|
|
1557
1627
|
run["tokens"] = run.get("tokens", 0) + tokens
|
|
1558
1628
|
_save_json(paths.RUNS_DIR / run_id / "run.json", run)
|
|
1629
|
+
clear_stream_state(run_id, n)
|
|
1559
1630
|
|
|
1560
1631
|
|
|
1561
1632
|
# ---------------------------------------------------------------- 运行中指挥(消息信箱)
|
package/app/core/volumes.py
CHANGED
|
@@ -169,8 +169,10 @@ def parse_spec_text(text):
|
|
|
169
169
|
「第一卷 少年初入江湖 第1-20章;卷二 风云再起 21-40章」)。
|
|
170
170
|
|
|
171
171
|
保守策略:必须出现 ≥1 个卷标记(第X卷/卷X/第X部/第X篇)才认为用户在
|
|
172
|
-
|
|
173
|
-
|
|
172
|
+
分卷;且**每个卷段都必须带边界**(章号范围 / 章数 / 「N章起」开放标记)。
|
|
173
|
+
缺边界的段一律判为「不是分卷信息」——「第一卷要吸引人」这类句子里的
|
|
174
|
+
「第一卷」是修辞不是结构,绝不能据此把全书切成一个卷。正常写法都带边界
|
|
175
|
+
(「第一卷 少年初入江湖 20章」「第1-20章」「21章起」),因此不受影响。
|
|
174
176
|
|
|
175
177
|
切段方式:按**卷标记出现位置**切(而非按行/分号),这样「卷一 甲 20章,
|
|
176
178
|
卷二 乙 25章」这种同一行多卷也能正确拆开。"""
|
|
@@ -191,7 +193,7 @@ def parse_spec_text(text):
|
|
|
191
193
|
end = marks[i + 1].start() if i + 1 < len(marks) else len(text)
|
|
192
194
|
segs.append((text[m.start():end], key))
|
|
193
195
|
out = []
|
|
194
|
-
for
|
|
196
|
+
for seg, _num in segs:
|
|
195
197
|
m = _VOL_RE.match(seg) # seg 以卷标记起头,相对位置 0
|
|
196
198
|
if not m:
|
|
197
199
|
continue
|
|
@@ -208,8 +210,8 @@ def parse_spec_text(text):
|
|
|
208
210
|
m2 = _COUNT_RE.search(body) or _COUNT_RE.search(seg[:m.start()])
|
|
209
211
|
chapters = _to_int(m2.group(1)) if m2 else None
|
|
210
212
|
start, end = None, None
|
|
211
|
-
if
|
|
212
|
-
#
|
|
213
|
+
if not (chapters or (start and end) or (op and start)):
|
|
214
|
+
# 该卷段没有可用的边界:整段判为不是分卷信息(见 docstring)
|
|
213
215
|
return []
|
|
214
216
|
out.append({"title": _clean_title(seg, m), "start": start,
|
|
215
217
|
"end": end, "chapters": chapters})
|
package/app/core/zentao.py
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
数据落盘 <data>/zentao.json(tmp + os.replace 原子写;TUTTI_DATA 环境变量感知)。
|
|
5
5
|
修复走与 /api/tasks 完全相同的链路(store.create_task → store.create_run →
|
|
6
6
|
jobs.enqueue,code 引擎=实现→验证→评审→修复),不自造运行器。调度挂在
|
|
7
|
-
automation._tick(同 publish/auto.fire_due 模式),内部按
|
|
7
|
+
automation._tick(同 publish/auto.fire_due 模式),内部按 interval_minutes 节流。
|
|
8
8
|
|
|
9
9
|
禅道 REST API v1(开源版 15.x+;请求头 Token: <token>):
|
|
10
10
|
POST {base}/api.php/v1/tokens {account, password} → {token}
|
|
@@ -76,7 +76,9 @@ PAGE_LIMIT = 100
|
|
|
76
76
|
MAX_BUGS = 500
|
|
77
77
|
RESOLVE_MAX_ATTEMPTS = 3
|
|
78
78
|
RETRY_DELAY_MIN = 30
|
|
79
|
-
INTERVAL_MIN, INTERVAL_MAX =
|
|
79
|
+
INTERVAL_MIN, INTERVAL_MAX = 5, 10080 # 扫描间隔(分钟):5 分钟 ~ 7 天
|
|
80
|
+
INTERVAL_DEFAULT = 5
|
|
81
|
+
_LEGACY_INTERVAL_HOURS_DEFAULT = 2 # 老配置 interval_hours 缺省值(迁移用)
|
|
80
82
|
|
|
81
83
|
SIDES = ("backend", "frontend")
|
|
82
84
|
TRIAGE_SIDES = ("backend", "frontend", "both", "not_ours") # unknown 单列
|
|
@@ -91,7 +93,7 @@ _CFG_DEFAULTS = {
|
|
|
91
93
|
"auto_merge": True,
|
|
92
94
|
"triage_ai": True, # 模块路由未命中时用 AI 兜底排查
|
|
93
95
|
"poll_enabled": False,
|
|
94
|
-
"
|
|
96
|
+
"interval_minutes": INTERVAL_DEFAULT,
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
_REPO_DEFAULTS = {"workdir": "", "git_rev": "", "verify_command": ""}
|
|
@@ -109,7 +111,7 @@ _PROFILE_DEFAULTS = {
|
|
|
109
111
|
|
|
110
112
|
_UPDATABLE = ("base_url", "account", "password", "product_profiles",
|
|
111
113
|
"auto_resolve", "auto_merge", "triage_ai",
|
|
112
|
-
"poll_enabled", "
|
|
114
|
+
"poll_enabled", "interval_minutes")
|
|
113
115
|
|
|
114
116
|
|
|
115
117
|
# ---------------------------------------------------------------- 出网边界(SSRF)
|
|
@@ -232,6 +234,17 @@ def load(force=False):
|
|
|
232
234
|
# 必须在按 _CFG_DEFAULTS 过滤之前完成迁移)
|
|
233
235
|
if isinstance(cfg, dict) and not cfg.get("product_profiles"):
|
|
234
236
|
cfg = _migrate_legacy(dict(cfg))
|
|
237
|
+
# 老配置用 interval_hours(小时),新配置改用 interval_minutes(分钟):
|
|
238
|
+
# 用户没动过间隔(还是老默认 2 小时)就跟随新默认 5 分钟;真改过的按小时换算保留。
|
|
239
|
+
if isinstance(cfg, dict) and "interval_minutes" not in cfg \
|
|
240
|
+
and "interval_hours" in cfg:
|
|
241
|
+
try:
|
|
242
|
+
hours = int(cfg.get("interval_hours") or _LEGACY_INTERVAL_HOURS_DEFAULT)
|
|
243
|
+
except (TypeError, ValueError):
|
|
244
|
+
hours = _LEGACY_INTERVAL_HOURS_DEFAULT
|
|
245
|
+
cfg = dict(cfg)
|
|
246
|
+
mins = INTERVAL_DEFAULT if hours == _LEGACY_INTERVAL_HOURS_DEFAULT else hours * 60
|
|
247
|
+
cfg["interval_minutes"] = max(INTERVAL_MIN, min(INTERVAL_MAX, mins))
|
|
235
248
|
merged = dict(_CFG_DEFAULTS)
|
|
236
249
|
if isinstance(cfg, dict):
|
|
237
250
|
merged.update({k: v for k, v in cfg.items() if k in _CFG_DEFAULTS})
|
|
@@ -623,7 +636,8 @@ def _pairs_to_list(pairs, id_key, name_key):
|
|
|
623
636
|
def _module_items(d):
|
|
624
637
|
"""老版模块树形状兼容(2026-09-21 真机反馈「响应形状不认识」):
|
|
625
638
|
sons/modules 数组、{id: {name,...}} 字典、children/sons 嵌套树,统一
|
|
626
|
-
递归展开成 [{id,name}] 平铺清单(id=0
|
|
639
|
+
递归展开成 [{id,name}] 平铺清单(id=0 是树根容器,跳过);全数字键的
|
|
640
|
+
{id: 名字} 扁平映射也认(部分版本的模块摘要形状)。"""
|
|
627
641
|
out = []
|
|
628
642
|
|
|
629
643
|
def walk(node):
|
|
@@ -637,11 +651,19 @@ def _module_items(d):
|
|
|
637
651
|
if isinstance(node.get(k), (list, dict)):
|
|
638
652
|
walk(node[k])
|
|
639
653
|
else:
|
|
654
|
+
if node and all(str(k).isdigit() for k in node.keys()):
|
|
655
|
+
flat = [{"id": k, "name": str(v)}
|
|
656
|
+
for k, v in node.items() if str(v or "").strip()]
|
|
657
|
+
if flat:
|
|
658
|
+
out.extend(flat)
|
|
659
|
+
return
|
|
640
660
|
for v in node.values():
|
|
641
661
|
walk(v)
|
|
642
662
|
|
|
643
663
|
if isinstance(d, dict):
|
|
644
|
-
|
|
664
|
+
# tree 优先:真机 tree=带 children 的完整嵌套树,sons 只有顶层扁平表
|
|
665
|
+
# (走 sons 会漏子模块)
|
|
666
|
+
walk(d.get("tree") or d.get("sons") or d.get("modules") or d)
|
|
645
667
|
elif isinstance(d, list):
|
|
646
668
|
walk(d)
|
|
647
669
|
return out
|
|
@@ -724,8 +746,27 @@ def _old_route(api, method, p, q, body):
|
|
|
724
746
|
|
|
725
747
|
m = re.match(r"^/products/(\d+)/modules$", p)
|
|
726
748
|
if m and method == "GET":
|
|
727
|
-
|
|
728
|
-
|
|
749
|
+
# 2026-09-22 真机实探:该版模块行 type=story,viewType=module 查出空树
|
|
750
|
+
# (status 仍 success)——「响应形状不认识」的真根因。改按 bug 视图拉
|
|
751
|
+
# (bug 表单模块下拉用的就是这棵树),story/case 兜底。
|
|
752
|
+
pid = m.group(1)
|
|
753
|
+
last, saw_js = None, False
|
|
754
|
+
for vt in ("bug", "story", "case"):
|
|
755
|
+
d = go(get("/tree-browse-%s-%s.json" % (pid, vt)), "(拉模块清单)")
|
|
756
|
+
last = d
|
|
757
|
+
if isinstance(d, dict) and d.get("_js"):
|
|
758
|
+
saw_js = True # 接口回了页面脚本:换视图再试
|
|
759
|
+
continue
|
|
760
|
+
items = [x for x in _module_items(d) if isinstance(x, dict) and x.get("id")]
|
|
761
|
+
if items:
|
|
762
|
+
return {"_list": items}
|
|
763
|
+
if saw_js:
|
|
764
|
+
raise ZenError("禅道老接口(拉模块清单):回了页面脚本而不是数据"
|
|
765
|
+
"(模块树接口不在或被重定向)——请在禅道产品视图 URL 里查模块 ID 手工填写")
|
|
766
|
+
keys = ",".join(sorted(str(k) for k in last.keys())) \
|
|
767
|
+
if isinstance(last, dict) else type(last).__name__
|
|
768
|
+
raise ZenError("该产品模块树为空(bug/story/case 三个视图都没拉到模块,"
|
|
769
|
+
"响应顶层键:%s)——请到禅道确认产品下建过模块,或手工填模块 ID" % keys)
|
|
729
770
|
|
|
730
771
|
if p == "/products" and method == "GET":
|
|
731
772
|
d = go(get("/api-getmodel-product-getpairs.json"), "(拉产品清单)")
|
|
@@ -847,8 +888,12 @@ def fetch_modules(product_id):
|
|
|
847
888
|
try:
|
|
848
889
|
d = _call("GET", "/products/%s/modules" % product_id, cfg=cfg)
|
|
849
890
|
except ZenError as e:
|
|
850
|
-
|
|
851
|
-
|
|
891
|
+
msg = str(e)
|
|
892
|
+
# 只有「接口不存在」类错误才补「没有该接口」提示;老通道自己报的
|
|
893
|
+
# 空树/脚本回包已带人话结论,别再叠一层误导
|
|
894
|
+
if "404" in msg or "不认识" in msg:
|
|
895
|
+
msg += "——也可能你的禅道没有该接口:请在禅道产品视图 URL 里查模块 ID 手工填写"
|
|
896
|
+
return {"ok": False, "error": msg}
|
|
852
897
|
# 形状兼容器({_list} / {modules:[...]} / {id:{...}} 字典 / 裸数组)
|
|
853
898
|
items = _list_items(d, "modules")
|
|
854
899
|
if items is None:
|
|
@@ -858,6 +903,11 @@ def fetch_modules(product_id):
|
|
|
858
903
|
if isinstance(m, dict) and m.get("id"):
|
|
859
904
|
out.append({"id": m.get("id"), "name": str(m.get("name") or "")})
|
|
860
905
|
if not out:
|
|
906
|
+
if isinstance(d, dict) and set(d.keys()) == {"_list"}:
|
|
907
|
+
# 自家归一包装(裸数组进 _api/_old_route 时打的包):空清单=
|
|
908
|
+
# 产品真没模块,不是形状问题,别拿顶层键吓人
|
|
909
|
+
return {"ok": False,
|
|
910
|
+
"error": "禅道回了空模块清单(该产品可能没建模块)——请手工填模块 ID"}
|
|
861
911
|
# 带响应形状摘要帮排查(顶层键名,不吐正文——用户实测反馈
|
|
862
912
|
# 「响应形状不认识」却看不到真实形状,没法报修)
|
|
863
913
|
keys = ",".join(sorted(str(k) for k in d.keys())) if isinstance(d, dict) else type(d).__name__
|
|
@@ -1627,7 +1677,7 @@ def _poll(force=False):
|
|
|
1627
1677
|
err = "扫描失败:%s" % e
|
|
1628
1678
|
log.warning("zentao: %s", err)
|
|
1629
1679
|
now = datetime.now()
|
|
1630
|
-
delay = timedelta(
|
|
1680
|
+
delay = timedelta(minutes=max(INTERVAL_MIN, int(cfg.get("interval_minutes") or INTERVAL_DEFAULT)))
|
|
1631
1681
|
if err:
|
|
1632
1682
|
delay = timedelta(minutes=RETRY_DELAY_MIN)
|
|
1633
1683
|
with _LOCK:
|
|
@@ -1770,11 +1820,11 @@ def save_config(patch):
|
|
|
1770
1820
|
v = _norm_profiles(v)
|
|
1771
1821
|
if v is None:
|
|
1772
1822
|
continue
|
|
1773
|
-
elif k == "
|
|
1823
|
+
elif k == "interval_minutes":
|
|
1774
1824
|
try:
|
|
1775
1825
|
v = max(INTERVAL_MIN, min(INTERVAL_MAX, int(v)))
|
|
1776
1826
|
except (TypeError, ValueError):
|
|
1777
|
-
raise ValueError("
|
|
1827
|
+
raise ValueError("interval_minutes 必须是 %d-%d 的整数"
|
|
1778
1828
|
% (INTERVAL_MIN, INTERVAL_MAX))
|
|
1779
1829
|
elif k in ("auto_resolve", "auto_merge", "triage_ai", "poll_enabled"):
|
|
1780
1830
|
v = bool(v)
|
package/app/main.py
CHANGED
|
@@ -20,7 +20,17 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
20
20
|
from pathlib import Path
|
|
21
21
|
from urllib.parse import parse_qs, unquote, urlparse
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
# 测试主目录隔离(TUTTI_ISOLATE_HOME=1):必须先于 core.* 导入生效——
|
|
24
|
+
# modelhub/manager 在 import 期就绑定 ~ 路径。launch/运行前防线会直写
|
|
25
|
+
# ~/.claude 等真实 CLI 配置(2026-09-22 a.test 毒配置案),测试服务一律带上
|
|
26
|
+
# 本开关,绝不落真实用户目录。
|
|
27
|
+
if os.environ.get("TUTTI_ISOLATE_HOME", "").strip() == "1":
|
|
28
|
+
import tempfile as _tempfile
|
|
29
|
+
_fake_home = _tempfile.mkdtemp(prefix="tutti-home-")
|
|
30
|
+
os.environ["USERPROFILE"] = _fake_home
|
|
31
|
+
os.environ["HOME"] = _fake_home
|
|
32
|
+
|
|
33
|
+
from core import automation, catalog, flows, jobs, manager, market, market_remote, preview, registry, remote, settings, store
|
|
24
34
|
from core import paths
|
|
25
35
|
from core import health
|
|
26
36
|
import pick_dialog
|
|
@@ -196,6 +206,10 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
196
206
|
# 静态页不设防(无敏感信息):远程裸地址打开时由前端令牌门引导输入
|
|
197
207
|
if path in ("/", "/index.html"):
|
|
198
208
|
return self._static("index.html")
|
|
209
|
+
# 运行预览:工作目录里网页成品的只读挂载,供 iframe/新窗口直接打开。
|
|
210
|
+
# 令牌走路径段(子资源继承不了查询串),鉴权复用 remote.request_authed。
|
|
211
|
+
if path == preview.BASE or path.startswith(preview.BASE + "/"):
|
|
212
|
+
return self._api_preview(path)
|
|
199
213
|
if path.startswith("/api/"):
|
|
200
214
|
if not self._authed():
|
|
201
215
|
return self._json(401, {"error": "需要访问令牌(启动 CodeBee 时控制台会显示)"})
|
|
@@ -390,6 +404,10 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
390
404
|
files = []
|
|
391
405
|
return self._json(200, {"workdir": wd, "files": files,
|
|
392
406
|
"task_id": run.get("task_id") or ""})
|
|
407
|
+
m = re.match(r"^/api/runs/([^/]+)/preview$", path)
|
|
408
|
+
if m:
|
|
409
|
+
# 网页成品预览:入口 HTML + 同目录代码文件(前端渲染「预览」页签)
|
|
410
|
+
return self._json(200, preview.list_app(m.group(1), remote.token()))
|
|
393
411
|
m = re.match(r"^/api/tasks/([^/]+)/side$", path)
|
|
394
412
|
if m:
|
|
395
413
|
# 任务检查器(右缘停靠列)专用:轻量聚合、可轮询,不带 diff 文本
|
|
@@ -1892,6 +1910,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1892
1910
|
body = s.get("output") or ""
|
|
1893
1911
|
if not body:
|
|
1894
1912
|
body = s.get("summary") or ""
|
|
1913
|
+
running = (s.get("status") or "") == "running"
|
|
1895
1914
|
items.append({
|
|
1896
1915
|
"kind": "agent",
|
|
1897
1916
|
"at": s.get("ended_at") or s.get("started_at") or "",
|
|
@@ -1904,6 +1923,13 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1904
1923
|
"run": run_id_of_step,
|
|
1905
1924
|
"log": s.get("log") or "",
|
|
1906
1925
|
"followups": s.get("followups") or [],
|
|
1926
|
+
# 思考过程(2026-09-22 用户诉求):内置智能体流式抓的思维链随步骤
|
|
1927
|
+
# 落库,收尾保留;运行中另有 stream(正文实时预览)与 activity
|
|
1928
|
+
# (工具活动行)——前端边跑边打印,不再只有三点打字动画。
|
|
1929
|
+
"thinking": s.get("thinking") or "",
|
|
1930
|
+
"stream": (s.get("stream") or "") if running else "",
|
|
1931
|
+
"activity": (s.get("activity") or []) if running else [],
|
|
1932
|
+
"live": s.get("live") or 0,
|
|
1907
1933
|
})
|
|
1908
1934
|
|
|
1909
1935
|
for r in runs:
|
|
@@ -2269,6 +2295,45 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
2269
2295
|
suffix = p.suffix.lower()
|
|
2270
2296
|
self._send(200, p.read_bytes(), MIME.get(suffix, "application/octet-stream"))
|
|
2271
2297
|
|
|
2298
|
+
# ------------------------------------------------------------ 运行预览
|
|
2299
|
+
def _api_preview(self, path):
|
|
2300
|
+
"""GET /preview/<run_id>/<令牌>/<相对路径>:工作目录网页成品的只读挂载。
|
|
2301
|
+
|
|
2302
|
+
令牌放路径段而不是查询串:iframe 里的 style.css / script.js 是浏览器按
|
|
2303
|
+
文档 URL 相对解析的,查询串不会被子资源继承(令牌会掉)。
|
|
2304
|
+
"""
|
|
2305
|
+
rest = path[len(preview.BASE):].lstrip("/")
|
|
2306
|
+
parts = rest.split("/")
|
|
2307
|
+
if len(parts) < 2:
|
|
2308
|
+
return self._json(404, {"error": "not found"})
|
|
2309
|
+
run_id, tok = unquote(parts[0]), unquote(parts[1])
|
|
2310
|
+
rel = unquote("/".join(parts[2:]))
|
|
2311
|
+
# 令牌直接交给 remote.request_authed:与 /api/* 同一把尺子(本机豁免、
|
|
2312
|
+
# 代理下带转发头的 loopback 算远程),不另写一套鉴权
|
|
2313
|
+
ip, fw = self._forwarded_ip()
|
|
2314
|
+
if not remote.request_authed(ip, fw, tok, self.headers.get("X-CodeBee-Token") or ""):
|
|
2315
|
+
return self._json(401, {"error": "需要访问令牌(启动 CodeBee 时控制台会显示)"})
|
|
2316
|
+
if not rel or rel.endswith("/"):
|
|
2317
|
+
# 目录地址(/<令牌>/ 或 /<令牌>/web/):回落到该目录的入口页
|
|
2318
|
+
app = preview.list_app(run_id, tok)
|
|
2319
|
+
if not app.get("ok"):
|
|
2320
|
+
return self._json(404, {"error": "没有可预览的页面"})
|
|
2321
|
+
rel = app["entry"]
|
|
2322
|
+
if not self._preview_dir_ok(path, rel):
|
|
2323
|
+
return self._json(404, {"error": "not found"})
|
|
2324
|
+
data, ctype, err = preview.serve(run_id, rel, tok)
|
|
2325
|
+
if err:
|
|
2326
|
+
return self._json(404, {"error": err})
|
|
2327
|
+
return self._send(200, data, ctype)
|
|
2328
|
+
|
|
2329
|
+
def _preview_dir_ok(self, path, entry):
|
|
2330
|
+
"""目录请求只允许回落到该目录自己的入口(/x/ 只解析 x/index.html),
|
|
2331
|
+
免得 /sub/ 悄悄把根入口当子目录入口发出去。"""
|
|
2332
|
+
want = urlparse(path).path[len(preview.BASE):].strip("/").split("/")[2:]
|
|
2333
|
+
want = "/".join(unquote(p) for p in want if p)
|
|
2334
|
+
edir = entry.rsplit("/", 1)[0] if "/" in entry else ""
|
|
2335
|
+
return want == edir
|
|
2336
|
+
|
|
2272
2337
|
|
|
2273
2338
|
def _state_payload(client_id="", ver=None):
|
|
2274
2339
|
agents = registry.effective_agents(catalog.load(), manager.detect_all())
|