codebee 0.1.11 → 0.1.13

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 CHANGED
@@ -4,6 +4,21 @@ CodeBee 的用户可感知变更记录。发布新版时:最上面加一节,
4
4
  `<!-- relnotes:start -->…<!-- relnotes:end -->` 段(那段会被 `npm view` 的
5
5
  README 元数据带回,供老版本在「发现新版本」时展示新版更新内容)。
6
6
 
7
+ ## v0.1.13(2026-09-19)
8
+
9
+ - 封面图生成:连载任务建书面板新增「生成封面」——调编排者供应商图像 API(cogview 候选,竖版优先)产出 cover.png 到运行目录,SSRF 边界校验 + curl 子进程落盘(Python 不经手图像字节)
10
+ - 需求拷问采访态:目标描述过短时先问 1-3 个澄清问题(选项芯片点选),创建更准
11
+ - 代码任务 Best-of-N:单路实现失败后多候选 worktree 隔离赛马择优
12
+ - 计划落盘任务档案:.codebee/ 三件套(spec.md 意图 / task_plan.md 计划 / evidence.md 验证证据)
13
+ - 步骤卡「实际派发模型」徽章 + 类型菜单成本预估行
14
+
15
+ ## v0.1.12(2026-09-19)
16
+
17
+ - 代码任务计划落盘:每次运行把编排计划写进工作目录 .codebee/task_plan.md,与任务规格(spec.md)、验证证据(evidence.md)同居「任务档案」,断点续跑/复盘全程可查
18
+ - 多子任务防漂移:实现子任务提示词自动带上「第 i/N 项 + 已完成清单」,长任务链不再重做已完成项
19
+ - 连载剧情模块库:plot-modules.md(拆文沉淀的桥段/爽点素材)每章自动注入
20
+ - 连载赛马败者精华:落选候选稿的优点自动并入首轮修订意见
21
+
7
22
  ## v0.1.11(2026-09-18)
8
23
 
9
24
  - **连载发布闭环(主轨)**:番茄/七猫作家后台 CDP 直操通道(连接扫码/建书/发章/表单探测/截图存证)+ 详情页出版台面板;发布台账幂等(章号去重)+ 三道护栏(每日上限/连续失败暂停/人工确认闸——默认填好表单停,提交权留给用户)
@@ -0,0 +1,200 @@
1
+ # -*- coding: utf-8 -*-
2
+ """封面图生成:调供应商图像 API 产出竖版封面插画,curl 直接落盘到运行目录。
3
+
4
+ 接口形状:OpenAI 兼容 POST {base}/images/generations(Z.ai cogview 系列、
5
+ 多数聚合网关都支持,默认返回图片 URL)。模型解析:环境变量
6
+ CODEBEE_IMAGE_MODEL 优先,否则逐个试候选(cogview-3-flash → cogview-4),
7
+ 第一个 2xx 的胜出;同一模型先试竖版尺寸再回落方图。
8
+ 状态机与建书生成同款(running/done/failed 写任务 cover_gen 字段,
9
+ bump_state 推 SSE)。仅支持 openai 协议供应商。
10
+
11
+ 安全边界:
12
+ - 图像 URL 仅接受 https,解析后 IP 命中私网/环回/链路本地一律拒绝
13
+ (防 SSRF 打内网与云元数据);禁用重定向(curl -L 不加)。
14
+ - Python 不经手图像字节:下载与落盘由 curl 子进程 -o 一步完成。
15
+ - 产物只落 paths.RUNS_DIR 受管路径(与 report.md 同款),不写任务工作目录。
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import os
21
+ import socket
22
+ import threading
23
+ import time
24
+ from pathlib import Path
25
+
26
+ log = logging.getLogger(__name__)
27
+
28
+ _IMAGE_CANDIDATES = ("cogview-3-flash", "cogview-4")
29
+ _SIZES = ("768x1344", "1024x1024") # 竖版优先,方图兜底
30
+
31
+
32
+ def _cover_prompt(task):
33
+ """从任务与建书资料拼图像提示词:场景氛围向,不要文字(平台会自行压字)。"""
34
+ bm = ((task.get("book_meta") or {}).get("fanqie") or {}).get("data") or {}
35
+ if not isinstance(bm, dict):
36
+ bm = {}
37
+ title = bm.get("书名") or task.get("title") or ""
38
+ genre = bm.get("类型") or bm.get("分类") or ""
39
+ brief = (bm.get("一句话简介") or bm.get("简介") or task.get("goal") or "")
40
+ return ("竖版小说封面插画,画面中不要出现任何文字。题材:%s %s。故事梗概:%s。"
41
+ "商业网文封面质感:主体人物或核心场景突出,色彩浓郁有冲击力,"
42
+ "构图上方留白便于后期压标题。" % (genre, title, str(brief)[:300]))
43
+
44
+
45
+ def _pick_key(prov):
46
+ from . import modelhub
47
+ keys = modelhub._chain_keys(prov) or []
48
+ if keys and keys[0].get("key"):
49
+ return keys[0]["key"]
50
+ return prov.get("api_key") or ""
51
+
52
+
53
+ def _safe_image_url(url):
54
+ """SSRF 校验:仅 https;解析主机全部 IP,私网/环回/链路本地/保留段拒绝。"""
55
+ import ipaddress
56
+ from urllib.parse import urlparse
57
+ u = urlparse(url)
58
+ if u.scheme != "https":
59
+ raise ValueError("仅允许 https 图像地址")
60
+ host = u.hostname or ""
61
+ if not host:
62
+ raise ValueError("图像地址缺少主机")
63
+ infos = socket.getaddrinfo(host, u.port or 443, proto=socket.IPPROTO_TCP)
64
+ if not infos:
65
+ raise ValueError("图像主机无法解析")
66
+ for info in infos:
67
+ ip = ipaddress.ip_address(info[4][0])
68
+ if (ip.is_private or ip.is_loopback or ip.is_link_local
69
+ or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
70
+ raise ValueError("图像主机解析到受限地址,已拒绝")
71
+ return url
72
+
73
+
74
+ def _curl_to(url, out_path):
75
+ """curl 子进程下载落盘(Python 不经手图像字节;不加 -L 禁重定向)。"""
76
+ from . import runner
77
+ r = runner.run_process(
78
+ argv=["curl", "-sS", "--max-time", "180",
79
+ "--proto", "=https", "--fail",
80
+ "-o", str(out_path), url],
81
+ timeout=200)
82
+ if not r["ok"]:
83
+ raise RuntimeError((r.get("stderr") or r.get("stdout") or "下载失败")[:200])
84
+ p = Path(out_path)
85
+ if not p.is_file() or p.stat().st_size < 1024:
86
+ raise RuntimeError("下载内容过小或为空")
87
+
88
+
89
+ def _call_images(base, key, model, prompt, size, allow_private):
90
+ """POST /images/generations。返回 (data_item dict, 错误串);data_item 含 url 或 b64_json。"""
91
+ from . import builtin_agent
92
+ url = base.rstrip("/") + "/images/generations"
93
+ status, data, err = builtin_agent._post_json(
94
+ url,
95
+ {"Authorization": "Bearer " + key, "Content-Type": "application/json"},
96
+ {"model": model, "prompt": prompt, "size": size},
97
+ allow_private, 180)
98
+ if status == 0:
99
+ return None, err or "网络错误"
100
+ if 200 <= status < 300:
101
+ items = (data or {}).get("data") or []
102
+ if items and isinstance(items[0], dict):
103
+ return items[0], ""
104
+ return None, "响应缺少 data[0]"
105
+ last = str((data or {}).get("error", {}).get("message", "") if isinstance(data, dict) else "") \
106
+ or err or ("HTTP %s" % status)
107
+ return None, last
108
+
109
+
110
+ def make_cover(run_id, task):
111
+ """同步生成封面到运行目录。成功/失败返回 entry dict(写任务 cover_gen 用)。"""
112
+ from . import builtin_agent, modelhub, paths
113
+ try:
114
+ orch = modelhub.resolve_orchestrator()
115
+ if not orch:
116
+ raise RuntimeError("未配置编排者供应商(编排设置)")
117
+ prov, _m = orch
118
+ if str(prov.get("protocol") or "openai") not in ("openai", ""):
119
+ raise RuntimeError("封面生成仅支持 openai 协议供应商")
120
+ base = str(prov.get("base_url") or "").rstrip("/")
121
+ key = _pick_key(prov)
122
+ if not base or not key:
123
+ raise RuntimeError("供应商缺少 base_url 或密钥")
124
+ allow_private = bool(prov.get("allow_private"))
125
+ prompt = _cover_prompt(task)
126
+ models = []
127
+ env_model = os.environ.get("CODEBEE_IMAGE_MODEL", "").strip()
128
+ if env_model:
129
+ models.append(env_model)
130
+ models.extend(m for m in _IMAGE_CANDIDATES if m not in models)
131
+ last_err = ""
132
+ for model in models:
133
+ for size in _SIZES:
134
+ item, err = _call_images(base, key, model, prompt, size, allow_private)
135
+ if item is None:
136
+ last_err = err
137
+ continue
138
+ img_url = str(item.get("url") or "")
139
+ if not img_url:
140
+ last_err = "该供应商未返回图片 URL(仅内嵌数据),暂不支持"
141
+ continue
142
+ try:
143
+ safe_url = _safe_image_url(img_url)
144
+ except ValueError as e:
145
+ last_err = str(e)
146
+ continue
147
+ out_dir = paths.RUNS_DIR / str(run_id)
148
+ out_dir.mkdir(parents=True, exist_ok=True)
149
+ out = out_dir / "cover.png"
150
+ try:
151
+ _curl_to(safe_url, out)
152
+ except RuntimeError as e:
153
+ last_err = str(e)
154
+ continue
155
+ return {"status": "done", "file": "cover.png", "run_id": str(run_id),
156
+ "model": model, "size": size,
157
+ "at": time.strftime("%Y-%m-%d %H:%M:%S")}
158
+ raise RuntimeError(last_err or "图像接口无可用模型")
159
+ except Exception as e:
160
+ return {"status": "failed", "error": str(e)[:300],
161
+ "at": time.strftime("%Y-%m-%d %H:%M:%S")}
162
+
163
+
164
+ def generate_async(run_id, task_id, task):
165
+ """后台线程入口:生成封面并把终态写回任务 cover_gen(仿建书 generate_async)。"""
166
+ from . import store
167
+ if not store.get_task(task_id):
168
+ return
169
+ entry = make_cover(run_id, task)
170
+ cur = store.get_task(task_id)
171
+ if not cur:
172
+ return
173
+ prev = cur.get("cover_gen") or {}
174
+ if prev.get("status") != "running": # 期间被删/重置:丢弃结果
175
+ return
176
+ store.set_cover_gen(task_id, entry)
177
+
178
+
179
+ def start(task_id, run_id=None):
180
+ """起后台封面生成线程。run_id 缺省用该任务最近一次 run。返回 (ok, err)。running 幂等拒绝。"""
181
+ from . import store
182
+ task = store.get_task(task_id)
183
+ if not task:
184
+ return False, "任务不存在"
185
+ cur = task.get("cover_gen") or {}
186
+ if cur.get("status") == "running":
187
+ return True, ""
188
+ rid = run_id
189
+ if not rid:
190
+ runs = store.task_runs(task_id)
191
+ rid = (runs[-1].get("id") if runs else "")
192
+ if not rid:
193
+ return False, "没有可归属的运行记录(先跑一次任务再生成封面)"
194
+ if not store.set_cover_gen(task_id, {"status": "running",
195
+ "at": time.strftime("%Y-%m-%d %H:%M:%S")}):
196
+ return False, "任务不存在"
197
+ task = dict(task, cover_gen={"status": "running"})
198
+ threading.Thread(target=generate_async, daemon=True,
199
+ name="cover-gen-%s" % task_id, args=(rid, task_id, task)).start()
200
+ return True, ""
@@ -891,6 +891,8 @@ def _run_code(run, task, agents, ev, stats, mode):
891
891
  plan = {"source": "manual", "steps": [{"title": "实现任务", "detail": task["goal"]}]}
892
892
  store.update_run(run_id, plan=plan, difficulty=difficulty)
893
893
  subtasks = plan["steps"]
894
+ # 计划落盘(planning-with-files):磁盘上的计划与 spec/evidence 同居任务档案
895
+ _write_task_plan(task, workdir, plan)
894
896
 
895
897
  # ---- 评审者(有会话延续时评审者仍用新鲜上下文,避免偏见)
896
898
  if mode == "auto":
@@ -923,12 +925,20 @@ def _run_code(run, task, agents, ev, stats, mode):
923
925
  except Exception:
924
926
  pass
925
927
  for i, sub in enumerate(subtasks):
928
+ # 子任务进度注入(planning-with-files 的每轮注入计划头):多子任务时
929
+ # 让实现者知道全局位置与已完成项,防止长链目标漂移;单子任务零噪音
930
+ prog = ""
931
+ if len(subtasks) > 1:
932
+ done_titles = "、".join(
933
+ (s.get("title") or "")[:40] for s in subtasks[:i]) or "(无)"
934
+ prog = ("\n\n## 计划进度(第 %d/%d 项)\n已完成:%s。当前接着做下面这一项,"
935
+ "不要重做已完成的。" % (i + 1, len(subtasks), done_titles))
926
936
  prompt = (CODE_IMPL_PROMPT
927
937
  .replace("__GOAL__", task["goal"])
928
938
  .replace("__SUBTASK__",
929
939
  sub["detail"] if sub["detail"] else sub["title"])
930
940
  .replace("__CONTEXT__", task.get("context") or "(无)")
931
- .replace("__VERIFY_HINT__", _verify_hint(task)))
941
+ .replace("__VERIFY_HINT__", _verify_hint(task))) + prog
932
942
  role = "implement" if len(subtasks) == 1 else "implement-%d/%d" % (i + 1, len(subtasks))
933
943
  res = _run_step(run_id, role, agt_b, prompt, step_wd,
934
944
  readonly=False, ev=ev,
@@ -2871,6 +2881,29 @@ def _evidence_lines_from_run(run_id, task):
2871
2881
  return lines
2872
2882
 
2873
2883
 
2884
+ def _write_task_plan(task, workdir, plan):
2885
+ """计划落盘 .codebee/task_plan.md(planning-with-files 精髓:计划活在磁盘上,
2886
+ /clear、压缩、崩溃、续跑都不丢)。与 spec.md/evidence.md 同居任务档案;
2887
+ 失败静默——计划文件永远不能挡住任务执行。"""
2888
+ try:
2889
+ from pathlib import Path as _P
2890
+ steps = (plan or {}).get("steps") or []
2891
+ if not steps:
2892
+ return "" # 无步骤不产空计划文件
2893
+ root = _P(workdir).resolve()
2894
+ target = (root / ".codebee" / "task_plan.md").resolve()
2895
+ if root not in target.parents:
2896
+ return ""
2897
+ target.parent.mkdir(parents=True, exist_ok=True)
2898
+ lines = ["# 任务计划", "", "来源:%s" % (plan or {}).get("source", "?"), ""]
2899
+ for i, s in enumerate((plan or {}).get("steps") or [], 1):
2900
+ lines.append("%d. %s" % (i, str(s.get("detail") or s.get("title") or "")[:200]))
2901
+ target.write_text("\n".join(lines) + "\n", encoding="utf-8")
2902
+ return str(target)
2903
+ except Exception:
2904
+ return ""
2905
+
2906
+
2874
2907
  def execute_run(run_id):
2875
2908
  run = store.get_run(run_id)
2876
2909
  if not run:
@@ -310,24 +310,69 @@ class Page:
310
310
  """填输入框/文本域/富文本。
311
311
 
312
312
  React/Vue 受控组件直接赋 value 不触发框架状态,必须走原型链 native
313
- setter 再补 input/change 事件;网文编辑器正文多为 contenteditable,
314
- execCommand('insertText') 以触发其内部输入管道(先清空再插入)。"""
313
+ setter 再补 input/change 事件;富文本(章节正文)走 CDP
314
+ Input.insertText——execCommand('insertText') 对千字长文会截断
315
+ (真机实测 1020 字只进 615),insertText 走完整输入管道无此限。"""
316
+ text = str(text)
317
+ ce = None
318
+ for _try in range(24): # 编辑器慢加载 + class 动态 + edit-mask
319
+ ce = self.call( # 遮罩需真实点击激活后才可编辑
320
+ "(s)=>{const els=[...document.querySelectorAll(s)];"
321
+ "let best=null;for(const e of els){"
322
+ "if(!e.isContentEditable)continue;"
323
+ "const r=e.getBoundingClientRect();"
324
+ "if(r.width<=0)continue;"
325
+ "const a=r.width*r.height;"
326
+ "if(!best||a>best.a)best={e,a};}"
327
+ "if(best){"
328
+ "const el=best.e;"
329
+ "el.scrollIntoView({block:'center'});el.focus();"
330
+ "const r=document.createRange();r.selectNodeContents(el);"
331
+ "const g=getSelection();g.removeAllRanges();g.addRange(r);return {hit:true};}"
332
+ "const vis=els.filter(e=>e.getBoundingClientRect().width>0);"
333
+ "if(!vis.length)return null;"
334
+ "let big=vis[0];for(const e of vis){"
335
+ "const r=e.getBoundingClientRect();"
336
+ "if(r.width*r.height>big.getBoundingClientRect().width*big.getBoundingClientRect().height)big=e;}"
337
+ "const r=big.getBoundingClientRect();"
338
+ "return {hit:false,x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)};}", sel)
339
+ if ce is None:
340
+ break
341
+ if isinstance(ce, dict) and ce.get("hit"):
342
+ ce = True
343
+ break
344
+ if isinstance(ce, dict) and "x" in ce: # 遮罩未揭:真实点击激活
345
+ self.send("Input.dispatchMouseEvent",
346
+ {"type": "mousePressed", "x": ce["x"], "y": ce["y"],
347
+ "button": "left", "clickCount": 1}, timeout=8.0)
348
+ self.send("Input.dispatchMouseEvent",
349
+ {"type": "mouseReleased", "x": ce["x"], "y": ce["y"],
350
+ "button": "left", "clickCount": 1}, timeout=8.0)
351
+ ce = False
352
+ time.sleep(0.8)
353
+ continue
354
+ ce = False
355
+ time.sleep(0.5)
356
+ if ce is True: # 富文本:焦点就位后键盘级插入
357
+ self.send("Input.insertText", {"text": text}, timeout=30.0)
358
+ n = self.call("(s)=>{const els=[...document.querySelectorAll(s)].filter(e=>e.isContentEditable);"
359
+ "let n=0;for(const e of els)n=Math.max(n,(e.innerText||'').length);return n;}", sel)
360
+ if int(n or 0) < min(len(text), 20):
361
+ raise BrowserError("富文本插入不完整(%s/%d 字)" % (n, len(text)))
362
+ return True
363
+ if ce is None:
364
+ raise BrowserError("找不到输入框 %s" % sel)
315
365
  r = self.call(
316
366
  "(s,t)=>{const el=document.querySelector(s);if(!el)"
317
367
  "return{ok:false,err:'找不到输入框 '+s};"
318
368
  "el.scrollIntoView({block:'center'});el.focus();"
319
- "if(el.isContentEditable){"
320
- "const r=document.createRange();r.selectNodeContents(el);"
321
- "const g=getSelection();g.removeAllRanges();g.addRange(r);"
322
- "document.execCommand('insertText',false,t);"
323
- "return{ok:el.textContent.length>=Math.min(t.length,10)};}"
324
369
  "const proto=el.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype"
325
370
  ":HTMLInputElement.prototype;"
326
371
  "const d=Object.getOwnPropertyDescriptor(proto,'value');"
327
372
  "(d&&d.set?d.set:function(v){el.value=v}).call(el,t);"
328
373
  "el.dispatchEvent(new Event('input',{bubbles:true}));"
329
374
  "el.dispatchEvent(new Event('change',{bubbles:true}));"
330
- "return{ok:true};}", sel, str(text))
375
+ "return{ok:true};}", sel, text)
331
376
  if not (r or {}).get("ok"):
332
377
  raise BrowserError("填入失败:%s" % ((r or {}).get("err") or "目标不是可输入元素"))
333
378
  return True
@@ -39,8 +39,10 @@ def _click_match_js():
39
39
  "return x && x.length<=maxLen && keys.every(k=>x.includes(k));});"
40
40
  "if(!hit.length)return{ok:false,err:'找不到同时含 '+keys.join('+')+' 的元素'};"
41
41
  "hit.sort((a,b)=>(a.innerText||'').length-(b.innerText||'').length);"
42
- "hit[0].scrollIntoView({block:'center'});hit[0].click();"
43
- "return{ok:true,tag:hit[0].tagName};}")
42
+ "let el=hit[0];"
43
+ "const act=el.closest('a,button,[role=button],[class*=btn]')||el;"
44
+ "act.scrollIntoView({block:'center'});act.click();"
45
+ "return{ok:true,tag:act.tagName};}")
44
46
 
45
47
 
46
48
  def _fill_label_js():
@@ -137,9 +139,64 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
137
139
  time.sleep(0.9)
138
140
  if not (r or {}).get("ok"):
139
141
  raise FlowError((r or {}).get("err") or text)
142
+ elif act == "click_real":
143
+ # 真实鼠标事件点击(CDP Input 派发):qm-btn 等自定义按钮
144
+ # 只认真实事件序列,el.click() 无效。发布确认链全靠它。
145
+ text = str(st.get("text") or "")
146
+ for k, v in (values or {}).items():
147
+ text = text.replace("{%s}" % k, str(v))
148
+ if not text:
149
+ continue
150
+ note(i, "真实点击「%s」" % text)
151
+ r = None
152
+ for _try in range(int(st.get("tries") or 25)):
153
+ r = page.real_click_text(text, st.get("scope") or
154
+ "a,button,[class*=btn]")
155
+ if (r or {}).get("ok"):
156
+ break
157
+ time.sleep(0.4)
158
+ if not (r or {}).get("ok"):
159
+ if st.get("optional"):
160
+ note(i, "「%s」未出现,跳过(optional)" % text)
161
+ continue
162
+ raise FlowError((r or {}).get("err") or text)
163
+ elif act == "click_in":
164
+ # 先按 scope_text 定位容器(如书卡),再在容器内点 text 按钮。
165
+ # 解决「按钮与书名同卡片但不在彼此祖先链」的组合定位。
166
+ scope_t = str(st.get("scope_text") or "")
167
+ text = str(st.get("text") or "")
168
+ for k, v in (values or {}).items():
169
+ scope_t = scope_t.replace("{%s}" % k, str(v))
170
+ text = text.replace("{%s}" % k, str(v))
171
+ note(i, "在含「%s」的卡片内点「%s」" % (scope_t, text))
172
+ r = None
173
+ for _try in range(10): # 表格异步渲染:重试窗口加长
174
+ r = page.call(
175
+ "(sc,t)=>{"
176
+ "const cards=[...document.querySelectorAll('div,li,section,tr')].filter(e=>{"
177
+ "const x=(e.innerText||'').trim();"
178
+ "return x.includes(sc)&&x.length<600&&e.getBoundingClientRect().width>0;});"
179
+ "if(!cards.length)return{ok:false,err:'找不到含'+sc+'的卡片'};"
180
+ "cards.sort((a,b)=>(b.innerText||'').length-(a.innerText||'').length);"
181
+ "const card=cards[0];"
182
+ "const btns=[...card.querySelectorAll('a,button,[role=button],[class*=btn],span')].filter(e=>{"
183
+ "const x=(e.innerText||'').trim();return x===t||x.includes(t)&&x.length<=t.length+6;});"
184
+ "if(!btns.length)return{ok:false,err:'卡片内没有'+t};"
185
+ "btns.sort((a,b)=>(a.innerText||'').length-(b.innerText||'').length);"
186
+ "let el=btns[0];"
187
+ "const act=el.closest('a,button,[role=button],[class*=btn]')||el;"
188
+ "act.scrollIntoView({block:'center'});act.click();"
189
+ "return{ok:true};}", scope_t, text)
190
+ if (r or {}).get("ok"):
191
+ break
192
+ time.sleep(0.9)
193
+ if not (r or {}).get("ok"):
194
+ raise FlowError((r or {}).get("err") or "click_in 失败")
140
195
  elif act == "click_match":
141
196
  # 关键词选块(站点卡片等):点同时包含所有关键词的最小元素
142
197
  keys = [str(x) for x in (st.get("any") or []) if str(x).strip()]
198
+ for k2, v2 in (values or {}).items(): # {book_name} 等动态值
199
+ keys = [x.replace("{%s}" % k2, str(v2)) for x in keys]
143
200
  note(i, "点击含 %s 的卡片" % keys)
144
201
  r = None
145
202
  for _try in range(4): # 弹层渲染慢:找不到先等再试
@@ -253,6 +310,26 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
253
310
  note(i, "提交 %s" % st.get("sel") or "")
254
311
  page.wait_for(st["sel"], timeout=8)
255
312
  page.click(st["sel"])
313
+ elif act == "verify":
314
+ # 上线验证:导航到验证页断言文本在场——防「流程完成但平台
315
+ # 静默未发布」的假成功(0 字草稿案)。url/any 支持 values 占位。
316
+ url = str(st.get("url") or "")
317
+ for k, v in (values or {}).items():
318
+ url = url.replace("{%s}" % k, str(v))
319
+ note(i, "验证 %s" % url[:60])
320
+ page.navigate(url, timeout=30)
321
+ time.sleep(float(st.get("settle") or 4))
322
+ marks = [str(m) for m in (st.get("any") or [])]
323
+ body_txt = str(page.call(
324
+ "()=>(document.body.innerText||'')", timeout=10) or "")
325
+ for m in marks:
326
+ mk = m
327
+ for k, v in (values or {}).items():
328
+ mk = mk.replace("{%s}" % k, str(v))
329
+ if mk not in body_txt:
330
+ raise FlowError("上线验证失败:%s 页面上没有「%s」(章节未真正发布)"
331
+ % (url[:60], mk[:40]))
332
+ note(i, "验证通过")
256
333
  elif act == "url_any":
257
334
  u = str(page.url() or "")
258
335
  marks = st.get("any") or []
@@ -382,6 +382,11 @@ def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False):
382
382
  n_chars = len(body.replace("\n", "").replace(" ", ""))
383
383
  if n_chars < 100:
384
384
  return False, "正文过短(%d 字),疑似未完成章节" % n_chars
385
+ # 平台级下限(如七猫编辑器明示「最少 1000 字」:不足时发布被静默拦截)
386
+ min_chars = int(PLATFORMS[plat].CONFIG.get("min_chapter_chars") or 100)
387
+ if n_chars < min_chars:
388
+ return False, ("正文 %d 字未达该平台下限(%d 字),补足后再发"
389
+ % (n_chars, min_chars))
385
390
  if n_chars > 30000:
386
391
  return False, "正文超长(%d 字),平台单章上限一般 2 万字" % n_chars
387
392
  done = ledger.published_chapters(task_id, plat)
@@ -395,6 +400,11 @@ def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False):
395
400
  mod = PLATFORMS[plat]
396
401
  values = {"chapter_title": title, "chapter_body": body,
397
402
  "book_name": book.get("title") or ""}
403
+ try: # verify/draft 页 URL(流程占位)
404
+ values["chapter_manage_url"] = mod.chapter_manage_url(book)
405
+ values["draft_url"] = mod.draft_url(book)
406
+ except AttributeError:
407
+ pass
398
408
  logs = []
399
409
 
400
410
  def run():
@@ -95,3 +95,19 @@ def tag_groups(meta):
95
95
  if isinstance(v, list) and v:
96
96
  out.append((key, [str(x) for x in v]))
97
97
  return out
98
+
99
+
100
+ def chapter_manage_url(book):
101
+ """章节管理页(上线验证用):有 book_id 直达,否则回作品管理页。"""
102
+ bid = str((book or {}).get("book_id") or "")
103
+ if bid: # 实测 .../front/book-manage/manage?id=
104
+ return CONFIG["book_manage"] + "/manage?id=" + bid
105
+ return CONFIG["book_manage"]
106
+
107
+
108
+ def draft_url(book):
109
+ """草稿箱页(发章真发布链中转站)。"""
110
+ bid = str((book or {}).get("book_id") or "")
111
+ if bid:
112
+ return CONFIG["book_manage"] + "/draft?id=" + bid
113
+ return CONFIG["book_manage"]
package/app/core/store.py CHANGED
@@ -299,6 +299,22 @@ def set_book_meta(task_id, platform, entry):
299
299
  return True
300
300
 
301
301
 
302
+ def set_cover_gen(task_id, entry):
303
+ """写任务的封面生成状态(cover_gen = {status, file?, run_id?, model?, error?, at})。
304
+
305
+ 与 set_book_meta 同理必须 bump_state(SSE 推送翻卡片)。任务不存在返回 False。"""
306
+ if not _valid_id(task_id):
307
+ return False
308
+ with LOCK:
309
+ task = _TASKS.get(task_id)
310
+ if not task:
311
+ return False
312
+ task["cover_gen"] = entry
313
+ _save_json(paths.TASKS_DIR / (task_id + ".json"), task)
314
+ bump_state()
315
+ return True
316
+
317
+
302
318
  def set_auto_publish(task_id, ap):
303
319
  """写任务的定时发布配置(auto.py 每日到点读它触发批量发布)。
304
320
 
package/app/main.py CHANGED
@@ -580,6 +580,11 @@ class Handler(BaseHTTPRequestHandler):
580
580
  m = re.match(r"^/api/tasks/([^/]+)/book-meta$", path)
581
581
  if m:
582
582
  return self._api_book_meta_generate(m.group(1))
583
+ m = re.match(r"^/api/tasks/([^/]+)/cover$", path)
584
+ if m:
585
+ from core import covergen
586
+ ok, err = covergen.start(m.group(1))
587
+ return self._json(400, {"error": err}) if not ok else self._json(200, {"ok": True})
583
588
  m = re.match(r"^/api/publish/(fanqie|qimao)/(connect|disconnect|probe)$", path)
584
589
  if m:
585
590
  return self._api_publish_platform_op(m.group(1), m.group(2))
package/app/ui/app.js CHANGED
@@ -3852,6 +3852,27 @@ function renderBookMetaPanel(task) {
3852
3852
  pbBlock(task, p.id) +
3853
3853
  "</div>";
3854
3854
  }).join("") + "</div>";
3855
+ // 封面卡(covergen,借鉴 oh-story 封面图环节):curl 落盘运行目录,done 后可点开预览
3856
+ const cg = task.cover_gen || {};
3857
+ const cgSt = cg.status || "";
3858
+ let cgBody = "";
3859
+ if (cgSt === "running") {
3860
+ cgBody = '<div class="bm-empty"><svg class="ico spin" aria-hidden="true"><use href="#i-refresh"/></svg> ' + esc(t("生成中…")) + "</div>";
3861
+ } else if (cgSt === "done") {
3862
+ cgBody = '<div class="bm-empty">' + esc(t("封面已生成(cover.png),在「成果」页签查看")) + "</div>";
3863
+ } else if (cgSt === "failed") {
3864
+ cgBody = '<div class="bm-errhint">' + esc(cg.error || t("生成失败")) + "</div>";
3865
+ } else {
3866
+ cgBody = '<div class="bm-empty">' + esc(t("生成竖版封面插画,产出 cover.png")) + "</div>";
3867
+ }
3868
+ const cgBusy = taskBusy || cgSt === "running";
3869
+ const cgAction = cgSt === "running"
3870
+ ? '<button class="ghost" disabled><svg class="ico spin" aria-hidden="true"><use href="#i-refresh"/></svg>' + t("生成中…") + "</button>"
3871
+ : '<button class="primary" onclick="coverGen(\'' + esc(task.id) + '\')">' +
3872
+ (cgSt === "failed" ? t("重试") : t("生成封面")) + "</button>";
3873
+ html += '<div class="bm-cards"><div class="bm-card st-' + (cgSt || "new") + '">' +
3874
+ '<div class="bm-card-head"><span class="bm-plat-name"><svg class="ico" aria-hidden="true"><use href="#i-book-open"/></svg>' + esc(t("封面图")) + "</span>" +
3875
+ bmStatusChip(cgSt) + '<span class="flex1"></span>' + cgAction + "</div>" + cgBody + "</div></div>";
3855
3876
  box.classList.remove("hidden");
3856
3877
  box.innerHTML = html;
3857
3878
  // TAB 徽章:已生成平台数(生成中显示 ●,随下次轮询刷新)
@@ -3874,6 +3895,16 @@ window.bmGen = async function (taskId, platform) {
3874
3895
  } catch (e) { toast(t("生成失败:") + e.message, true); }
3875
3896
  };
3876
3897
 
3898
+ /* 封面图生成(covergen):curl 子进程直接落盘运行目录,Python 不经手图像字节 */
3899
+ window.coverGen = async function (taskId) {
3900
+ try {
3901
+ await api("/api/tasks/" + encodeURIComponent(taskId) + "/cover",
3902
+ { method: "POST", body: "{}" });
3903
+ toast(t("已开始生成封面,完成后这里会自动更新"));
3904
+ await refreshState(); render();
3905
+ } catch (e) { toast(t("封面生成失败:") + e.message, true); }
3906
+ };
3907
+
3877
3908
  window.bmCopyBtn = function (btn) {
3878
3909
  copyText(btn.dataset.text || "");
3879
3910
  toast(t("已复制:") + (btn.dataset.label || ""));
package/app/ui/i18n.js CHANGED
@@ -285,6 +285,7 @@
285
285
  "可选": "Optional",
286
286
  "人物、世界观、主线冲突、伏笔和不能违背的设定;会在开跑前写入 story-bible.md": "Characters, world rules, main conflict, foreshadowing, and non-negotiable canon; saved to story-bible.md before the run starts",
287
287
  "连载任务会在每章起草与评审时自动注入;工作目录已有 story-bible.md 时请留空,避免覆盖已有设定。": "Serial tasks inject it into every drafting and review step; leave this blank when the workdir already has story-bible.md to avoid overwriting existing canon.",
288
+ "连载任务会在每章起草与评审时自动注入;工作目录已有 story-bible.md 时请留空,避免覆盖已有设定。另可放 plot-modules.md 剧情模块库(拆文沉淀的桥段/爽点素材,每章自动注入)。": "Serial tasks inject it into every drafting and review step; leave this blank when the workdir already has story-bible.md to avoid overwriting existing canon. You can also add plot-modules.md (a reusable scene/trope library) — it's injected into every chapter too.",
288
289
  "在岗": "busy",
289
290
  "全部空闲": "all idle",
290
291
  "工作中": "working",
@@ -1570,6 +1571,15 @@
1570
1571
  "重新生成": "Regenerate",
1571
1572
  "重新生成会覆盖现有内容": "Regenerating overwrites the existing content",
1572
1573
  "生成失败": "Generation failed",
1574
+ "封面图": "Cover image",
1575
+ "生成封面": "Generate cover",
1576
+ "重试": "Retry",
1577
+ "生成中…": "Generating…",
1578
+ "封面生成中…": "Cover generation started…",
1579
+ "封面已生成(cover.png),在「成果」页签查看": "Cover generated (cover.png) — view it in the Artifacts tab",
1580
+ "生成竖版封面插画,产出 cover.png": "Generate a portrait cover illustration → cover.png",
1581
+ "封面生成失败:": "Cover generation failed: ",
1582
+ "已开始生成封面,完成后这里会自动更新": "Cover generation started — this panel updates automatically when done",
1573
1583
  "已开始生成,完成后这里会自动更新": "Generation started — this panel updates automatically when done",
1574
1584
  "来源:": "Source: ",
1575
1585
  "(待补充)": "(to fill)",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebee",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "CodeBee · 多智能体编排台 — 统一编排本机 AI 编码 CLI(Codex / Claude Code / QwenCode / OpenCode / Aider…):目标→拆解→路由→执行→验证→跨厂商评审→修复→报告。纯 Python 标准库实现,本地 Web UI。",
5
5
  "license": "MIT",
6
6
  "bin": {