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.
@@ -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
- # 「第一卷 少年初入江湖 第1-20章」式分卷信息(保守识别,
223
- # 认不出就返回空,绝不会把普通文字误判成分卷)。
224
- vol_spec = volumes.parse_spec_text(goal) \
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
- return run
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
  # ---------------------------------------------------------------- 运行中指挥(消息信箱)
@@ -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 idx, (seg, _num) in enumerate(segs):
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 idx < len(segs) - 1 and not (chapters or (start and end)):
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})
@@ -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 模式),内部按 interval_hours 节流。
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 = 1, 168
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
- "interval_hours": 2,
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", "interval_hours")
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
- walk(d.get("sons") or d.get("modules") or d.get("tree") or d)
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
- d = go(get("/tree-browse-%s-module.json" % m.group(1)), "(拉模块清单)")
728
- return {"_list": [x for x in _module_items(d) if isinstance(x, dict) and x.get("id")]}
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
- return {"ok": False,
851
- "error": "%s——也可能你的禅道没有该接口:请在禅道产品视图 URL 里查模块 ID 手工填写" % e}
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(hours=max(INTERVAL_MIN, int(cfg.get("interval_hours") or 2)))
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 == "interval_hours":
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("interval_hours 必须是 %d-%d 的整数"
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
- from core import automation, catalog, flows, jobs, manager, market, market_remote, registry, remote, settings, store
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())