codebee 0.1.98 → 0.1.99

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,7 +4,12 @@ CodeBee 的用户可感知变更记录。发布新版时:最上面加一节,
4
4
  `<!-- relnotes:start -->…<!-- relnotes:end -->` 段(那段会被 `npm view` 的
5
5
  README 元数据带回,供老版本在「发现新版本」时展示新版更新内容)。
6
6
 
7
- ## 未发布
7
+ ## v0.1.99(2026-10-09)
8
+
9
+ - **直连对话「本轮失败 NameError」修复**:内置智能体(CodeBee 直连)每轮起跑数秒即失败,根因是沙箱加固提交把 `sandbox=task.get("sandbox")` 误引入无 task 上下文的内置步骤函数——实参求值即 NameError,Mac/Windows 全平台直连对话不可用。现改为显式传参,任务沙箱策略照常透传生效(回归测试锁定形参与全链)
10
+ - **命令执行沙箱闸策略感知**:沙箱加固曾把内置智能体的 run_command 在 Windows/macOS 一律拒跑(「未接入文件系统隔离后端」)——无后端平台上命令工具全死。现与外部 CLI 闸同口径:默认宽策略照常全信任直跑(2026-09-22 拍板档位),进程内无法兑现的收窄仍拒(禁网需 OS 后端;环境变量白名单在子进程侧实际滤不掉,宁拒不假装);Linux bubblewrap 全策略强制不变
11
+ - **大屏 KPI 长数值不再裁字**:万位级数值(如「2246.7万」)在 KPI 卡被挤到第二行、遭 overflow 裁切只剩半截;现 KPI 数字禁止换行,并按内容长度三档自动降字号,长数值单行完整显示
12
+ - **经验库设置页文案对齐**:注入范围说明「小说类任务」改「同类任务」,与知识注入按任务类型全量覆盖的实现口径一致
8
13
 
9
14
  ## v0.1.98(2026-10-09)
10
15
 
package/README.md CHANGED
@@ -25,11 +25,12 @@ Trae Agent、OpenClaw……),提供
25
25
  ---
26
26
 
27
27
  <!-- relnotes:start -->
28
- ### 最新版更新内容(v0.1.98)
28
+ ### 最新版更新内容(v0.1.99)
29
29
 
30
- - 修复连载/常规任务被沙箱闸误拒:非 codex 外部 CLI 默认策略放行,仅收窄策略仍拒绝(89—96 批连载起草误拒实案)
31
- - 蜂巢 3D 场景重做为真 3D 蜜蜂办公室(WebGL):运行打字颤翅、失败趴桌、蜂王督工,悬停看实时输出
32
- - uv 缺失自动补装(winget/pip/brew/curl 免 sudo),uv 系安装链 Mac PATH 兜底
30
+ - 修复直连对话每轮起跑即「本轮失败 NameError」:Mac/Windows 全平台直连对话恢复可用
31
+ - 内置智能体命令执行沙箱闸策略感知:默认策略照常直跑,仅收窄策略仍拒绝
32
+ - 大屏 KPI 长数值不再裁字:禁换行 + 按内容长度三档降字号
33
+ - 经验库设置页注入范围文案与全类型实现口径对齐
33
34
  <!-- relnotes:end -->
34
35
 
35
36
 
@@ -98,15 +98,15 @@ def resolve(provider_id="", model="", difficulty="default"):
98
98
  if selected not in enabled_names:
99
99
  return None
100
100
  if not selected:
101
- selected = (p.get("model_hard") if difficulty == "hard" else
102
- p.get("model_easy") if difficulty == "easy" else
103
- p.get("model") or "").strip()
104
- if not selected:
101
+ # 默认/难度模型键位与 models[] 启停开关不联动:键位指向已停用
102
+ # 模型时对账启用名单回落,不冒充可用(停用的模型不准用)
105
103
  try:
106
- names = modelhub._enabled_models(p)
104
+ selected = modelhub._usable_default_model(
105
+ p, (p.get("model_hard") if difficulty == "hard" else
106
+ p.get("model_easy") if difficulty == "easy" else
107
+ p.get("model")) or "")
107
108
  except Exception:
108
- names = []
109
- selected = names[0]["name"] if names else ""
109
+ selected = (p.get("model") or "").strip()
110
110
  if selected:
111
111
  return {"prov": p, "model": selected,
112
112
  "provider_id": p.get("id") or "",
@@ -125,15 +125,13 @@ def resolve(provider_id="", model="", difficulty="default"):
125
125
  for p in modelhub.providers():
126
126
  if not p.get("enabled", True) or not p.get("api_key"):
127
127
  continue
128
- m = ((p.get("model_hard") if difficulty == "hard" else
129
- p.get("model_easy") if difficulty == "easy" else
130
- p.get("model")) or "").strip()
131
- if not m:
132
- try:
133
- names = modelhub._enabled_models(p)
134
- except Exception:
135
- names = []
136
- m = names[0]["name"] if names else ""
128
+ try:
129
+ m = modelhub._usable_default_model(
130
+ p, (p.get("model_hard") if difficulty == "hard" else
131
+ p.get("model_easy") if difficulty == "easy" else
132
+ p.get("model")) or "")
133
+ except Exception:
134
+ m = (p.get("model") or "").strip()
137
135
  if m:
138
136
  return {"prov": p, "model": m, "provider_id": p.get("id") or "",
139
137
  "provider_name": p.get("name") or p.get("id") or ""}
@@ -422,7 +420,11 @@ def _tool_run_command(workdir, args, cancel_event=None, deadline=None, sandbox=N
422
420
  → cmd /c 或 /bin/sh -c),仓库唯一的 shell 落点,不另起炉灶。全信任档
423
421
  (用户拍板 2026-09-22):无白名单无确认闸,与 codex/claude 全权沙箱同档。
424
422
  提权靠模型自己包 Start-Process -Verb RunAs——UAC 必须人点,绕不过也不该绕。
425
- 输出按字符头尾截断(解码已在 run_process 完成,无残缺多字节问题)。"""
423
+ 输出按字符头尾截断(解码已在 run_process 完成,无残缺多字节问题)。
424
+ 沙箱闸策略感知(与外部 CLI 闸 b267107 同口径):Linux 有 bwrap 全策略
425
+ 强制;Win/Mac 无 OS 后端,进程内无法兑现的收窄仍拒(禁网、env 白名单
426
+ ——run_process 的 env 是注入语义滤不掉),默认宽策略保持全信任档直跑,
427
+ 目录闸由 path_allowed 兑现。"""
426
428
  cmdline = str(args.get("command") or "").strip()
427
429
  if not cmdline:
428
430
  return "(命令为空,未执行)"
@@ -432,15 +434,20 @@ def _tool_run_command(workdir, args, cancel_event=None, deadline=None, sandbox=N
432
434
  cwd = os.path.abspath(workdir or ".")
433
435
  if not policy.path_allowed(cwd, sandbox):
434
436
  return "(沙箱拒绝:工作目录不在允许范围内,命令未执行)"
435
- # This process runner has no OS-level network/filesystem namespace. Do not
436
- # imply network isolation when policy requests it; fail closed instead.
437
- if sandbox.get("network") is False:
438
- if os.name != "posix" or not shutil.which("bwrap"):
439
- return "(沙箱拒绝:当前系统没有可用的网络隔离后端,命令未执行)"
440
- # Enforce the declared filesystem boundary at the process boundary too.
441
- # bubblewrap is the supported Linux backend. Windows/macOS without an
442
- # equivalent backend fail closed rather than pretending cwd is a sandbox.
443
- if os.name == "posix" and shutil.which("bwrap"):
437
+ # Enforce the declared boundary at the process boundary where an OS
438
+ # backend exists (bubblewrap on Linux). Windows/macOS have no equivalent:
439
+ # stay policy-aware like the external-CLI gate (b267107) — the narrowings
440
+ # this in-process runner cannot honor fail closed, while a default wide
441
+ # policy keeps the user-approved full-trust tier (2026-09-22) instead of
442
+ # refusing every command.
443
+ has_bwrap = os.name == "posix" and bool(shutil.which("bwrap"))
444
+ if sandbox.get("network") is False and not has_bwrap:
445
+ return "(沙箱拒绝:当前系统没有可用的网络隔离后端,命令未执行)"
446
+ if sandbox.get("env_allowlist"):
447
+ # run_process 的 env 是注入语义(合并进完整父环境,供凭据注入),
448
+ # 不是白名单替换——环境收窄在子进程侧无法兑现,宁拒不假装
449
+ return "(沙箱拒绝:该任务带环境变量白名单,命令执行无法兑现该策略,未执行)"
450
+ if has_bwrap:
444
451
  argv = ["bwrap", "--die-with-parent", "--new-session", "--tmpfs", "/"]
445
452
  # Mount only the runtime needed by ordinary commands. Do not expose the
446
453
  # entire host root read-only: read access can still leak user secrets.
@@ -461,18 +468,10 @@ def _tool_run_command(workdir, args, cancel_event=None, deadline=None, sandbox=N
461
468
  argv.extend(["--dir", root_path])
462
469
  argv.extend(["--bind", root_path, root_path])
463
470
  argv.extend(["--chdir", cwd, "--", "/bin/sh", "-lc", cmdline])
464
- elif os.name == "posix" and sys.platform == "darwin":
465
- return "(沙箱拒绝:当前 macOS 执行器尚未接入文件系统隔离后端,命令未执行)"
466
- elif os.name != "posix":
467
- return "(沙箱拒绝:当前 Windows 执行器尚未接入文件系统隔离后端,命令未执行)"
468
471
  else:
469
- return "(沙箱拒绝:未安装 bubblewrap 隔离后端,命令未执行)"
470
- env_policy = dict(sandbox)
471
- env_policy["env_allowlist"] = list(dict.fromkeys(
472
- list(sandbox.get("env_allowlist") or [])
473
- + ["PATH", "SYSTEMROOT", "WINDIR", "TEMP", "TMP", "COMSPEC", "HOME", "USERPROFILE"]))
474
- r = runner.run_process(argv=argv, cwd=cwd,
475
- env=policy.filter_env(os.environ, env_policy),
472
+ argv = None # 全信任档:runner 按平台自包 cmd /c 或 /bin/sh -c
473
+ r = runner.run_process(argv=argv, shell_cmd=cmdline if argv is None else None,
474
+ cwd=cwd, env=None,
476
475
  timeout=min(timeout, CMD_MAX_TIMEOUT),
477
476
  deadline=deadline, cancel_event=cancel_event)
478
477
  out = runner.clean_cli_text(
@@ -2177,6 +2177,20 @@ def _model_bindable(prov, model):
2177
2177
  return True
2178
2178
 
2179
2179
 
2180
+ def _usable_default_model(prov, model=""):
2181
+ """默认模型键位(model / model_easy / model_hard / 编排者配置)对账启用名单。
2182
+
2183
+ 默认/难度模型是自由文本,与 models[] 的启停开关互不联动——键位指向已
2184
+ 停用/已删除的模型时不能冒充可用:回落该厂商启用名单首个;全停用返回
2185
+ 空串(调用方按「无默认模型」处理)。空键位本身合法(用网关默认),
2186
+ 也返回空。"""
2187
+ model = (model or "").strip()
2188
+ if model and _model_bindable(prov, model):
2189
+ return model
2190
+ names = [m.get("name") for m in _enabled_models(prov) if m.get("name")]
2191
+ return names[0] if names else ""
2192
+
2193
+
2180
2194
  def _model_image_in(prov, model):
2181
2195
  """模型是否声明支持图片输入:models[] 条目 image_in;缺省/查不到=False(纯文本)。"""
2182
2196
  for m in ((prov or {}).get("models") or []):
@@ -2591,7 +2605,7 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
2591
2605
  if len(entries) >= MAX_CHAIN_ATTEMPTS:
2592
2606
  break
2593
2607
  entries.append(_chain_entry_env(
2594
- prov, model or prov.get("model") or "",
2608
+ prov, model or _usable_default_model(prov, prov.get("model")),
2595
2609
  target=agent_kind_or_id, endpoint=ep, key=kk["key"],
2596
2610
  key_id=kk.get("id") or "", provider_id=pid))
2597
2611
  if len(entries) >= MAX_CHAIN_ATTEMPTS:
@@ -2662,6 +2676,8 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
2662
2676
  model = prov.get("model_" + tier) or "" if (routing and tier) else ""
2663
2677
  if not model:
2664
2678
  model = prov.get("model") or ""
2679
+ if model and not _model_bindable(prov, model):
2680
+ model = "" # 默认/难度模型键位指向停用或已删模型:不冒充可用,回落启用名单
2665
2681
  if not model and names:
2666
2682
  model = names[0] if (not routing or difficulty != "easy") else names[-1]
2667
2683
  if not _evaluation_is_usable(pid, model):
@@ -2724,7 +2740,9 @@ def recommend_binding(agent_kind_or_id, difficulty="default", task_type="", role
2724
2740
  names = [m.get("name") for m in _enabled_models(prov)
2725
2741
  if isinstance(m, dict) and m.get("name")]
2726
2742
  if not names and prov.get("model"):
2727
- names = [str(prov.get("model"))]
2743
+ dflt = str(prov.get("model"))
2744
+ if _model_bindable(prov, dflt): # 默认模型被停用时不再充作唯一候选
2745
+ names = [dflt]
2728
2746
  # 候选阶段不能先截前三个:便宜档往往排在供应商列表后面,easy 任务
2729
2747
  # 需要看到完整启用列表后再按成本与档位评分。最终调用链仍受上限约束。
2730
2748
  for model in names:
@@ -3618,9 +3636,7 @@ def resolve_orchestrator():
3618
3636
  if not prov or not prov.get("enabled", True) or not prov.get("api_key"):
3619
3637
  return None
3620
3638
  model = (cfg.get("model") or "").strip() or prov.get("model") or ""
3621
- if not model:
3622
- names = _enabled_models(prov)
3623
- model = names[0]["name"] if names else ""
3639
+ model = _usable_default_model(prov, model) # 配置的模型被停用时回落启用名单,不冒充可用
3624
3640
  if not model:
3625
3641
  return None
3626
3642
  if not _evaluation_is_usable(pid, model):
@@ -628,14 +628,16 @@ def _create_task_from_builtin(payload):
628
628
 
629
629
 
630
630
  def _run_builtin_step(run_id, role, bi, prompt, workdir, ev, note="", images=None,
631
- followups=False):
631
+ followups=False, sandbox=None):
632
632
  """内置智能体步骤:直连模型 API + 工具循环(builtin_agent),不经 CLI 进程。
633
633
 
634
634
  与 _run_step 对齐的三件事:暂停/取消闸门、运行中指令 drain 注入、重复调用
635
635
  守门;结果同样经 _finish_step_result 落步骤(output=干净回答)并入用量台账。
636
636
  日志只有「迭代/工具」摘要行——对话视图吃 output,日志抽屉看工具轨迹。
637
637
  followups=True 时从回答末尾解析「建议追问」块(直连对话专用协议):
638
- 剥离出结构化列表落步骤记录,正文保持干净。"""
638
+ 剥离出结构化列表落步骤记录,正文保持干净。
639
+ sandbox:任务沙箱策略由调用方透传(本函数无 task 上下文),builtin_agent
640
+ 内部 normalize 后在工具执行期强制。"""
639
641
  _wait_gate(run_id, ev)
640
642
  deadline = _ensure_budget(run_id)
641
643
  step, log_abs = store.add_step(run_id, role, "builtin", "CodeBee", note=note,
@@ -689,7 +691,7 @@ def _run_builtin_step(run_id, role, bi, prompt, workdir, ev, note="", images=Non
689
691
  on_reason=_on_reason, on_stream=_on_stream,
690
692
  on_activity=_on_activity,
691
693
  task_creator=_create_task_from_builtin,
692
- sandbox=task.get("sandbox"), checkpoint_run_id=run_id)
694
+ sandbox=sandbox, checkpoint_run_id=run_id)
693
695
  if followups and res.get("ok"):
694
696
  clean, fups = _parse_followups(res.get("text") or "")
695
697
  if fups:
@@ -2334,7 +2336,7 @@ def _run_direct(run, task, agents, ev, stats, mode):
2334
2336
  if bi is not None:
2335
2337
  res = _run_builtin_step(run_id, "direct" if first else "chat", bi, prompt,
2336
2338
  step_wd, ev=ev, note=note, images=images,
2337
- followups=True)
2339
+ followups=True, sandbox=task.get("sandbox"))
2338
2340
  else:
2339
2341
  res = _run_step(run_id, "direct" if first else "chat", impl, prompt, step_wd,
2340
2342
  readonly=False, ev=ev, note=note,
@@ -135,7 +135,9 @@ def pending(task_id, platform):
135
135
  """待发章节清单:任务工作目录里的章节文件 − 已发章号,按章号升序。
136
136
 
137
137
  已发口径连载链合并(含校准所得平台实况),所以任何一个批次任务上发
138
- 起发布,看到的都是整本书的待发清单。返回 (list, err);list 项
138
+ 起发布,看到的都是整本书的待发清单。已存草稿的章同样剔除(内容已在
139
+ 平台草稿箱,再跑只会造重复稿;提交发布走平台草稿箱由用户手工完成)。
140
+ 返回 (list, err);list 项
139
141
  {chapter_no, file, size},file 为工作目录相对路径。章号解析不出的
140
142
  文件不进自动发布(防同章多文件误发),API 单章发(manager 直调)
141
143
  不受此限。
@@ -154,7 +156,8 @@ def pending(task_id, platform):
154
156
  if fs:
155
157
  files = fs # 任一 run 的成品口径都从任务首跑起,取到即够
156
158
  break
157
- done = ledger.published_chapters(task_id, platform)
159
+ done = ledger.published_chapters(task_id, platform) \
160
+ | ledger.drafted_chapters(task_id, platform)
158
161
  # 分卷标注(2026-10-08 用户需求:待发清单按卷展示):卷计划解析不出时
159
162
  # volume 为空串,前端不分组平铺。
160
163
  plan = []
@@ -182,7 +185,12 @@ def pending(task_id, platform):
182
185
 
183
186
 
184
187
  def status(task_id):
185
- """前端视图:待发清单 + 护栏状态 + 自动发布进度。"""
188
+ """前端视图:待发清单 + 护栏状态 + 自动发布进度。
189
+
190
+ 护栏分两层给前端:guard_ok/guard_reason=完整发布闸(硬护栏+质量闸),
191
+ 管「发布」类按钮;draft_ok/draft_reason=硬护栏(每日上限/连败退避/建书
192
+ 确认),管「存草稿」按钮——草稿不上线,质量闸拦它只会堵死「先落平台
193
+ 再人工把关」的通路;quality_blockers 单独带出去做提示性展示。"""
186
194
  from .. import store
187
195
  from . import ledger, manager
188
196
  ent = ledger.books_for(str(task_id)) # 沿连载链继承:续写批次同书同账
@@ -190,19 +198,25 @@ def status(task_id):
190
198
  books = []
191
199
  for plat, info in ent.items():
192
200
  pend, err = pending(task_id, plat)
193
- ok, why = guards(task_id, plat)
201
+ hard_ok, hard_why = guards(task_id, plat)
194
202
  if not _book_ready(info):
195
- ok, why = False, "该平台建书结果未确认(缺少作品 ID),请重试创建作品或登记已有作品"
196
- elif task:
203
+ hard_ok, hard_why = False, "该平台建书结果未确认(缺少作品 ID),请重试创建作品或登记已有作品"
204
+ blockers = []
205
+ if task and _book_ready(info):
197
206
  quality = manager._quality_release_guard(task, plat, action="publish")
198
207
  if not quality.get("allowed"):
199
- ok, why = False, "质量门禁拦截:%s" % ";".join(
200
- quality.get("blockers") or [])
208
+ blockers = [str(b) for b in (quality.get("blockers") or [])]
209
+ ok, why = hard_ok, hard_why
210
+ if ok and blockers:
211
+ ok, why = False, "质量门禁拦截:%s" % ";".join(blockers)
201
212
  books.append({"platform": plat, "bound": True,
202
213
  "title": info.get("title") or "",
203
214
  "pending": len(pend),
204
215
  "items": pend[:200],
205
216
  "guard_ok": ok, "guard_reason": why,
217
+ "draft_ok": hard_ok, "draft_reason": hard_why,
218
+ "quality_blockers": blockers,
219
+ "drafted": len(ledger.drafted_chapters(task_id, plat)),
206
220
  "calibrated": calibrated(plat)})
207
221
  run = _running.get(task_id) or None
208
222
  if run:
@@ -236,8 +250,27 @@ def calibrated(platform):
236
250
  return (paths.PUBLISH_DIR / ("flows-%s.json" % platform)).is_file()
237
251
 
238
252
 
253
+ def flow_calibrated(plat, action):
254
+ """某动作的步骤表是否有真机校准来源(用户校准文件或仓库校准模板里
255
+ 有该动作的表)。内置推测表不算——存草稿虽不上线,批量乱点照样惹
256
+ 风控/攒一箱废稿,无人值守跑之前必须有验证过的步骤表。"""
257
+ import json as _json
258
+ from pathlib import Path as _Path
259
+ from .. import paths as _paths
260
+ for fp in (_paths.PUBLISH_DIR / ("flows-%s.json" % plat),
261
+ _Path(__file__).with_name("flows-%s-calibrated.json" % plat)):
262
+ try:
263
+ data = _json.loads(fp.read_text(encoding="utf-8"))
264
+ if isinstance(data, dict) and isinstance(data.get(action), list):
265
+ return True
266
+ except Exception:
267
+ pass
268
+ return False
269
+
270
+
239
271
  def publish_pending_async(task_id, platform, auto_submit=False, only=None,
240
- force=False, force_confirmed=False, force_reason=""):
272
+ force=False, force_confirmed=False, force_reason="",
273
+ as_draft=False):
241
274
  """把任务的待发章节按章号顺序发出(后台线程)。返回 (ok, err)。
242
275
 
243
276
  only=章号清单(批量选择发布):从待发清单里挑出所选章号,保持章号
@@ -245,12 +278,22 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
245
278
  auto_submit=True(直发)逐章提交走完全程;False(人工确认)每轮只填
246
279
  **一章**就停在 manual_pause——表单填好后提交权在用户,walker 若直接
247
280
  填下一章会导航离开未提交的编辑器,把上一章内容丢掉(平台草稿自动
248
- 保存不可依赖)。用户在浏览器提交后再次发起即发下一章。"""
281
+ 保存不可依赖)。用户在浏览器提交后再次发起即发下一章。
282
+ as_draft=True(全部发草稿,2026-10-09):逐章自动填稿并点「存草稿」,
283
+ 不上线、不受质量闸拦、无需人工确认,一章接一章连跑到清空待发清单
284
+ (章间仍按 PACE_S 防风控节奏);提交发布由用户到平台草稿箱手工完成,
285
+ 届时质量闸照常把关。与 auto_submit 互斥(草稿模式忽略 auto_submit)。"""
249
286
  from .. import store
250
287
  from . import ledger, manager
251
288
  if platform not in manager.PLATFORMS:
252
289
  return False, "未知平台"
253
- if auto_submit and not calibrated(platform):
290
+ if as_draft:
291
+ auto_submit = False # 草稿模式没有「提交」语义
292
+ if not flow_calibrated(platform, "upload_chapter_draft"):
293
+ return False, ("存草稿模式需要该平台有 upload_chapter_draft 步骤表"
294
+ "(仓库校准模板或 data/publish/flows-%s.json);"
295
+ "未校准不许无人值守批量动表单" % platform)
296
+ elif auto_submit and not calibrated(platform):
254
297
  return False, ("自动提交模式需要先校准该平台发布流程:用「探测」按钮 dump "
255
298
  "表单后把真实步骤写进 data/publish/flows-%s.json(缺省选择器"
256
299
  "只是推测,未校准不许无人值守直发)" % platform)
@@ -271,9 +314,13 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
271
314
  return False, "该任务尚未在此平台建书,请先「创建作品」"
272
315
  if not _book_ready(book):
273
316
  return False, "该平台建书结果未确认(缺少作品 ID),请重试创建作品或登记已有作品"
274
- quality = manager._quality_release_guard(
275
- task, platform, action="publish", force=force,
276
- force_confirmed=force_confirmed, force_reason=force_reason)
317
+ if as_draft:
318
+ # 草稿不上线:质量闸不适用(同 manager 口径),硬护栏照查
319
+ quality = {"allowed": True, "status": "draft_only"}
320
+ else:
321
+ quality = manager._quality_release_guard(
322
+ task, platform, action="publish", force=force,
323
+ force_confirmed=force_confirmed, force_reason=force_reason)
277
324
  if not quality.get("allowed"):
278
325
  return False, "质量门禁拦截:%s" % ";".join(quality.get("blockers") or [])
279
326
  ok, why = guards(task_id, platform)
@@ -298,7 +345,8 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
298
345
  wd = task.get("workdir") or ""
299
346
  st = {"platform": platform, "at": time.strftime("%Y-%m-%d %H:%M:%S"),
300
347
  "done": 0, "total": len(pend), "status": "running", "error": "",
301
- "auto_submit": bool(auto_submit), "last_chapter": 0}
348
+ "auto_submit": bool(auto_submit), "as_draft": bool(as_draft),
349
+ "last_chapter": 0}
302
350
  with _LOCK:
303
351
  _running[task_id] = st
304
352
 
@@ -310,7 +358,7 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
310
358
  st["status"] = "error"
311
359
  st["error"] = "第 %d 章前护栏拦截:%s" % (item["chapter_no"], why)
312
360
  return
313
- upload_kwargs = {"auto_submit": auto_submit}
361
+ upload_kwargs = {"auto_submit": auto_submit, "as_draft": as_draft}
314
362
  if force or force_confirmed or force_reason:
315
363
  upload_kwargs.update(force=force,
316
364
  force_confirmed=force_confirmed,
@@ -324,10 +372,29 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
324
372
  return
325
373
  if not _wait_idle(platform):
326
374
  st["status"] = "error"
327
- st["error"] = ("第 %d 章发布等待超时(%.0f 分钟);若在等人工提交,"
328
- "请提交后重跑剩余章节" % (item["chapter_no"],
329
- IDLE_TIMEOUT_S / 60))
375
+ if as_draft:
376
+ st["error"] = ("第 %d 章存草稿等待超时(%.0f 分钟),后续"
377
+ "章节未存" % (item["chapter_no"],
378
+ IDLE_TIMEOUT_S / 60))
379
+ else:
380
+ st["error"] = ("第 %d 章发布等待超时(%.0f 分钟);若在等人工提交,"
381
+ "请提交后重跑剩余章节" % (item["chapter_no"],
382
+ IDLE_TIMEOUT_S / 60))
330
383
  return
384
+ if as_draft:
385
+ # manager 走完草稿流程(含平台侧验证)才返回;台账落了
386
+ # upload_chapter_draft 才算数,防「流程跑完但平台没存上」。
387
+ if item["chapter_no"] not in ledger.drafted_chapters(
388
+ task_id, platform):
389
+ st["status"] = "error"
390
+ st["error"] = ("第 %d 章存草稿未确认,后续章节未存(详见"
391
+ "发布台账与截图存证)" % item["chapter_no"])
392
+ return
393
+ st["done"] += 1
394
+ st["last_chapter"] = item["chapter_no"]
395
+ if st["done"] < st["total"]:
396
+ time.sleep(PACE_S)
397
+ continue
331
398
  if not auto_submit:
332
399
  # The manager stopped before the submit step. There is no
333
400
  # remote success receipt until the user clicks submit.
@@ -348,7 +415,11 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
348
415
  if st["done"] < st["total"]:
349
416
  time.sleep(PACE_S)
350
417
  st["status"] = "done"
351
- if not auto_submit:
418
+ if as_draft:
419
+ st["message"] = ("已存草稿 %d 章(未发布,不受质量闸拦不代表可直发);"
420
+ "请到平台章节管理/草稿箱逐章检查后提交发布"
421
+ % st["done"])
422
+ elif not auto_submit:
352
423
  st["message"] = "第 %d 章已填好,请在浏览器里确认提交,再重新校准" % st["last_chapter"]
353
424
  except Exception as e: # 线程内绝不能悬挂无终态
354
425
  st["status"] = "error"
@@ -110,6 +110,35 @@ UPLOAD_CHAPTER = [
110
110
  "optional": True},
111
111
  ]
112
112
 
113
+ # 发章存草稿(2026-10-09 全部发草稿):与 UPLOAD_CHAPTER 同一张填表前段,
114
+ # 终点不同——点编辑器头部「存草稿」直接落草稿箱,不走「下一步→内容检测→
115
+ # 发布提示→提交」发布链。草稿不上线,批量连跑无需人工确认;提交发布由
116
+ # 用户到章节管理/草稿箱手工完成。verify 沿用章节管理页断言(草稿行同样
117
+ # 出现在列表里,标题/章号标记可命中);若平台改版把草稿挪去独立草稿箱页,
118
+ # 首轮失败的 step 截图会指出来,照 flows-fanqie.json 校准即可。
119
+ UPLOAD_CHAPTER_DRAFT = [
120
+ {"do": "navigate", "url": "{editor_url}"},
121
+ {"do": "url_any", "any": ["fanqienovel.com"]},
122
+ {"do": "wait", "sel": "[contenteditable=true],textarea[class*=content],div[class*=editor]", "timeout": 10},
123
+ {"do": "volume", "key": "volume_name", "optional": True,
124
+ "open_sel": "input[type=number],input[class*=serial],div[class*=volume]",
125
+ "modal_sel": "[class*=volume][class*=modal],[class*=modal][class*=volume]",
126
+ "item_sel": "[class*=volume] li,[class*=volume-item]",
127
+ "add_sel": "[class*=add-volume],[class*=volume] button",
128
+ "confirm_sel": "[class*=confirm]", "settle": 1.5},
129
+ {"do": "fill", "sel": "input[type=number],input[class*=serial],input[class*=byte]", "key": "chapter_no"},
130
+ {"do": "fill", "sel": "input[placeholder*='章节'],input[placeholder*='标题']", "key": "chapter_name"},
131
+ {"do": "wait", "sel": "[contenteditable=true],textarea[class*=content],div[class*=editor]", "timeout": 10},
132
+ {"do": "fill", "sel": "[contenteditable=true],textarea[class*=content]", "key": "chapter_body"},
133
+ {"do": "shot", "name": "chapter-filled-draft"},
134
+ {"do": "submit", "text": "存草稿", "scope": "button", "tries": 15},
135
+ {"do": "js_click", "text": "存草稿", "scope": "button", "tries": 10,
136
+ "optional": True, "skip_if_modal": True},
137
+ {"do": "sleep", "s": 2.5},
138
+ {"do": "verify", "url": "{chapter_manage_url}",
139
+ "any": ["第{chapter_no}章", "{chapter_name}"], "settle": 6, "new_tab": True},
140
+ ]
141
+
113
142
  # 登录态探测:打开后台首页,URL 被踢到登录页 → 未登录
114
143
  CHECK_LOGIN = [
115
144
  {"do": "navigate", "url": "{home}"},
@@ -125,6 +154,7 @@ PROBE_FORM = [
125
154
  ]
126
155
 
127
156
  FLOWS = {"create_book": CREATE_BOOK, "upload_chapter": UPLOAD_CHAPTER,
157
+ "upload_chapter_draft": UPLOAD_CHAPTER_DRAFT,
128
158
  "check_login": CHECK_LOGIN, "probe_form": PROBE_FORM}
129
159
 
130
160
 
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "_note": "番茄真机校准(2026-09-19 定稿,2026-09-24 加固):编辑器=/main/writer/<book_id>/publish/ 直达(章节号 input.serial-input 纯数字;标题 div.serial-editor-input-hint input ≥5字/30上限;正文 .ProseMirror ≥1000字;「下一步」保存校验后按钮组右缘箭头展开下拉含「定时发布」)。建书=/main/writer/create 直达表单:radio 签约模式/目标读者(label 真实点击)→ 阅读标签 select(y<430)弹层 arco-anchor 切主分类/主题/角色/情节 + 标签云点击 + 确认 → 内容标签 select(y>430)弹层 情感/人设/世界观 同构 → 名称/主角1/主角2/简介 → 立即创建(submit 步骤:auto_submit=false 时填好即停,留给人工提交) → 跳 book-info/<id>。锚点切换渲染慢(0924 三连败):弹层打开后 settle,锚点 tries=12。0928 再加固(弹层没开连败案):签约/读者 radio 点击后断言选中态(expect_checked);两处标签触发器点完 wait .category-modal 存在替代盲 sleep;real_click_text 落点 elementFromPoint 守卫,找不到目标时报错自带定位范围可见项诊断。2026-10-08 发章三修:values 补 chapter_no/chapter_name 键(此前序号框从未被填、被平台以「章节序号只支持阿拉伯数字」打回);标题位改填 chapter_name(read_chapter 拆掉「第X章」前缀,序号/标题分开填);verify 标记改「第{chapter_no}章」或「{chapter_name}」任一命中(用户手工改过标题时按名对版必假失败)。。2026-10-08 批量自动发布·自动分卷:upload_chapter 增加 volume 步骤(values.volume_name 由 manager 按工作目录卷计划解析:分卷.json 优先,其次 大纲/series-outline.md 的「## 第X卷:名」+「依次48、56…章」;平台卷弹窗 = .publish-header-volume-name 开 → .editor-volume-list-item 按去前缀名匹配选中,缺卷走 .editor-volume-footer-add-volume 新建→行内 input 输名→i.tomato-confirm 落名→选中→确定;新建卷名会追加进 values._volumes 由 manager 回写 books.json 缓存)。。2026-10-08 晚:平台发布确认 UI 已改——下一步(保存校验)后弹「发布提示」错别字确认框(取消/提交),不再有「定时发布」下拉组;流程撤 click_arrow/定时发布/发布,改点确认框「提交」(optional:无错别字时可能不弹,直接 verify)。。verify 改 new_tab(开新页签验证、编辑器页签不动):编辑器页签内导航触发 beforeunload 原生确认,渲染挂起+发布请求被取消——直发章全卡死在验证导航上的实案修法。。1008深夜终版:publish链=submit(下一步,gate)→js_click下一步兜底(skip_if_modal)→js_click仅基础检测(检测方式弹窗,tries60)→sleep5→js_click提交(错别字确认,tries75)→verify new_tab;真实鼠标在 headless/后台页签不触发平台处理器,DOM click 可靠。",
3
+ "upload_chapter_draft": "2026-10-09 全部发草稿:前段(volume/序号/标题/正文填表)与 upload_chapter 同源选择器;终点改点编辑器头部「存草稿」直接落草稿箱,不走下一步/检测/发布提示发布链。verify 沿用章节管理页断言(草稿行同样在列表里);若平台把草稿挪去独立草稿箱页,首轮失败截图会指出,届时按此校准。「存草稿」按钮文本未真机实证(校准时浏览器离线),首次批量跑请盯首章截图。",
3
4
  "check_login": [
4
5
  {
5
6
  "do": "navigate",
@@ -111,6 +112,86 @@
111
112
  "new_tab": true
112
113
  }
113
114
  ],
115
+ "upload_chapter_draft": [
116
+ {
117
+ "do": "navigate",
118
+ "url": "about:blank"
119
+ },
120
+ {
121
+ "do": "navigate",
122
+ "url": "{editor_url}"
123
+ },
124
+ {
125
+ "do": "url_any",
126
+ "any": [
127
+ "fanqienovel.com"
128
+ ]
129
+ },
130
+ {
131
+ "do": "wait",
132
+ "sel": ".ProseMirror",
133
+ "timeout": 20
134
+ },
135
+ {
136
+ "do": "volume",
137
+ "key": "volume_name",
138
+ "optional": true,
139
+ "open_sel": ".publish-header-volume-name",
140
+ "modal_sel": ".serial-modal.editor-volume",
141
+ "item_sel": ".editor-volume-list-item",
142
+ "add_sel": ".editor-volume-footer-add-volume",
143
+ "input_sel": ".editor-volume-list-item input",
144
+ "confirm_sel": "i.tomato-confirm",
145
+ "settle": 1.5
146
+ },
147
+ {
148
+ "do": "fill",
149
+ "sel": "input.serial-input, input.byte-input",
150
+ "key": "chapter_no"
151
+ },
152
+ {
153
+ "do": "fill",
154
+ "sel": "div.serial-editor-input-hint input",
155
+ "key": "chapter_name"
156
+ },
157
+ {
158
+ "do": "fill",
159
+ "sel": ".ProseMirror",
160
+ "key": "chapter_body"
161
+ },
162
+ {
163
+ "do": "shot",
164
+ "name": "chapter-filled-draft"
165
+ },
166
+ {
167
+ "do": "submit",
168
+ "text": "存草稿",
169
+ "scope": "button",
170
+ "tries": 15
171
+ },
172
+ {
173
+ "do": "js_click",
174
+ "text": "存草稿",
175
+ "scope": "button",
176
+ "tries": 10,
177
+ "optional": true,
178
+ "skip_if_modal": true
179
+ },
180
+ {
181
+ "do": "sleep",
182
+ "s": 2.5
183
+ },
184
+ {
185
+ "do": "verify",
186
+ "url": "{chapter_manage_url}",
187
+ "any": [
188
+ "第{chapter_no}章",
189
+ "{chapter_name}"
190
+ ],
191
+ "settle": 6,
192
+ "new_tab": true
193
+ }
194
+ ],
114
195
  "probe_form": [
115
196
  {
116
197
  "do": "navigate",
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "_note": "七猫真机校准(2026-09-19 凌晨定稿):建书=book-manage「新建小说」→站点弹层(click_match+确认)→information 表单(fill_label/radio/级联分类/tags 四组各1-3个+确定/主角名平铺/简介);书名仅允许,:!?中文标点(·被拦)。发章=编辑器填好后「立即发布」落草稿箱(未签约书限制)→草稿行「立即发布」→「更正序号去发布」→「确认发布」三层弹窗→章节待审核。正文下限 1000 字(min_chapter_chars)。book-upload?id=<书id> 直达编辑器。;2026-09-24 加固:二级分类选完 settle 1s 再点「添加标签」(下拉关闭动画吞点击致标签弹层未开、组名切换失败案);2026-09-24 夜修:平台编辑器「立即发布」变为弹重型确认框(点后页面假死、章节未提交、草稿箱亦空——旧「立即发布落草稿箱」语义过时),改点「存草稿」再走草稿箱三层发布(草稿行立即发布→更正序号→确认发布,22:47 实证成功路径)",
3
+ "upload_chapter_draft": "2026-10-09 全部发草稿:按 upload_chapter 的「存草稿」段拆出(填标题/正文→click_real 存草稿→draft_url 草稿箱 verify 行在场)。存草稿段是 0924 实证路径的前半段,选择器同源;提交发布(立即发布三层弹窗)留给用户在草稿箱手工完成。",
3
4
  "probe_form": [
4
5
  {
5
6
  "do": "navigate",
@@ -226,6 +227,63 @@
226
227
  "settle": 6
227
228
  }
228
229
  ],
230
+ "upload_chapter_draft": [
231
+ {
232
+ "do": "navigate",
233
+ "url": "about:blank"
234
+ },
235
+ {
236
+ "do": "navigate",
237
+ "url": "{editor_url}"
238
+ },
239
+ {
240
+ "do": "url_any",
241
+ "any": [
242
+ "qimao.com"
243
+ ]
244
+ },
245
+ {
246
+ "do": "wait",
247
+ "sel": "textarea[placeholder*='章节名称']",
248
+ "timeout": 20
249
+ },
250
+ {
251
+ "do": "fill",
252
+ "sel": "textarea[placeholder*='章节名称']",
253
+ "key": "chapter_title"
254
+ },
255
+ {
256
+ "do": "wait",
257
+ "sel": ".q-contenteditable",
258
+ "timeout": 12
259
+ },
260
+ {
261
+ "do": "fill",
262
+ "sel": ".q-contenteditable",
263
+ "key": "chapter_body"
264
+ },
265
+ {
266
+ "do": "shot",
267
+ "name": "chapter-filled-draft"
268
+ },
269
+ {
270
+ "do": "click_real",
271
+ "text": "存草稿",
272
+ "tries": 25
273
+ },
274
+ {
275
+ "do": "sleep",
276
+ "s": 2
277
+ },
278
+ {
279
+ "do": "verify",
280
+ "url": "{draft_url}",
281
+ "any": [
282
+ "{chapter_title}"
283
+ ],
284
+ "settle": 5
285
+ }
286
+ ],
229
287
  "check_login": [
230
288
  {
231
289
  "do": "navigate",
@@ -111,6 +111,28 @@ def published_chapters(task_id, platform):
111
111
  return out
112
112
 
113
113
 
114
+ def drafted_chapters(task_id, platform):
115
+ """该任务在该平台已成功存草稿的章节号集合(发草稿模式的幂等口径)。
116
+
117
+ 与 published_chapters 同款连载链合并;不含校准的平台实况(实况口径里
118
+ 没有「草稿」桶,草稿章是否已由用户手工发布以重新校准后的
119
+ remote_published_nos 为准)。草稿章从待发清单剔除:已在平台存稿的章
120
+ 再跑一遍只会造出重复草稿。"""
121
+ from .. import store
122
+ try:
123
+ ids = set(store.serial_chain_ids(task_id))
124
+ except Exception:
125
+ ids = {str(task_id)}
126
+ out = set()
127
+ for r in _iter_records(3650):
128
+ if (r.get("action") == "upload_chapter_draft" and r.get("ok")
129
+ and r.get("task_id") in ids and r.get("platform") == platform):
130
+ n = r.get("chapter_no") or 0
131
+ if n > 0:
132
+ out.add(int(n))
133
+ return out
134
+
135
+
114
136
  def recent(task_id=None, platform=None, limit=50):
115
137
  """最近记录(新在前),详情页发布历史用。task_id 给定时按连载链合并。"""
116
138
  from .. import store
@@ -338,7 +360,8 @@ def consecutive_failures(platform):
338
360
  for r in reversed(list(_iter_records(7))):
339
361
  if r.get("platform") != platform:
340
362
  continue
341
- if r.get("action") not in ("upload_chapter", "create_book"):
363
+ if r.get("action") not in ("upload_chapter", "create_book",
364
+ "upload_chapter_draft"):
342
365
  continue
343
366
  t = _rec_time(r)
344
367
  if t is None:
@@ -872,8 +872,14 @@ def read_chapter(fp):
872
872
 
873
873
 
874
874
  def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False,
875
- force=False, force_confirmed=False, force_reason=""):
876
- """把一章发到平台(或填好待人工确认)。幂等:已成功发布的章号拒绝重发。"""
875
+ force=False, force_confirmed=False, force_reason="",
876
+ as_draft=False):
877
+ """把一章发到平台(或填好待人工确认/as_draft 只存草稿)。
878
+
879
+ 幂等:已成功发布**或已存草稿**的章号拒绝重做——重复发会平台上出双章,
880
+ 重复存草稿会攒一箱重复稿。as_draft=True 走「存草稿」流程表
881
+ (upload_chapter_draft):内容落平台草稿箱不上线,不构成发布动作,
882
+ 质量闸不拦(发布时照拦);存稿动作无需人工确认,适合批量连跑。"""
877
883
  from .. import store
878
884
  if plat not in PLATFORMS:
879
885
  return False, "未知平台"
@@ -894,9 +900,14 @@ def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False,
894
900
  ch_no, title, body, err = read_chapter(chapter_file)
895
901
  if err:
896
902
  return False, err
897
- quality = _quality_release_guard(
898
- task, plat, action="publish", target_chapter=ch_no or None,
899
- force=force, force_confirmed=force_confirmed, force_reason=force_reason)
903
+ if as_draft:
904
+ # 草稿不上线:质量闸不适用(evaluate_release action=draft 直通),
905
+ # 人工复核挪到「平台草稿箱 → 用户逐章检查后提交」那一步。
906
+ quality = {"allowed": True}
907
+ else:
908
+ quality = _quality_release_guard(
909
+ task, plat, action="publish", target_chapter=ch_no or None,
910
+ force=force, force_confirmed=force_confirmed, force_reason=force_reason)
900
911
  if not quality.get("allowed"):
901
912
  return False, "质量门禁拦截:%s" % ";".join(quality.get("blockers") or [])
902
913
  n_chars = len(body.replace("\n", "").replace(" ", ""))
@@ -912,10 +923,14 @@ def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False,
912
923
  done = ledger.published_chapters(task_id, plat)
913
924
  if ch_no and ch_no in done:
914
925
  return False, "第 %d 章已成功发布过(台账幂等拦截);确需重发请手工处理" % ch_no
926
+ if as_draft and ch_no and ch_no in ledger.drafted_chapters(task_id, plat):
927
+ return False, ("第 %d 章已存过草稿(台账幂等拦截);请到平台草稿箱检查后"
928
+ "提交发布,确需重存请先在平台删旧稿" % ch_no)
915
929
  ok_login, why = _login_guard(plat)
916
930
  if not ok_login:
917
931
  return False, why
918
- _set(plat, status="busy", last_action="upload_chapter", error="")
932
+ flow_key = "upload_chapter_draft" if as_draft else "upload_chapter"
933
+ _set(plat, status="busy", last_action=flow_key, error="")
919
934
 
920
935
  mod = PLATFORMS[plat]
921
936
  values = chapter_fill_values(ch_no, title, body,
@@ -934,7 +949,7 @@ def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False,
934
949
  operation_id = operations.begin(
935
950
  "publish:%s:upload_chapter" % plat,
936
951
  {"task_id": task_id, "chapter_no": ch_no, "title": title}, task_id=task_id,
937
- metadata={"platform": plat, "action": "upload_chapter"})
952
+ metadata={"platform": plat, "action": flow_key})
938
953
 
939
954
  def run():
940
955
  try:
@@ -948,14 +963,26 @@ def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False,
948
963
  elif not book_ref.get("book_id"):
949
964
  raise RuntimeError("未按作品名找到远端作品,未打开章节编辑器;"
950
965
  "请检查平台作品名或补填作品 ID")
951
- flow.run_flow(page, load_flow(plat, "upload_chapter"), values=values,
966
+ flow.run_flow(page, load_flow(plat, flow_key), values=values,
952
967
  config=mod.CONFIG, auto_submit=auto_submit,
953
968
  shot=lambda n: page.screenshot(ledger.shot_path(plat, task_id, n)),
954
969
  log=logs.append)
955
970
  new_vols = values.get("_volumes")
956
971
  if isinstance(new_vols, list) and new_vols != (book_ref.get("volumes") or []):
957
972
  ledger.update_book(task_id, plat, volumes=new_vols)
958
- if auto_submit:
973
+ if as_draft:
974
+ # 草稿已落平台草稿箱(流程含验证步,走到这=验证过)。发布确认权
975
+ # 仍在用户:到平台草稿箱逐章检查后手工提交,质量闸在「发布」时
976
+ # 照常把关。
977
+ operations.confirm(operation_id, metadata={"platform": plat,
978
+ "action": "upload_chapter_draft",
979
+ "chapter_no": ch_no})
980
+ ledger.record(plat, "upload_chapter_draft", task_id=task_id,
981
+ chapter_no=ch_no, book_id=book_ref.get("book_id") or "",
982
+ title=title, ok=True, operation_id=operation_id,
983
+ operation_status="confirmed",
984
+ remote_receipt="platform-draft")
985
+ elif auto_submit:
959
986
  operations.confirm(operation_id, metadata={"platform": plat,
960
987
  "action": "upload_chapter",
961
988
  "chapter_no": ch_no})
@@ -60,6 +60,22 @@ UPLOAD_CHAPTER = [
60
60
  {"do": "submit", "sel": "button[class*=publish],button[class*=submit]"},
61
61
  ]
62
62
 
63
+ # 发章存草稿(2026-10-09 全部发草稿):内置推测表——真机校准表在
64
+ # flows-qimao-calibrated.json(按其 upload_chapter 的「存草稿」段拆出:
65
+ # 填好→存草稿→草稿箱验证行在场即成)。草稿不上线;提交发布由用户在
66
+ # 草稿箱手工点(立即发布→更正序号→确认发布三层弹窗照旧人工走)。
67
+ UPLOAD_CHAPTER_DRAFT = [
68
+ {"do": "navigate", "url": "{editor_url}"},
69
+ {"do": "url_any", "any": ["qimao.com"]},
70
+ {"do": "fill", "sel": "textarea[placeholder*='章节名称']", "key": "chapter_title"},
71
+ {"do": "wait", "sel": "[contenteditable=true],textarea[class*=content],iframe", "timeout": 10},
72
+ {"do": "fill", "sel": "[contenteditable=true],textarea[class*=content]", "key": "chapter_body"},
73
+ {"do": "shot", "name": "chapter-filled-draft"},
74
+ {"do": "click_real", "text": "存草稿", "tries": 25},
75
+ {"do": "sleep", "s": 2},
76
+ {"do": "verify", "url": "{draft_url}", "any": ["{chapter_title}"], "settle": 5},
77
+ ]
78
+
63
79
  CHECK_LOGIN = [
64
80
  {"do": "navigate", "url": "{home}"},
65
81
  {"do": "url_any", "any": ["qimao.com"]},
@@ -73,6 +89,7 @@ PROBE_FORM = [
73
89
  ]
74
90
 
75
91
  FLOWS = {"create_book": CREATE_BOOK, "upload_chapter": UPLOAD_CHAPTER,
92
+ "upload_chapter_draft": UPLOAD_CHAPTER_DRAFT,
76
93
  "check_login": CHECK_LOGIN, "probe_form": PROBE_FORM}
77
94
 
78
95
 
@@ -127,6 +127,13 @@ def evaluate_release(verdict, action="publish", target_chapter=None,
127
127
  if action == "continue":
128
128
  return {"allowed": True, "status": "continue_only", "forced": False,
129
129
  "blockers": [], "warnings": []}
130
+ if action == "draft":
131
+ # 存草稿不是发布:内容不公开展示(读者不可见),正式提交前用户还会
132
+ # 在平台上逐章过目——质量闸只在「发布/签约」这类上线动作上拦,
133
+ # 拦草稿只会把「先落平台再人工把关」的通路堵死(2026-10-09 全部
134
+ # 发草稿需求:质量未达标的 53 章先存草稿,发布仍受本闸约束)。
135
+ return {"allowed": True, "status": "draft_only", "forced": False,
136
+ "blockers": [], "warnings": []}
130
137
 
131
138
  blockers = []
132
139
  warnings = []
package/app/main.py CHANGED
@@ -2263,7 +2263,9 @@ class Handler(BaseHTTPRequestHandler):
2263
2263
  """批量发布全部待发章节(publish/auto.py)。
2264
2264
 
2265
2265
  护栏(每日上限/连败退避/幂等/单飞)在 auto 层,每章发起前复查;
2266
- auto_submit 默认 false——每章表单填好后停,提交权留给用户。"""
2266
+ auto_submit 默认 false——每章表单填好后停,提交权留给用户。
2267
+ as_draft=true 走「全部发草稿」:逐章存草稿不上线、不受质量闸拦、
2268
+ 无需人工确认连跑到清空(2026-10-09);草稿与直发互斥,草稿优先。"""
2267
2269
  from core import store
2268
2270
  from core.publish import auto as pub_auto
2269
2271
  if not store.get_task(task_id):
@@ -2276,7 +2278,8 @@ class Handler(BaseHTTPRequestHandler):
2276
2278
  force_confirmed = bool(body.get("force_confirmed"))
2277
2279
  force_reason = str(body.get("force_reason") or "").strip()
2278
2280
  only = body.get("chapters") # 批量选择发布:章号数组(可省=全部待发)
2279
- kwargs = {"only": only if isinstance(only, list) else None}
2281
+ kwargs = {"only": only if isinstance(only, list) else None,
2282
+ "as_draft": bool(body.get("as_draft"))}
2280
2283
  if force or force_confirmed or force_reason:
2281
2284
  kwargs.update(force=force, force_confirmed=force_confirmed,
2282
2285
  force_reason=force_reason)
package/app/ui/app.js CHANGED
@@ -812,8 +812,14 @@ function onTypeChange() {
812
812
  }
813
813
 
814
814
  function directProviders() {
815
- /* 停用厂商不列出(用户拍板 2026-09-21「停用的不弄出来」);只列启用且有 key 的 */
816
- return (S.providers || []).filter((p) => p.enabled !== false && p.api_key);
815
+ /* 停用厂商不列出(用户拍板 2026-09-21「停用的不弄出来」);只列启用且有 key 的。
816
+ * 模型名单已抓取但启用数为零的也不列——「随厂商推荐」没有可落的模型。 */
817
+ return (S.providers || []).filter((p) => {
818
+ if (p.enabled === false || !p.api_key) return false;
819
+ const ms = p.models;
820
+ if (Array.isArray(ms) && ms.length && !ms.some((m) => m.enabled !== false && !m.hidden)) return false;
821
+ return true;
822
+ });
817
823
  }
818
824
 
819
825
  function renderDirectModelPicker() {
@@ -826,7 +832,8 @@ function renderDirectModelPicker() {
826
832
  if (prev && provs.some((p) => p.id === prev)) ps.value = prev;
827
833
  const p = provs.find((x) => x.id === ps.value);
828
834
  const oldModel = ms.value;
829
- const names = p ? (p.models || []).filter((m) => !m.hidden)
835
+ // 只列启用模型(停用/已删除的与后端 resolve 同口径;已选模型被停用时自然回落「随厂商推荐」)
836
+ const names = p ? (p.models || []).filter((m) => !m.hidden && m.enabled !== false)
830
837
  .map((m) => m.name).filter(Boolean) : [];
831
838
  ms.innerHTML = '<option value="">' + t("随厂商推荐") + '</option>' + names.map((name) =>
832
839
  '<option value="' + esc(name) + '">' + esc(name) + "</option>").join("");
@@ -6166,7 +6173,7 @@ function pbBlock(task, platform) {
6166
6173
  const run = (S.pubAuto && S.pubAuto.running &&
6167
6174
  S.pubAuto.running.platform === platform) ? S.pubAuto.running : null;
6168
6175
  if (run && run.status === "running") {
6169
- btns += '<span class="pb-book">' + esc(t("自动发布中 ") + run.done + "/" + run.total) + "</span>";
6176
+ btns += '<span class="pb-book">' + esc((run.as_draft ? t("存草稿中 ") : t("自动发布中 ")) + run.done + "/" + run.total) + "</span>";
6170
6177
  } else if (au.pending > 0) {
6171
6178
  // 门禁不再禁用按钮:点了走 pbApiWithQualityOverride 的复审/强制对话,
6172
6179
  // 死按钮只会让用户无路可走(2026-10-08 实案:guard false 全灰点不了)
@@ -6174,6 +6181,12 @@ function pbBlock(task, platform) {
6174
6181
  ' title="' + esc(au.guard_ok ? t("按章号顺序逐章填稿(人工模式每点一次填一章,浏览器里提交后再点发下一章)") : t("护栏提示:") + (au.guard_reason || "")) + '"' +
6175
6182
  ' onclick="pbPublishAll(\'' + esc(task.id) + "', '" + platform + '\')">' +
6176
6183
  t("发布全部待发") + t("(") + au.pending + t(")") + "</button>";
6184
+ // 全部发草稿(2026-10-09):质量闸拦发布不拦草稿——逐章自动填稿点
6185
+ // 「存草稿」连跑到清空,提交发布由用户到平台草稿箱手工完成
6186
+ btns += ' <button class="ghost" ' + (busy || au.draft_ok === false ? "disabled" : "") +
6187
+ ' title="' + esc(au.draft_ok !== false ? t("逐章自动填稿并点「存草稿」:内容落平台草稿箱不上线,不受质量门禁拦截,无需逐章人工确认;完成后到平台检查并提交发布") : t("护栏提示:") + (au.draft_reason || "")) + '"' +
6188
+ ' onclick="pbDraftAll(\'' + esc(task.id) + "', '" + platform + '\')">' +
6189
+ t("全部存草稿") + t("(") + au.pending + t(")") + "</button>";
6177
6190
  }
6178
6191
  // 批量选择:待发章号 chips 按卷分组(卷计划来自服务端 items[].volume),
6179
6192
  // 「发布所选」人工模式 / 「直发所选」逐章自动提交(护栏照常生效)。
@@ -6222,10 +6235,23 @@ function pbBlock(task, platform) {
6222
6235
  (au.guard_ok ? "" : ";" + t("护栏提示:") + (au.guard_reason || ""))) + '"' +
6223
6236
  ' onclick="pbPublishSelected(\'' + esc(task.id) + "','" + platform + '\',true)">' +
6224
6237
  t("直发所选") + t("(") + sel.size + t(")") + "</button>" +
6238
+ '<button class="ghost" ' + (busy || !sel.size || au.draft_ok === false ? "disabled" : "") +
6239
+ ' title="' + esc(t("所选章节逐章自动存草稿(不上线、不受质量门禁拦截),完成后到平台检查并提交发布") +
6240
+ (au.draft_ok !== false ? "" : ";" + t("护栏提示:") + (au.draft_reason || ""))) + '"' +
6241
+ ' onclick="pbDraftSelected(\'' + esc(task.id) + "','" + platform + '\')">' +
6242
+ t("存草稿所选") + t("(") + sel.size + t(")") + "</button>" +
6225
6243
  "</div></div>";
6226
6244
  }
6227
- if (au.pending > 0 && !au.guard_ok) {
6228
- btns += '<div class="pb-err">' + esc(au.guard_reason || t("护栏拦截")) + "</div>";
6245
+ if (au.pending > 0 && au.draft_ok === false) {
6246
+ // 硬护栏(建书未确认/每日上限/连败退避):存草稿也走不了,红色示警
6247
+ btns += '<div class="pb-err">' + esc(au.draft_reason || au.guard_reason || t("护栏拦截")) + "</div>";
6248
+ } else if (au.pending > 0 && !au.guard_ok && au.quality_blockers && au.quality_blockers.length) {
6249
+ // 纯质量闸拦截:不给死路——提示先存草稿(2026-10-09 全部发草稿通路)
6250
+ btns += '<div class="pb-hint">' + esc(t("质量闸未过:") + au.quality_blockers.join(t(";")) +
6251
+ t("——可先「全部存草稿」落平台(不上线),正式发布前需复审达标或强制放行")) + "</div>";
6252
+ }
6253
+ if (au.drafted > 0) {
6254
+ btns += '<div class="pb-hint">' + esc(t("已存草稿 {0} 章(未发布):到平台章节管理/草稿箱逐章检查后提交发布", au.drafted)) + "</div>";
6229
6255
  }
6230
6256
  if (run && run.status === "manual_pause") {
6231
6257
  // 人工模式一轮只填一章:等用户在浏览器提交后再发起(连发会导航离开
@@ -6580,6 +6606,38 @@ window.pbPublishAll = async function (taskId, platform) { const au = (((S.pubAu
6580
6606
  S._pbSig = ""; pbKick();
6581
6607
  };
6582
6608
 
6609
+ /* 全部发草稿(2026-10-09):逐章自动填稿点「存草稿」连跑到清空,不上线、
6610
+ * 不受质量闸拦、无需人工确认;提交发布由用户到平台草稿箱手工完成。 */
6611
+ window.pbDraftAll = async function (taskId, platform) {
6612
+ const au = (((S.pubAuto && S.pubAuto.books) || [])
6613
+ .find((b) => b.platform === platform)) || {};
6614
+ if (!au.pending) return;
6615
+ if (!(await uiConfirm(t("将对全部 {0} 章待发稿逐章自动填稿并点「存草稿」:内容落平台草稿箱不上线,不受质量门禁拦截,无需逐章人工确认(章间隔约 1 分钟防风控)。跑完后请到平台章节管理/草稿箱逐章检查,再手工提交发布。继续?", au.pending),
6616
+ { ok: t("开始存草稿") }))) return;
6617
+ try {
6618
+ await api("/api/publish/task/" + encodeURIComponent(taskId) + "/publish-all",
6619
+ { method: "POST", body: JSON.stringify({ platform, as_draft: true }) });
6620
+ toast(t("存草稿已启动:逐章自动填稿并保存草稿,进度见发布台"));
6621
+ } catch (e) { toast(t("存草稿启动失败:") + e.message, true); }
6622
+ S._pbSig = ""; pbKick();
6623
+ };
6624
+
6625
+ window.pbDraftSelected = async function (taskId, platform) {
6626
+ const key = taskId + ":" + platform;
6627
+ const ent = (S.pbSel || {})[key];
6628
+ const chapters = ent ? [...ent.sel].sort((a, b) => a - b) : [];
6629
+ if (!chapters.length) return;
6630
+ if (!(await uiConfirm(t("将对所选 {0} 章(第 {1} 章—第 {2} 章)逐章自动填稿并点「存草稿」(不上线、不受质量门禁拦截),完成后到平台章节管理/草稿箱检查并提交发布。继续?",
6631
+ chapters.length, chapters[0], chapters[chapters.length - 1]),
6632
+ { ok: t("开始存草稿") }))) return;
6633
+ try {
6634
+ await api("/api/publish/task/" + encodeURIComponent(taskId) + "/publish-all",
6635
+ { method: "POST", body: JSON.stringify({ platform, chapters, as_draft: true }) });
6636
+ toast(t("存草稿已启动:逐章自动填稿并保存草稿,进度见发布台"));
6637
+ } catch (e) { toast(t("存草稿启动失败:") + e.message, true); }
6638
+ S._pbSig = ""; pbKick();
6639
+ };
6640
+
6583
6641
  function pbKick() {
6584
6642
  const key = detailSideTaskKey();
6585
6643
  if (!key) return;
@@ -9781,7 +9839,8 @@ function rdModelMenuRender() {
9781
9839
  t("管理模型…") + "</span></span></button></div>";
9782
9840
  } else {
9783
9841
  const p = directProviders().find((x) => x.id === rdMenuProv);
9784
- const names = p ? (p.models || []).filter((m) => !m.hidden)
9842
+ // 只列启用模型:停用的选了也会被运行时拒(builtin_agent.resolve 对账启用名单)
9843
+ const names = p ? (p.models || []).filter((m) => !m.hidden && m.enabled !== false)
9785
9844
  .map((m) => m.name).filter(Boolean) : [];
9786
9845
  menu.innerHTML =
9787
9846
  '<button type="button" class="type-item" data-back="1"><span class="ti-body"><span class="ti-name">‹ ' +
@@ -10486,6 +10545,14 @@ function apnSyncActive() {
10486
10545
  /* ---------------------------------------------------------- 智能体目录:模型选择 */
10487
10546
  const MAX_ORCH_MODELS = 3; // 与后端 modelhub.MAX_BIND_MODELS、runner 降级链保持一致
10488
10547
 
10548
+ /* 供应商有无可用密钥:keys 结构以「有启用且未冷却」为准;老数据单 KEY 看
10549
+ * api_key 镜像(与后端 _chain_keys / resolve_binding 的「无可用密钥」同口径) */
10550
+ function provKeyOk(p) {
10551
+ const ks = (p || {}).keys || [];
10552
+ if (ks.length) return ks.some((k) => k.enabled !== false && !k.cooling);
10553
+ return !!(p || {}).api_key;
10554
+ }
10555
+
10489
10556
  /* 供应商 → 已启用模型(按优先级),用于分组下拉与多选面板 */
10490
10557
  function modelGroups() {
10491
10558
  const out = [];
@@ -10496,7 +10563,7 @@ function modelGroups() {
10496
10563
  .sort((a, b) => (a.priority || 0) - (b.priority || 0))
10497
10564
  .map((m) => m.name).filter(Boolean);
10498
10565
  if (ms.length) out.push({ id: p.id, name: p.name, models: ms,
10499
- protocol: p.protocol || "", caps: p.wire_caps || {} });
10566
+ protocol: p.protocol || "", caps: p.wire_caps || {}, keyOk: provKeyOk(p) });
10500
10567
  }
10501
10568
  return out;
10502
10569
  }
@@ -10556,9 +10623,10 @@ function bindAllowedProtocols(kind) {
10556
10623
  function modelSelectHtml(id, current, kind) {
10557
10624
  const cur = fmtModel(current);
10558
10625
  const allow = bindAllowedProtocols(kind);
10626
+ // 无可用密钥的供应商不进下拉:默认模型配上去解析时也会被跳过(死配置)
10559
10627
  const groups = modelGroups().filter((g) => {
10560
10628
  const p = (S.providers || []).find((x) => x.id === g.id);
10561
- return provAdaptedProto(p, allow, kind);
10629
+ return g.keyOk !== false && provAdaptedProto(p, allow, kind);
10562
10630
  });
10563
10631
  let opts = '<option value="">' + t("(未设置)") + '</option>';
10564
10632
  if (cur && !groups.some((g) => g.models.includes(cur))) {
@@ -10685,7 +10753,7 @@ function chainProvUsable(p, allow, kind) {
10685
10753
  if (!p || p.enabled === false) return false;
10686
10754
  if (!provAdaptedProto(p, allow, kind)) return false;
10687
10755
  const ks = p.keys || [];
10688
- if (!ks.length) return true; // 老数据单 KEY(api_key 镜像):无 keys 结构视为可用
10756
+ if (!ks.length) return !!p.api_key; // 老数据单 KEY(api_key 镜像);两者皆无=没配密钥,不推荐
10689
10757
  return ks.some((k) => k.enabled !== false && !k.cooling);
10690
10758
  }
10691
10759
 
@@ -10847,16 +10915,19 @@ function bindPanel(c) {
10847
10915
  ? t("注入该厂商凭据") : t("已适配 · 注入该厂商凭据"))) + '</span></div>' +
10848
10916
  g.models.map((m) => {
10849
10917
  // 本供应商注入本 CLI 实际走的 wire(与后端 _entry_endpoint 同规则);
10850
- // 无命中 = 勾了也会被解析层整条跳过,标死防止白配
10918
+ // 无命中 = 勾了也会被解析层整条跳过,标死防止白配。
10919
+ // 无可用密钥同理:勾了也是死条目(解析层「无可用密钥」跳过)
10851
10920
  const wire = provAdaptedProto(g, allow, c.orch_kind);
10852
- const dead = !wire;
10921
+ const dead = !wire || g.keyOk === false;
10853
10922
  const has = st.chain.some((x) => x.p === g.id && x.m === m);
10854
10923
  return '<label class="oitem' + (dead ? " dim" : "") + '"><input type="checkbox" value="' + esc(m) + '" data-p="' + esc(g.id) + '"' +
10855
10924
  (has ? " checked" : "") + (dead ? " disabled" : "") +
10856
10925
  " onchange=\"bindPick('" + esc(c.id) + "', this, this.checked)\">" + esc(m) +
10857
10926
  (modelHasImage(g.id, m)
10858
10927
  ? ' <span class="tag ok" title="' + esc(t("支持图片输入")) + '">' + t("图") + "</span>" : "") +
10859
- (dead ? ' <span class="hint warn">' + esc(t("无可用 wire,解析时跳过")) + "</span>" : "") +
10928
+ (dead ? ' <span class="hint warn">' + esc(!wire
10929
+ ? t("无可用 wire,解析时跳过")
10930
+ : t("无可用密钥,解析时跳过")) + "</span>" : "") +
10860
10931
  "</label>";
10861
10932
  }).join("") +
10862
10933
  "</div>").join("") + "</div>";
@@ -11995,7 +12066,8 @@ function renderAuModelPicker(pid, model) {
11995
12066
  ps.value = provs.some((p) => p.id === keepP) ? keepP : "";
11996
12067
  const fillModels = (keepM) => {
11997
12068
  const p = provs.find((x) => x.id === ps.value);
11998
- const names = p ? (p.models || []).filter((m) => !m.hidden)
12069
+ // 只列启用模型:停用的不允许在定时任务里选(运行时同样会拒)
12070
+ const names = p ? (p.models || []).filter((m) => !m.hidden && m.enabled !== false)
11999
12071
  .map((m) => m.name).filter(Boolean) : [];
12000
12072
  ms.innerHTML = '<option value="">' + t("随厂商推荐") + "</option>" + names.map((name) =>
12001
12073
  '<option value="' + esc(name) + '">' + esc(name) + "</option>").join("");
package/app/ui/board.css CHANGED
@@ -207,7 +207,11 @@ button { font: inherit; cursor: pointer; }
207
207
  }
208
208
  .k-lab { font-size: 11px; color: var(--t3); letter-spacing: .8px; }
209
209
  .k-row { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
210
- .k-num { font: 700 27px/1 var(--mono); }
210
+ /* k-num 不许换行:卡高 78px 固定 + overflow hidden,换行必裁字(「2246.7万」案);
211
+ 放不下由 fitKpis() 降档,仍放不下才在末档省略号 */
212
+ .k-num { font: 700 27px/1 var(--mono); white-space: nowrap; }
213
+ .k-num.sm { font-size: 21px; }
214
+ .k-num.xs { font-size: 16px; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
211
215
  .k-sub { font: 11px var(--mono); color: var(--t2); white-space: nowrap; }
212
216
  .k-run .k-num { color: var(--live-hi); }
213
217
  .k-queue .k-num { color: var(--data); }
package/app/ui/board.js CHANGED
@@ -42,6 +42,20 @@ function parseTs(s) {
42
42
  const t = Date.parse(String(s || "").replace(" ", "T"));
43
43
  return isNaN(t) ? 0 : t;
44
44
  }
45
+ /* 指标条长数值自适应:行宽放不下就逐档降字号(27→21→16px)。
46
+ 判据用 k-row 的 scrollWidth:num/sub 都 nowrap 不收缩,超宽才会外溢。 */
47
+ function fitKpis() {
48
+ document.querySelectorAll("#kpis .k-row").forEach((row) => {
49
+ const num = row.querySelector(".k-num");
50
+ if (!num) return;
51
+ num.classList.remove("sm", "xs");
52
+ if (row.scrollWidth <= row.clientWidth + 1) return;
53
+ num.classList.add("sm");
54
+ if (row.scrollWidth <= row.clientWidth + 1) return;
55
+ num.classList.remove("sm");
56
+ num.classList.add("xs");
57
+ });
58
+ }
45
59
  function esc(s) {
46
60
  return String(s == null ? "" : s).replace(/[&<>"']/g,
47
61
  (c) => ({"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"}[c]));
@@ -167,6 +181,7 @@ function render(d) {
167
181
 
168
182
  $("#k-tokens").textContent = fmtTokens(usage.tokens);
169
183
  $("#k-use-sub").textContent = fmtCost(usage.cost_usd) + " · " + (Number(usage.calls) || 0) + " " + t("次调用");
184
+ fitKpis();
170
185
 
171
186
  // 顶栏态势:整屏只在这里说一次「有没有活」
172
187
  const st = $("#hd-state");
@@ -493,6 +508,7 @@ document.addEventListener("keydown", (e) => {
493
508
  if (e.key === "T" || e.key === "t") $("#sw-mode").click();
494
509
  if (e.key === "S" || e.key === "s") $("#sw-skin").click();
495
510
  });
511
+ window.addEventListener("resize", fitKpis);
496
512
 
497
513
  /* ---------------------------------------------------------------- 时钟与耗时每秒跳 */
498
514
  setInterval(() => {
package/app/ui/i18n.js CHANGED
@@ -824,6 +824,7 @@
824
824
  "openai 面仅 chat 形态(codex 只讲 responses)": "openai face is chat-only (codex speaks responses only)",
825
825
  "openai 面仅 chat 形态(codex 只讲 responses),解析时跳过": "openai face is chat-only (codex speaks responses only) — skipped when resolving",
826
826
  "无可用 wire,解析时跳过": "no usable wire — skipped when resolving",
827
+ "无可用密钥,解析时跳过": "no usable key — skipped when resolving",
827
828
  "该 CLI 只接受特定 wire 协议的供应商,当前没有匹配项——先到「模型接入」页导入对应协议的网关。": "This CLI only accepts providers on a specific wire protocol and none match — import a compatible gateway on the Models page first.",
828
829
  "%1 个供应商 wire 协议不匹配已隐藏(降级只在同协议网关间进行)。": "%1 provider(s) hidden due to wire-protocol mismatch — fallback stays within the same protocol.",
829
830
  "已适配(%1 wire)": "adapted (%1 wire)",
@@ -1407,7 +1408,7 @@
1407
1408
  "每套皮肤都是一份完整配色,并且各带「日间 / 夜间」两版,明暗切换不会丢皮肤。选中的皮肤立即生效并记在这台设备上(手机与电脑各自独立)。": "Each skin is a complete palette, with day / night variants — switching brightness never loses your skin. The selected skin applies instantly and is remembered on this device (phone and computer are independent).",
1408
1409
  "系统自己的经验沉淀:": "The system's own distilled experience:",
1409
1410
  "这台设备第一次连接,请输入访问令牌": "First connection from this device — enter the access token",
1410
- "(如七猫签约标准,自动注入小说类任务的规划与评审提示词) 与": " (e.g. the Qimao signing standard, auto-injected into planning & review prompts for novel tasks)",
1411
+ "(如七猫签约标准,自动注入同类任务的规划与评审提示词) 与": " (e.g. the Qimao signing standard, auto-injected into planning & review prompts for matching tasks)",
1411
1412
  "(每次任务结束由编排者复盘本次评审暴露的问题,去重沉淀,下次同类任务自动带上)——越跑越好,无需人工维护。": " (after each task the orchestrator reviews the problems this review exposed, dedupes and distills them, and re-attaches them to the next similar task) — it gets better with every run, no manual upkeep.",
1412
1413
  "条": " entries",
1413
1414
  "(暂无,跑一次任务后自动生成)": " (none yet — generated after the first run)",
@@ -2600,6 +2601,30 @@
2600
2601
  "自动发布失败:": "Auto-publish failed: ",
2601
2602
  "已填好一章,请在浏览器确认提交": "One chapter is filled — please review and submit in the browser",
2602
2603
  "护栏拦截": "Blocked by guardrail",
2604
+ "全部存草稿": "Save all as drafts",
2605
+ "存草稿所选": "Draft selected",
2606
+ "逐章自动填稿并点「存草稿」:内容落平台草稿箱不上线,不受质量门禁拦截,无需逐章人工确认;完成后到平台检查并提交发布": "Fills each chapter and clicks \"Save draft\": content lands in the platform's draft box without going live, bypasses the quality gate, no per-chapter confirmation; review and submit on the platform when done",
2607
+ "所选章节逐章自动存草稿(不上线、不受质量门禁拦截),完成后到平台检查并提交发布": "Saves the selected chapters as drafts one by one (not live, quality gate not applied); review and submit on the platform when done",
2608
+ "存草稿中 ": "Saving drafts ",
2609
+ "已存草稿 {0} 章(未发布):到平台章节管理/草稿箱逐章检查后提交发布": "{0} chapters saved as drafts (not published): review each in the platform's chapter management/draft box, then submit",
2610
+ "质量闸未过:": "Quality gate not passed: ",
2611
+ "——可先「全部存草稿」落平台(不上线),正式发布前需复审达标或强制放行": " — you can \"Save all as drafts\" first (not live); publishing requires a passing re-review or a forced override",
2612
+ "将对全部 {0} 章待发稿逐章自动填稿并点「存草稿」:内容落平台草稿箱不上线,不受质量门禁拦截,无需逐章人工确认(章间隔约 1 分钟防风控)。跑完后请到平台章节管理/草稿箱逐章检查,再手工提交发布。继续?": "Will fill and save every pending chapter ({0} total) as a draft: content lands in the platform's draft box without going live, quality gate not applied, no per-chapter confirmation (~1 min pacing between chapters). Review each draft on the platform and submit manually when done. Continue?",
2613
+ "将对所选 {0} 章(第 {1} 章—第 {2} 章)逐章自动填稿并点「存草稿」(不上线、不受质量门禁拦截),完成后到平台章节管理/草稿箱检查并提交发布。继续?": "Will fill and save the selected {0} chapters (ch. {1}–ch. {2}) as drafts (not live, quality gate not applied); review and submit on the platform when done. Continue?",
2614
+ "开始存草稿": "Start saving drafts",
2615
+ "存草稿已启动:逐章自动填稿并保存草稿,进度见发布台": "Draft saving started: chapters are filled and saved one by one — watch progress in the publish panel",
2616
+ "存草稿启动失败:": "Failed to start draft saving: ",
2617
+ "发布所选": "Publish selected",
2618
+ "直发所选": "Auto-publish selected",
2619
+ "开始直发": "Start auto-publish",
2620
+ "清空": "Clear",
2621
+ "待发 ": "Pending ",
2622
+ " 章(按卷计划自动分卷)": " chapters (volumes auto-assigned per plan)",
2623
+ "直发已启动,进度见发布台": "Auto-publish started — track progress in the publish panel",
2624
+ "所选章节逐章自动提交(无人值守),护栏每日上限/连败退避照常生效": "Selected chapters submit automatically one by one (unattended); guards (daily cap / failure backoff) stay active",
2625
+ "将对所选 {0} 章(第 {1} 章—第 {2} 章)逐章自动填稿并提交发布,无需人工确认;护栏(每日上限/连续失败暂停)生效。确定直发?": "Will fill and submit the selected {0} chapters (ch. {1}–ch. {2}) one by one automatically, no manual confirmation; guards (daily cap / consecutive-failure pause) stay active. Start auto-publish?",
2626
+ "将按章号顺序逐章填所选 {0} 章(第 {1} 章—第 {2} 章)。人工模式每轮填一章并停在表单页,由你在浏览器里确认提交。分卷会按卷计划自动选/建。继续?": "Will fill the selected {0} chapters (ch. {1}–ch. {2}) in order. Manual mode fills one chapter per round and stops at the form page — you confirm each submission in the browser. Volumes are auto-picked/created per plan. Continue?",
2627
+ "护栏提示:": "Guardrail notice: ",
2603
2628
 
2604
2629
  // —— 报告区 / 状态 / 杂项 ——
2605
2630
  "将于 {0} 自动续跑": "Auto-resumes at {0}",
package/app/ui/index.html CHANGED
@@ -957,7 +957,7 @@
957
957
  <div class="panel wide">
958
958
  <div class="panel-head"><h2 data-i18n="经验库">经验库</h2>
959
959
  <button class="hhelp" data-help-topic="features" title="这是什么?点开看帮助" data-i18n-title="这是什么?点开看帮助" aria-label="帮助" data-i18n-aria="帮助"><svg class="ico" aria-hidden="true"><use href="#i-help"></use></svg></button></div>
960
- <p class="hint"><span data-i18n="系统自己的经验沉淀:">系统自己的经验沉淀:</span><b data-i18n="内置规范包">内置规范包</b><span data-i18n="(如七猫签约标准,自动注入小说类任务的规划与评审提示词) 与">(如七猫签约标准,自动注入小说类任务的规划与评审提示词)
960
+ <p class="hint"><span data-i18n="系统自己的经验沉淀:">系统自己的经验沉淀:</span><b data-i18n="内置规范包">内置规范包</b><span data-i18n="(如七猫签约标准,自动注入同类任务的规划与评审提示词) 与">(如七猫签约标准,自动注入同类任务的规划与评审提示词)
961
961
  与</span><b data-i18n="运行中自动学到的教训">运行中自动学到的教训</b><span data-i18n="(每次任务结束由编排者复盘本次评审暴露的问题,去重沉淀,下次同类任务自动带上)——越跑越好,无需人工维护。">(每次任务结束由编排者复盘本次评审暴露的问题,去重沉淀,下次同类任务自动带上)——越跑越好,无需人工维护。</span></p>
962
962
  <h3 data-i18n="内置规范包" class="sec-title">内置规范包</h3>
963
963
  <div id="skill-packs" class="cards"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebee",
3
- "version": "0.1.98",
3
+ "version": "0.1.99",
4
4
  "description": "CodeBee · 多智能体编排台 — 统一编排本机 AI 编码 CLI(Codex / Claude Code / QwenCode / OpenCode / Aider…):目标→拆解→路由→执行→验证→跨厂商评审→修复→报告。纯 Python 标准库实现,本地 Web UI。",
5
5
  "license": "MIT",
6
6
  "bin": {