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 +6 -1
- package/README.md +5 -4
- package/app/core/builtin_agent.py +36 -37
- package/app/core/modelhub.py +21 -5
- package/app/core/pipeline.py +6 -4
- package/app/core/publish/auto.py +91 -20
- package/app/core/publish/fanqie.py +30 -0
- package/app/core/publish/flows-fanqie-calibrated.json +81 -0
- package/app/core/publish/flows-qimao-calibrated.json +58 -0
- package/app/core/publish/ledger.py +24 -1
- package/app/core/publish/manager.py +36 -9
- package/app/core/publish/qimao.py +17 -0
- package/app/core/quality_gate.py +7 -0
- package/app/main.py +5 -2
- package/app/ui/app.js +86 -14
- package/app/ui/board.css +5 -1
- package/app/ui/board.js +16 -0
- package/app/ui/i18n.js +26 -1
- package/app/ui/index.html +1 -1
- package/package.json +1 -1
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.
|
|
28
|
+
### 最新版更新内容(v0.1.99)
|
|
29
29
|
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
p.get("model") or "").strip()
|
|
104
|
-
if not selected:
|
|
101
|
+
# 默认/难度模型键位与 models[] 启停开关不联动:键位指向已停用
|
|
102
|
+
# 模型时对账启用名单回落,不冒充可用(停用的模型不准用)
|
|
105
103
|
try:
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
#
|
|
436
|
-
#
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
#
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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(
|
package/app/core/modelhub.py
CHANGED
|
@@ -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")
|
|
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
|
-
|
|
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
|
-
|
|
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):
|
package/app/core/pipeline.py
CHANGED
|
@@ -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=
|
|
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,
|
package/app/core/publish/auto.py
CHANGED
|
@@ -135,7 +135,9 @@ def pending(task_id, platform):
|
|
|
135
135
|
"""待发章节清单:任务工作目录里的章节文件 − 已发章号,按章号升序。
|
|
136
136
|
|
|
137
137
|
已发口径连载链合并(含校准所得平台实况),所以任何一个批次任务上发
|
|
138
|
-
|
|
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
|
-
|
|
201
|
+
hard_ok, hard_why = guards(task_id, plat)
|
|
194
202
|
if not _book_ready(info):
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
200
|
-
|
|
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
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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), "
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
|
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
|
-
|
|
898
|
-
|
|
899
|
-
|
|
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
|
-
|
|
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":
|
|
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,
|
|
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
|
|
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
|
|
package/app/core/quality_gate.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 &&
|
|
6228
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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) => ({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"}[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
|
-
"
|
|
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