codebee 0.1.4 → 0.1.6

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/app/main.py CHANGED
@@ -25,6 +25,8 @@ from core import health
25
25
 
26
26
  MIME = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
27
27
  ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png",
28
+ ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif",
29
+ ".webp": "image/webp", ".bmp": "image/bmp",
28
30
  ".ico": "image/x-icon", ".json": "application/manifest+json; charset=utf-8",
29
31
  ".webmanifest": "application/manifest+json; charset=utf-8"}
30
32
 
@@ -320,6 +322,21 @@ class Handler(BaseHTTPRequestHandler):
320
322
  m = re.match(r"^/api/runs/([^/]+)/timeline$", path)
321
323
  if m:
322
324
  return self._api_run_timeline(m.group(1))
325
+ m = re.match(r"^/api/attachments/([0-9a-f]{16})$", path)
326
+ if m:
327
+ # 待提交附件原文(对话输入条胶囊点击预览):只读,id 猜不中即 404
328
+ from core import attachments
329
+ data, mime, name = attachments.read_pending(m.group(1))
330
+ if not data:
331
+ return self._json(404, {"error": "附件不存在或已提交"})
332
+ ext = Path(name.lower()).suffix
333
+ ctype = MIME.get(ext, mime or "application/octet-stream")
334
+ if ctype.startswith("text/") or ext in (
335
+ ".txt", ".md", ".log", ".csv", ".json", ".xml",
336
+ ".yml", ".yaml", ".toml", ".py", ".js", ".ts", ".html", ".css"):
337
+ ctype = "text/plain; charset=utf-8"
338
+ data = _utf8_bytes(data)
339
+ return self._send(200, data, ctype)
323
340
  if path == "/api/automation":
324
341
  return self._json(200, {"tasks": automation.list_tasks(),
325
342
  "templates": automation.templates()})
@@ -572,6 +589,14 @@ class Handler(BaseHTTPRequestHandler):
572
589
  err = modelhub.reorder_models(body.get("provider_id") or "",
573
590
  body.get("names") or [])
574
591
  return self._json(400, {"error": err}) if err else self._json(200, {"ok": True})
592
+ if path == "/api/models/model-caps":
593
+ # 模态能力声明:当前仅 image_in(图片输入),内置智能体传图以此为准
594
+ from core import modelhub
595
+ body = self._body()
596
+ err = modelhub.set_model_caps(body.get("provider_id") or "",
597
+ body.get("name") or "",
598
+ body.get("image_in"))
599
+ return self._json(400, {"error": err}) if err else self._json(200, {"ok": True})
575
600
  if path == "/api/models/test-provider":
576
601
  from core import modelhub
577
602
  return self._json(200, modelhub.test_provider(self._body().get("id") or ""))
@@ -1122,16 +1147,51 @@ class Handler(BaseHTTPRequestHandler):
1122
1147
  return self._json(200, {"ok": True, "message": msg})
1123
1148
 
1124
1149
  def _api_run_timeline(self, run_id):
1125
- """直连对话视图的时间线:用户消息与各步 CLI 输出按序合并成一个气泡流。
1150
+ """直连对话视图的时间线:目标 + 用户消息 + 各步最终回答按序合并成气泡流。
1126
1151
 
1127
- 步骤正文取自该步日志文件(CLI 原始输出),按 created/序号排序:
1128
- [user 消息] → [assistant 输出] → [user 追问] → …。供详情页「对话」分区直读,
1129
- 前端不必逐条拉日志。非 direct 任务也能取(返回步骤流),只是视图不启用。"""
1152
+ 形状:[任务目标] [用户消息] → [assistant 输出] → [用户追问] → …。
1153
+ 步骤正文只认 output/summary(干净回答),供详情页「对话」分区直读。
1154
+
1155
+ 直连任务按「任务级」回放(跨该任务全部 run 依时间合并):追话每轮
1156
+ 起一个新 run,用户消息有继承、智能体回答(步骤 output)不继承——只
1157
+ 回放单个 run 会把历史轮的回答全丢掉(2026-09-17 实测:一发消息,
1158
+ 之前智能体输出从时间线消失)。继承的消息是同 id 副本,跨 run 按
1159
+ (id, 时间, 文本) 去重;每个 run 内先消息后步骤(消息总是先于本轮
1160
+ 回答送达)。非 direct 任务保持单 run 步骤流(视图未启用)。"""
1130
1161
  run = store.get_run(run_id)
1131
1162
  if not run:
1132
1163
  return self._json(404, {"error": "not found"})
1164
+ task = store.get_task(run.get("task_id") or "") if run.get("task_id") else None
1165
+ engine = (task or {}).get("engine") or ""
1133
1166
  items = []
1134
- for m in (run.get("messages") or []):
1167
+ if engine == "direct":
1168
+ runs = list(reversed(store.task_runs(run["task_id"]))) # 旧→新
1169
+ else:
1170
+ runs = [run]
1171
+ # 首条用户气泡=任务目标:否则对话开场只有智能体在说话(用户看不到自己问了什么)。
1172
+ # 附件归一成 _attachments/ 相对路径:前端展示仍取文件名(attName 剥目录),
1173
+ # 但「点击查看」需要相对路径走 /api/runs/<id>/file 通道——只下发文件名的话
1174
+ # 点开永远 404。时间取任务创建时刻(对话的开场是任务本身,不是某一轮续跑的起始时间)
1175
+ if task and engine == "direct":
1176
+ from core import attachments as _att
1177
+ atts = [p for p in (_att.norm_rel(a) for a in (task.get("attachments") or [])) if p]
1178
+ items.append({
1179
+ "kind": "user",
1180
+ "at": task.get("created_at") or (runs[0].get("created_at") if runs else "") or "",
1181
+ "who": "用户",
1182
+ "text": task.get("goal") or "",
1183
+ "attachments": atts,
1184
+ "consumed": True,
1185
+ "id": None,
1186
+ })
1187
+ seen_msgs = set()
1188
+
1189
+ def _emit_msg(m):
1190
+ key = (str(m.get("id") or ""), str(m.get("created_at") or ""),
1191
+ str(m.get("text") or ""))
1192
+ if key in seen_msgs:
1193
+ return # retry 继承的同一条消息:只在原 run 位置显示一次
1194
+ seen_msgs.add(key)
1135
1195
  items.append({
1136
1196
  "kind": "user",
1137
1197
  "at": m.get("created_at") or "",
@@ -1141,7 +1201,8 @@ class Handler(BaseHTTPRequestHandler):
1141
1201
  "consumed": bool(m.get("consumed")),
1142
1202
  "id": m.get("id"),
1143
1203
  })
1144
- for s in (run.get("steps") or []):
1204
+
1205
+ def _emit_step(s):
1145
1206
  # 正文只认 output(runner 抽好的最终回答)→ summary。绝不回退读原始
1146
1207
  # 日志:日志是全量事件流(下发提示词回显 + CLI 报错),塞进气泡就成了
1147
1208
  # 「看日志」(2026-09-17 实测症状);运行中的步骤两者都还没有,留空给
@@ -1159,9 +1220,29 @@ class Handler(BaseHTTPRequestHandler):
1159
1220
  "text": body,
1160
1221
  "note": s.get("note") or "",
1161
1222
  })
1223
+
1224
+ for r in runs:
1225
+ msgs = r.get("messages") or []
1226
+ steps = r.get("steps") or []
1227
+ # 消息没有日期(只有 HH:MM:SS),跨 run 不能按时间混排;用语义顺序:
1228
+ # 已消费消息(驱动了本轮回答)→ 已完成步骤 → 未消费消息(run 结束后
1229
+ # 才追话到达的)→ 运行中/排队步骤(打字动画)。追问因此总在上一轮
1230
+ # 回答之后、本轮打字动画之前。
1231
+ for m in msgs:
1232
+ if m.get("consumed"):
1233
+ _emit_msg(m)
1234
+ for s in steps:
1235
+ if (s.get("status") or "") == "done":
1236
+ _emit_step(s)
1237
+ for m in msgs:
1238
+ if not m.get("consumed"):
1239
+ _emit_msg(m)
1240
+ for s in steps:
1241
+ if (s.get("status") or "") != "done":
1242
+ _emit_step(s)
1162
1243
  return self._json(200, {
1163
1244
  "run_id": run_id, "status": run.get("status") or "",
1164
- "engine": (store.get_task(run.get("task_id") or "") or {}).get("engine") or "",
1245
+ "engine": engine,
1165
1246
  "items": items,
1166
1247
  })
1167
1248
 
@@ -1389,6 +1470,19 @@ def main():
1389
1470
  n_rc = store.recover_orphaned_runs()
1390
1471
  if n_rc:
1391
1472
  print("[CodeBee] 崩溃恢复:%d 个遗留运行标记为 failed(interrupted at startup)" % n_rc)
1473
+ try:
1474
+ from core import manager as _mgr
1475
+ n_z = _mgr.sweep_orphan_cli_processes()
1476
+ if n_z:
1477
+ # 服务重启孤儿化的 CLI 孙进程:僵尸 opencode 会劫持后续会话的项目根,
1478
+ # 必须在恢复运行之前清掉(2026-09-17 mo-so「工作目录是 Temp」真凶)
1479
+ print("[CodeBee] 崩溃恢复:清扫 %d 个孤儿 CLI 进程(opencode/codex)" % n_z)
1480
+ except Exception:
1481
+ pass
1482
+ from core import bookmeta
1483
+ n_bo = bookmeta.recover_orphans() # 作品信息生成线程同样会被重启杀掉,遗留 running 收尸
1484
+ if n_bo:
1485
+ print("[CodeBee] 崩溃恢复:%d 条作品信息生成中断标记为 failed(可点重试)" % n_bo)
1392
1486
  try:
1393
1487
  from core import settings_schema
1394
1488
  settings_schema.register_default_namespaces() # budget/cascade/compaction 配置就绪(幂等)