codebee 0.1.74 → 0.1.76
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 +17 -0
- package/README.md +5 -4
- package/app/core/bookmeta.py +115 -0
- package/app/core/health.py +14 -12
- package/app/core/jobs.py +22 -0
- package/app/core/manager.py +11 -11
- package/app/core/pipeline.py +169 -18
- package/app/core/runner.py +17 -4
- package/app/core/story_tracking.py +38 -4
- package/app/main.py +59 -0
- package/app/ui/app.js +108 -12
- package/app/ui/i18n.js +15 -0
- package/app/ui/index.html +4 -0
- package/app/ui/style.css +22 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,23 @@ README 元数据带回,供老版本在「发现新版本」时展示新版更
|
|
|
6
6
|
|
|
7
7
|
## 未发布
|
|
8
8
|
|
|
9
|
+
## v0.1.76(2026-10-03)
|
|
10
|
+
|
|
11
|
+
- **连载「打磨永远不过」的三道结构性死闸拆了**(2026-10-03 不周山守村人 21–28 章脱纲空转实案:8 章章分全 8+ 全过、全局情节/节奏 5.0 不到线,两轮打磨重改的全是达标好章,病灶章没人碰,单轮 $12/6.9M tokens 打水漂):
|
|
12
|
+
- **aider 全局评审恒拒答**:超长指令落文件后提示词让模型「用读文件工具读」,被 glm-5.3 当提示注入直接拒答(十秒白卷)——全局有效评审永远只有 1 名,低于门禁「≥2 名」要求,数学上不可能通过。现在 aider 的超长指令改走 `--read` 参数由 CLI 自己读入会话(语义同交互式 `aider file.md`),不再指挥模型读文件。
|
|
13
|
+
- **打磨选章永远选不中病灶章**:选章按章分挑,而脱纲章的章分往往全部达标(章级评审只看局部、看不到章纲),全局评审点名的病灶章(成稿与锁定章纲脱轨的那几章)永远进不了打磨面。现在全局评审 major 点名的章优先进入打磨面,且点名章放开「不得改主线」禁令——按本章大纲要点推翻重写自创情节才是脱纲章的唯一修法,保事实微调恰恰修不动;点名原话与本章节拍一并注入重改提示词。
|
|
14
|
+
- **重评达标旧账不撤**:三处评审账本(全局 issues / 打磨章 issues / 章内修订回路 issues)都是只增不减——重写修好了、重评也过了线,第一轮的旧 majors 仍压着 `publishable=false`。现在三处统一「重评出分即换新账、评审链全挂保留旧账」(评审链挂了不代表书变差)。
|
|
15
|
+
- **续写批不再被「故事追踪漂移」误杀**:Windows 上章稿以 CRLF 入账(`_read_chapter` 保真读回),追踪文件再用文本模式落盘会把换行二次翻译成 `\r\r\n`,磁盘稿与状态摘要从此永久错位——下一批续写任务一开场就被漂移闸拦下「章节正文不一致」(0.1.71 引入起 Windows 全量中招,续写批无法续跑;test_continue_serial 自 09-30 起预存挂)。现在写侧归一 LF+字节落盘钉死换行,查侧按字节愈合比对、兼容新旧三种历史形态——已损坏的旧状态不用手工修复,同源即认;真被手工改过的内容照常拦截。
|
|
16
|
+
- **续写批全书评审不再「失忆」**:前作章节发布后归档成单文件(chapter-NN.md 不在场)时,全书一致性评审只看得到本批——现在从「已成稿/」找回前文,且 60k 字预算优先保本批完整(前文从尾部截取,越靠近本批越要紧);合并成书同样带回归档前文,续写批的「成书」不再静默丢掉此前所有章节;跨轮重复的问题清单去重,不再把同一处问题计成三四条 major。
|
|
17
|
+
- 回归:tests/test_serial_outline_polish.py(9 项)+ tests/test_serial_polish_named.py(13 项);tests/test_continue_serial.py 预存挂随 CRLF 修复转绿。
|
|
18
|
+
|
|
19
|
+
## v0.1.75(2026-10-03)
|
|
20
|
+
|
|
21
|
+
- **一键急停来了**:侧栏底部新增红色急停钮(有活跃任务才现身、确认框点名、busy 防双击)——`cancel_all` 全量扫描取消 queued/running 任务,终态不动,`cancelled_by_user` 先落防自动续跑续上(借鉴 hcom/amux 同类标配)。回归:tests/test_kill_all.py。
|
|
22
|
+
- **成品文件长出「第 N 版」徽章**:多版本成品一眼分先后——头部徽章(run 总数-列表序位)、对比 chip「与第 N 版对比」、diff 弹窗副标「第 N 版 → 当前」(run_id 摘要降为审计尾注;total 缺失不编不显示,纯增强不加闸门)。i18n 四键;回归:tests/ui_ver_badge.mjs。
|
|
23
|
+
- **作品信息「换名」按钮**:书名撞名只换名不重摇——排除名单=books.json 登记+全站 book_meta 书名,轻模型同步接口、成功才写盘;前端书名行换名钮+防双击+失败就地恢复。回归:tests/test_bookmeta_rename.py + tests/ui_bookmeta_rename.mjs。
|
|
24
|
+
- **用户作品数据不再漏进 npm 包**:工作目录里生成的 `作品信息-*.md`(用户书目资料,非代码)此前会被 `npm pack` 打进发布包——发版冒烟校验当场拦下(tar 内路径含非 ASCII 段即拒)。现在 .npmignore 增补排除(根目录与 app/ 两处;app/.npmignore 就近生效),文件本体不动。
|
|
25
|
+
- README 首屏 CLI 名册补齐 OpenClaw(catalog 14 条目中唯一漏列者)。
|
|
9
26
|
- **扫榜选材报告逐条带「证据分级」了**:热门题材/高频人设/差异化切入建议每一条末尾标注【证据 A-E】——A=三平台及以上重合、B=两平台重合、C=单平台多次出现、D=单平台仅一次、E=素材无直接证据系推断(借鉴 career-ops 的分级报告交付形态)。此前 AI 选题洞察里的「大热题材」不区分三平台重合的真风口和单平台单次的孤证,读者无从判断该信几分;现在分级只认四源榜单素材的真实出现情况,组合条目按组成元素较低档取值、拿不准就降不升级,报告第一行自带图例。菜单/流程说明与 README 同步。回归:tests/test_rank_scan_grading.py(新增)+ tests/test_rank_scan_copy.py。
|
|
10
27
|
- **作品信息「复制」按钮没用了的根因治了**:作品简介/书名/标签等字段旁的复制按钮点了只弹「已复制」、剪贴板却是空的——数据备份区在文件尾部又定义了一次 `copyText`(读 `data-copy` 属性的元素版),后定义覆盖了前定义,所有传字符串的复制入口(作品信息各字段、对话消息复制、详情页工作目录路径、预览页工作目录)一进函数就 TypeError 静默断掉,而 toast 照弹(async 函数把异常转成无人接的 rejected promise,不阻断后续代码),UI 测试只断言 toast 所以一直全绿。现在:重复定义移除,统一为单一字符串版 `copyText`(剪贴板 API 失败自动回退 execCommand 兜底——局域网 IP/远程地址打开页面时 `navigator.clipboard` 本就不存在,此前这条路也是死的);「已复制」只在真实写进剪贴板后弹,失败明说。回归:tests/ui_bookmeta.mjs 复制断言升级为对账剪贴板真拿到文本(旧断言抓不住这类假成功)。
|
|
11
28
|
- **禅道自动修复失败不再打扰业务流**:修复任务失败时此前会往禅道评论「自动修复未成功」并转派端负责人——失败只是中间态,不是修复结论,写回是拿工具故障打扰业务流,转派更是甩锅(2026-09-29 #27828 实案:评审器没跑起来也照评照转)。现在失败只记台账+群通知,回炉「待重试」状态等下轮扫描自动重试(只重建失败端、成功端不重跑,上限 3 次),到限置「修复失败」终态等人工;禅道页新增「立即处理」按钮,失败/需人工的记录不等下轮扫描随时手工重触发。全程零禅道写入,PUT 清空指派人的老坑彻底不再踩。回归:tests/test_zentao.py 53 项 + tests/ui_zentao.mjs。
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
**AI 是引擎,你的经验是方向盘。** CodeBee 为你指挥一支跨厂商的执笔/编码小队:
|
|
12
12
|
统一调度本机已装的 AI 编码 CLI(Codex CLI、Claude Code、Qwen Code、OpenCode、Aider、
|
|
13
13
|
Kimi Code、MiMo Code、Grok Build、Pi、DeepSeek Harness、Gemini CLI、CodeBuddy CLI、
|
|
14
|
-
Trae Agent……),提供
|
|
14
|
+
Trae Agent、OpenClaw……),提供
|
|
15
15
|
**目标 → 自动拆解 → 智能路由 → 执行 → 客观验证 → 跨厂商评审 → 自动修复/换将 → 汇总报告**
|
|
16
16
|
的完整闭环。你不在时它们自动推进、自我打磨;需要你拍板的地方——质量闸门、任务分支的
|
|
17
17
|
采纳与丢弃——它们会亮起「待裁决」等你,绝不静默替你决定。
|
|
@@ -33,10 +33,11 @@ Trae Agent……),提供
|
|
|
33
33
|
---
|
|
34
34
|
|
|
35
35
|
<!-- relnotes:start -->
|
|
36
|
-
### 最新版更新内容(v0.1.
|
|
36
|
+
### 最新版更新内容(v0.1.76)
|
|
37
37
|
|
|
38
|
-
-
|
|
39
|
-
-
|
|
38
|
+
- 连载「打磨永远不过」死锁拆除:aider 全局评审拒答(恒 1 名评审 < 门禁 2 名)、打磨选章选不中脱纲病灶章、重评达标旧问题清单不撤——三道结构性死闸全修,全局点名章优先重改并放开禁改主线
|
|
39
|
+
- 续写批不再被「故事追踪漂移」误杀:Windows 换行二次翻译(\r\r\n)致磁盘稿与摘要永久错位的 0.1.71 起预存病根拔除,旧损坏状态免手工修复自动兼容
|
|
40
|
+
- 续写批全书评审找回前文(已成稿/归档)、合并成书不再静默丢掉此前章节
|
|
40
41
|
<!-- relnotes:end -->
|
|
41
42
|
|
|
42
43
|
|
package/app/core/bookmeta.py
CHANGED
|
@@ -781,6 +781,121 @@ def make_book_meta(task, platform, author_agent=None, log_path=None):
|
|
|
781
781
|
return meta, _apply_fix_note(meta, meta["source"])
|
|
782
782
|
|
|
783
783
|
|
|
784
|
+
# ---- 换名(2026-10-02 用户实案:生成的书名与本站已有作品重名,用户要的是
|
|
785
|
+
# 「换一个名字」而不是整套重摇——重摇会覆盖已核对过的分类/标签/简介)
|
|
786
|
+
|
|
787
|
+
RENAME_TIMEOUT = 120
|
|
788
|
+
# 排除名单注入上限:全站书名可能上百个,超出的按序丢弃(提示词预算保护;
|
|
789
|
+
# 排序后字母序前 80 个 + 调用方传入的近期撞名足够防撞,撞了还有重试兜底)
|
|
790
|
+
RENAME_EXCLUDE_LIMIT = 80
|
|
791
|
+
|
|
792
|
+
RENAME_PROMPT = """你是网文编辑。这本书《__OLD__》的书名与已有作品重名了,请另起一个新书名。
|
|
793
|
+
|
|
794
|
+
书的基本情况:
|
|
795
|
+
__MATERIAL__
|
|
796
|
+
|
|
797
|
+
硬性要求:
|
|
798
|
+
- 只输出一个 ```json 代码块:{"book_name": "新书名"},不要输出其他内容;
|
|
799
|
+
- 新书名不超过 15 个字,贴合题材、有网文点击欲,与旧书名同一题材但用词明显不同;
|
|
800
|
+
- 严禁与下面任何一本书重名,也不得只加「2」「新」「之」等后缀敷衍:
|
|
801
|
+
__EXCLUDE__"""
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def used_book_names():
|
|
805
|
+
"""本站全部已用书名(换名排除名单):已建书登记(books.json 的 title)
|
|
806
|
+
+ 所有任务 book_meta 各平台 done 态的 book_name。读取异常只降级不阻断
|
|
807
|
+
(名单不全最坏结果是换出来的名字仍撞,用户再点一次)。"""
|
|
808
|
+
from .publish import ledger
|
|
809
|
+
names = set()
|
|
810
|
+
try:
|
|
811
|
+
# books.json 结构:{task_id: {platform: {book_id, title, url, ...}}}
|
|
812
|
+
for by_plat in (ledger.load_books() or {}).values():
|
|
813
|
+
if not isinstance(by_plat, dict):
|
|
814
|
+
continue
|
|
815
|
+
for rec in by_plat.values():
|
|
816
|
+
t = str((rec or {}).get("title") or "").strip()
|
|
817
|
+
if t:
|
|
818
|
+
names.add(t)
|
|
819
|
+
except Exception:
|
|
820
|
+
pass
|
|
821
|
+
try:
|
|
822
|
+
from . import store
|
|
823
|
+
for task in store.list_tasks(limit=10 ** 9):
|
|
824
|
+
for entry in (task.get("book_meta") or {}).values():
|
|
825
|
+
if isinstance(entry, dict) and entry.get("status") == "done":
|
|
826
|
+
n = str((entry.get("data") or {}).get("book_name") or "").strip()
|
|
827
|
+
if n:
|
|
828
|
+
names.add(n)
|
|
829
|
+
except Exception:
|
|
830
|
+
pass
|
|
831
|
+
return names
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def rename_book(task, platform, old_name, exclude_names=()):
|
|
835
|
+
"""给已生成的作品信息换一个不重名的书名。返回 (new_name, "") 或 ("", 错误)。
|
|
836
|
+
|
|
837
|
+
只产出新名,不写盘——调用方负责更新 book_meta 与归档同步;这样模型失败
|
|
838
|
+
时现有数据原样保留(区别于整套生成的线程+状态机,轻调用同步返回即可)。
|
|
839
|
+
新名若仍撞排除名单,把撞名项并入名单重试一次。"""
|
|
840
|
+
from . import modelhub, planner, runner
|
|
841
|
+
old_name = str(old_name or "").strip()
|
|
842
|
+
exclude = {str(x).strip() for x in (exclude_names or ()) if str(x).strip()}
|
|
843
|
+
exclude.add(old_name)
|
|
844
|
+
goal_lines = [x.strip() for x in (task.get("goal") or "").splitlines() if x.strip()]
|
|
845
|
+
meta_lines = []
|
|
846
|
+
if goal_lines:
|
|
847
|
+
meta_lines.append("目标:" + goal_lines[0][:200])
|
|
848
|
+
try:
|
|
849
|
+
_, bible, _ = store_read_bible(task)
|
|
850
|
+
if bible:
|
|
851
|
+
meta_lines.append("故事圣经摘录:" + bible[:400])
|
|
852
|
+
except Exception:
|
|
853
|
+
pass
|
|
854
|
+
material = "\n".join(meta_lines) if meta_lines else "(素材缺失,按旧书名题材另起)"
|
|
855
|
+
orch = None
|
|
856
|
+
try:
|
|
857
|
+
orch = modelhub.resolve_orchestrator()
|
|
858
|
+
except Exception:
|
|
859
|
+
orch = None
|
|
860
|
+
if not orch:
|
|
861
|
+
return "", "编排者模型不可用,无法自动换名(可稍后重试)"
|
|
862
|
+
prov, model = orch
|
|
863
|
+
last = ""
|
|
864
|
+
for _ in range(2): # 首轮撞名单 → 带撞名项重试一次
|
|
865
|
+
ex_block = "\n".join("- " + n for n in sorted(exclude)[:RENAME_EXCLUDE_LIMIT])
|
|
866
|
+
prompt = (RENAME_PROMPT.replace("__OLD__", old_name)
|
|
867
|
+
.replace("__MATERIAL__", material)
|
|
868
|
+
.replace("__EXCLUDE__", ex_block or "(暂无)"))
|
|
869
|
+
# max_tokens 4000 对齐主生成(2026-10-03 实案:glm-5.3 思考计入
|
|
870
|
+
# max_tokens,给 400 三次全被思考烧穿 output 打满,JSON 没成形)
|
|
871
|
+
res = modelhub.chat(prov["id"], model, prompt, max_tokens=4000,
|
|
872
|
+
timeout=RENAME_TIMEOUT)
|
|
873
|
+
planner._log_usage("bookmeta", "bookmeta-rename", task, res, model=model,
|
|
874
|
+
provider=prov.get("name", prov.get("id", "")),
|
|
875
|
+
provider_id=prov.get("id", ""))
|
|
876
|
+
if not res["ok"]:
|
|
877
|
+
last = str(res.get("error") or "模型调用失败")[:200]
|
|
878
|
+
continue
|
|
879
|
+
text = str(res.get("text") or "")
|
|
880
|
+
data = runner.extract_json(text)
|
|
881
|
+
new_name = _name(data.get("book_name") if isinstance(data, dict) else "", 15)
|
|
882
|
+
if not new_name:
|
|
883
|
+
# 兜底:思考烧预算时 JSON 可能没成形,正文里有《书名》/「书名」
|
|
884
|
+
# 形态就抠出来用——抠出的名字仍要过下面的排除名单检查
|
|
885
|
+
m = re.search(r"[《「]([^《》「」]{1,15})[》」]", text)
|
|
886
|
+
if m:
|
|
887
|
+
new_name = _name(m.group(1), 15)
|
|
888
|
+
if not new_name:
|
|
889
|
+
last = "返回内容无法解析出新书名"
|
|
890
|
+
continue
|
|
891
|
+
if new_name in exclude:
|
|
892
|
+
last = "新书名《%s》仍与本站已有作品重名" % new_name
|
|
893
|
+
exclude.add(new_name)
|
|
894
|
+
continue
|
|
895
|
+
return new_name, ""
|
|
896
|
+
return "", last or "换名失败,请稍后重试"
|
|
897
|
+
|
|
898
|
+
|
|
784
899
|
def render_markdown(task, platform, meta):
|
|
785
900
|
"""落工作目录的归档 Markdown(与前端弹框同一份字段顺序)。"""
|
|
786
901
|
plat = PLATFORMS[platform]["label"]
|
package/app/core/health.py
CHANGED
|
@@ -141,24 +141,26 @@ def report_success(provider: str):
|
|
|
141
141
|
_persist()
|
|
142
142
|
|
|
143
143
|
|
|
144
|
+
# run_process 包裹 Popen 启动异常的固定前缀。与 error_codes._TOOL_LAUNCH 的
|
|
145
|
+
# 判定标记同源双写(error_codes 在 L0,health 在 L1,策略禁止 L1→L0 import;
|
|
146
|
+
# 供应商侧任何报错都不会带这个前缀,单靠前缀即可零误判)。
|
|
147
|
+
_LAUNCH_FAILURE_PREFIX = "启动失败"
|
|
148
|
+
|
|
149
|
+
|
|
144
150
|
def report_failure(provider: str, error: str = "", *, model: str = "",
|
|
145
151
|
provider_id: str = ""):
|
|
146
152
|
"""一次真实失败调用。provider 用展示名(与 usage 台账一致)。
|
|
147
153
|
|
|
148
|
-
本地 CLI
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
154
|
+
本地 CLI 启动失败(找不到可执行文件等)与供应商无关,记进来会把健康
|
|
155
|
+
供应商的评测结论作废、链被解析层过滤成死链——2026-09-30 claude 垫片被
|
|
156
|
+
清案:Bigmodel 全程健康却被连续启动失败拖进 down。只打日志提醒修本地
|
|
157
|
+
环境,不进健康台账。"""
|
|
152
158
|
if not provider:
|
|
153
159
|
return
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
provider, str(error or "")[:160])
|
|
159
|
-
return
|
|
160
|
-
except Exception:
|
|
161
|
-
pass
|
|
160
|
+
if _LAUNCH_FAILURE_PREFIX in str(error or "")[:80]:
|
|
161
|
+
log.warning("[health] 本地 CLI 启动失败(与供应商 %s 无关,不记健康故障):%s",
|
|
162
|
+
provider, str(error or "")[:160])
|
|
163
|
+
return
|
|
162
164
|
|
|
163
165
|
# A failed live call invalidates capability evidence for this upstream.
|
|
164
166
|
# Keep this hook lazy and best-effort: health telemetry must never make a
|
package/app/core/jobs.py
CHANGED
|
@@ -601,6 +601,28 @@ def cancel(run_id):
|
|
|
601
601
|
return False
|
|
602
602
|
|
|
603
603
|
|
|
604
|
+
def cancel_all():
|
|
605
|
+
"""急停(kill switch):取消全部 queued/running 运行,返回实际取消数。
|
|
606
|
+
|
|
607
|
+
逐跑走 cancel() 同一条链路(事件置位 + 终态落盘 + 退避定时器清理),
|
|
608
|
+
单跑失败不影响其余。cancelled_by_user 先落——与单跑取消端点同一语义,
|
|
609
|
+
自动续跑不得把急停的任务又续上。
|
|
610
|
+
"""
|
|
611
|
+
from . import store
|
|
612
|
+
n = 0
|
|
613
|
+
for r in store.list_runs(limit=None):
|
|
614
|
+
rid = r.get("id")
|
|
615
|
+
if not rid or (r.get("status") or "") not in ("queued", "running"):
|
|
616
|
+
continue
|
|
617
|
+
try:
|
|
618
|
+
store.update_run(rid, cancelled_by_user=True)
|
|
619
|
+
except Exception:
|
|
620
|
+
pass
|
|
621
|
+
if cancel(rid):
|
|
622
|
+
n += 1
|
|
623
|
+
return n
|
|
624
|
+
|
|
625
|
+
|
|
604
626
|
def cancel_event_for(run_id):
|
|
605
627
|
ev = CANCELS.get(run_id)
|
|
606
628
|
if ev is None:
|
package/app/core/manager.py
CHANGED
|
@@ -647,7 +647,7 @@ def write_model(entry, model):
|
|
|
647
647
|
# settings.json、openclaw 缺失即安全默认——官方文档明确「缺失即
|
|
648
648
|
# 内置默认」);它正是用户层覆盖的落点,缺了按需创建
|
|
649
649
|
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
650
|
-
_atomic_write_bytes(path,b"{}" if fmt in ("json", "json-path", "jsonc") else b"")
|
|
650
|
+
_atomic_write_bytes(path, b"{}" if fmt in ("json", "json-path", "jsonc") else b"")
|
|
651
651
|
if fmt == "jsonc":
|
|
652
652
|
# mimo(mimocode.jsonc)有注释,整体重解析会丢注释——复用
|
|
653
653
|
# _jsonc_set 做就地片段改写(只动目标键,其余原样保留)
|
|
@@ -658,7 +658,7 @@ def write_model(entry, model):
|
|
|
658
658
|
if not ok:
|
|
659
659
|
return {"ok": False,
|
|
660
660
|
"error": "jsonc 结构异常,未能就地写入 %s(已避免覆盖)" % path}
|
|
661
|
-
_atomic_write_bytes(path,new_text.encode("utf-8"))
|
|
661
|
+
_atomic_write_bytes(path, new_text.encode("utf-8"))
|
|
662
662
|
elif fmt == "toml-line":
|
|
663
663
|
text = _read_text(path)
|
|
664
664
|
new_line = 'model = "%s"' % model
|
|
@@ -666,17 +666,17 @@ def write_model(entry, model):
|
|
|
666
666
|
text = re.sub(r'(?m)^\s*model\s*=\s*"[^"]*"', new_line, text)
|
|
667
667
|
else:
|
|
668
668
|
text = text.rstrip("\n") + "\n" + new_line + "\n"
|
|
669
|
-
_atomic_write_bytes(path,text.encode("utf-8"))
|
|
669
|
+
_atomic_write_bytes(path, text.encode("utf-8"))
|
|
670
670
|
elif fmt == "toml-section":
|
|
671
671
|
text = _read_text(path, preserve_newlines=True)
|
|
672
672
|
text = _toml_write_value(text, *_dotted_key(cfg), value=model)
|
|
673
|
-
_atomic_write_bytes(path,text.encode("utf-8"))
|
|
673
|
+
_atomic_write_bytes(path, text.encode("utf-8"))
|
|
674
674
|
elif fmt == "yaml-line":
|
|
675
675
|
# newline="" 关掉通用换行转换:文本层面看不出 \r\n 就会被静默改写成 LF,
|
|
676
676
|
# 用户的 Windows 配置不该因为写个模型名而整篇换行符被替换
|
|
677
677
|
text = _read_text(path, preserve_newlines=True)
|
|
678
678
|
text = _yaml_write_value(text, *_yaml_model_path(cfg), value=model)
|
|
679
|
-
_atomic_write_bytes(path,text.encode("utf-8"))
|
|
679
|
+
_atomic_write_bytes(path, text.encode("utf-8"))
|
|
680
680
|
elif fmt == "json-path":
|
|
681
681
|
# openclaw(agents.defaults.model.primary):默认模型藏在嵌套对象里,
|
|
682
682
|
# 且 openclaw 对未知顶层键直接拒绝启动——绝不能写顶层 "model"
|
|
@@ -859,7 +859,7 @@ def _sync_dsh_settings(entry, model, base_url):
|
|
|
859
859
|
if text2 != text:
|
|
860
860
|
if os.path.isfile(path):
|
|
861
861
|
shutil.copyfile(path, path + ".bak")
|
|
862
|
-
_atomic_write_bytes(path,text2.encode("utf-8"))
|
|
862
|
+
_atomic_write_bytes(path, text2.encode("utf-8"))
|
|
863
863
|
return None
|
|
864
864
|
except Exception as e:
|
|
865
865
|
return repr(e)
|
|
@@ -1016,7 +1016,7 @@ def _sync_codex_settings(entry, model, cp):
|
|
|
1016
1016
|
if text2 != text:
|
|
1017
1017
|
if os.path.isfile(path):
|
|
1018
1018
|
shutil.copyfile(path, path + ".bak")
|
|
1019
|
-
_atomic_write_bytes(path,text2.encode("utf-8"))
|
|
1019
|
+
_atomic_write_bytes(path, text2.encode("utf-8"))
|
|
1020
1020
|
return None
|
|
1021
1021
|
except Exception as e:
|
|
1022
1022
|
return repr(e)
|
|
@@ -1233,7 +1233,7 @@ def _sync_settings_env(path, updates, remove_keys=()):
|
|
|
1233
1233
|
data["env"] = env
|
|
1234
1234
|
if os.path.isfile(path):
|
|
1235
1235
|
shutil.copyfile(path, path + ".bak")
|
|
1236
|
-
_atomic_write_bytes(path,json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
|
|
1236
|
+
_atomic_write_bytes(path, json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
|
|
1237
1237
|
return None
|
|
1238
1238
|
except Exception as e:
|
|
1239
1239
|
return repr(e)
|
|
@@ -1345,7 +1345,7 @@ def _sync_mimo_settings(entry, model, prov):
|
|
|
1345
1345
|
json.dumps("codebee/" + model))
|
|
1346
1346
|
if not ok:
|
|
1347
1347
|
return "mimocode.jsonc 结构异常,未能写入 model(已避免覆盖)"
|
|
1348
|
-
_atomic_write_bytes(path,new_text.encode("utf-8"))
|
|
1348
|
+
_atomic_write_bytes(path, new_text.encode("utf-8"))
|
|
1349
1349
|
return None
|
|
1350
1350
|
except OSError as e:
|
|
1351
1351
|
return "写入 mimocode.jsonc 失败:%s" % str(e)[:80]
|
|
@@ -1425,7 +1425,7 @@ def _sync_opencode_settings(entry, model, prov):
|
|
|
1425
1425
|
json.dumps(top_perms))
|
|
1426
1426
|
if os.path.isfile(path):
|
|
1427
1427
|
shutil.copyfile(path, path + ".bak")
|
|
1428
|
-
_atomic_write_bytes(path,new_text.encode("utf-8"))
|
|
1428
|
+
_atomic_write_bytes(path, new_text.encode("utf-8"))
|
|
1429
1429
|
except Exception as e:
|
|
1430
1430
|
errs.append("%s: %r" % (path, e))
|
|
1431
1431
|
return ";".join(errs) or None
|
|
@@ -1499,7 +1499,7 @@ def _sync_kimi_settings(entry, model, prov):
|
|
|
1499
1499
|
new_text = top + "\n" + body + "\n" + tables
|
|
1500
1500
|
else:
|
|
1501
1501
|
new_text = (body + "\n" if body else "") + managed
|
|
1502
|
-
_atomic_write_bytes(path,new_text.encode("utf-8"))
|
|
1502
|
+
_atomic_write_bytes(path, new_text.encode("utf-8"))
|
|
1503
1503
|
except Exception as e:
|
|
1504
1504
|
errs.append("%s: %r" % (path, e))
|
|
1505
1505
|
return ";".join(errs) or None
|
package/app/core/pipeline.py
CHANGED
|
@@ -2832,6 +2832,97 @@ def _weakest_chapters(chapter_scores, global_means, threshold, limit=2):
|
|
|
2832
2832
|
return cand[:limit]
|
|
2833
2833
|
|
|
2834
2834
|
|
|
2835
|
+
_ARCHIVE_DIR = "已成稿"
|
|
2836
|
+
_CH_MENTION_RE = re.compile(r"第\s*(\d{1,4})\s*(?:[—–\-~至到]\s*(\d{1,4})\s*)?章")
|
|
2837
|
+
|
|
2838
|
+
|
|
2839
|
+
def _archived_preface(workdir, start):
|
|
2840
|
+
"""前文回退:续写批次的前作章文件被归档成单文件(发布后 chapter-NN.md
|
|
2841
|
+
不在场)时,从「已成稿/」目录找回前文全文,供全书评审与合并成书使用。
|
|
2842
|
+
任一前文章节文件仍在场则视为未归档,返回空串(正常路径零影响)。"""
|
|
2843
|
+
if start <= 1:
|
|
2844
|
+
return ""
|
|
2845
|
+
for i in range(1, start):
|
|
2846
|
+
if _read_chapter(workdir, i):
|
|
2847
|
+
return ""
|
|
2848
|
+
adir = os.path.join(str(workdir), _ARCHIVE_DIR)
|
|
2849
|
+
try:
|
|
2850
|
+
names = sorted(os.listdir(adir))
|
|
2851
|
+
except OSError:
|
|
2852
|
+
return ""
|
|
2853
|
+
parts = []
|
|
2854
|
+
for name in names:
|
|
2855
|
+
if not name.lower().endswith((".md", ".txt")):
|
|
2856
|
+
continue
|
|
2857
|
+
if "目录" in name: # 阅读目录/章节目录是索引不是正文
|
|
2858
|
+
continue
|
|
2859
|
+
p = os.path.join(adir, name)
|
|
2860
|
+
if not os.path.isfile(p):
|
|
2861
|
+
continue
|
|
2862
|
+
try:
|
|
2863
|
+
t = _read_text_any_enc(p)
|
|
2864
|
+
except Exception:
|
|
2865
|
+
continue
|
|
2866
|
+
if t and t.strip():
|
|
2867
|
+
parts.append(t.strip())
|
|
2868
|
+
return "\n\n".join(parts)
|
|
2869
|
+
|
|
2870
|
+
|
|
2871
|
+
def _full_manuscript(workdir, start, end, cap=60000):
|
|
2872
|
+
"""全书评审文本:本批 start..end 全文保底在场,前文(章文件缺失时回退
|
|
2873
|
+
归档)用剩余预算从尾部截取——越靠近本批的前文对一致性越要紧。直拼再
|
|
2874
|
+
[:cap] 会把预算整段喂给前文、把本批(真正的评审对象)截掉。"""
|
|
2875
|
+
new_text = "\n\n".join(t for t in (_read_chapter(workdir, i)
|
|
2876
|
+
for i in range(start, end + 1)) if t)
|
|
2877
|
+
old_text = "\n\n".join(t for t in (_read_chapter(workdir, i)
|
|
2878
|
+
for i in range(1, start)) if t)
|
|
2879
|
+
if not old_text and start > 1:
|
|
2880
|
+
old_text = _archived_preface(workdir, start)
|
|
2881
|
+
budget = cap - len(new_text) - 2
|
|
2882
|
+
if budget <= 0:
|
|
2883
|
+
return new_text[:cap]
|
|
2884
|
+
if len(old_text) > budget:
|
|
2885
|
+
cut_mark = "……(前文较长,仅保留紧邻本批的尾部)……\n\n"
|
|
2886
|
+
keep = budget - len(cut_mark)
|
|
2887
|
+
old_text = cut_mark + (old_text[-keep:] if keep > 0 else "")
|
|
2888
|
+
return (old_text + "\n\n" + new_text)[:cap] if old_text else new_text[:cap]
|
|
2889
|
+
|
|
2890
|
+
|
|
2891
|
+
def _chapters_named_in(issues, lo=1, hi=None):
|
|
2892
|
+
"""评审 issue 文本里点名的章号集合(「第22章」「第26—28章」都认)。
|
|
2893
|
+
用于把全局一致性 major 落到具体章;范围外的章号丢弃。"""
|
|
2894
|
+
out = set()
|
|
2895
|
+
for it in issues or []:
|
|
2896
|
+
if not isinstance(it, dict):
|
|
2897
|
+
continue
|
|
2898
|
+
for m in _CH_MENTION_RE.finditer(str(it.get("note") or it.get("issue") or "")):
|
|
2899
|
+
a = int(m.group(1))
|
|
2900
|
+
b = int(m.group(2)) if m.group(2) else a
|
|
2901
|
+
if b < a:
|
|
2902
|
+
a, b = b, a
|
|
2903
|
+
for c in range(a, b + 1):
|
|
2904
|
+
if c >= lo and (hi is None or c <= hi):
|
|
2905
|
+
out.add(c)
|
|
2906
|
+
return out
|
|
2907
|
+
|
|
2908
|
+
|
|
2909
|
+
def _dedup_issues(items, key_len=60):
|
|
2910
|
+
"""跨轮评审 issues 去重:同章同维度同要点(归一化前缀)只保留首条。
|
|
2911
|
+
全局评审每轮重跑都会把上一轮的问题原样或换个说法再列一遍,不去重会把
|
|
2912
|
+
同一处问题计成三四条 major,门禁报告数字虚高。"""
|
|
2913
|
+
seen, out = set(), []
|
|
2914
|
+
for x in items or []:
|
|
2915
|
+
if not isinstance(x, dict):
|
|
2916
|
+
continue
|
|
2917
|
+
k = (str(x.get("chapter")), str(x.get("dim")),
|
|
2918
|
+
re.sub(r"\s", "", str(x.get("note") or ""))[:key_len])
|
|
2919
|
+
if k in seen:
|
|
2920
|
+
continue
|
|
2921
|
+
seen.add(k)
|
|
2922
|
+
out.append(x)
|
|
2923
|
+
return out
|
|
2924
|
+
|
|
2925
|
+
|
|
2835
2926
|
def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route, resume_ctx, difficulty):
|
|
2836
2927
|
"""连载流水线:大纲 → 逐章起草/评审/修订 → 全局一致性评审 → 合并成书。"""
|
|
2837
2928
|
import json as _json
|
|
@@ -3881,6 +3972,16 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
3881
3972
|
store.update_run(run_id, story_tracking_warning=str(exc)[:300])
|
|
3882
3973
|
except Exception:
|
|
3883
3974
|
pass
|
|
3975
|
+
# 旧账换新账(章内修订回路):本章终评达标 → 用终轮评审的 issues 换掉
|
|
3976
|
+
# 第 1 轮的旧账。不换的话「修订后已达标」的章仍顶着第 1 轮 majors,
|
|
3977
|
+
# 最终 publishable 被陈账永远压着 False。放在 story_tracking 取材之后:
|
|
3978
|
+
# 事实沉淀仍吃得到修订前的完整意见,门禁只认终轮评判。
|
|
3979
|
+
if cs_new["passed"] and cj_by_agent:
|
|
3980
|
+
issues_all[:] = [x for x in issues_all if x.get("chapter") != i]
|
|
3981
|
+
for _cj in cj_by_agent.values():
|
|
3982
|
+
if isinstance(_cj, dict):
|
|
3983
|
+
issues_all.extend({"chapter": i, **it}
|
|
3984
|
+
for it in (_cj.get("issues") or [])[:6])
|
|
3884
3985
|
# 资源账本(借鉴角色资源账本):评审提出的道具/伤情/承诺/伏笔增量
|
|
3885
3986
|
# 追加到 .codebee/resource-ledger.md,下一章起草时注入,防跨章穿帮。
|
|
3886
3987
|
# 失败静默——账本是增强不是硬依赖。
|
|
@@ -3973,16 +4074,17 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
3973
4074
|
store.update_run(run_id, signing_checkpoint=cp)
|
|
3974
4075
|
|
|
3975
4076
|
# ---- 3) 全局一致性评审(覆盖 1..end 全书:续写批次必须连同旧章一起查一致性)
|
|
3976
|
-
full_text =
|
|
4077
|
+
full_text = _full_manuscript(workdir, start, end)
|
|
3977
4078
|
global_issues = []
|
|
3978
4079
|
global_review_results = []
|
|
3979
4080
|
|
|
3980
4081
|
def run_global_round(agent_list):
|
|
3981
|
-
"""一轮全局评审:返回 (出分评审数,
|
|
4082
|
+
"""一轮全局评审:返回 (出分评审数, 按维累计分, 有效评审, 本轮 issues)。
|
|
4083
|
+
失败/不可解析不得当成低分计入。
|
|
3982
4084
|
|
|
3983
4085
|
多评审并发(2026-09-22,同章级评审并发):各自读同一份全书文本、互不依赖,
|
|
3984
4086
|
串行只是把等待时间叠起来。共享结构仍在 join 后按原顺序合并,结果与串行一致。"""
|
|
3985
|
-
gmeans_acc, scored, valid_reviews = {}, 0, []
|
|
4087
|
+
gmeans_acc, scored, valid_reviews, round_issues = {}, 0, [], []
|
|
3986
4088
|
results = {}
|
|
3987
4089
|
# 绑定解析提前到主线程(线程内不做会写盘的 bind_agent)
|
|
3988
4090
|
bound = [modelhub.bind_agent(a, difficulty) for a in agent_list]
|
|
@@ -4038,14 +4140,15 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
4038
4140
|
if not errors:
|
|
4039
4141
|
valid_reviews.append({"id": agent_list[idx].get("id") or "reviewer-%d" % idx,
|
|
4040
4142
|
"scores": scores})
|
|
4041
|
-
|
|
4143
|
+
round_issues.extend({"chapter": "全书", **it} for it in (gj.get("issues") or [])[:8])
|
|
4042
4144
|
for d in dims:
|
|
4043
4145
|
v = scores.get(d) if not errors else partial_scores.get(d)
|
|
4044
4146
|
if v is not None:
|
|
4045
4147
|
gmeans_acc.setdefault(d, []).append(float(v))
|
|
4046
|
-
return scored, gmeans_acc, valid_reviews
|
|
4148
|
+
return scored, gmeans_acc, valid_reviews, round_issues
|
|
4047
4149
|
|
|
4048
|
-
gscored, gmeans_acc, global_review_results = run_global_round(critics)
|
|
4150
|
+
gscored, gmeans_acc, global_review_results, _gi = run_global_round(critics)
|
|
4151
|
+
global_issues.extend(_gi)
|
|
4049
4152
|
# 「评不上」≠「评了低分」:全局评审全挂时先从其它真实智能体补位(对齐章级
|
|
4050
4153
|
# 评审者级 fallback);补位后仍零分则判 run 失败——「无法评审」绝不能当成
|
|
4051
4154
|
# 「全局评审未通过」去盖「未达标」章(2026-09-18 假未达标案:codex 绑定链
|
|
@@ -4055,8 +4158,9 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
4055
4158
|
tried = {a.get("id") for a in critics}
|
|
4056
4159
|
for spare in [a for a in (agents or [])
|
|
4057
4160
|
if a.get("mode") == "real" and a.get("id") not in tried][:2]:
|
|
4058
|
-
sc, acc, spare_reviews = run_global_round([spare])
|
|
4161
|
+
sc, acc, spare_reviews, _sgi = run_global_round([spare])
|
|
4059
4162
|
global_review_results.extend(spare_reviews)
|
|
4163
|
+
global_issues.extend(_sgi)
|
|
4060
4164
|
for d, xs in acc.items():
|
|
4061
4165
|
gmeans_acc.setdefault(d, []).extend(xs)
|
|
4062
4166
|
gscored += sc
|
|
@@ -4083,9 +4187,17 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
4083
4187
|
while (not global_pass) and polish_rounds < 2 and chapter_scores and global_means:
|
|
4084
4188
|
polish_rounds += 1
|
|
4085
4189
|
weak = _weakest_chapters(chapter_scores, global_means, threshold, limit=2)
|
|
4190
|
+
# 全局一致性 major 点名的章优先进入打磨面:脱纲/主线漂移类问题的病灶章
|
|
4191
|
+
# 单章分数往往全部达标(章级评审只看局部),只按分数选章永远选不中
|
|
4192
|
+
# (2026-10-03 不周山守村人实案:21–28 章整体脱纲、章分全 8+,两轮
|
|
4193
|
+
# 打磨重改的全是达标章,全局分纹丝不动)。
|
|
4194
|
+
named = _chapters_named_in(global_issues, lo=start, hi=end)
|
|
4195
|
+
if named:
|
|
4196
|
+
weak = ([c for c in chapter_scores if c.get("chapter") in named]
|
|
4197
|
+
+ [c for c in weak if c.get("chapter") not in named])[:2]
|
|
4086
4198
|
if not weak:
|
|
4087
4199
|
break
|
|
4088
|
-
note = "自动打磨第 %d
|
|
4200
|
+
note = "自动打磨第 %d 轮:全局评审未过,重改 %s" % (
|
|
4089
4201
|
polish_rounds, "、".join("第 %d 章" % c["chapter"] for c in weak))
|
|
4090
4202
|
pstep, _ = store.add_step(run_id, "polish-r%d" % polish_rounds, impl["id"],
|
|
4091
4203
|
impl.get("label"), note=note)
|
|
@@ -4098,10 +4210,24 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
4098
4210
|
for d in dims if float((c.get("means") or {}).get(d, 0.0)) < threshold)
|
|
4099
4211
|
gj = ";".join("%s %.1f" % (d, s2) for d, s2 in global_means.items()
|
|
4100
4212
|
if float(s2) < threshold)
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4213
|
+
# 本章被全局评审点名时附上原话,并放开「不得改主线」约束——脱纲章
|
|
4214
|
+
# 唯一的修法就是按大纲推翻自创情节,保事实微调恰恰修不动
|
|
4215
|
+
ch_named = [x for x in global_issues
|
|
4216
|
+
if i in _chapters_named_in([x], lo=i, hi=i)]
|
|
4217
|
+
if ch_named:
|
|
4218
|
+
crit = ("- 本章维度不达标:%s(阈值 %.1f)\n- 全书一致性评审指出的短板:%s"
|
|
4219
|
+
"\n- 全书评审对本章的点名(逐条对照处理):\n%s"
|
|
4220
|
+
"\n- 重改要求:若现稿剧情与「本章按大纲应完成」脱轨(评审已点名),"
|
|
4221
|
+
"以本章大纲要点为准重写剧情,可推翻现稿自创的情节线;"
|
|
4222
|
+
"本书其余各章的既成事实仍不得改动。"
|
|
4223
|
+
% (dims_txt or "(无)", threshold, gj or "(无)",
|
|
4224
|
+
"\n".join("- [%s] %s" % (x.get("dim"), str(x.get("note") or "")[:220])
|
|
4225
|
+
for x in ch_named[:4])))
|
|
4226
|
+
else:
|
|
4227
|
+
crit = ("- 本章维度不达标:%s(阈值 %.1f)\n- 全书一致性评审指出的短板:%s"
|
|
4228
|
+
"\n- 重改要求:优先修全书节奏/衔接问题(章间过渡、信息倾泻、主角主动性),"
|
|
4229
|
+
"再补本章短板;不得改动既有剧情主线的关键事实。"
|
|
4230
|
+
% (dims_txt or "(无)", threshold, gj or "(无)"))
|
|
4105
4231
|
_psig = novel_quality.signal_summary(_read_chapter(workdir, i), chapter=i)
|
|
4106
4232
|
if _psig.get("hints"):
|
|
4107
4233
|
crit += ("\n- 确定性复查提示(修订须对应处理):"
|
|
@@ -4114,7 +4240,11 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
4114
4240
|
.replace("__I__", str(i)).replace("__FILE__", "chapter-%02d.md" % i)
|
|
4115
4241
|
.replace("__GOAL__", task["goal"])
|
|
4116
4242
|
.replace("__VOLUME__", vol_block_for(i))
|
|
4243
|
+
.replace("__BEATS__", ch.get("beats") or "按大纲推进")
|
|
4244
|
+
.replace("__HIGHLIGHT__", ch.get("highlight")
|
|
4245
|
+
or "按剧情要点自然铺设一处小冲突/小反转")
|
|
4117
4246
|
.replace("__CRITIQUE__", crit)
|
|
4247
|
+
.replace("__STYLE__", style_tpl)
|
|
4118
4248
|
.replace("__WORDS__", str(wpc)))
|
|
4119
4249
|
prompt = attachments.append_task_context(prompt, task)
|
|
4120
4250
|
res = _run_step(run_id, "polish-c%d" % i, modelhub.bind_agent(impl, difficulty),
|
|
@@ -4158,6 +4288,14 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
4158
4288
|
c2["polished"] = True
|
|
4159
4289
|
c2["words"] = _wc(_read_chapter(workdir, i))
|
|
4160
4290
|
fixed.append(i)
|
|
4291
|
+
if repolished:
|
|
4292
|
+
# 旧账换新账(章级):该章旧 issues 是对打磨前稿件的评判,重写
|
|
4293
|
+
# 已落盘并重评,陈账不撤会同全局陈账一起永远压着 publishable
|
|
4294
|
+
issues_all[:] = [x for x in issues_all if x.get("chapter") != i]
|
|
4295
|
+
for _cj in cj_by_agent.values():
|
|
4296
|
+
if isinstance(_cj, dict):
|
|
4297
|
+
issues_all.extend({"chapter": i, **it}
|
|
4298
|
+
for it in (_cj.get("issues") or [])[:6])
|
|
4161
4299
|
store.update_run(run_id, chapter_scores=chapter_scores)
|
|
4162
4300
|
_check_cancel(ev)
|
|
4163
4301
|
if not fixed:
|
|
@@ -4173,8 +4311,8 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
4173
4311
|
# 重评全书一致性(同一评审闭包;本轮全挂则保留上一轮结论——评审链挂了
|
|
4174
4312
|
# 不代表书变差,不能拿「无法评审」覆盖真实分数)
|
|
4175
4313
|
try:
|
|
4176
|
-
full_text =
|
|
4177
|
-
gscored2, gmeans_acc2, global_review_results2 = run_global_round(critics)
|
|
4314
|
+
full_text = _full_manuscript(workdir, start, end)
|
|
4315
|
+
gscored2, gmeans_acc2, global_review_results2, gi2 = run_global_round(critics)
|
|
4178
4316
|
except BaseException:
|
|
4179
4317
|
store.finish_step(run_id, pstep["n"], "failed",
|
|
4180
4318
|
summary="打磨后全书重评异常中止,已落盘章稿不受影响")
|
|
@@ -4185,6 +4323,9 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
4185
4323
|
global_pass = _all_ge(global_means, threshold)
|
|
4186
4324
|
global_review = quality_gate.aggregate_reviews(
|
|
4187
4325
|
global_review_results, dims, threshold=threshold)
|
|
4326
|
+
# 旧账换新账:上一轮的 majors 是对打磨前稿件的评判,重评出了新分
|
|
4327
|
+
# 却不换 issues,重写修好了也永远 publishable=False(陈账死闸)
|
|
4328
|
+
global_issues[:] = gi2
|
|
4188
4329
|
store.finish_step(run_id, pstep["n"], "done" if global_pass else "failed",
|
|
4189
4330
|
summary="重改 %s;打磨后全局 %s(%s)" % (
|
|
4190
4331
|
"、".join("第 %d 章" % x for x in fixed),
|
|
@@ -4197,6 +4338,14 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
4197
4338
|
ms_name = _ms_name(task.get("manuscript"))
|
|
4198
4339
|
book_title = outline.get("book_title") or task["title"]
|
|
4199
4340
|
parts = ["# %s" % book_title, ""]
|
|
4341
|
+
# 前文归档回退:前作章文件已被归档(chapter-NN.md 不在场)时,合并成品
|
|
4342
|
+
# 不能只剩本批——否则续写批次的「成书」会静默丢掉此前全部章节
|
|
4343
|
+
arch_preface = _archived_preface(workdir, start) if start > 1 else ""
|
|
4344
|
+
if arch_preface:
|
|
4345
|
+
parts.append("## 前文(第 1–%d 章 · 归档稿)" % (start - 1))
|
|
4346
|
+
parts.append("")
|
|
4347
|
+
parts.append(arch_preface)
|
|
4348
|
+
parts.append("")
|
|
4200
4349
|
# 分卷标题:卷首章之前插「第 X 卷 《卷名》」分隔(全书 1..end 都插,续写
|
|
4201
4350
|
# 批次合并时上一批的卷标题也一并补上,不会只在首批出现)
|
|
4202
4351
|
vol_heads = {}
|
|
@@ -4217,13 +4366,14 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
4217
4366
|
f.write("\n".join(parts))
|
|
4218
4367
|
total_words = _wc("\n".join(parts))
|
|
4219
4368
|
store.finish_step(run_id, step["n"], "done",
|
|
4220
|
-
summary="已合并 %d 章为 %s(约 %d 字%s)" % (
|
|
4369
|
+
summary="已合并 %d 章为 %s(约 %d 字%s%s)" % (
|
|
4221
4370
|
n, ms_name, total_words,
|
|
4222
|
-
",分 %d 卷" % len(vol_heads) if vol_heads else ""
|
|
4371
|
+
",分 %d 卷" % len(vol_heads) if vol_heads else "",
|
|
4372
|
+
",前文 1–%d 章取自归档" % (start - 1) if arch_preface else ""),
|
|
4223
4373
|
duration_s=0.1)
|
|
4224
4374
|
|
|
4225
4375
|
chapters_pass = all(c["passed"] for c in chapter_scores)
|
|
4226
|
-
major_issues = [x for x in issues_all + global_issues
|
|
4376
|
+
major_issues = [x for x in _dedup_issues(list(issues_all) + list(global_issues))
|
|
4227
4377
|
if isinstance(x, dict) and x.get("severity") == "major"]
|
|
4228
4378
|
publishable = bool(chapters_pass and global_pass and not major_issues)
|
|
4229
4379
|
signing_checkpoint = (store.get_run(run_id) or {}).get("signing_checkpoint") or {}
|
|
@@ -4288,7 +4438,8 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
4288
4438
|
lines += ["", "## 全局评审(阈值 %.1f)" % threshold, ""]
|
|
4289
4439
|
lines += ["- %s:%.1f" % (d, global_means.get(d, 0.0)) for d in dims]
|
|
4290
4440
|
lines += ["", "## 主要问题", ""]
|
|
4291
|
-
majors = [x for x in issues_all + global_issues
|
|
4441
|
+
majors = [x for x in _dedup_issues(list(issues_all) + list(global_issues))
|
|
4442
|
+
if x.get("severity") == "major"][:12]
|
|
4292
4443
|
if majors:
|
|
4293
4444
|
lines.extend("- [第%s章][%s] %s" % (str(x.get("chapter", "?")), x.get("dim", "?"),
|
|
4294
4445
|
str(x.get("note", ""))[:150]) for x in majors)
|
package/app/core/runner.py
CHANGED
|
@@ -763,7 +763,9 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
|
763
763
|
except FileNotFoundError as e:
|
|
764
764
|
# filename=None 时 %r 只有一句「系统找不到指定的文件」,无从排查
|
|
765
765
|
# (2026-09-30 claude winget 迁移实案:服务 PATH 快照过旧)。
|
|
766
|
-
|
|
766
|
+
# e.filename 在场(如 POSIX cwd 缺失)时点名真实缺失路径,不猜 argv[0]。
|
|
767
|
+
tgt = str(e.filename) if getattr(e, "filename", None) \
|
|
768
|
+
else (str(argv[0]) if argv else "")
|
|
767
769
|
return {"ok": False, "exit_code": None, "stdout": "",
|
|
768
770
|
"stderr": ("启动失败:找不到可执行文件「%s」。CLI 未安装或已卸载;"
|
|
769
771
|
"若刚安装,重启 CodeBee 服务让 PATH 生效即可。"
|
|
@@ -1554,9 +1556,20 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None, workdir=
|
|
|
1554
1556
|
and argv.count(prompt) == 1:
|
|
1555
1557
|
pf = _prompt_to_file(prompt, workdir)
|
|
1556
1558
|
if pf:
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1559
|
+
if kind == "aider":
|
|
1560
|
+
# aider 非交互态没有「读文件工具」语义:--message 指挥它读文件
|
|
1561
|
+
# 会被模型判定为提示注入直接拒绝(2026-10-03 不周山守村人全局
|
|
1562
|
+
# 评审实案:glm-5.3 十秒交白卷 → 全局有效评审恒 1 名,门禁
|
|
1563
|
+
# 「≥2 名」结构性不可能通过)。aider 的 --read 把文件作为只读
|
|
1564
|
+
# 会话文件由 CLI 自己读入,语义与交互式 aider file.md 一致。
|
|
1565
|
+
argv[argv.index(prompt)] = (
|
|
1566
|
+
"本次完整任务指令已作为只读文件加入会话,"
|
|
1567
|
+
"请直接依据该文件内容执行任务。")
|
|
1568
|
+
argv += ["--read", pf]
|
|
1569
|
+
else:
|
|
1570
|
+
argv[argv.index(prompt)] = (
|
|
1571
|
+
"[系统] 本次完整指令因命令行长度限制已写入文件:%s\n"
|
|
1572
|
+
"请先用读文件工具完整读取该文件,然后把文件内容当作你的任务指令执行。" % pf)
|
|
1560
1573
|
# Keep the original payload on stdin as a compatibility path for
|
|
1561
1574
|
# simple generic adapters and test shims that read stdin. CLI tools
|
|
1562
1575
|
# that intentionally use argv still receive the bounded file hint.
|
|
@@ -37,6 +37,13 @@ def _digest(text):
|
|
|
37
37
|
return hashlib.sha256(str(text).encode("utf-8")).hexdigest()
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
def _norm_lf(text):
|
|
41
|
+
"""换行归一到 LF。章稿经 _read_chapter 保真读回时带 CRLF(Windows 上
|
|
42
|
+
_write_chapter 文本模式落盘所致),不归一会让同一份稿在不同路径下
|
|
43
|
+
摘要不一致。"""
|
|
44
|
+
return str(text).replace("\r\n", "\n").replace("\r", "\n")
|
|
45
|
+
|
|
46
|
+
|
|
40
47
|
def _empty(task_id, title, premise=""):
|
|
41
48
|
return {"version": 1, "task_id": str(task_id), "title": str(title or "")[:120],
|
|
42
49
|
"premise": str(premise or "")[:2000], "state_revision": 0,
|
|
@@ -126,7 +133,7 @@ def commit_chapter(workdir, chapter_no, outline, prose, *, facts=None, character
|
|
|
126
133
|
if not 1 <= number <= _MAX_CHAPTERS:
|
|
127
134
|
raise ValueError("章节号必须在 1-%d 之间" % _MAX_CHAPTERS)
|
|
128
135
|
outline = str(outline or "").strip()
|
|
129
|
-
prose = str(prose or "").strip()
|
|
136
|
+
prose = _norm_lf(str(prose or "").strip())
|
|
130
137
|
if not outline:
|
|
131
138
|
raise ValueError("提交章节前必须有细纲")
|
|
132
139
|
if not prose:
|
|
@@ -163,8 +170,10 @@ def commit_chapter(workdir, chapter_no, outline, prose, *, facts=None, character
|
|
|
163
170
|
state["updated_at"] = _now()
|
|
164
171
|
chapter_dir = _CHAPTERS / ("%04d" % number)
|
|
165
172
|
(root / chapter_dir).mkdir(parents=True, exist_ok=True)
|
|
166
|
-
|
|
167
|
-
|
|
173
|
+
# 字节落盘钉死 LF:文本模式写会把已含 CRLF 的章稿二次翻译成 \r\r\n,
|
|
174
|
+
# 磁盘稿与状态摘要永久错位,续写批开场漂移闸必拦(2026-10-03 实案)
|
|
175
|
+
(root / chapter_dir / "outline.md").write_bytes((outline + "\n").encode("utf-8"))
|
|
176
|
+
(root / chapter_dir / "prose.md").write_bytes((prose + "\n").encode("utf-8"))
|
|
168
177
|
_save(root, state)
|
|
169
178
|
_render(root, state)
|
|
170
179
|
return state
|
|
@@ -206,6 +215,31 @@ def _render(root, state):
|
|
|
206
215
|
pass
|
|
207
216
|
|
|
208
217
|
|
|
218
|
+
def _prose_matches(path, digest):
|
|
219
|
+
"""章稿摘要比对,兼容三种历史形态。
|
|
220
|
+
|
|
221
|
+
- 新写:文件纯 LF,状态摘要按 LF 文本计算;
|
|
222
|
+
- 旧写(文本模式二次翻译):磁盘 \\r\\r\\n,状态摘要按 CRLF 原文计算——
|
|
223
|
+
CRCRLF 是写出路径独有的字节形态,先按字节愈合回 CRLF 再比对即可认定
|
|
224
|
+
同源,不必要求用户手工修复历史状态(2026-10-03 续写批漂移闸误杀实案);
|
|
225
|
+
- 旧写(未二次翻译):磁盘普通 CRLF,摘要按 CRLF 原文计算。
|
|
226
|
+
注意愈合必须在字节层做:read_text 的通用换行翻译会把 \\r\\r\\n 折成 \\n\\n,
|
|
227
|
+
事后无从区分「真空行」与「二次翻译」,就再也对不上了。
|
|
228
|
+
"""
|
|
229
|
+
if not digest:
|
|
230
|
+
return False
|
|
231
|
+
try:
|
|
232
|
+
data = path.read_bytes()
|
|
233
|
+
except OSError:
|
|
234
|
+
return False
|
|
235
|
+
candidates = set()
|
|
236
|
+
for raw in (data, data.replace(b"\r\r\n", b"\r\n")):
|
|
237
|
+
text = raw.decode("utf-8", "replace")
|
|
238
|
+
candidates.add(_digest(text.strip()))
|
|
239
|
+
candidates.add(_digest(_norm_lf(text).strip()))
|
|
240
|
+
return digest in candidates
|
|
241
|
+
|
|
242
|
+
|
|
209
243
|
def check(workdir):
|
|
210
244
|
root = _root(workdir)
|
|
211
245
|
state = _load(root)
|
|
@@ -235,7 +269,7 @@ def check(workdir):
|
|
|
235
269
|
current = outline.read_text(encoding="utf-8").strip()
|
|
236
270
|
if persisted and persisted not in current:
|
|
237
271
|
errors.append("章节细纲不一致: %04d" % number)
|
|
238
|
-
elif
|
|
272
|
+
elif not _prose_matches(prose, chapter.get("prose_digest")):
|
|
239
273
|
errors.append("章节正文不一致: %04d" % number)
|
|
240
274
|
return {"ok": not errors, "errors": errors, "state_revision": state.get("state_revision", 0),
|
|
241
275
|
"current_chapter": state.get("current_chapter", 0)}
|
package/app/main.py
CHANGED
|
@@ -1096,6 +1096,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1096
1096
|
m = re.match(r"^/api/tasks/([^/]+)/book-meta$", path)
|
|
1097
1097
|
if m:
|
|
1098
1098
|
return self._api_book_meta_generate(m.group(1))
|
|
1099
|
+
m = re.match(r"^/api/tasks/([^/]+)/book-meta/rename$", path)
|
|
1100
|
+
if m:
|
|
1101
|
+
return self._api_book_meta_rename(m.group(1))
|
|
1099
1102
|
m = re.match(r"^/api/tasks/([^/]+)/cover$", path)
|
|
1100
1103
|
if m:
|
|
1101
1104
|
from core import covergen
|
|
@@ -1205,6 +1208,10 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1205
1208
|
if err and not n:
|
|
1206
1209
|
return self._json(400, {"error": err})
|
|
1207
1210
|
return self._json(200, {"ok": True, "count": n, "skipped": skipped, "message": err})
|
|
1211
|
+
if path == "/api/runs/cancel_all":
|
|
1212
|
+
# 急停(kill switch):取消全部进行中任务。全量扫描不受侧栏
|
|
1213
|
+
# 窗口限制——窗口外的活跃运行也一并取消(hcom/amux 族竞品标配)。
|
|
1214
|
+
return self._json(200, {"ok": True, "count": jobs.cancel_all()})
|
|
1208
1215
|
if path == "/api/runs/clear":
|
|
1209
1216
|
n, skipped = store.clear_runs()
|
|
1210
1217
|
return self._json(200, {"ok": True, "count": n, "skipped": skipped})
|
|
@@ -1953,6 +1960,58 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1953
1960
|
args=(target_id, platform, author)).start()
|
|
1954
1961
|
return self._json(200, {"ok": True, "started": True})
|
|
1955
1962
|
|
|
1963
|
+
def _api_book_meta_rename(self, task_id):
|
|
1964
|
+
"""作品信息换名(POST /api/tasks/<id>/book-meta/rename,body: {platform})。
|
|
1965
|
+
|
|
1966
|
+
只换 book_name 一个字段:整套重生成会覆盖已核对过的分类/标签/简介,
|
|
1967
|
+
而用户的痛点只是书名与本站已有作品撞名。同步接口(单次轻模型调用),
|
|
1968
|
+
成功才写盘,模型失败时现有数据原样不动。排除名单=全站已用书名
|
|
1969
|
+
(books.json 登记 + 各任务 book_meta),从根上防「换出来的还是重名」。
|
|
1970
|
+
落点同生成:连载链根任务(2026-09-30 都是用第一个)。"""
|
|
1971
|
+
from core import bookmeta
|
|
1972
|
+
task = store.get_task(task_id)
|
|
1973
|
+
if not task:
|
|
1974
|
+
return self._json(404, {"error": "任务不存在"})
|
|
1975
|
+
body = self._body() or {}
|
|
1976
|
+
platform = (body.get("platform") or "").strip()
|
|
1977
|
+
if platform not in bookmeta.PLATFORMS:
|
|
1978
|
+
return self._json(400, {"error": "platform 必须是 fanqie 或 qimao"})
|
|
1979
|
+
chain = store.serial_chain_ids(task_id)
|
|
1980
|
+
target = (store.get_task(chain[0]) if chain else None) or task
|
|
1981
|
+
target_id = str(target.get("id"))
|
|
1982
|
+
if target.get("status") in ("queued", "running"):
|
|
1983
|
+
return self._json(400, {"error": "任务正在运行,请等本轮结束后再换名"})
|
|
1984
|
+
entry = ((target.get("book_meta") or {}).get(platform) or {})
|
|
1985
|
+
if entry.get("status") != "done" or not isinstance(entry.get("data"), dict):
|
|
1986
|
+
return self._json(400, {"error": "该平台作品信息尚未生成,没有书名可换"})
|
|
1987
|
+
old_name = str((entry["data"] or {}).get("book_name") or "").strip()
|
|
1988
|
+
if not old_name:
|
|
1989
|
+
return self._json(400, {"error": "现有记录没有书名可换"})
|
|
1990
|
+
new_name, err = bookmeta.rename_book(target, platform, old_name,
|
|
1991
|
+
bookmeta.used_book_names())
|
|
1992
|
+
if err:
|
|
1993
|
+
return self._json(400, {"error": err})
|
|
1994
|
+
data = dict(entry["data"])
|
|
1995
|
+
data["book_name"] = new_name
|
|
1996
|
+
payload = dict(entry)
|
|
1997
|
+
payload["data"] = data
|
|
1998
|
+
payload["at"] = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
1999
|
+
payload["source"] = (str(entry.get("source") or "") +
|
|
2000
|
+
";换名:《%s》→《%s》" % (old_name, new_name)).lstrip(";")
|
|
2001
|
+
if not store.set_book_meta(target_id, platform, payload):
|
|
2002
|
+
return self._json(404, {"error": "任务不存在"})
|
|
2003
|
+
# 归档 Markdown 同步(工作目录在场才写;source 只进归档不进 book_meta
|
|
2004
|
+
# 数据——数据里多的键会让 repair_existing 的幂等比较每次不等)
|
|
2005
|
+
try:
|
|
2006
|
+
fp = Path(target.get("workdir") or "") / bookmeta.PLATFORMS[platform]["file"]
|
|
2007
|
+
if fp.parent.is_dir():
|
|
2008
|
+
fp.write_text(bookmeta.render_markdown(
|
|
2009
|
+
target, platform, dict(data, source=payload["source"])),
|
|
2010
|
+
encoding="utf-8")
|
|
2011
|
+
except OSError:
|
|
2012
|
+
pass
|
|
2013
|
+
return self._json(200, {"ok": True, "old": old_name, "new": new_name})
|
|
2014
|
+
|
|
1956
2015
|
def _api_publish_platform_op(self, platform, op):
|
|
1957
2016
|
"""平台会话操作:connect 开浏览器等扫码 / disconnect 关 / probe 探测表单。"""
|
|
1958
2017
|
from core.publish import manager as pub
|
package/app/ui/app.js
CHANGED
|
@@ -4458,6 +4458,14 @@ function renderSideTasks() {
|
|
|
4458
4458
|
vb.classList.toggle("hidden", !vcount);
|
|
4459
4459
|
vb.title = vcount ? vcount + t(" 个任务待裁决") : "";
|
|
4460
4460
|
}
|
|
4461
|
+
// 急停钮现身条件同源侧栏真相(全量组,不受搜索过滤):有排队/运行中任务
|
|
4462
|
+
// 才显示。点击走后端全量扫描,窗口外/侧栏外的活跃运行也一并取消。
|
|
4463
|
+
const acount = groups.filter((g) => g.status === "running" || g.status === "queued").length;
|
|
4464
|
+
const kb = $("btn-kill-all");
|
|
4465
|
+
if (kb) {
|
|
4466
|
+
kb.classList.toggle("hidden", !acount);
|
|
4467
|
+
kb.title = acount ? t("急停:取消全部进行中(排队/运行)任务") + "(" + acount + ")" : "";
|
|
4468
|
+
}
|
|
4461
4469
|
}
|
|
4462
4470
|
|
|
4463
4471
|
/* 主视图打开详情面板的公共部分:留在任务树,主栏切到运行/任务详情 */
|
|
@@ -5061,6 +5069,26 @@ async function clearRuns() {
|
|
|
5061
5069
|
poll();
|
|
5062
5070
|
}
|
|
5063
5071
|
|
|
5072
|
+
/* 急停(kill switch):取消全部排队/运行中任务。危险操作——确认框点名
|
|
5073
|
+
* 数量;后端全量扫,侧栏窗口外的活跃运行也一并取消。防双击:busy 期间
|
|
5074
|
+
* 直接吞掉重复点击。 */
|
|
5075
|
+
async function cancelAllRuns() {
|
|
5076
|
+
const btn = $("btn-kill-all");
|
|
5077
|
+
if (!btn || btn.classList.contains("busy")) return;
|
|
5078
|
+
if (!await uiConfirm(t("确认急停:取消全部进行中(排队/运行)任务?"), { ok: t("急停"), danger: true })) return;
|
|
5079
|
+
btn.classList.add("busy");
|
|
5080
|
+
try {
|
|
5081
|
+
const d = await api("/api/runs/cancel_all", { method: "POST", timeout: 30000 });
|
|
5082
|
+
toast(t("已取消 ") + ((d && d.count) || 0) + t(" 个进行中任务"));
|
|
5083
|
+
} catch (e) {
|
|
5084
|
+
toast(t("急停失败:") + e.message, true);
|
|
5085
|
+
} finally {
|
|
5086
|
+
btn.classList.remove("busy");
|
|
5087
|
+
}
|
|
5088
|
+
poll();
|
|
5089
|
+
}
|
|
5090
|
+
window.cancelAllRuns = cancelAllRuns;
|
|
5091
|
+
|
|
5064
5092
|
async function openRun(id, pinTab) {
|
|
5065
5093
|
_taskStepRenderToken++;
|
|
5066
5094
|
S.detailRunId = id;
|
|
@@ -5706,10 +5734,26 @@ function renderBookMetaPanel(task) {
|
|
|
5706
5734
|
const v = tags
|
|
5707
5735
|
? '<span class="bm-chips">' + raw.map((x) => '<span class="bm-chip">' + esc(String(x)) + "</span>").join("") + "</span>"
|
|
5708
5736
|
: '<span class="bm-f-v' + (long ? " bm-f--scroll" : "") + '" title="' + esc(val) + '">' + esc(val) + "</span>";
|
|
5737
|
+
// 书名行加「换名」(2026-10-02 撞名实案):只换书名一个字段,其余
|
|
5738
|
+
// 已核对过的分类/标签/简介不被重摇覆盖;换名中禁用态由 S.bmRenaming
|
|
5739
|
+
// 跨重绘保持(轮询整块 innerHTML 重建会把按钮态复位)
|
|
5740
|
+
let rename = "";
|
|
5741
|
+
if (key === "book_name") {
|
|
5742
|
+
const renaming = S.bmRenaming === task.id + "|" + p.id;
|
|
5743
|
+
rename = '<button class="bm-rename" data-task="' + esc(task.id) +
|
|
5744
|
+
'" data-plat="' + p.id + '" data-old="' + esc(val) + '"' + (renaming ? " disabled" : "") +
|
|
5745
|
+
' title="' + esc(t("书名与本站已有作品重名时,点这里换一个新书名(其余字段不动)")) + '"' +
|
|
5746
|
+
' onclick="bmRename(this)">' +
|
|
5747
|
+
(renaming
|
|
5748
|
+
? '<svg class="ico spin" aria-hidden="true"><use href="#i-refresh"/></svg>' + esc(t("换名中…"))
|
|
5749
|
+
: '<svg class="ico" aria-hidden="true"><use href="#i-refresh"/></svg>' + esc(t("换名"))) +
|
|
5750
|
+
"</button>";
|
|
5751
|
+
}
|
|
5709
5752
|
return '<div class="bm-f' + (long || tags ? " bm-f--full" : "") + '">' +
|
|
5710
5753
|
'<span class="bm-f-k">' + esc(t(label)) + "</span>" + v +
|
|
5711
5754
|
'<button class="bm-copy" data-text="' + esc(val) + '" data-label="' + esc(t(label)) +
|
|
5712
|
-
'" title="' + esc(t("点击复制")) + '" onclick="bmCopyBtn(this)"><svg class="ico" aria-hidden="true"><use href="#i-file-text"/></svg>' + t("复制") + "</button
|
|
5755
|
+
'" title="' + esc(t("点击复制")) + '" onclick="bmCopyBtn(this)"><svg class="ico" aria-hidden="true"><use href="#i-file-text"/></svg>' + t("复制") + "</button>" +
|
|
5756
|
+
rename + "</div>";
|
|
5713
5757
|
}).join("") + "</div>";
|
|
5714
5758
|
} else if (st === "failed") {
|
|
5715
5759
|
body = '<div class="bm-errhint">' + esc(entry.error || t("生成失败")) + "</div>";
|
|
@@ -5794,6 +5838,34 @@ window.bmGen = async function (taskId, platform) {
|
|
|
5794
5838
|
} catch (e) { toast(t("生成失败:") + e.message, true); }
|
|
5795
5839
|
};
|
|
5796
5840
|
|
|
5841
|
+
/* 换名(书名撞名专用):同步接口单次轻模型调用,成功后新书名随 SSE/轮询
|
|
5842
|
+
* 回填;失败 toast 报错,现有字段原样保留。期间 S.bmRenaming 让按钮禁用态
|
|
5843
|
+
* 跨轮询重绘保持,防连点打两个换名请求。 */
|
|
5844
|
+
window.bmRename = async function (btn) {
|
|
5845
|
+
if (btn.disabled) return;
|
|
5846
|
+
// 立即禁用+换文案(防 await 期间双击发第二个请求);S.bmRenaming 让禁用态
|
|
5847
|
+
// 跨轮询重绘保持(轮询整块 innerHTML 重建会把按钮态复位,渲染端按它还原)
|
|
5848
|
+
btn.disabled = true;
|
|
5849
|
+
btn.innerHTML = '<svg class="ico spin" aria-hidden="true"><use href="#i-refresh"/></svg>' + esc(t("换名中…"));
|
|
5850
|
+
const taskId = btn.dataset.task, platform = btn.dataset.plat;
|
|
5851
|
+
S.bmRenaming = taskId + "|" + platform;
|
|
5852
|
+
try {
|
|
5853
|
+
const r = await api("/api/tasks/" + encodeURIComponent(taskId) + "/book-meta/rename",
|
|
5854
|
+
{ method: "POST", body: JSON.stringify({ platform }) });
|
|
5855
|
+
toast(t("已换名:") + "《" + (r.old || btn.dataset.old || "") + "》→《" + (r.new || "") + "》");
|
|
5856
|
+
} catch (e) {
|
|
5857
|
+
toast(t("换名失败:") + e.message, true);
|
|
5858
|
+
} finally {
|
|
5859
|
+
S.bmRenaming = "";
|
|
5860
|
+
// 就地恢复按钮:render 的详情签名守卫在服务端数据没变时(换名失败正是)
|
|
5861
|
+
// 会跳过重绘,按钮不能指望 render 归位;若 render 真重建了整卡,btn 已
|
|
5862
|
+
// detach,改它是无害空操作
|
|
5863
|
+
btn.disabled = false;
|
|
5864
|
+
btn.innerHTML = '<svg class="ico" aria-hidden="true"><use href="#i-refresh"/></svg>' + esc(t("换名"));
|
|
5865
|
+
try { await refreshState(); render(); } catch (e) { /* 拉状态失败不影响上面的恢复 */ }
|
|
5866
|
+
}
|
|
5867
|
+
};
|
|
5868
|
+
|
|
5797
5869
|
/* 封面图生成(covergen):curl 子进程直接落盘运行目录,Python 不经手图像字节 */
|
|
5798
5870
|
window.coverGen = async function (taskId) {
|
|
5799
5871
|
try {
|
|
@@ -7434,8 +7506,10 @@ function artifactsChips(runId, files, prevRun, hasPlan) {
|
|
|
7434
7506
|
(isTxt ? '<a class="file-chip prev artifact-file-preview" title="' + esc(t("查看内容")) +
|
|
7435
7507
|
'" data-file-run="' + esc(runId) + '" data-file-name="' + esc(f.name) + '" data-file-size="' + (Number(f.size) || 0) + '">' +
|
|
7436
7508
|
'<svg class="ico" aria-hidden="true"><use href="#i-book"/></svg>' + t("预览") + "</a>" : "") +
|
|
7437
|
-
(diffable ? '<a class="file-chip prev artifact-file-diff" title="' +
|
|
7509
|
+
(diffable ? '<a class="file-chip prev artifact-file-diff" title="' +
|
|
7510
|
+
esc(prevRun.ver ? t("与第 {0} 版对比").replace("{0}", prevRun.ver) : t("与上一版对比")) +
|
|
7438
7511
|
'" data-file-run="' + esc(runId) + '" data-prev-run="' + esc(prevRun.id) +
|
|
7512
|
+
'" data-prev-ver="' + (prevRun.ver || "") +
|
|
7439
7513
|
'" data-file-name="' + esc(f.name) + '" data-file-size="' + (Number(f.size) || 0) + '">' +
|
|
7440
7514
|
'<svg class="ico" aria-hidden="true"><use href="#i-git-branch"/></svg>' + t("对比") + "</a>" : "") +
|
|
7441
7515
|
"</span>";
|
|
@@ -7472,12 +7546,17 @@ function artifactDiffBody(a, b) {
|
|
|
7472
7546
|
return '<pre class="art-diff">' + html + "</pre>";
|
|
7473
7547
|
}
|
|
7474
7548
|
|
|
7475
|
-
/* 对比上一版弹窗:取两版文本 → diff 渲染(复用成品弹窗骨架)。
|
|
7476
|
-
|
|
7549
|
+
/* 对比上一版弹窗:取两版文本 → diff 渲染(复用成品弹窗骨架)。
|
|
7550
|
+
* prevVer 在场时副标用「第 N 版 → 当前」(序号比 run_id 前 8 位对用户可读);
|
|
7551
|
+
* 缺号退回原 run_id 摘要,审计语义不丢。 */
|
|
7552
|
+
window.artDiffPopup = async function (runId, prevRunId, name, prevVer) {
|
|
7477
7553
|
_fpSaveCtx = null; _fpRawText = ""; _fpDirty = false; _fpCopyText = null;
|
|
7478
7554
|
const nm = String(name).split("/").pop();
|
|
7479
|
-
|
|
7480
|
-
'<i>' + esc(
|
|
7555
|
+
const sub = prevVer
|
|
7556
|
+
? '<i>' + esc(t("第 {0} 版 → 当前").replace("{0}", prevVer)) +
|
|
7557
|
+
" · " + esc(String(prevRunId).slice(0, 8)) + " → " + esc(String(runId).slice(0, 8)) + "</i>"
|
|
7558
|
+
: '<i>' + esc(String(prevRunId).slice(0, 8)) + " → " + esc(String(runId).slice(0, 8)) + "</i>";
|
|
7559
|
+
_fpOpen(nm + " · " + t("与上一版对比"), sub, "");
|
|
7481
7560
|
_fpSetBody('<div class="fp-hint">' + esc(t("正在取两版内容…")) + "</div>");
|
|
7482
7561
|
const u1 = urlAuth("/api/runs/" + encodeURIComponent(prevRunId) + "/file?name=" + encodeURIComponent(name));
|
|
7483
7562
|
const u2 = urlAuth("/api/runs/" + encodeURIComponent(runId) + "/file?name=" + encodeURIComponent(name));
|
|
@@ -7525,20 +7604,35 @@ async function loadArtifacts(runId) {
|
|
|
7525
7604
|
return;
|
|
7526
7605
|
}
|
|
7527
7606
|
const head = '<div class="files-head"><span class="sec-title">' + t("成品文件") + '</span>' +
|
|
7607
|
+
verBadge +
|
|
7528
7608
|
'<span class="wd" title="' + esc(t("点击复制")) + '" onclick="copyText(this.textContent)">' + esc(d.workdir) + "</span></div>";
|
|
7529
7609
|
// 上一版 run 的文件清单(详情上下文才有):同名文本文件给「对比」入口;
|
|
7530
|
-
//
|
|
7531
|
-
|
|
7610
|
+
// 拉不到/无历史静默降级——对比是增强不是闸门。
|
|
7611
|
+
// 版本序号(OpenCreator「版本化」剩余面):任务 run 列表最新在前,序号 =
|
|
7612
|
+
// run 总数 - 列表内序位;total 缺失(分页未拉过)就不编版本号,徽章/对比
|
|
7613
|
+
// 标题退回原样——序号是可读性增强不是闸门
|
|
7614
|
+
let prevRun = null, curVer = 0;
|
|
7532
7615
|
if (detailOwns && S.detailTaskKey) {
|
|
7533
|
-
const
|
|
7616
|
+
const runs = _taskRunsCache.get(S.detailTaskKey) || [];
|
|
7617
|
+
const total = ((_taskRunsPaging.get(S.detailTaskKey) || {}).total) || 0;
|
|
7618
|
+
const idx = runs.findIndex((r) => r.id === runId);
|
|
7619
|
+
if (total && idx >= 0) curVer = total - idx;
|
|
7620
|
+
const prev = runs.find((r) => r.id !== runId);
|
|
7534
7621
|
if (prev) {
|
|
7535
7622
|
try {
|
|
7536
7623
|
const pd = await api("/api/runs/" + encodeURIComponent(prev.id) + "/files");
|
|
7537
|
-
if (pd && (pd.files || []).length)
|
|
7538
|
-
|
|
7624
|
+
if (pd && (pd.files || []).length) {
|
|
7625
|
+
const pIdx = runs.indexOf(prev);
|
|
7626
|
+
prevRun = { id: prev.id, names: new Set(pd.files.map((f2) => f2.name)),
|
|
7627
|
+
ver: (total && pIdx >= 0) ? total - pIdx : 0 };
|
|
7628
|
+
}
|
|
7539
7629
|
} catch (e) { /* 上一版清单不可用就不给对比 */ }
|
|
7540
7630
|
}
|
|
7541
7631
|
}
|
|
7632
|
+
const verBadge = curVer > 1
|
|
7633
|
+
? '<span class="ver-badge" title="' + esc(t("该任务第 N 次运行的产出,序号含修订与重试")) + '">' +
|
|
7634
|
+
esc(t("第 {0} 版").replace("{0}", curVer)) + "</span>"
|
|
7635
|
+
: "";
|
|
7542
7636
|
const chips = artifactsChips(runId, files, prevRun, d.has_plan);
|
|
7543
7637
|
if (box) box.innerHTML = head + '<div class="file-chips">' + chips + "</div>";
|
|
7544
7638
|
if (mainBox) { mainBox.classList.remove("hidden");
|
|
@@ -15258,6 +15352,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
15258
15352
|
b.addEventListener("click", () => setUsageHeatMode(b.dataset.heat)));
|
|
15259
15353
|
$("btn-back").addEventListener("click", closeRun);
|
|
15260
15354
|
$("btn-cancel").addEventListener("click", cancelRun);
|
|
15355
|
+
$("btn-kill-all").addEventListener("click", cancelAllRuns);
|
|
15261
15356
|
bindDirector();
|
|
15262
15357
|
bindChat();
|
|
15263
15358
|
const bindArtifactClicks = (root) => {
|
|
@@ -15266,7 +15361,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
15266
15361
|
const diff = e.target.closest(".artifact-file-diff[data-prev-run]");
|
|
15267
15362
|
if (diff) { // 对比上一版:先于通用预览分支(chip 同样带 data-file-run/name)
|
|
15268
15363
|
e.preventDefault();
|
|
15269
|
-
artDiffPopup(diff.dataset.fileRun, diff.dataset.prevRun, diff.dataset.fileName || ""
|
|
15364
|
+
artDiffPopup(diff.dataset.fileRun, diff.dataset.prevRun, diff.dataset.fileName || "",
|
|
15365
|
+
Number(diff.dataset.prevVer) || 0);
|
|
15270
15366
|
return;
|
|
15271
15367
|
}
|
|
15272
15368
|
const file = e.target.closest("[data-file-run][data-file-name]");
|
package/app/ui/i18n.js
CHANGED
|
@@ -730,6 +730,12 @@
|
|
|
730
730
|
"删除这条教训?": "Delete this lesson?",
|
|
731
731
|
"删除全部已结束的运行记录(运行中的会保留)": "Delete all finished runs (active ones are kept)",
|
|
732
732
|
"清除全部运行记录(含日志与报告)?运行中的记录会保留,需先取消。不可恢复。": "Clear all runs (logs and reports)? Active runs are kept — cancel them first. This cannot be undone.",
|
|
733
|
+
"急停": "Kill all",
|
|
734
|
+
"急停:取消全部进行中(排队/运行)任务": "Kill switch: cancel all in-flight (queued/running) tasks",
|
|
735
|
+
"确认急停:取消全部进行中(排队/运行)任务?": "Kill all in-flight (queued/running) tasks?",
|
|
736
|
+
"已取消 ": "Cancelled ",
|
|
737
|
+
" 个进行中任务": " in-flight task(s)",
|
|
738
|
+
"急停失败:": "Kill switch failed: ",
|
|
733
739
|
"全选": "Select all",
|
|
734
740
|
"全不选": "Select none",
|
|
735
741
|
"取消选择": "Clear selection",
|
|
@@ -1929,6 +1935,10 @@
|
|
|
1929
1935
|
"读取附带的缺陷导出数据 → AI 输出产品/开发/测试三视角复盘报告:整体画像、根因聚类、漏测分析、改进动作(快档直出报告)": "Reads the attached defect export → AI produces a Product/Dev/QA three-lens retrospective: overall profile, root-cause clusters, missed-test analysis, improvement actions (fast lane, report straight out)",
|
|
1930
1936
|
"复盘哪个版本/时间段的缺陷(建议附带禅道或 Jira 导出 CSV)": "Which version/time window to retrospect (attaching a ZenTao or Jira CSV export is recommended)",
|
|
1931
1937
|
"与上一版对比": "Diff vs previous run",
|
|
1938
|
+
"与第 {0} 版对比": "Diff vs version {0}",
|
|
1939
|
+
"第 {0} 版": "Version {0}",
|
|
1940
|
+
"第 {0} 版 → 当前": "Version {0} → current",
|
|
1941
|
+
"该任务第 N 次运行的产出,序号含修订与重试": "Output of this task's Nth run; the number counts revisions and retries too",
|
|
1932
1942
|
"对比": "Diff",
|
|
1933
1943
|
"两版内容一致。": "The two versions are identical.",
|
|
1934
1944
|
"正在取两版内容…": "Fetching both versions…",
|
|
@@ -2094,6 +2104,11 @@
|
|
|
2094
2104
|
"生成": "Generate",
|
|
2095
2105
|
"重新生成": "Regenerate",
|
|
2096
2106
|
"重新生成会覆盖现有内容": "Regenerating overwrites the existing content",
|
|
2107
|
+
"换名": "Rename",
|
|
2108
|
+
"换名中…": "Renaming…",
|
|
2109
|
+
"已换名:": "Renamed: ",
|
|
2110
|
+
"换名失败:": "Rename failed: ",
|
|
2111
|
+
"书名与本站已有作品重名时,点这里换一个新书名(其余字段不动)": "If the title clashes with an existing work here, click to generate a new one (other fields stay untouched)",
|
|
2097
2112
|
"生成失败": "Generation failed",
|
|
2098
2113
|
"分享页": "Share page",
|
|
2099
2114
|
"生成自包含分享页(单文件 HTML),可直接发给他人查看任务成果": "Generate a self-contained share page (single-file HTML) to send to others",
|
package/app/ui/index.html
CHANGED
|
@@ -206,6 +206,10 @@
|
|
|
206
206
|
</span>
|
|
207
207
|
</button>
|
|
208
208
|
<div class="side-foot">
|
|
209
|
+
<!-- 急停(kill switch):有进行中任务才现身;点击取消全部排队/运行中任务 -->
|
|
210
|
+
<button id="btn-kill-all" class="foot-btn kill hidden" title="急停:取消全部进行中(排队/运行)任务" aria-label="急停" data-i18n-aria="急停" data-i18n-title="急停:取消全部进行中(排队/运行)任务">
|
|
211
|
+
<svg class="ico ico-lg" aria-hidden="true"><use href="#i-x"></use></svg>
|
|
212
|
+
</button>
|
|
209
213
|
<button id="btn-settings" class="foot-btn" title="设置" aria-label="设置" data-i18n-aria="设置" data-i18n-title="设置">
|
|
210
214
|
<svg class="ico ico-lg" aria-hidden="true"><use href="#i-gear"></use></svg>
|
|
211
215
|
</button>
|
package/app/ui/style.css
CHANGED
|
@@ -2842,16 +2842,19 @@ details.sf-dir > summary i, a.sf-file i { font-style: normal; color: var(--muted
|
|
|
2842
2842
|
transition: border-color .15s; }
|
|
2843
2843
|
.bm-card:hover .bm-chip { border-color: var(--border-strong); }
|
|
2844
2844
|
|
|
2845
|
-
/* 复制按钮:图标 + 文字,默认淡出,行 hover /
|
|
2846
|
-
|
|
2845
|
+
/* 复制按钮:图标 + 文字,默认淡出,行 hover / 键盘聚焦时点亮。
|
|
2846
|
+
* 换名按钮(.bm-rename)同款外观独立类——不加进 .bm-copy 是为了「复制钮」
|
|
2847
|
+
* 计数语义干净(ui_bookmeta 基线按每字段一钮数)。 */
|
|
2848
|
+
.bm-copy, .bm-rename { flex: none; display: inline-flex; align-items: center; gap: 4px; align-self: center;
|
|
2847
2849
|
font-size: 11.5px; line-height: 1; padding: 4px 9px; border-radius: 999px;
|
|
2848
2850
|
border: 1px solid transparent; background: none; color: var(--muted); cursor: pointer;
|
|
2849
2851
|
opacity: .55; transition: opacity .15s, color .15s, background .15s, border-color .15s; }
|
|
2850
|
-
.bm-copy .ico { width: 12px; height: 12px; }
|
|
2851
|
-
.bm-f:hover .bm-copy { opacity: 1; }
|
|
2852
|
-
.bm-copy:hover, .bm-copy:focus-visible
|
|
2852
|
+
.bm-copy .ico, .bm-rename .ico { width: 12px; height: 12px; }
|
|
2853
|
+
.bm-f:hover .bm-copy, .bm-f:hover .bm-rename { opacity: 1; }
|
|
2854
|
+
.bm-copy:hover, .bm-copy:focus-visible, .bm-rename:hover, .bm-rename:focus-visible {
|
|
2855
|
+
color: var(--accent); border-color: var(--border-strong);
|
|
2853
2856
|
background: var(--panel2); opacity: 1; }
|
|
2854
|
-
.bm-copy:active { transform: translateY(1px); }
|
|
2857
|
+
.bm-copy:active, .bm-rename:active { transform: translateY(1px); }
|
|
2855
2858
|
|
|
2856
2859
|
/* 生成中骨架屏 */
|
|
2857
2860
|
.bm-skwrap { padding: 6px 18px 16px; }
|
|
@@ -7068,3 +7071,16 @@ main.browser-page-active #page-settings > #sub-browser .browser-shell {
|
|
|
7068
7071
|
.prov-status { margin: 2px 0 8px; }
|
|
7069
7072
|
.prov-status .cat-chip { padding: 3px 9px; font-size: 12px; }
|
|
7070
7073
|
.pm-status { margin: 10px 0 2px; }
|
|
7074
|
+
|
|
7075
|
+
/* 成品文件头部「第 N 版」徽章(多版本序号可见化,追加尾防并行重排):
|
|
7076
|
+
* 只在任务有 ≥2 次运行时出现;中性色小药丸,不给操作语义 */
|
|
7077
|
+
.files-head .ver-badge {
|
|
7078
|
+
font-size: 11px; color: var(--muted); border: 1px solid var(--border);
|
|
7079
|
+
border-radius: 9px; padding: 1px 8px; white-space: nowrap; cursor: help;
|
|
7080
|
+
}
|
|
7081
|
+
|
|
7082
|
+
/* 侧栏底部急停钮(kill switch,追加尾防并行重排):有排队/运行中任务才
|
|
7083
|
+
* 现身;危险红着色与常规 foot-btn 区分,busy 期间降透明度防双击。 */
|
|
7084
|
+
.foot-btn.kill { color: var(--bad); }
|
|
7085
|
+
.foot-btn.kill:hover { color: #fff; background: var(--bad); }
|
|
7086
|
+
.foot-btn.kill.busy { opacity: .5; pointer-events: none; }
|
package/package.json
CHANGED