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 +6 -0
- package/app/core/aiflavor.py +80 -2
- package/app/core/automation.py +55 -2
- package/app/core/pipeline.py +24 -42
- package/app/core/planner.py +45 -0
- package/app/core/store.py +4 -5
- package/app/core/volumes.py +7 -5
- package/app/ui/app.js +345 -66
- package/app/ui/i18n.js +23 -1
- package/app/ui/index.html +12 -10
- package/app/ui/style.css +24 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
package/app/core/aiflavor.py
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/app/core/automation.py
CHANGED
|
@@ -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", "
|
|
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()
|
package/app/core/pipeline.py
CHANGED
|
@@ -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
|
-
"""
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
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
|
-
|
|
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":
|
package/app/core/planner.py
CHANGED
|
@@ -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
|
-
#
|
|
223
|
-
|
|
224
|
-
|
|
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
|
package/app/core/volumes.py
CHANGED
|
@@ -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
|
|
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
|
|
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"
|
|
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 {
|
|
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
|
-
|
|
4744
|
-
|
|
4745
|
-
|
|
4746
|
-
|
|
4747
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5867
|
-
|
|
5868
|
-
|
|
5869
|
-
|
|
5870
|
-
|
|
5871
|
-
|
|
5872
|
-
|
|
5873
|
-
|
|
5874
|
-
|
|
5875
|
-
|
|
5876
|
-
|
|
5877
|
-
|
|
5878
|
-
|
|
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)
|
|
8398
|
-
|
|
8399
|
-
|
|
8400
|
-
|
|
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>" : "") +
|
|
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-
|
|
9165
|
-
'<input class="hook-name" value="' + esc(h.name || "") + '" placeholder="' + esc(t("
|
|
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(
|
|
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('
|
|
9177
|
-
'<div class="hook-foot">' +
|
|
9178
|
-
'<
|
|
9179
|
-
|
|
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)); //
|
|
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
|
-
|
|
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
|
-
|
|
11562
|
-
|
|
11563
|
-
document.body.classList.
|
|
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(
|
|
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
|
-
//
|
|
11959
|
-
|
|
11960
|
-
$("btn-q-
|
|
11961
|
-
$("btn-q-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
|
5719
|
-
.hook-card .hook-
|
|
5720
|
-
.hook-card .hook-
|
|
5721
|
-
.hook-card .hook-
|
|
5722
|
-
.hook-card .hook-
|
|
5723
|
-
.hook-card .hook-
|
|
5724
|
-
.hook-card .hook-
|
|
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