codebee 0.1.48 → 0.1.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,12 @@ README 元数据带回,供老版本在「发现新版本」时展示新版更
6
6
 
7
7
  ## 未发布
8
8
 
9
+ ## v0.1.49(2026-09-22)
10
+
11
+ ### 🛠 问题修复
12
+
13
+ - 修复设置 →「钩子」卡片样式坍缩:下拉框漏写闭合标签导致命令框与操作行被吞进内部,整卡挤成一行。
14
+
9
15
  ## v0.1.48(2026-09-22)
10
16
 
11
17
  ### ✨ 新功能
@@ -8,6 +8,8 @@
8
8
  """
9
9
  from __future__ import annotations
10
10
 
11
+ import re
12
+
11
13
  # 译制腔/套话词表(按误导性排序,持续运维)
12
14
  AI_PHRASES = (
13
15
  "空气仿佛凝固", "眸中闪过一丝", "嘴角勾起一抹", "心中一动",
@@ -81,8 +83,73 @@ def narrative_analyze(text):
81
83
  return {"cats": cats, "per_kilo": per_kilo, "alert": alert, "ending_hits": ending_hits}
82
84
 
83
85
 
84
- def report_line(text):
85
- """生成注入评审提示词的报告行(措辞层 + 叙事架构层);全部无命中返回空串。"""
86
+ # ---------- 节奏/钩子密度(借鉴 web-novel-pacing-analyzer:连载作品的章级
87
+ # 节奏审计——读者留存看「每章有没有钩子、段落有没有淤积、是不是通篇独白」,
88
+ # 这些是能确定性统计的客观量。与套话检测同路:只给评审官一条参考线,
89
+ # 不直接扣分——短段落多的快节奏文也可能开篇没有冲突词。)
90
+ NARRATIVE_FLOWS = ("novel", "serial_novel", "video_script")
91
+
92
+ # 冲突/悬念信号词表:开篇用来看「抓不抓人」,章末用来看「留不留钩子」
93
+ HOOK_WORDS = (
94
+ "突然", "忽然", "竟然", "没想到", "万万没想到", "难道", "究竟", "为什么",
95
+ "是谁", "怎么回事", "就在这时", "就在此时", "然而", "可是", "却见", "却听",
96
+ "不对劲", "不对!", "脸色一变", "猛地", "陡然", "下一秒", "醒来", "睁开眼",
97
+ "电话响", "门被推开", "传来", "砸", "吼", "喊",
98
+ )
99
+ DIALOG_RE = re.compile(r"[「『“\"]")
100
+ OPENING_SCAN_CHARS = 300 # 开篇抓力只看前 300 字(黄金三章 / 短视频黄金 3 秒)
101
+ PACING_TAIL_CHARS = 200 # 章末钩子只看最后 200 字
102
+ PARA_LONG_CHARS = 400 # 单段超过此字数算「信息淤积」
103
+ PARA_AVG_ALERT = 300 # 段均长告警线
104
+ DIALOG_ALERT_RATIO = 0.12 # 含对话段占比低于此值算「通篇叙述独白」
105
+ PACING_MIN_CHARS = 400 # 太短(摘要/片段)不做节奏统计,避免噪声
106
+
107
+
108
+ def _paras(text):
109
+ """切段:空行或单换行都算分隔;丢掉标题行(# 开头)与空白。"""
110
+ out = []
111
+ for p in re.split(r"\n\s*\n|\n", text or ""):
112
+ p = p.strip()
113
+ if p and not p.startswith("#"):
114
+ out.append(p)
115
+ return out
116
+
117
+
118
+ def pacing_analyze(text):
119
+ """统计叙事节奏客观量。返回 {ok, paras, avg_len, longest, dialog_ratio,
120
+ open_hits, tail_hits, alerts, alert};文本过短时 ok=False。"""
121
+ text = text or ""
122
+ paras = _paras(text)
123
+ if len(text) < PACING_MIN_CHARS or not paras:
124
+ return {"ok": False}
125
+ lens = [len(p) for p in paras]
126
+ avg = round(sum(lens) * 1.0 / len(lens), 1)
127
+ longest = max(lens)
128
+ dialog = sum(1 for p in paras if DIALOG_RE.search(p))
129
+ ratio = round(dialog * 1.0 / len(paras), 3)
130
+ opening, tail = text[:OPENING_SCAN_CHARS], text[-PACING_TAIL_CHARS:]
131
+ open_hits = sum(1 for w in HOOK_WORDS if w in opening)
132
+ tail_hits = sum(1 for w in HOOK_WORDS if w in tail)
133
+ alerts = []
134
+ if avg >= PARA_AVG_ALERT:
135
+ alerts.append("段落偏长(均 %.0f 字),阅读有淤积感" % avg)
136
+ if longest >= PARA_LONG_CHARS:
137
+ alerts.append("存在超长段(最长 %d 字)" % longest)
138
+ if ratio < DIALOG_ALERT_RATIO:
139
+ alerts.append("对话段占比偏低(%.0f%%),易成通篇独白" % (ratio * 100))
140
+ if not tail_hits:
141
+ alerts.append("章末 200 字无悬念信号,缺钩子")
142
+ if not open_hits:
143
+ alerts.append("开篇 300 字无冲突信号,开头偏平")
144
+ return {"ok": True, "paras": len(paras), "avg_len": avg, "longest": longest,
145
+ "dialog_ratio": ratio, "open_hits": open_hits, "tail_hits": tail_hits,
146
+ "alerts": alerts, "alert": bool(alerts)}
147
+
148
+
149
+ def report_line(text, kind=None):
150
+ """生成注入评审提示词的报告行(措辞层 + 叙事架构层 + 节奏层);
151
+ 全部无命中返回空串。kind 为流程类型:只有叙事类流程才下发节奏检测
152
+ (邮件/汇报/翻译等非叙事文体统计段落节奏没有意义)。"""
86
153
  lines = []
87
154
  r = analyze(text)
88
155
  if r["hits"]:
@@ -102,4 +169,15 @@ def report_line(text):
102
169
  "请评审情节结构:主题是否被叙述者直接说破、情绪是否只有身体描写、"
103
170
  "结尾是否靠主角想通收束。")
104
171
  lines.append(line)
172
+ if kind in NARRATIVE_FLOWS:
173
+ pr = pacing_analyze(text)
174
+ if pr["ok"]:
175
+ line = ("- [节奏检测] 确定性统计:%d 段(均 %.0f 字 / 最长 %d 字),"
176
+ "含对话段 %.0f%%,开篇 300 字冲突信号 %d 处,章末 200 字悬念信号 %d 处。"
177
+ % (pr["paras"], pr["avg_len"], pr["longest"],
178
+ pr["dialog_ratio"] * 100, pr["open_hits"], pr["tail_hits"]))
179
+ if pr["alert"]:
180
+ line += ("提示:%s。连载留存取决于每章的钩子与段落节奏,"
181
+ "请评审官结合剧情判断是否真问题。" % "、".join(pr["alerts"]))
182
+ lines.append(line)
105
183
  return "\n".join(lines)
@@ -10,8 +10,13 @@ store.create_run → jobs.enqueue)拉起一次真实运行,不自己造运
10
10
  任务模型:
11
11
  {id, name, prompt, workdir, kind: daily|interval|weekly|once, time: "HH:MM",
12
12
  interval_hours, weekday(0-6 周一=0), run_at(once 用, ISO), flow(编排流程 id),
13
+ mode(编排模式), thinking(思考程度), direct_provider_id/direct_model(仅 direct 流程),
13
14
  enabled, created_at, last_run, next_run, run_count, last_status}
14
15
 
16
+ 运行偏好(mode/thinking/对话模型)与新建任务 Composer 的三颗胶囊同一套取值,
17
+ 到点拉起时原样透传给 store.create_task——定时任务不是另一条执行链,用户在这里
18
+ 选的编排强度与模型偏好必须与手动建任务完全等价。
19
+
15
20
  重启语义:错过的 once 不补跑(启动恢复时直接停用);daily/weekly/interval
16
21
  重算 next_run 到下一个未来时刻即可,不追赶停机期间错过的周期。
17
22
 
@@ -48,17 +53,24 @@ KINDS = ("daily", "interval", "weekly", "once")
48
53
  INTERVAL_MIN, INTERVAL_MAX = 1, 720 # interval_hours 合法区间(小时)
49
54
  DEFAULT_FLOW = "doc" # 未指定编排流程时的兜底:review 引擎产出文档,适配巡检/整理类提示词
50
55
 
56
+ # 运行偏好取值(必须与 store.create_task 的口径一致,否则到点拉起时被静默改档)
57
+ MODES = ("auto", "fast", "expert", "manual")
58
+ THINKINGS = ("auto", "low", "standard", "high")
59
+
51
60
  _TIME_RE = re.compile(r"\s*(\d{1,2}):(\d{1,2})\s*")
52
61
 
53
62
  # 字段缺省(加载历史文件/脏数据时兜底;enabled 兜底为 False,绝不意外触发)
54
63
  _DEFAULTS = {"id": "", "name": "", "prompt": "", "workdir": "", "kind": "", "time": "",
55
64
  "interval_hours": 0, "weekday": -1, "run_at": "", "flow": "",
65
+ "mode": "auto", "thinking": "standard",
66
+ "direct_provider_id": "", "direct_model": "",
56
67
  "enabled": False, "created_at": "", "last_run": "", "next_run": "",
57
68
  "run_count": 0, "last_status": ""}
58
69
 
59
70
  # 允许通过 update() 修改的字段(id/created_at/run_count 等运行痕迹不可改)
60
71
  _UPDATABLE = ("name", "prompt", "workdir", "kind", "time", "interval_hours",
61
- "weekday", "run_at", "flow", "enabled")
72
+ "weekday", "run_at", "flow", "mode", "thinking",
73
+ "direct_provider_id", "direct_model", "enabled")
62
74
 
63
75
 
64
76
  # ---------------------------------------------------------------- 时间工具
@@ -206,6 +218,37 @@ def _apply_schedule(t, check_flow=True):
206
218
  t["flow"] = DEFAULT_FLOW
207
219
 
208
220
 
221
+ def _flow_engine(flow_id):
222
+ """流程引擎名(查不到返回空串)。"""
223
+ from . import flows as flows_mod
224
+ f = flows_mod.get_flow(str(flow_id or "").strip())
225
+ return (f or {}).get("engine") or ""
226
+
227
+
228
+ def _apply_run_prefs(t):
229
+ """校验并归一运行偏好(编排模式/思考程度/对话模型),就地改写 t。必须在
230
+ _apply_schedule 之后调用——只有那里才把 flow 归一成最终值。
231
+
232
+ mode/thinking 非法值一律收敛到默认(auto/standard),不抛异常:这两个是
233
+ 「偏好」不是「配置」,脏值不该把保存卡死。对话模型只在流程引擎为 direct
234
+ 时有意义:非 direct 流程一律清空,避免换流程后残留的模型绑定在下一次
235
+ 触发时静默生效(用户看不到却在生效的配置,比没有更糟)。direct 流程下
236
+ 「有模型没厂商」则明确报错——猜一个厂商等于把模型请求打到别家。
237
+ """
238
+ mode = str(t.get("mode") or "").strip().lower()
239
+ t["mode"] = mode if mode in MODES else "auto"
240
+ thinking = str(t.get("thinking") or "").strip().lower()
241
+ t["thinking"] = thinking if thinking in THINKINGS else "standard"
242
+ pid = str(t.get("direct_provider_id") or "").strip()[:80]
243
+ model = str(t.get("direct_model") or "").strip()[:160]
244
+ if _flow_engine(t.get("flow")) != "direct":
245
+ t["direct_provider_id"], t["direct_model"] = "", ""
246
+ return
247
+ if model and not pid:
248
+ raise ValueError("指定对话模型时必须同时选择厂商")
249
+ t["direct_provider_id"], t["direct_model"] = pid, model
250
+
251
+
209
252
  # ---------------------------------------------------------------- 持久化
210
253
 
211
254
  def _list_locked():
@@ -298,7 +341,15 @@ def _launch_run(t):
298
341
  "title": ("%s %s" % (t.get("name") or "定时任务",
299
342
  time.strftime("%m-%d %H:%M"))).strip()[:40],
300
343
  "goal": t.get("prompt") or "",
301
- "workdir": (t.get("workdir") or "").strip()}
344
+ "workdir": (t.get("workdir") or "").strip(),
345
+ # 运行偏好与 Composer 同一套字段:create_task 里已支持,透传即可
346
+ "mode": t.get("mode") or "auto",
347
+ "thinking": t.get("thinking") or "standard"}
348
+ # direct 流程才有对话模型;非 direct 流程这两个键连传都不传(避免碰 store
349
+ # 里「有模型没厂商就报错」的校验,也避免给别的引擎塞无意义字段)
350
+ if t.get("direct_provider_id"):
351
+ payload["direct_provider_id"] = t["direct_provider_id"]
352
+ payload["direct_model"] = t.get("direct_model") or ""
302
353
  task = None
303
354
  run = None
304
355
  try:
@@ -470,6 +521,7 @@ def create(payload):
470
521
  t["kind"] = payload.get("kind") or "daily"
471
522
  _validate_core(t)
472
523
  _apply_schedule(t)
524
+ _apply_run_prefs(t) # 依赖 _apply_schedule 归一后的 flow 判引擎
473
525
  t["next_run"] = compute_next_run(t)
474
526
  with _LOCK:
475
527
  _TASKS[t["id"]] = t
@@ -492,6 +544,7 @@ def update(tid, patch):
492
544
  merged[k] = patch[k]
493
545
  _validate_core(merged, check_workdir=("workdir" in patch))
494
546
  _apply_schedule(merged, check_flow=("flow" in patch))
547
+ _apply_run_prefs(merged) # 改了 flow 就重判:非 direct 流程清掉残留模型
495
548
  merged["next_run"] = compute_next_run(merged)
496
549
  _TASKS[tid] = merged
497
550
  _save_locked()
@@ -1807,45 +1807,11 @@ def _plot_modules(workdir):
1807
1807
  "鼓励化用,不要照抄原句)\n\n" + txt)
1808
1808
 
1809
1809
 
1810
- def _chain_volume_names(task):
1811
- """沿 serial.continues 链收集各批次大纲里的卷名/卷弧光(旧 → 新,后者覆盖)。
1812
- 续写批次合并成书时要给全书(含此前各批)的卷都写上标题,那些卷的命名记在
1813
- 更早的批次任务上,本批 run 的 outline 里没有。失败静默返回 {}。"""
1814
- from . import store as _store
1815
- names, seen, cur = {}, set(), task
1816
- chain = []
1817
- while cur and len(chain) < 20:
1818
- tid = str(cur.get("id") or "")
1819
- if not tid or tid in seen:
1820
- break
1821
- seen.add(tid)
1822
- chain.append(cur)
1823
- nxt = str((cur.get("serial") or {}).get("continues") or "")
1824
- cur = _store.get_task(nxt) if nxt else None
1825
- for t in reversed(chain): # 旧 → 新:新批次的命名覆盖旧批次
1826
- try:
1827
- for r in _store.task_runs(t["id"]):
1828
- o = r.get("outline") or {}
1829
- if isinstance(o.get("volumes"), dict):
1830
- names.update(o["volumes"])
1831
- except Exception:
1832
- continue
1833
- return names
1834
-
1835
-
1836
1810
  def _book_volume_plan(task, outline, upto):
1837
- """本书的卷规划表:显式卷表/每卷章数(serial)→ 规划表 → 合并卷名
1838
- (本批大纲 + 沿链更早批次)→ 保证覆盖到第 upto 章。
1839
- 不分卷返回 [];调用方据此决定是否插卷标题/卷末约束。"""
1840
- serial = task.get("serial") or {}
1841
- per = volumes.norm_per(serial.get("volume_chapters"))
1842
- plan = volumes.build_plan(serial.get("volumes"), per, upto=upto)
1843
- if not plan:
1844
- return []
1845
- named = dict(_chain_volume_names(task))
1846
- if isinstance(outline, dict):
1847
- named.update(outline.get("volumes") or {})
1848
- return volumes.merge_titles(plan, named)
1811
+ """本书的卷规划表(卷边界+卷名+卷弧光)。实现见 planner.book_volume_plan
1812
+ ——放在 planner 是因为它要沿 serial.continues 链读历史 run 的卷名,属
1813
+ 「大纲/命名」职责;此处保留薄封装便于连载流程阅读。"""
1814
+ return planner.book_volume_plan(task, outline, upto)
1849
1815
 
1850
1816
 
1851
1817
  LEDGER_FILE = os.path.join(".codebee", "resource-ledger.md")
@@ -2119,6 +2085,20 @@ def _weakest_chapters(chapter_scores, global_means, threshold, limit=2):
2119
2085
  return cand[:limit]
2120
2086
 
2121
2087
 
2088
+ def _inject_detection(tpl, text, task):
2089
+ """把确定性检测行(AI 味/叙事架构/节奏)插进评审提示词。
2090
+
2091
+ 命中才插,位置固定为「## 待评审稿件」之前——与技能包/圣经/知识库注入
2092
+ 同一位置,保持前缀稳定(§07 T1.2' 前缀缓存)。单稿件评审与连载逐章
2093
+ 评审共用本函数:此前连载逐章完全没有这条参考线(旗舰场景漏挂)。
2094
+ """
2095
+ det = aiflavor.report_line(text, task.get("type"))
2096
+ if not det:
2097
+ return tpl
2098
+ block = "## 确定性检测结果(供评审参考)\n%s\n\n" % det
2099
+ return tpl.replace("## 待评审稿件", block + "## 待评审稿件", 1)
2100
+
2101
+
2122
2102
  def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route, resume_ctx, difficulty):
2123
2103
  """连载流水线:大纲 → 逐章起草/评审/修订 → 全局一致性评审 → 合并成书。"""
2124
2104
  import json as _json
@@ -2194,6 +2174,10 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2194
2174
  kb = knowledge.block_for(task)
2195
2175
  if kb:
2196
2176
  tpl = tpl.replace("## 待评审稿件", "%s\n\n## 待评审稿件" % kb, 1)
2177
+ # 确定性检测(AI 味/叙事架构/节奏)此前只挂在单稿件评审上,连载逐章
2178
+ # 评审从未拿到——连载是我们的旗舰场景,逐章节奏与钩子恰恰最需要这条
2179
+ # 参考线。与单稿件一致:命中才追加,只作参考不直接扣分。
2180
+ tpl = _inject_detection(tpl, text, task)
2197
2181
  return tpl.replace("__DIMKEYS__", dimkey).replace(
2198
2182
  "__MANUSCRIPT__", text or "(稿件为空!)")
2199
2183
 
@@ -3375,10 +3359,8 @@ def _run_content_review(run, task, agents, ev, stats, mode):
3375
3359
  if task.get("context"):
3376
3360
  crit_prompt += "\n\n## 原始任务背景与附件参考\n" + task["context"]
3377
3361
  # AI 味确定性检测(借鉴 oh-story 去AI味):客观参考线随评审下发,
3378
- # 命中才追加——评审官结合上下文判断是否真问题,脚本不直接扣分
3379
- _aiflavor_line = aiflavor.report_line(manuscript)
3380
- if _aiflavor_line:
3381
- crit_prompt += "\n\n## 确定性检测结果(供评审参考)\n" + _aiflavor_line
3362
+ # 命中才追加——评审官结合上下文判断是否真问题,脚本不直接扣分。
3363
+ crit_prompt = _inject_detection(crit_prompt, manuscript, task)
3382
3364
  for agent in critics:
3383
3365
  role = "critique-r%d" % r
3384
3366
  if agent.get("mode") == "mock":
@@ -298,6 +298,51 @@ def _prev_serial_story(task):
298
298
  return "\n".join(lines), max(best_i, 0), book_title, tail, volnames
299
299
 
300
300
 
301
+ def _chain_volume_names(task):
302
+ """沿 serial.continues 链收集各批次大纲里的卷名/卷弧光(旧 → 新,后者覆盖)。
303
+ 续写批次合并成书时要给全书(含此前各批)的卷都写上标题,那些卷的命名记在
304
+ 更早的批次任务上,本批 run 的 outline 里没有。失败静默返回 {}。"""
305
+ from . import store # 惰性导入:与 _prev_serial_story 同一理由
306
+ names, seen, cur = {}, set(), task
307
+ chain = []
308
+ while cur and len(chain) < 20:
309
+ tid = str(cur.get("id") or "")
310
+ if not tid or tid in seen:
311
+ break
312
+ seen.add(tid)
313
+ chain.append(cur)
314
+ nxt = str((cur.get("serial") or {}).get("continues") or "")
315
+ cur = store.get_task(nxt) if nxt else None
316
+ for t in reversed(chain): # 旧 → 新:新批次的命名覆盖旧批次
317
+ try:
318
+ for r in store.task_runs(t["id"]):
319
+ o = r.get("outline") or {}
320
+ if isinstance(o.get("volumes"), dict):
321
+ names.update(o["volumes"])
322
+ except Exception:
323
+ continue
324
+ return names
325
+
326
+
327
+ def book_volume_plan(task, outline, upto):
328
+ """本书的卷规划表:显式卷表/每卷章数(serial)→ 规划表 → 合并卷名
329
+ (本批大纲 + 沿链更早批次)→ 保证覆盖到第 upto 章。
330
+
331
+ 卷边界由 volumes.build_plan 从章号确定性推导,因此同一份卷配置在任何
332
+ 批次都给出同一张表(续写不会重切卷);模型/用户只提供卷名与卷弧光。
333
+ 不分卷返回 []——调用方据此决定是否插卷标题、卷末约束。"""
334
+ serial = task.get("serial") or {}
335
+ plan = volumes.build_plan(serial.get("volumes"),
336
+ volumes.norm_per(serial.get("volume_chapters")),
337
+ upto=upto)
338
+ if not plan:
339
+ return []
340
+ named = dict(_chain_volume_names(task))
341
+ if isinstance(outline, dict):
342
+ named.update(outline.get("volumes") or {})
343
+ return volumes.merge_titles(plan, named)
344
+
345
+
301
346
  def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path=None):
302
347
  """连载大纲:编排者 API 优先 → 作者 CLI → 模板。返回 {book_title, chapters:[{title,beats,hook}]}。
303
348
 
package/app/core/store.py CHANGED
@@ -218,11 +218,10 @@ def create_task(payload):
218
218
  # 不会因分批而错位。
219
219
  vol_spec = volumes.norm_spec(serial.get("volumes"))
220
220
  if not vol_spec:
221
- # 表单没填卷表时,从目标/上下文文字里识别用户手写的
222
- # 「第一卷 少年初入江湖 第1-20章」式分卷信息(保守识别,
223
- # 认不出就返回空,绝不会把普通文字误判成分卷)。
224
- vol_spec = volumes.parse_spec_text(goal) \
225
- or volumes.parse_spec_text(serial.get("volumes_text")) \
221
+ # 否则从文字里识别:表单「指定卷结构」框 > 任务目标 > 上下文。
222
+ # 保守识别——认不出返回空,绝不把普通文字误判成分卷。
223
+ vol_spec = volumes.parse_spec_text(serial.get("volumes_text")) \
224
+ or volumes.parse_spec_text(goal) \
226
225
  or volumes.parse_spec_text(task.get("context") or "")
227
226
  if vol_spec:
228
227
  s["volumes"] = vol_spec
@@ -169,8 +169,10 @@ def parse_spec_text(text):
169
169
  「第一卷 少年初入江湖 第1-20章;卷二 风云再起 21-40章」)。
170
170
 
171
171
  保守策略:必须出现 ≥1 个卷标记(第X卷/卷X/第X部/第X篇)才认为用户在
172
- 分卷,且除末卷外每个卷段都要有章号范围或章数(末卷可省 = 开放到书末)。
173
- 识别不到就返回 [],绝不猜——误判会把普通文字里的「第一卷」当成分卷要求。
172
+ 分卷;且**每个卷段都必须带边界**(章号范围 / 章数 / 「N章起」开放标记)。
173
+ 缺边界的段一律判为「不是分卷信息」——「第一卷要吸引人」这类句子里的
174
+ 「第一卷」是修辞不是结构,绝不能据此把全书切成一个卷。正常写法都带边界
175
+ (「第一卷 少年初入江湖 20章」「第1-20章」「21章起」),因此不受影响。
174
176
 
175
177
  切段方式:按**卷标记出现位置**切(而非按行/分号),这样「卷一 甲 20章,
176
178
  卷二 乙 25章」这种同一行多卷也能正确拆开。"""
@@ -191,7 +193,7 @@ def parse_spec_text(text):
191
193
  end = marks[i + 1].start() if i + 1 < len(marks) else len(text)
192
194
  segs.append((text[m.start():end], key))
193
195
  out = []
194
- for idx, (seg, _num) in enumerate(segs):
196
+ for seg, _num in segs:
195
197
  m = _VOL_RE.match(seg) # seg 以卷标记起头,相对位置 0
196
198
  if not m:
197
199
  continue
@@ -208,8 +210,8 @@ def parse_spec_text(text):
208
210
  m2 = _COUNT_RE.search(body) or _COUNT_RE.search(seg[:m.start()])
209
211
  chapters = _to_int(m2.group(1)) if m2 else None
210
212
  start, end = None, None
211
- if idx < len(segs) - 1 and not (chapters or (start and end)):
212
- # 非末卷却没有边界:整段解析不可信,放弃(避免半数卷无边界的错切)
213
+ if not (chapters or (start and end) or (op and start)):
214
+ # 该卷段没有可用的边界:整段判为不是分卷信息(见 docstring)
213
215
  return []
214
216
  out.append({"title": _clean_title(seg, m), "start": start,
215
217
  "end": end, "chapters": chapters})
package/app/ui/app.js CHANGED
@@ -10,7 +10,7 @@ function codebeeDocumentTitle(lang) {
10
10
  document.title = codebeeDocumentTitle((localStorage.getItem("orch.lang") || "zh").toLowerCase());
11
11
 
12
12
  const $ = (id) => document.getElementById(id);
13
- const S = { state: null, catalog: null, catSig: "", providers: null, bindings: null, modelsSig: "", bindSig: "", tab: "tasks", detailRunId: null, pollTimer: null, showArchived: false, /* 会话内开关:每次加载默认隐藏已归档、图标不选中(不持久化,见 btn-side-arch) */ selProvs: {}, selModels: {}, selRuns: {}, bindSel: {}, catalogChecking: false, updateCheckAt: 0, control: null, sseLive: false, es: null, flows: null, orch: null, skills: null, orchSig: "", settings: null, sessionAgents: new Set(), atts: [], gitInfo: null, gitWb: null, gitWbKey: "", gitWbAt: 0, gitWbBusy: false, creatingTask: false, inspKey: null, inspData: null, inspSig: "", inspAt: 0, inspTab: "git", inspAutoSig: "", rdTab: null, rdTabSig: "", rdTabPin: false, _rdCtx: {}, lastPrefs: null, prefsApplied: false };
13
+ const S = { state: null, catalog: null, catSig: "", providers: null, bindings: null, modelsSig: "", bindSig: "", tab: "tasks", mainPage: "", /* 主栏正显示的全局页("" = 新建任务表单);左栏是任务树还是设置导航看 body.settings-mode */ detailRunId: null, pollTimer: null, showArchived: false, /* 会话内开关:每次加载默认隐藏已归档、图标不选中(不持久化,见 btn-side-arch) */ selProvs: {}, selModels: {}, selRuns: {}, bindSel: {}, catalogChecking: false, updateCheckAt: 0, control: null, sseLive: false, es: null, flows: null, orch: null, skills: null, orchSig: "", settings: null, sessionAgents: new Set(), atts: [], gitInfo: null, gitWb: null, gitWbKey: "", gitWbAt: 0, gitWbBusy: false, creatingTask: false, inspKey: null, inspData: null, inspSig: "", inspAt: 0, inspTab: "git", inspAutoSig: "", rdTab: null, rdTabSig: "", rdTabPin: false, _rdCtx: {}, lastPrefs: null, prefsApplied: false };
14
14
 
15
15
  /* ---------------------------------------------------------- 任务类型(流程) */
16
16
  async function loadFlows() {
@@ -301,7 +301,8 @@ function onTypeChange() {
301
301
  const bibleCreate = $("bible-create-field");
302
302
  const isSerialReview = isReview && !!(flow && flow.serial);
303
303
  if (bibleCreate) bibleCreate.classList.toggle("hidden", !isSerialReview);
304
- const clearSerialFields = () => ["f-chapters", "f-words-per-ch", "f-variants", "f-bible"].forEach((id) => {
304
+ const clearSerialFields = () => ["f-chapters", "f-words-per-ch", "f-variants", "f-bible",
305
+ "f-vol-chapters", "f-vol-spec"].forEach((id) => {
305
306
  const el = $(id);
306
307
  if (el) el.value = "";
307
308
  });
@@ -330,6 +331,12 @@ function onTypeChange() {
330
331
  if (flow.serial) {
331
332
  if (!$("f-chapters").value) $("f-chapters").value = lp.chapters || flow.serial.chapters || "";
332
333
  if (!$("f-words-per-ch").value) $("f-words-per-ch").value = lp.words_per_chapter || flow.serial.words_per_chapter || "";
334
+ // 分卷开箱即用:没填过就预填一个常见每卷章数(用户可改;清空 = 不分卷)。
335
+ // 卷边界由后端按全书章号推导,续写批次自动接上同一卷。
336
+ if (!$("f-vol-chapters").value && !$("f-vol-spec").value) {
337
+ $("f-vol-chapters").value = (lp.volume_chapters != null && lp.volume_chapters !== "")
338
+ ? lp.volume_chapters : (flow.serial.volume_chapters || 20);
339
+ }
333
340
  } else clearSerialFields();
334
341
  } else {
335
342
  // 隐藏控件仍保留 DOM 值;换流程时清理,避免章节/圣经状态污染下一次提交。
@@ -2465,6 +2472,12 @@ async function createTask() {
2465
2472
  words_per_chapter: parseInt($("f-words-per-ch").value, 10) || 2500,
2466
2473
  variants: Math.max(1, Math.min(3, parseInt($("f-variants").value, 10) || 1)),
2467
2474
  };
2475
+ // 分卷:显式卷结构文本优先(后端解析成卷表),否则用每卷章数。
2476
+ // 两个都留空 = 不分卷(后端收到 0/缺字段即不分卷)。
2477
+ const volSpec = ($("f-vol-spec") || {}).value ? $("f-vol-spec").value.trim() : "";
2478
+ const volPer = parseInt(($("f-vol-chapters") || {}).value, 10);
2479
+ if (volSpec) payload.serial.volumes_text = volSpec;
2480
+ else if (volPer >= 2) payload.serial.volume_chapters = volPer;
2468
2481
  } else if (flow.serial) {
2469
2482
  // 显式 null 覆盖 serial_novel 的流程默认,空章节就是单稿件。
2470
2483
  payload.serial = null;
@@ -3139,6 +3152,16 @@ function newFromTask(id) {
3139
3152
  $("f-chapters").value = tk.serial ? tk.serial.chapters : "";
3140
3153
  $("f-words-per-ch").value = tk.serial ? tk.serial.words_per_chapter : "";
3141
3154
  $("f-variants").value = tk.serial && tk.serial.variants ? tk.serial.variants : "";
3155
+ // 分卷:还原每卷章数;指定卷结构还原成可读文本(卷名 + 章数/章号范围)
3156
+ $("f-vol-chapters").value = tk.serial && tk.serial.volume_chapters ? tk.serial.volume_chapters : "";
3157
+ $("f-vol-spec").value = tk.serial && Array.isArray(tk.serial.volumes)
3158
+ ? tk.serial.volumes.map((v) => {
3159
+ const ttl = (v && v.title) || "未命名";
3160
+ if (v && v.start && v.end) return ttl + " 第" + v.start + "-" + v.end + "章";
3161
+ if (v && v.chapters) return ttl + " " + v.chapters + "章";
3162
+ return ttl;
3163
+ }).join(";")
3164
+ : "";
3142
3165
  const want = new Set(tk.critics || []);
3143
3166
  $("f-critics").querySelectorAll("input").forEach((i) => { i.checked = want.has(i.value); });
3144
3167
  // 同目录同稿件名再开一篇会覆盖旧产出——提醒但不阻止(有意重写也合理)
@@ -3644,7 +3667,7 @@ function showDetailInMain() {
3644
3667
  function jumpToRun(id) {
3645
3668
  S.focusStep = 0;
3646
3669
  S.focusDone = false;
3647
- if (document.body.classList.contains("settings-mode")) switchTab("runs");
3670
+ if (document.body.classList.contains("settings-mode")) switchTab("runs", "settings"); // 已在设置里:留在设置侧栏
3648
3671
  else showDetailInMain();
3649
3672
  openRun(id);
3650
3673
  }
@@ -3992,12 +4015,17 @@ function closeRun() {
3992
4015
  detailSideReset();
3993
4016
  $("run-detail").classList.add("hidden");
3994
4017
  renderChatNav(); // 解除 main.chat-fill(对话为主的固定高度),恢复外层滚动
3995
- if (document.body.classList.contains("settings-mode")) {
4018
+ if (document.body.classList.contains("settings-mode") || S.mainPage === "runs") {
4019
+ // 回运行列表:设置导航里点进来的,或主栏本就停在运行记录页(快捷导航/概览的入口)
3996
4020
  document.querySelector("#sub-runs .panel:first-child").classList.remove("hidden");
4021
+ } else if (S.mainPage) {
4022
+ // 主栏停在别的快捷导航页(概览/自动化):返回点开详情前那一页,左栏任务树不动
4023
+ switchTab(S.mainPage, "main");
3997
4024
  } else {
3998
4025
  // 从主视图(侧栏点任务行)进来的详情:返回直接回任务页,不露出运行列表
3999
4026
  document.querySelectorAll("#page-settings .subpage").forEach((d) => d.classList.toggle("hidden", d.id !== "sub-tasks"));
4000
4027
  document.querySelectorAll(".set-item").forEach((b) => b.classList.toggle("active", b.dataset.sub === "tasks"));
4028
+ document.querySelectorAll(".qitem").forEach((b) => b.classList.remove("active"));
4001
4029
  const title = $("page-title");
4002
4030
  if (title) title.textContent = tabTitle("tasks");
4003
4031
  }
@@ -4726,8 +4754,11 @@ window.pbUploadChapter = async function (taskId, platform) {
4726
4754
  if (!box.classList.contains("hidden")) { box.classList.add("hidden"); return; }
4727
4755
  box.classList.remove("hidden");
4728
4756
  box.innerHTML = '<div class="pb-loading">' + esc(t("正在列出章节文件…")) + "</div>";
4729
- let wd = "";
4730
- try { const tk = ((S.state || {}).tasks || []).find((x) => x.id === taskId); wd = (tk && tk.workdir) || ""; } catch (e) { /* 兜底空 */ }
4757
+ let wd = "", pubTask = null;
4758
+ try {
4759
+ pubTask = ((S.state || {}).tasks || []).find((x) => x.id === taskId);
4760
+ wd = (pubTask && pubTask.workdir) || "";
4761
+ } catch (e) { /* 兜底空 */ }
4731
4762
  if (!wd) { box.innerHTML = '<div class="pb-err">' + esc(t("找不到任务工作目录")) + "</div>"; return; }
4732
4763
  let files = [];
4733
4764
  try {
@@ -4740,11 +4771,33 @@ window.pbUploadChapter = async function (taskId, platform) {
4740
4771
  }
4741
4772
  if (!files.length) { box.innerHTML = '<div class="pb-err">' + esc(t("工作目录里没有 .md 章稿")) + "</div>"; return; }
4742
4773
  files.sort((a, b) => (a.name > b.name ? 1 : -1));
4743
- box.innerHTML = '<div class="pb-hint">' + esc(t("选一章发送(表单填好后停,人工确认提交);已成功发过的章会被台账拦下:")) + "</div>" +
4744
- files.map((f) =>
4745
- '<button class="ghost pb-ch-item" onclick="pbSendChapter(\'' + esc(taskId) + "', '" + platform +
4746
- '\',\'' + esc(f.name) + '\')" title="' + esc(f.name) + '">' + esc(f.name.split("/").pop()) + "</button>"
4747
- ).join("");
4774
+ const item = (f) =>
4775
+ '<button class="ghost pb-ch-item" onclick="pbSendChapter(\'' + esc(taskId) + "', '" + platform +
4776
+ '\',\'' + esc(f.name) + '\')" title="' + esc(f.name) + '">' + esc(f.name.split("/").pop()) + "</button>";
4777
+ const hint = '<div class="pb-hint">' + esc(t("选一章发送(表单填好后停,人工确认提交);已成功发过的章会被台账拦下:")) + "</div>";
4778
+ // 最高章号:让卷规划表铺到最后一章(否则超出显式卷表的章节会掉进「其他」)
4779
+ let maxCh = 0;
4780
+ files.forEach((f) => {
4781
+ const n = chapterNumOf(f.name);
4782
+ if (n > maxCh) maxCh = n;
4783
+ });
4784
+ const plan = volumePlanOf(pubTask, maxCh);
4785
+ if (!plan.length) { box.innerHTML = hint + files.map(item).join(""); return; }
4786
+ // 分卷:按卷分组列出可发章节(章号取自 chapter-NN.md)。未落入卷的文件归「其他」。
4787
+ const groups = plan.map((v) => ({ v: v, files: [] }));
4788
+ const rest = [];
4789
+ files.forEach((f) => {
4790
+ const n = chapterNumOf(f.name);
4791
+ if (!n) { rest.push(f); return; }
4792
+ const g = groups.find((x) => n >= x.v.first && (x.v.last == null || n <= x.v.last));
4793
+ if (g) g.files.push(f); else rest.push(f);
4794
+ });
4795
+ box.innerHTML = hint + groups.filter((g) => g.files.length).map((g) => {
4796
+ const label = t("第") + " " + g.v.vol + " " + t("卷") + (g.v.title ? " 《" + g.v.title + "》" : "");
4797
+ return '<div class="pb-vol"><div class="pb-vol-head">' + esc(label) + "</div>" +
4798
+ g.files.map(item).join("") + "</div>";
4799
+ }).join("") + (rest.length ? '<div class="pb-vol"><div class="pb-vol-head">' +
4800
+ esc(t("其他文件")) + "</div>" + rest.map(item).join("") + "</div>" : "");
4748
4801
  };
4749
4802
 
4750
4803
  window.pbSendChapter = async function (taskId, platform, relName) {
@@ -5232,7 +5285,7 @@ window.toggleInspector = function () {
5232
5285
  openInspector(key);
5233
5286
  };
5234
5287
 
5235
- /* 检查器只属于「列表快捷预览」上下文:设置子页、新建表单、运行/任务详情一律收起。
5288
+ /* 检查器只属于「列表快捷预览」上下文:设置子页、快捷导航页、新建表单、运行/任务详情一律收起。
5236
5289
  * 详情页已铺开全部信息(蜂巢/步骤/成果/版本/圣经/指挥),检查器留着只会同屏
5237
5290
  * 出现两份标题/成品/Git——让它让位,但不丢选中,回到列表上下文自动滑回 */
5238
5291
  function syncInspectorVis() {
@@ -5244,11 +5297,12 @@ function syncInspectorVis() {
5244
5297
  // 主视图下正在看「要完成什么?」新建表单:不是任务上下文,不自动滑出。
5245
5298
  // (本函数只在模式切换时调用,不在轮询里——用户在表单页手动点树里的任务,
5246
5299
  // openInspector 直接开,不会被这里关掉)
5247
- const onComposer = !document.body.classList.contains("settings-mode") &&
5300
+ const onComposer = !S.mainPage && !document.body.classList.contains("settings-mode") &&
5248
5301
  !$("sub-tasks").classList.contains("hidden");
5249
5302
  // 主栏正开着运行/任务详情:详情页是全功能视图,检查器同屏只会重复
5250
5303
  const onDetail = !$("run-detail").classList.contains("hidden");
5251
- if (document.body.classList.contains("settings-mode") || onComposer || onDetail ||
5304
+ // 主栏停在快捷导航页(概览/运行记录/自动化):同设置子页,检查器让位不抢版面
5305
+ if (document.body.classList.contains("settings-mode") || S.mainPage || onComposer || onDetail ||
5252
5306
  !S.inspKey || runsGone) {
5253
5307
  document.body.classList.remove("inspector-open");
5254
5308
  insp.classList.add("hidden");
@@ -5854,36 +5908,117 @@ function stepsMatch(runs, current) {
5854
5908
  (r.steps || []).some((s) => s.log === current.rel));
5855
5909
  }
5856
5910
 
5911
+ /* 从章稿文件名取全书章号:chapter-07.md → 7;非章稿(或解析不出)返回 0。
5912
+ * 只认「chapter-数字.md」这一约定(与后端 _CH_FILE_RE 一致)。 */
5913
+ function chapterNumOf(name) {
5914
+ const base = String(name || "").split("/").pop();
5915
+ if (!/^chapter-\d{1,4}\.md$/i.test(base)) return 0;
5916
+ const digits = base.slice("chapter-".length, -3);
5917
+ const n = parseInt(digits, 10);
5918
+ return n > 0 ? n : 0;
5919
+ }
5920
+
5921
+ /* 分卷规划表(前端只读副本):把任务的 serial 分卷配置还原成
5922
+ * [{vol, title, first, last}],供发布页按卷分组列章。后端 volumes.py 是唯一
5923
+ * 真源(写作与成书都走它);这里只做「列清单」这一用途的最小推导,取不到
5924
+ * 分卷配置时返回 [](调用方退化为平铺列表)。
5925
+ * serial.volumes 显式卷表:[{title, chapters|start/end}](优先)
5926
+ * serial.volume_chapters 每卷章数:等长切卷(兜底)
5927
+ * last=null 表示开放到书末。 */
5928
+ function volumePlanOf(task, upto) {
5929
+ const s = (task || {}).serial || {};
5930
+ const spec = Array.isArray(s.volumes) ? s.volumes : [];
5931
+ let per = parseInt(s.volume_chapters, 10);
5932
+ if (!(per >= 2)) per = 0;
5933
+ if (!spec.length && !per) return [];
5934
+ const plan = [];
5935
+ let prevLast = 0;
5936
+ const push = (title, first, last) => plan.push({ vol: plan.length + 1, title: title || "", first: first, last: last });
5937
+ for (const it of spec) {
5938
+ if (!it) continue;
5939
+ const first = (it.start > 0) ? it.start : prevLast + 1;
5940
+ let last = null;
5941
+ if (it.end > 0) last = it.end;
5942
+ else if (it.chapters > 0) last = first + it.chapters - 1;
5943
+ if (last != null && last < first) last = first;
5944
+ push(it.title, first, last);
5945
+ if (last == null) return plan; // 开放卷:后面不再有卷
5946
+ prevLast = last;
5947
+ }
5948
+ // 显式卷表用尽后按每卷章数(或末卷长度)续卷,保证覆盖到 upto(与后端
5949
+ // volumes.build_plan 同规则)——否则第 21 章起会掉进「其他文件」分组。
5950
+ const fb = per || (plan.length && plan[plan.length - 1].last != null
5951
+ ? plan[plan.length - 1].last - plan[plan.length - 1].first + 1 : 0);
5952
+ if (fb > 0) {
5953
+ let cursor = prevLast + 1;
5954
+ const limit = (upto > 0) ? upto : (plan.length ? prevLast : fb);
5955
+ while (cursor <= limit && plan.length < 200) {
5956
+ push("", cursor, cursor + fb - 1);
5957
+ cursor += fb;
5958
+ }
5959
+ }
5960
+ return plan;
5961
+ }
5962
+
5963
+
5857
5964
  /* 连载章节评审卡:每章均分/是否达标/字数 + 逐项目标审稿结果(event_check,
5858
5965
  * 借鉴 AI-Novel-Writer:大纲要点逐项判定+正文证据)。非连载 run 无
5859
- * chapter_scores 保持隐藏。 */
5966
+ * chapter_scores 保持隐藏。
5967
+ * 分卷:章节卡按卷分组显示(卷名来自 run.volumes / chapter_scores[].vol_title),
5968
+ * 不分卷时退化为原来的平铺列表,视觉零变化。 */
5860
5969
  function renderChapterScores(run) {
5861
5970
  const box = $("rd-chapters");
5862
5971
  if (!box) return;
5863
5972
  const cs = run && run.chapter_scores;
5864
5973
  if (!Array.isArray(cs) || !cs.length) { box.classList.add("hidden"); return; }
5865
5974
  box.classList.remove("hidden");
5866
- box.innerHTML = '<h3 class="sec-title">' + t("章节评审") +
5867
- '<span class="tag">' + cs.filter((c) => c.passed).length + "/" + cs.length + " " + t("达标") + "</span></h3>" +
5868
- cs.map((c) => {
5869
- const means = c.means || {};
5870
- const dims = Object.keys(means).map((d) =>
5871
- '<span class="ch-dim' + (means[d] >= 7 ? "" : " low") + '">' + esc(d) + " " + means[d] + "</span>").join("");
5872
- const ev = (c.event_check || []).map((line) => {
5873
- const ok = /已完成/.test(line), bad = /未完成/.test(line);
5874
- return '<div class="ch-ev' + (ok ? " ok" : bad ? " bad" : "") + '">' + esc(line) + "</div>";
5875
- }).join("");
5876
- return '<div class="ch-row' + (c.passed ? "" : " fail") + '">' +
5877
- '<div class="ch-head"><b>' + esc(t("第") + " " + c.chapter + " " + t("章")) + '</b>' +
5878
- '<span class="ch-title">' + esc(c.title || "") + "</span>" +
5879
- '<span class="tag">' + (c.passed ? t("达标") : t("未达标")) + "</span>" +
5880
- (c.reused ? '<span class="tag">' + t("沿用") + "</span>" : "") +
5881
- '<span class="ch-meta">' + Number(c.words || 0) + t(" 字") +
5882
- (c.rounds > 1 ? " · " + c.rounds + t(" 轮") : "") + "</span></div>" +
5883
- (dims ? '<div class="ch-dims">' + dims + "</div>" : "") +
5884
- (ev ? '<div class="ch-evs"><span class="hint">' + t("逐项目标核对:") + "</span>" + ev + "</div>" : "") +
5885
- "</div>";
5975
+ // 卷名表:优先 run.volumes(全书卷规划),退到章节自带的 vol_title
5976
+ const volTitles = {};
5977
+ (run.volumes || []).forEach((v) => { volTitles[v.vol] = v.title || ""; });
5978
+ cs.forEach((c) => {
5979
+ if (c.vol && c.vol_title && !volTitles[c.vol]) volTitles[c.vol] = c.vol_title;
5980
+ });
5981
+ const rowHtml = (c) => {
5982
+ const means = c.means || {};
5983
+ const dims = Object.keys(means).map((d) =>
5984
+ '<span class="ch-dim' + (means[d] >= 7 ? "" : " low") + '">' + esc(d) + " " + means[d] + "</span>").join("");
5985
+ const ev = (c.event_check || []).map((line) => {
5986
+ const ok = /已完成/.test(line), bad = /未完成/.test(line);
5987
+ return '<div class="ch-ev' + (ok ? " ok" : bad ? " bad" : "") + '">' + esc(line) + "</div>";
5886
5988
  }).join("");
5989
+ return '<div class="ch-row' + (c.passed ? "" : " fail") + '">' +
5990
+ '<div class="ch-head"><b>' + esc(t("第") + " " + c.chapter + " " + t("章")) + '</b>' +
5991
+ '<span class="ch-title">' + esc(c.title || "") + "</span>" +
5992
+ '<span class="tag">' + (c.passed ? t("达标") : t("未达标")) + "</span>" +
5993
+ (c.reused ? '<span class="tag">' + t("沿用") + "</span>" : "") +
5994
+ '<span class="ch-meta">' + Number(c.words || 0) + t(" 字") +
5995
+ (c.rounds > 1 ? " · " + c.rounds + t(" 轮") : "") + "</span></div>" +
5996
+ (dims ? '<div class="ch-dims">' + dims + "</div>" : "") +
5997
+ (ev ? '<div class="ch-evs"><span class="hint">' + t("逐项目标核对:") + "</span>" + ev + "</div>" : "") +
5998
+ "</div>";
5999
+ };
6000
+ const head = '<h3 class="sec-title">' + t("章节评审") +
6001
+ '<span class="tag">' + cs.filter((c) => c.passed).length + "/" + cs.length + " " + t("达标") + "</span></h3>";
6002
+ const hasVol = cs.some((c) => c.vol);
6003
+ if (!hasVol) { box.innerHTML = head + cs.map(rowHtml).join(""); return; }
6004
+ // 按卷分组(章号顺序,卷内保持原序)
6005
+ const groups = [];
6006
+ cs.forEach((c) => {
6007
+ const v = c.vol || 0;
6008
+ let g = groups.find((x) => x.vol === v);
6009
+ if (!g) { g = { vol: v, rows: [] }; groups.push(g); }
6010
+ g.rows.push(c);
6011
+ });
6012
+ box.innerHTML = head + groups.map((g) => {
6013
+ const title = volTitles[g.vol] || "";
6014
+ const nPass = g.rows.filter((c) => c.passed).length;
6015
+ const label = g.vol ? (t("第") + " " + g.vol + " " + t("卷") + (title ? " 《" + title + "》" : "")) : t("未分卷");
6016
+ return '<div class="ch-vol"><div class="ch-vol-head"><b>' + esc(label) + "</b>" +
6017
+ '<span class="tag">' + nPass + "/" + g.rows.length + " " + t("达标") + "</span>" +
6018
+ '<span class="ch-meta">' + t("第 ") + g.rows[0].chapter + "–" +
6019
+ g.rows[g.rows.length - 1].chapter + t(" 章") + "</span></div>" +
6020
+ g.rows.map(rowHtml).join("") + "</div>";
6021
+ }).join("");
5887
6022
  }
5888
6023
 
5889
6024
  function renderPlan(run) {
@@ -8355,6 +8490,7 @@ function flowForm(fid) {
8355
8490
  '<div class="field"><label>' + t("连载章节数(留空 = 单稿件)") + '</label><input id="fl-chapters" type="number" min="2" max="20" value="' + (f && f.serial ? f.serial.chapters : "") + '" placeholder="' + t("例:8") + '"></div>' +
8356
8491
  '<div class="field"><label>' + t("每章约字数") + '</label><input id="fl-words-per-ch" type="number" min="500" max="8000" step="100" value="' + (f && f.serial ? f.serial.words_per_chapter : "") + '" placeholder="' + t("例:2500") + '"></div>' +
8357
8492
  '<div class="field"><label>' + t("同章赛马稿件数") + '</label><input id="fl-variants" type="number" min="1" max="3" step="1" value="' + (f && f.serial ? (f.serial.variants || 1) : 1) + '" placeholder="' + t("1 = 关闭") + '"></div>' +
8493
+ '<div class="field"><label>' + t("分卷:每卷章数(留空 = 不分卷)") + '</label><input id="fl-vol-chapters" type="number" min="2" max="200" step="1" value="' + (f && f.serial && f.serial.volume_chapters ? f.serial.volume_chapters : "") + '" placeholder="' + t("例:20") + '"></div>' +
8358
8494
  "</div>" +
8359
8495
  '<div class="field"><label>' + t("起草提示词(可选,占位符 __FILE__ __GOAL__ __CONTEXT__ __SKILLS__)") + '</label><textarea id="fl-draft" rows="3" placeholder="' + t("留空 = 内置通用模板") + '">' + esc(f && f.draft_prompt ? f.draft_prompt : "") + "</textarea></div>" +
8360
8496
  '<div class="field"><label>' + t("评审提示词(可选,占位符 __DIMKEYS__ __MANUSCRIPT__)") + '</label><textarea id="fl-critique" rows="3" placeholder="' + t("留空 = 内置通用模板") + '">' + esc(f && f.critique_prompt ? f.critique_prompt : "") + "</textarea></div>" +
@@ -8394,11 +8530,15 @@ async function saveFlow() {
8394
8530
  const rb = $("fl-rubric").value.split(/[,,、]/).map((s) => s.trim()).filter(Boolean);
8395
8531
  if (rb.length) payload.rubric = rb;
8396
8532
  const ch = parseInt($("fl-chapters").value, 10);
8397
- if (ch >= 2) payload.serial = {
8398
- chapters: ch,
8399
- words_per_chapter: parseInt($("fl-words-per-ch").value, 10) || 2500,
8400
- variants: Math.max(1, Math.min(3, parseInt($("fl-variants").value, 10) || 1)),
8401
- };
8533
+ if (ch >= 2) {
8534
+ payload.serial = {
8535
+ chapters: ch,
8536
+ words_per_chapter: parseInt($("fl-words-per-ch").value, 10) || 2500,
8537
+ variants: Math.max(1, Math.min(3, parseInt($("fl-variants").value, 10) || 1)),
8538
+ };
8539
+ const vp = parseInt(($("fl-vol-chapters") || {}).value, 10);
8540
+ if (vp >= 2) payload.serial.volume_chapters = vp; // 流程默认分卷(任务级可覆盖)
8541
+ }
8402
8542
  const dp = $("fl-draft").value.trim();
8403
8543
  if (dp) payload.draft_prompt = dp;
8404
8544
  const cp = $("fl-critique").value.trim();
@@ -8695,6 +8835,26 @@ function autoStatusTag(tsk) {
8695
8835
  return "";
8696
8836
  }
8697
8837
 
8838
+ /* 运行偏好标签:只显示「偏离默认」的那些(编排默认 auto、思考默认 standard),
8839
+ * 否则每张卡都挂两颗没有信息量的胶囊,反而盖住真正要看的节奏与流程 */
8840
+ function autoPrefTags(tsk) {
8841
+ const MODE = { auto: "自动", fast: "快速", expert: "专家", manual: "手动" };
8842
+ const TH = { auto: "自动", low: "快速", standard: "标准", high: "深度" };
8843
+ const out = [];
8844
+ if (tsk.mode && tsk.mode !== "auto" && MODE[tsk.mode]) {
8845
+ out.push(t("编排模式") + ":" + t(MODE[tsk.mode]));
8846
+ }
8847
+ if (tsk.thinking && tsk.thinking !== "standard" && TH[tsk.thinking]) {
8848
+ out.push(t("思考程度") + ":" + t(TH[tsk.thinking]));
8849
+ }
8850
+ if (tsk.direct_provider_id) {
8851
+ const p = directProviders().find((x) => x.id === tsk.direct_provider_id);
8852
+ out.push(t("对话模型") + ":" + (p ? (p.name || p.id) : tsk.direct_provider_id) +
8853
+ "/" + (tsk.direct_model || t("随厂商推荐")));
8854
+ }
8855
+ return out.map((s) => '<span class="tag">' + esc(s) + "</span>").join("");
8856
+ }
8857
+
8698
8858
  function autoCardHtml(tsk) {
8699
8859
  const flow = tsk.flow ? flowById(tsk.flow) : null;
8700
8860
  return '<div class="card' + (tsk.enabled ? "" : " off") + '"><div class="head">' +
@@ -8704,7 +8864,8 @@ function autoCardHtml(tsk) {
8704
8864
  ' onchange="autoToggle(\'' + esc(tsk.id) + '\', this.checked)"><span>' + (tsk.enabled ? t("启用中") : t("已停用")) + "</span></label>" +
8705
8865
  "</div>" +
8706
8866
  '<div class="auto-kind">' + esc(autoKindText(tsk)) +
8707
- (flow ? '<span class="tag">' + t("流程:") + esc(t(flow.name)) + "</span>" : "") + "</div>" +
8867
+ (flow ? '<span class="tag">' + t("流程:") + esc(t(flow.name)) + "</span>" : "") +
8868
+ autoPrefTags(tsk) + "</div>" +
8708
8869
  '<div class="auto-prompt">' + esc(tsk.prompt) + "</div>" +
8709
8870
  '<div class="auto-meta">' +
8710
8871
  (tsk.enabled && tsk.next_run ? "<span>" + t("下次运行 ") + esc(tsk.next_run) + "</span>" : "") +
@@ -8747,6 +8908,8 @@ async function autoForm(task, tpl) {
8747
8908
  const f = task || {};
8748
8909
  const kind = f.kind || (tpl && tpl.suggested_kind) || "daily";
8749
8910
  const curFlow = f.flow || (tpl && tpl.suggested_flow) || "";
8911
+ const mode = f.mode || "auto";
8912
+ const thinking = f.thinking || "standard";
8750
8913
  const wdOpts = AUTO_WD.map((w, i) =>
8751
8914
  '<option value="' + i + '"' + (Number(f.weekday) === i || (!task && tpl && tpl.suggested_weekday === i) ? " selected" : "") + ">" + t(w) + "</option>").join("");
8752
8915
  const runAt = f.run_at ? String(f.run_at).replace(" ", "T").slice(0, 16) : "";
@@ -8770,6 +8933,18 @@ async function autoForm(task, tpl) {
8770
8933
  '<option value="' + esc(fl.id) + '"' + (fl.id === curFlow ? " selected" : "") + ">" + esc(t(fl.name)) + "</option>").join("") +
8771
8934
  "</select></div>" +
8772
8935
  "</div>" +
8936
+ /* 运行偏好:与新建任务 Composer 的三颗胶囊同一套取值(编排模式/思考程度/
8937
+ 对话模型),到点拉起的运行与手动建的任务完全等价 */
8938
+ '<div class="grid-2">' +
8939
+ '<div class="field"><label>' + t("编排模式") + "</label>" +
8940
+ autoPillHtml("au-mode", AUTO_MODES, mode) + "</div>" +
8941
+ '<div class="field"><label>' + t("思考程度") + "</label>" +
8942
+ autoPillHtml("au-thinking", AUTO_THINKINGS, thinking) + "</div>" +
8943
+ "</div>" +
8944
+ '<div id="au-model-field" class="field hidden"><label>' + t("对话模型(可选)") + '</label><div class="grid-2">' +
8945
+ '<select id="au-direct-provider" aria-label="' + t("对话厂商") + '"></select>' +
8946
+ '<select id="au-direct-model-name" aria-label="' + t("对话模型") + '"></select>' +
8947
+ '</div><p class="hint">' + t("只对「直接执行」类流程生效;留「自动推荐」时按系统路由挑模型。") + "</p></div>" +
8773
8948
  '<div class="grid-2">' +
8774
8949
  '<div class="field au-fld" data-k="time"><label>' + t("时间") + '</label><input id="au-time" type="time" value="' +
8775
8950
  esc(f.time || (tpl && tpl.suggested_time) || "09:00") + '"></div>' +
@@ -8789,6 +8964,71 @@ async function autoForm(task, tpl) {
8789
8964
  kindSel.addEventListener("change", autoFormSyncKind);
8790
8965
  autoFormSyncKind();
8791
8966
  }
8967
+ ["au-mode", "au-thinking"].forEach((id) => {
8968
+ const sel = $(id);
8969
+ if (sel) sel.addEventListener("change", () => syncCmpSelFace(id));
8970
+ syncCmpSelFace(id); // 弹框是动态插入的:选项文字要在这里同步一次
8971
+ });
8972
+ renderAuModelPicker(f.direct_provider_id || "", f.direct_model || "");
8973
+ const flowSel = $("au-flow");
8974
+ if (flowSel) {
8975
+ flowSel.addEventListener("change", autoFormSyncFlow);
8976
+ autoFormSyncFlow();
8977
+ }
8978
+ }
8979
+
8980
+ /* 运行偏好取值(真源 = automation.py 的 MODES/THINKINGS,选项文案与 Composer 共用) */
8981
+ const AUTO_MODES = [
8982
+ { v: "auto", label: "自动(推荐):按任务类型与风险动态决定步骤" },
8983
+ { v: "fast", label: "快速:少步骤优先,先做确定性验收" },
8984
+ { v: "expert", label: "专家:完整规划、多评审、失败修复与换将" },
8985
+ { v: "manual", label: "手动:自行指定实现者与评审组" },
8986
+ ];
8987
+ const AUTO_THINKINGS = [
8988
+ { v: "standard", label: "标准:平衡质量与速度" },
8989
+ { v: "auto", label: "自动:随任务难度调整" },
8990
+ { v: "low", label: "快速:少推理、优先响应" },
8991
+ { v: "high", label: "深度:更多推理与检查" },
8992
+ ];
8993
+
8994
+ /* 偏好胶囊:外观与 Composer 同款(透明原生 select 铺满整颗,短名由
8995
+ * syncCmpSelFace 从选中 option 文本截取——语言切换后短名跟着换) */
8996
+ function autoPillHtml(id, opts, cur) {
8997
+ return '<span class="cmp-sel"><select id="' + id + '" class="cmp-sel-native" aria-label="' +
8998
+ esc(id === "au-mode" ? t("编排模式") : t("思考程度")) + '">' +
8999
+ opts.map((o) => '<option value="' + o.v + '"' + (o.v === cur ? " selected" : "") +
9000
+ ' data-i18n="' + esc(o.label) + '">' + esc(t(o.label)) + "</option>").join("") +
9001
+ '</select><span class="cmp-sel-face" id="' + id + '-face"></span>' +
9002
+ '<svg class="ico chev" aria-hidden="true"><use href="#i-chevron-r"></use></svg></span>';
9003
+ }
9004
+
9005
+ /* 对话模型(仅 direct 流程):复用 Composer 的厂商/模型数据源与重建规则 */
9006
+ function renderAuModelPicker(pid, model) {
9007
+ const ps = $("au-direct-provider"), ms = $("au-direct-model-name");
9008
+ if (!ps || !ms) return;
9009
+ const provs = directProviders();
9010
+ const keepP = pid != null ? pid : ps.value;
9011
+ ps.innerHTML = '<option value="">' + t("自动推荐") + "</option>" + provs.map((p) =>
9012
+ '<option value="' + esc(p.id) + '">' + esc(p.name || p.id) + "</option>").join("");
9013
+ ps.value = provs.some((p) => p.id === keepP) ? keepP : "";
9014
+ const fillModels = (keepM) => {
9015
+ const p = provs.find((x) => x.id === ps.value);
9016
+ const names = p ? (p.models || []).filter((m) => !m.hidden)
9017
+ .map((m) => m.name).filter(Boolean) : [];
9018
+ ms.innerHTML = '<option value="">' + t("随厂商推荐") + "</option>" + names.map((name) =>
9019
+ '<option value="' + esc(name) + '">' + esc(name) + "</option>").join("");
9020
+ ms.value = names.includes(keepM) ? keepM : "";
9021
+ };
9022
+ fillModels(model != null ? model : ms.value);
9023
+ // 换厂商 = 旧模型名多半不在新厂商名录里:回「随厂商推荐」,不留悬空值
9024
+ ps.onchange = () => fillModels("");
9025
+ }
9026
+
9027
+ /* 流程 → 对话模型字段显隐:只有 direct 引擎流程用得上(其余流程模型由系统路由) */
9028
+ function autoFormSyncFlow() {
9029
+ const flow = flowById(($("au-flow") || {}).value);
9030
+ const box = $("au-model-field");
9031
+ if (box) box.classList.toggle("hidden", !(flow && flow.engine === "direct"));
8792
9032
  }
8793
9033
 
8794
9034
  /* 类型 → 字段联动:daily/weekly→时间;weekly→星期几;interval→N 小时;once→未来时间 */
@@ -8813,6 +9053,17 @@ async function saveAutoForm(id) {
8813
9053
  if (!prompt) { toast(t("请填写执行内容"), true); return; }
8814
9054
  const kind = $("au-kind").value;
8815
9055
  const payload = { name, prompt, workdir: ($("au-workdir").value || "").trim(), kind, flow: $("au-flow").value };
9056
+ // 运行偏好:与 Composer 同名字段,后端 _apply_run_prefs 再归一一次
9057
+ payload.mode = $("au-mode") ? $("au-mode").value : "";
9058
+ payload.thinking = $("au-thinking") ? $("au-thinking").value : "";
9059
+ if (!$("au-model-field") || !$("au-model-field").classList.contains("hidden")) {
9060
+ payload.direct_provider_id = $("au-direct-provider") ? $("au-direct-provider").value : "";
9061
+ payload.direct_model = $("au-direct-model-name") ? $("au-direct-model-name").value : "";
9062
+ if (payload.direct_model && !payload.direct_provider_id) {
9063
+ toast(t("选择对话模型前请先选厂商"), true);
9064
+ return;
9065
+ }
9066
+ }
8816
9067
  if (kind === "daily" || kind === "weekly") payload.time = $("au-time").value;
8817
9068
  if (kind === "weekly") payload.weekday = parseInt($("au-weekday").value, 10);
8818
9069
  if (kind === "interval") payload.interval_hours = parseInt($("au-interval").value, 10) || 0;
@@ -9158,26 +9409,26 @@ function renderHooks() {
9158
9409
  if (!box) return;
9159
9410
  const list = S.hooks || [];
9160
9411
  if (empty) empty.classList.toggle("hidden", list.length > 0);
9412
+ const evLabel = { task_start: t("任务开始"), message_submit: t("消息送达"), run_end: t("运行结束") };
9161
9413
  box.innerHTML = list.map((h, i) => {
9162
- const evName = { task_start: t("任务开始"), message_submit: t("消息送达"), run_end: t("运行结束") }[h.event] || h.event;
9163
9414
  return '<div class="card hook-card" data-i="' + i + '">' +
9164
- '<div class="hook-head">' +
9165
- '<input class="hook-name" value="' + esc(h.name || "") + '" placeholder="' + esc(t("名称")) + '" data-hf="name">' +
9415
+ '<div class="hook-row">' +
9416
+ '<input class="hook-name" value="' + esc(h.name || "") + '" placeholder="' + esc(t("钩子名称")) + '" data-hf="name">' +
9166
9417
  '<select class="hook-event" data-hf="event">' +
9167
9418
  ["task_start", "message_submit", "run_end"].map((ev) =>
9168
9419
  '<option value="' + ev + '"' + (h.event === ev ? " selected" : "") + ">" +
9169
- esc({ task_start: t("任务开始"), message_submit: t("消息送达"), run_end: t("运行结束") }[ev]) + "</option>").join("") +
9420
+ esc(evLabel[ev]) + "</option>").join("") +
9170
9421
  "</select>" +
9171
- '<label class="switch" title="' + esc(t("启用")) + '"><input type="checkbox" data-hf="enabled"' +
9172
- (h.enabled ? " checked" : "") + '><span></span></label>' +
9173
- '<button type="button" class="ghost small" onclick="removeHook(' + i + ')">' + t("删除") + "</button>" +
9174
9422
  "</div>" +
9175
9423
  '<input class="hook-cmd mono" value="' + esc(h.cmd || "") + '" placeholder="' +
9176
- esc(t('命令,例:python E:\\proj\\hook.py --event {event}')) + '" data-hf="cmd">' +
9177
- '<div class="hook-foot">' +
9178
- '<span class="hint">' + esc(t("超时(秒)")) + '</span><input class="hook-timeout" type="number" min="1" max="120" value="' +
9179
- Number(h.timeout_s || 10) + '" data-hf="timeout_s">' +
9424
+ esc(t('命令:python D:\\proj\\hook.py(stdin 收 JSON,stdout 回 {"inject":"文本"})')) + '" data-hf="cmd">' +
9425
+ '<div class="hook-row hook-foot">' +
9426
+ '<label class="switch" title="' + esc(t("启用")) + '"><input type="checkbox" data-hf="enabled"' +
9427
+ (h.enabled ? " checked" : "") + '><span></span><em>' + esc(t("启用")) + '</em></label>' +
9428
+ '<span class="hook-foot-lab">' + esc(t("超时(秒)")) + '</span>' +
9429
+ '<input class="hook-timeout" type="number" min="1" max="120" value="' + Number(h.timeout_s || 10) + '" data-hf="timeout_s">' +
9180
9430
  '<button type="button" class="ghost small" data-hook-test="' + i + '">' + t("测试") + "</button>" +
9431
+ '<button type="button" class="ghost small" onclick="removeHook(' + i + ')">' + t("删除") + "</button>" +
9181
9432
  '<span class="hint hook-test-out"></span>' +
9182
9433
  "</div></div>";
9183
9434
  }).join("");
@@ -10458,12 +10709,28 @@ async function suStartupCheck() {
10458
10709
 
10459
10710
  /* ---------------------------------------------------------- 页签 & 初始化 */
10460
10711
  const TAB_TITLES = { overview: "概览", tasks: "任务", runs: "运行记录", automation: "自动化", zentao: "禅道 Bug 自动修复", __wxdigest: "群摘要", usage: "用量统计", agents: "本机智能体", models: "模型接入", bindings: "模型调度(可选)", skills: "经验库", knowledge: "知识库", market: "插件市场", orch: "编排设置", data: "数据与备份", appearance: "皮肤", about: "关于与更新" };
10461
- const SET_TABS = new Set(Object.keys(TAB_TITLES)); // 设置导航里的子页(__phone 是弹框,不算)
10712
+ const SET_TABS = new Set(Object.keys(TAB_TITLES)); // 全部设置子页(__phone 是弹框,不算)
10462
10713
 
10463
10714
  function tabTitle(name) {
10464
10715
  return t(TAB_TITLES[name]) || t("设置");
10465
10716
  }
10466
10717
 
10718
+ /* 左栏两套导航各管哪些页,一律从 index.html 现读——HTML 改了 JS 自动跟上,
10719
+ * 不再各存一份常量、两处漂移:
10720
+ * .side-quick 快捷导航(概览/运行记录/自动化)→ main 形态:只换主栏内容,左栏任务树不动
10721
+ * .side-settings 设置导航 → settings 形态:左栏整体换成设置导航
10722
+ * 同一个页只应挂在其中一边(重复挂就是两处入口)。 */
10723
+ function mainPageSubs() {
10724
+ const s = new Set();
10725
+ document.querySelectorAll(".side-quick .qitem").forEach((b) => { if (b.dataset.page) s.add(b.dataset.page); });
10726
+ return s;
10727
+ }
10728
+ function settingsNavSubs() {
10729
+ const s = new Set();
10730
+ document.querySelectorAll(".side-settings .set-item").forEach((b) => { if (b.dataset.sub) s.add(b.dataset.sub); });
10731
+ return s;
10732
+ }
10733
+
10467
10734
 
10468
10735
 
10469
10736
  /* ---------------------------------------------------------- 用量统计 */
@@ -11553,16 +11820,23 @@ function collapseDrawerIfMobile() {
11553
11820
  if (window.innerWidth < 900) document.body.classList.add("side-collapsed");
11554
11821
  }
11555
11822
 
11556
- function switchTab(name) {
11823
+ /* 切页:主栏换成目标子页。shell 决定左栏形态——"main" 保持任务树、"settings" 换成
11824
+ * 设置导航,缺省按目标页归哪套导航自动选(见 mainPageSubs/settingsNavSubs)。 */
11825
+ function switchTab(name, shell) {
11557
11826
  if (name === "__phone") { openPhoneConnect(); return; } // 手机连接是弹框,不切页
11558
11827
  if (name === "__guide") { welcomeOpen(); return; } // 帮助中心是弹层,不切页(设置导航「软件」组)
11559
- // 导航收进「设置」:进设置后左栏整体换成设置导航,内容铺满
11828
+ // 形态由目标页决定:快捷导航页(概览/运行记录/自动化)走 main——只换主栏内容、
11829
+ // 左栏任务树原地不动;其余页进设置导航。调用方可用 shell 显式覆盖(如已在设置里
11830
+ // 点运行详情,仍要留在设置侧栏)。用户主动进设置时左栏一定会换成设置导航。
11831
+ const inMain = (shell || (mainPageSubs().has(name) ? "main" : "settings")) === "main";
11560
11832
  S.tab = name;
11561
- if (SET_TABS.has(name)) localStorage.setItem("orch.setTab", name);
11562
- document.body.classList.add("settings-mode");
11563
- document.body.classList.remove("files-mode"); // 文件浏览页与设置导航互斥,别叠在左栏
11833
+ S.mainPage = inMain ? name : ""; // 主栏停在哪个全局页;closeRun 靠它回到点开详情前那一页
11834
+ if (settingsNavSubs().has(name)) localStorage.setItem("orch.setTab", name);
11835
+ document.body.classList.toggle("settings-mode", !inMain);
11836
+ document.body.classList.remove("files-mode"); // 文件浏览页与两套导航都互斥,别叠在左栏
11564
11837
  document.querySelectorAll(".page").forEach((p) => p.classList.toggle("hidden", p.id !== "page-settings"));
11565
- document.querySelectorAll(".set-item").forEach((b) => b.classList.toggle("active", b.dataset.sub === name));
11838
+ document.querySelectorAll(".set-item").forEach((b) => b.classList.toggle("active", !inMain && b.dataset.sub === name));
11839
+ document.querySelectorAll(".qitem").forEach((b) => b.classList.toggle("active", inMain && b.dataset.page === name));
11566
11840
  document.querySelectorAll("#page-settings .subpage").forEach((d) => d.classList.toggle("hidden", d.id !== "sub-" + name));
11567
11841
  const title = $("page-title");
11568
11842
  if (title) title.textContent = tabTitle(name);
@@ -11587,13 +11861,15 @@ function switchTab(name) {
11587
11861
  syncInspectorVis(); // 检查器只属于任务上下文:进设置子页自动收起
11588
11862
  }
11589
11863
 
11590
- /* 退出设置:左栏恢复任务树,内容回到任务页 */
11864
+ /* 退出设置/回到新建任务表单:左栏恢复任务树,主栏回到任务页 */
11591
11865
  function exitSettings() {
11592
11866
  S.tab = "tasks";
11867
+ S.mainPage = ""; // 主栏回到表单:不再是快捷导航页
11593
11868
  stopAutoPoll(); // 离开设置视图:自动化页轮询一并停掉
11594
11869
  document.querySelectorAll(".page").forEach((p) => p.classList.toggle("hidden", p.id !== "page-settings"));
11595
11870
  document.querySelectorAll("#page-settings .subpage").forEach((d) => d.classList.toggle("hidden", d.id !== "sub-tasks"));
11596
11871
  document.querySelectorAll(".set-item").forEach((b) => b.classList.toggle("active", b.dataset.sub === "tasks"));
11872
+ document.querySelectorAll(".qitem").forEach((b) => b.classList.remove("active"));
11597
11873
  document.body.classList.remove("settings-mode");
11598
11874
  document.body.classList.remove("files-mode");
11599
11875
  cmpGreeting(); // 回到任务页:问候语刷新
@@ -11603,10 +11879,12 @@ function exitSettings() {
11603
11879
  syncInspectorVis(); // 回到新建表单:不是任务上下文,检查器收起(点运行详情才会滑回)
11604
11880
  }
11605
11881
 
11606
- /* 侧栏底部齿轮:直接进设置(左栏换成设置导航,不再弹菜单)。回到上次看的那页,默认编排设置 */
11882
+ /* 侧栏底部齿轮:直接进设置(左栏换成设置导航,不再弹菜单)。回到上次看的那页,默认编排设置。
11883
+ * 只认设置导航里真实存在的子页——概览/运行记录/自动化已升为快捷导航,落回它们会停在
11884
+ * 一个没有导航项高亮的设置页。 */
11607
11885
  function enterSettings() {
11608
11886
  const saved = localStorage.getItem("orch.setTab") || "";
11609
- switchTab(SET_TABS.has(saved) ? saved : "orch");
11887
+ switchTab(settingsNavSubs().has(saved) ? saved : "orch", "settings");
11610
11888
  }
11611
11889
 
11612
11890
  window.openRun = openRun;
@@ -11955,10 +12233,11 @@ document.addEventListener("DOMContentLoaded", () => {
11955
12233
  if (e.target.closest("button, summary, .stask")) collapseDrawerIfMobile();
11956
12234
  });
11957
12235
  $("btn-new-task").addEventListener("click", exitSettings);
11958
- // 主侧栏快捷入口:直达概览 / 自动化 / 插件市场(switchTab 自己会切设置模式并挂载页面)
11959
- $("btn-q-overview").addEventListener("click", () => switchTab("overview"));
11960
- $("btn-q-automation").addEventListener("click", () => switchTab("automation"));
11961
- $("btn-q-market").addEventListener("click", () => switchTab("market"));
12236
+ // 侧栏快捷导航:主栏换成目标页,左栏任务树不动(shell="main")。
12237
+ // 页面清单以 index.html .side-quick 的 data-page 为准,这里逐项绑 id。
12238
+ $("btn-q-overview").addEventListener("click", () => switchTab("overview", "main"));
12239
+ $("btn-q-runs").addEventListener("click", () => switchTab("runs", "main"));
12240
+ $("btn-q-automation").addEventListener("click", () => switchTab("automation", "main"));
11962
12241
  bindCtxMenus();
11963
12242
  bindInspector();
11964
12243
  if (window.innerWidth < 900) document.body.classList.add("side-collapsed");
package/app/ui/i18n.js CHANGED
@@ -46,6 +46,16 @@
46
46
  "推荐任务类型": "Recommended task type",
47
47
  "切换类型": "Switch type",
48
48
  "这个需求更适合「{0}」流程,能使用对应的规划与质量门禁。是否切换后执行?": "This request fits the {0} workflow, which provides matching planning and quality gates. Switch before running?",
49
+ // 自动化卡片上的偏好标签(短名与胶囊面同步取用)
50
+ "自动": "Auto",
51
+ "快速": "Fast",
52
+ "专家": "Expert",
53
+ "自动推荐": "Auto-recommended",
54
+ "编排模式:": "Orchestration mode: ",
55
+ "思考程度:": "Reasoning effort: ",
56
+ "对话模型:": "Chat model: ",
57
+ "只对「直接执行」类流程生效;留「自动推荐」时按系统路由挑模型。": "Applies only to the Direct execution workflow; leave it on Auto-recommended to let routing pick the model.",
58
+ "选择对话模型前请先选厂商": "Choose a provider before picking a chat model",
49
59
 
50
60
  // —— 引擎/角色 ——
51
61
 
@@ -758,6 +768,16 @@
758
768
  "评审维度(逗号分隔,可自定义)": "Review dimensions (comma-separated; custom OK)",
759
769
  "连载模式:章节数(空 = 单稿件一次成文)": "Serial: chapters (blank = single draft)",
760
770
  "每章约字数": "Approx. words per chapter",
771
+ // —— 分卷(卷结构:大纲规划 / 写作注入 / 成书分割 / UI 分组)——
772
+ "分卷:每卷章数(空 = 不分卷)": "Volumes: chapters per volume (blank = no volumes)",
773
+ "分卷:指定卷结构(可选,优先于每卷章数)": "Volumes: explicit structure (optional; overrides chapters per volume)",
774
+ "例:20(卷边界按全书章号自动推导)": "e.g. 20 (boundaries derived from book-wide chapter numbers)",
775
+ "例:第一卷 少年初入江湖 20章;第二卷 风云再起 16章": "e.g. Vol 1 The Young Wanderer 20 ch; Vol 2 Storm Rising 16 ch",
776
+ "每卷一行或分号分隔:卷名 + 章数(或第1-20章)。也可以在任务目标里直接这样写,会自动识别": "One per line or separated by semicolons: volume name + chapter count (or ch.1-20). You can also write this in the task goal — it is detected automatically.",
777
+ "分卷:每卷章数(留空 = 不分卷)": "Volumes: chapters per volume (blank = no volumes)",
778
+ "卷": "Vol",
779
+ "未分卷": "No volumes",
780
+ "其他文件": "Other files",
761
781
  "实现者": "Implementer",
762
782
  "评审组": "Reviewers",
763
783
  "管理": "Manage",
@@ -2516,7 +2536,9 @@
2516
2536
  });
2517
2537
  // 编排 pill 短名取自 option 文本,重译后要跟着换语言
2518
2538
  if (typeof window.syncCmpSelFace === "function") {
2519
- ["f-mode", "f-thinking"].forEach((id) => window.syncCmpSelFace(id));
2539
+ // au-* 是自动化弹框里的偏好胶囊:弹框未开时这两个 id 不在 DOM 里,
2540
+ // syncCmpSelFace 对缺失元素直接返回,无需额外守卫
2541
+ ["f-mode", "f-thinking", "au-mode", "au-thinking"].forEach((id) => window.syncCmpSelFace(id));
2520
2542
  }
2521
2543
  if (typeof window.cmpDirectBtnSync === "function") {
2522
2544
  window.cmpDirectBtnSync(); // 「自动推荐/推荐」等词也要换语言
package/app/ui/index.html CHANGED
@@ -114,19 +114,22 @@
114
114
  <span class="ss-label" data-i18n="搜索">搜索</span>
115
115
  <span class="kbd" aria-hidden="true">Ctrl K</span>
116
116
  </button>
117
+ <!-- 快捷导航:只放最常用的全局页(data-page 是页面 id 真源)。
118
+ 点它们只换主栏内容,左栏任务树原地不动(app.js switchTab 的 main 形态);
119
+ 对应入口已从下面设置导航移除,同一页不再两处重复。 -->
117
120
  <div class="side-quick">
118
- <button class="qitem" id="btn-q-overview" title="概览" data-i18n-title="概览">
121
+ <button class="qitem" id="btn-q-overview" data-page="overview" title="概览" data-i18n-title="概览">
119
122
  <svg class="ico" aria-hidden="true"><use href="#i-gauge"></use></svg>
120
123
  <span data-i18n="概览">概览</span>
121
124
  </button>
122
- <button class="qitem" id="btn-q-automation" title="自动化" data-i18n-title="自动化">
125
+ <button class="qitem" id="btn-q-runs" data-page="runs" title="运行记录" data-i18n-title="运行记录">
126
+ <svg class="ico" aria-hidden="true"><use href="#i-history"></use></svg>
127
+ <span data-i18n="运行记录">运行记录</span>
128
+ </button>
129
+ <button class="qitem" id="btn-q-automation" data-page="automation" title="自动化" data-i18n-title="自动化">
123
130
  <svg class="ico" aria-hidden="true"><use href="#i-calendar-days"></use></svg>
124
131
  <span data-i18n="自动化">自动化</span>
125
132
  </button>
126
- <button class="qitem" id="btn-q-market" title="插件市场" data-i18n-title="插件市场">
127
- <svg class="ico" aria-hidden="true"><use href="#i-blocks"></use></svg>
128
- <span data-i18n="插件市场">插件市场</span>
129
- </button>
130
133
  </div>
131
134
  <div class="side-section tasks">
132
135
  <div class="side-label-row">
@@ -178,12 +181,9 @@
178
181
  <button id="btn-set-back" class="set-back" data-i18n="返回 CodeBee">
179
182
  <svg class="ico" aria-hidden="true"><use href="#i-arrow-left"></use></svg>返回 CodeBee
180
183
  </button>
181
- <div data-i18n="工作台" class="set-group">工作台</div>
182
- <button class="set-item" data-sub="overview" data-i18n="概览"><svg class="ico" aria-hidden="true"><use href="#i-gauge"></use></svg>概览</button>
184
+ <!-- 概览 / 运行记录 / 自动化已升为左栏快捷导航(见 .side-quick),此处不再重复挂条目 -->
183
185
  <div data-i18n="编排" class="set-group">编排</div>
184
186
  <button class="set-item active" data-sub="tasks" data-i18n="任务"><svg class="ico" aria-hidden="true"><use href="#i-tasks"></use></svg>任务</button>
185
- <button class="set-item" data-sub="runs" data-i18n="运行记录"><svg class="ico" aria-hidden="true"><use href="#i-history"></use></svg>运行记录</button>
186
- <button class="set-item" data-sub="automation" data-i18n="自动化"><svg class="ico" aria-hidden="true"><use href="#i-calendar-days"></use></svg>自动化</button>
187
187
  <button class="set-item" data-sub="hooks" data-i18n="钩子"><svg class="ico" aria-hidden="true"><use href="#i-link"></use></svg>钩子</button>
188
188
  <button class="set-item" data-sub="__wxdigest" data-i18n="微信群聊汇报"><svg class="ico" aria-hidden="true"><use href="#i-bee"></use></svg>微信群聊汇报</button>
189
189
  <button class="set-item" data-sub="zentao" data-i18n="禅道"><svg class="ico" aria-hidden="true"><use href="#i-search"></use></svg>禅道</button>
@@ -436,7 +436,9 @@
436
436
  <div class="field"><label data-i18n="连载模式:章节数(空 = 单稿件一次成文)">连载模式:章节数(空 = 单稿件一次成文)</label><input id="f-chapters" type="number" min="2" max="20" placeholder="例:8(逐章起草+评审+修订)" data-i18n-ph="例:8(逐章起草+评审+修订)"></div>
437
437
  <div class="field"><label data-i18n="每章约字数">每章约字数</label><input id="f-words-per-ch" type="number" min="500" max="8000" step="100" placeholder="例:2500" data-i18n-ph="例:2500"></div>
438
438
  <div class="field"><label data-i18n="同章赛马稿件数">同章赛马稿件数</label><input id="f-variants" type="number" min="1" max="3" step="1" placeholder="1 = 关闭(2-3 每章多稿择优,更贵)" data-i18n-ph="1 = 关闭(2-3 每章多稿择优,更贵)"></div>
439
+ <div class="field"><label data-i18n="分卷:每卷章数(空 = 不分卷)">分卷:每卷章数(空 = 不分卷)</label><input id="f-vol-chapters" type="number" min="2" max="200" step="1" placeholder="例:20(卷边界按全书章号自动推导)" data-i18n-ph="例:20(卷边界按全书章号自动推导)"></div>
439
440
  </div>
441
+ <div class="field" id="f-vol-spec-field"><label data-i18n="分卷:指定卷结构(可选,优先于每卷章数)">分卷:指定卷结构(可选,优先于每卷章数)</label><input id="f-vol-spec" placeholder="例:第一卷 少年初入江湖 20章;第二卷 风云再起 16章" data-i18n-ph="例:第一卷 少年初入江湖 20章;第二卷 风云再起 16章" data-i18n-title="每卷一行或分号分隔:卷名 + 章数(或第1-20章)。也可以在任务目标里直接这样写,会自动识别" title="每卷一行或分号分隔:卷名 + 章数(或第1-20章)。也可以在任务目标里直接这样写,会自动识别"></div>
440
442
  </div>
441
443
 
442
444
  <div id="f-manual-only" class="hidden">
package/app/ui/style.css CHANGED
@@ -5715,13 +5715,17 @@ button.request-busy > * { opacity: .18; }
5715
5715
 
5716
5716
  /* 钩子管理卡片(设置 → 钩子,2026-09-22):名称/事件/开关一行,命令整行,
5717
5717
  * 底部超时+测试。mono 字体命令输入便于读路径 */
5718
- .hook-card .hook-head { display: flex; align-items: center; gap: 10px; }
5719
- .hook-card .hook-head .hook-name { flex: 1 1 140px; min-width: 0; }
5720
- .hook-card .hook-head .hook-event { flex: none; }
5721
- .hook-card .hook-cmd { width: 100%; margin-top: 10px; font-family: var(--mono); font-size: 12px; }
5722
- .hook-card .hook-foot { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
5723
- .hook-card .hook-timeout { width: 64px; }
5724
- .hook-card .hook-test-out { flex: 1 1 auto; min-width: 0; overflow: hidden;
5718
+ .hook-card { display: flex; flex-direction: column; gap: 10px; }
5719
+ .hook-card .hook-row { display: flex; align-items: center; gap: 10px; }
5720
+ .hook-card .hook-name { flex: 1 1 auto; min-width: 180px; }
5721
+ .hook-card .hook-event { flex: none; width: 150px; }
5722
+ .hook-card .hook-cmd { width: 100%; box-sizing: border-box; font-family: var(--mono); font-size: 12px; }
5723
+ .hook-card .hook-foot { flex-wrap: wrap; }
5724
+ .hook-card .hook-foot .switch { display: inline-flex; align-items: center; gap: 6px; }
5725
+ .hook-card .hook-foot .switch em { font-style: normal; font-size: 12.5px; color: var(--muted); }
5726
+ .hook-card .hook-foot-lab { font-size: 12.5px; color: var(--muted); }
5727
+ .hook-card .hook-timeout { width: 64px; flex: none; }
5728
+ .hook-card .hook-test-out { flex: 1 1 160px; min-width: 0; overflow: hidden;
5725
5729
  text-overflow: ellipsis; white-space: nowrap; }
5726
5730
  .hook-card .switch { display: inline-flex; align-items: center; cursor: pointer; }
5727
5731
  .hook-card .switch input { appearance: none; width: 34px; height: 19px; border-radius: 999px;
@@ -5868,3 +5872,16 @@ button.request-busy > * { opacity: .18; }
5868
5872
  .kpi.has-spark .kpi-s { padding-right: 0; }
5869
5873
  }
5870
5874
 
5875
+
5876
+ /* 分卷:章节评审卡按卷分组(.ch-vol 卷头 + 卷内容器)。卷头是一条细分隔线
5877
+ * 加卷名/卷内达标数/章号区间;不分卷时完全不渲染,视觉与分卷前一致。 */
5878
+ .ch-vol { margin: 10px 0 14px; }
5879
+ .ch-vol-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
5880
+ padding: 4px 2px 6px; border-bottom: 1px solid var(--border, rgba(128,128,128,.22)); }
5881
+ .ch-vol-head b { font-size: 13px; letter-spacing: .01em; }
5882
+ .ch-vol > .ch-row { margin: 6px 0 0 10px; }
5883
+
5884
+ /* 分卷:发布页章节清单按卷分组(.pb-vol 卷头 + 其下章节按钮)。 */
5885
+ .pb-vol { margin: 8px 0 12px; }
5886
+ .pb-vol-head { font-size: 12.5px; color: var(--muted); padding: 2px 2px 6px;
5887
+ border-bottom: 1px solid var(--border, rgba(128,128,128,.22)); margin-bottom: 6px; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebee",
3
- "version": "0.1.48",
3
+ "version": "0.1.49",
4
4
  "description": "CodeBee · 多智能体编排台 — 统一编排本机 AI 编码 CLI(Codex / Claude Code / QwenCode / OpenCode / Aider…):目标→拆解→路由→执行→验证→跨厂商评审→修复→报告。纯 Python 标准库实现,本地 Web UI。",
5
5
  "license": "MIT",
6
6
  "bin": {