codebee 0.1.47 → 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 +12 -0
- package/app/core/aiflavor.py +80 -2
- package/app/core/automation.py +55 -2
- package/app/core/flows.py +12 -3
- package/app/core/hooks.py +156 -0
- package/app/core/pipeline.py +135 -8
- package/app/core/planner.py +127 -11
- package/app/core/store.py +67 -1
- package/app/core/usage.py +290 -286
- package/app/core/volumes.py +355 -0
- package/app/core/zentao.py +9 -4
- package/app/main.py +38 -0
- package/app/ui/app.js +737 -69
- package/app/ui/i18n.js +49 -1
- package/app/ui/index.html +81 -7
- package/app/ui/style.css +177 -0
- package/package.json +1 -1
package/app/core/pipeline.py
CHANGED
|
@@ -19,7 +19,7 @@ import re
|
|
|
19
19
|
import threading
|
|
20
20
|
import time
|
|
21
21
|
|
|
22
|
-
from . import aiflavor, attachments, catalog, dispatch_log, history, jobs, knowledge, manager, modelhub, mocks, paihang, planner, registry, router, runner, skills, store, task_compile, usage
|
|
22
|
+
from . import aiflavor, attachments, catalog, dispatch_log, history, hooks, jobs, knowledge, manager, modelhub, mocks, paihang, planner, registry, router, runner, skills, store, task_compile, usage, volumes
|
|
23
23
|
from . import builtin_agent
|
|
24
24
|
from . import diagnostics
|
|
25
25
|
from . import paths as paths_mod
|
|
@@ -1539,6 +1539,14 @@ def _run_direct(run, task, agents, ev, stats, mode):
|
|
|
1539
1539
|
# drain 的路径消息永远不消费,只看非空会空转到轮数上限。
|
|
1540
1540
|
# 比较步骤前后的未消费数即可区分。
|
|
1541
1541
|
before_n = len(_pending_messages(run_id))
|
|
1542
|
+
# 钩子注入(message_submit / task_start 通道):一次性消费拼提示词头
|
|
1543
|
+
hook_head = ""
|
|
1544
|
+
try:
|
|
1545
|
+
hook_head = store.pop_run_inject(run_id)
|
|
1546
|
+
except Exception:
|
|
1547
|
+
pass
|
|
1548
|
+
if hook_head:
|
|
1549
|
+
prompt = ("## 项目钩子注入\n" + hook_head + "\n\n" + prompt)
|
|
1542
1550
|
if bi is not None:
|
|
1543
1551
|
res = _run_builtin_step(run_id, "direct" if first else "chat", bi, prompt,
|
|
1544
1552
|
step_wd, ev=ev, note=note, images=images,
|
|
@@ -1799,6 +1807,13 @@ def _plot_modules(workdir):
|
|
|
1799
1807
|
"鼓励化用,不要照抄原句)\n\n" + txt)
|
|
1800
1808
|
|
|
1801
1809
|
|
|
1810
|
+
def _book_volume_plan(task, outline, upto):
|
|
1811
|
+
"""本书的卷规划表(卷边界+卷名+卷弧光)。实现见 planner.book_volume_plan
|
|
1812
|
+
——放在 planner 是因为它要沿 serial.continues 链读历史 run 的卷名,属
|
|
1813
|
+
「大纲/命名」职责;此处保留薄封装便于连载流程阅读。"""
|
|
1814
|
+
return planner.book_volume_plan(task, outline, upto)
|
|
1815
|
+
|
|
1816
|
+
|
|
1802
1817
|
LEDGER_FILE = os.path.join(".codebee", "resource-ledger.md")
|
|
1803
1818
|
_LEDGER_MAX_CHARS = 6000 # 账本注入上限:太老的状态让评审官收敛 recent 优先
|
|
1804
1819
|
|
|
@@ -1913,6 +1928,7 @@ __OUTLINE__
|
|
|
1913
1928
|
|
|
1914
1929
|
---
|
|
1915
1930
|
## 本章任务(执行这一条即可)
|
|
1931
|
+
__VOLUME__
|
|
1916
1932
|
- 撰写本书第 __I__ 章,把本章正文写入文件 `__FILE__`(直接写入该文件,只写本章)。文件必须以 UTF-8 编码保存:PowerShell 一律显式加 `-Encoding UTF8`(如 `Set-Content -Path __FILE__ -Encoding UTF8`),禁止依赖系统默认编码,否则中文会乱码。
|
|
1917
1933
|
- 章节标题:__TITLE__
|
|
1918
1934
|
- 剧情要点:__BEATS__
|
|
@@ -1932,6 +1948,8 @@ SERIAL_REVISE_PROMPT = """你是一名网文作者。第 __I__ 章没有通过
|
|
|
1932
1948
|
## 全书目标
|
|
1933
1949
|
__GOAL__
|
|
1934
1950
|
|
|
1951
|
+
## 本卷上下文
|
|
1952
|
+
__VOLUME__
|
|
1935
1953
|
## 本章评审意见
|
|
1936
1954
|
__CRITIQUE__
|
|
1937
1955
|
|
|
@@ -2067,6 +2085,20 @@ def _weakest_chapters(chapter_scores, global_means, threshold, limit=2):
|
|
|
2067
2085
|
return cand[:limit]
|
|
2068
2086
|
|
|
2069
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
|
+
|
|
2070
2102
|
def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route, resume_ctx, difficulty):
|
|
2071
2103
|
"""连载流水线:大纲 → 逐章起草/评审/修订 → 全局一致性评审 → 合并成书。"""
|
|
2072
2104
|
import json as _json
|
|
@@ -2142,6 +2174,10 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
2142
2174
|
kb = knowledge.block_for(task)
|
|
2143
2175
|
if kb:
|
|
2144
2176
|
tpl = tpl.replace("## 待评审稿件", "%s\n\n## 待评审稿件" % kb, 1)
|
|
2177
|
+
# 确定性检测(AI 味/叙事架构/节奏)此前只挂在单稿件评审上,连载逐章
|
|
2178
|
+
# 评审从未拿到——连载是我们的旗舰场景,逐章节奏与钩子恰恰最需要这条
|
|
2179
|
+
# 参考线。与单稿件一致:命中才追加,只作参考不直接扣分。
|
|
2180
|
+
tpl = _inject_detection(tpl, text, task)
|
|
2145
2181
|
return tpl.replace("__DIMKEYS__", dimkey).replace(
|
|
2146
2182
|
"__MANUSCRIPT__", text or "(稿件为空!)")
|
|
2147
2183
|
|
|
@@ -2201,6 +2237,34 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
2201
2237
|
("(章末钩子:%s)" % c.get("hook")) if c.get("hook") else "")
|
|
2202
2238
|
for k, c in enumerate(outline["chapters"]))
|
|
2203
2239
|
|
|
2240
|
+
# 分卷规划表(本书级、由章号确定性推导):覆盖到本批末章即可。写成
|
|
2241
|
+
# 全书顶层字段,UI/报告/发布页直接读,不必重算。
|
|
2242
|
+
vol_plan = _book_volume_plan(task, outline, end)
|
|
2243
|
+
if vol_plan:
|
|
2244
|
+
store.update_run(run_id, volumes=vol_plan)
|
|
2245
|
+
|
|
2246
|
+
def vol_block_for(chapter):
|
|
2247
|
+
"""单章的卷上下文:写作时知道自己身处哪一卷、卷内第几章、是否卷末。
|
|
2248
|
+
不分卷返回 ""(提示词零噪音)。"""
|
|
2249
|
+
ent = volumes.find(vol_plan, chapter) if vol_plan else None
|
|
2250
|
+
if not ent:
|
|
2251
|
+
return ""
|
|
2252
|
+
pos, total = volumes.position(vol_plan, chapter)
|
|
2253
|
+
title = ("《%s》" % ent["title"]) if ent.get("title") else "(本卷待命名)"
|
|
2254
|
+
lines = ["- 本卷:第 %d 卷 %s" % (ent["vol"], title)]
|
|
2255
|
+
if total:
|
|
2256
|
+
lines.append("- 本卷位置:第 %d 章 / 共 %d 章" % (pos, total))
|
|
2257
|
+
else:
|
|
2258
|
+
lines.append("- 本卷位置:第 %d 章" % pos)
|
|
2259
|
+
if ent.get("arc"):
|
|
2260
|
+
lines.append("- 本卷弧光(本卷写作须服务这条主线):%s" % ent["arc"])
|
|
2261
|
+
if total and pos == total:
|
|
2262
|
+
lines.append("- **本章是卷末章**:必须收束本卷主线冲突,写出本卷最高潮,"
|
|
2263
|
+
"并在结尾留下牵出下一卷的大钩子(卷末是最重要的读者留存点)。")
|
|
2264
|
+
elif total and pos >= max(1, total - 1):
|
|
2265
|
+
lines.append("- 本章临近卷末:开始向本卷高潮收拢,不要再铺新的支线。")
|
|
2266
|
+
return "\n".join(lines) + "\n"
|
|
2267
|
+
|
|
2204
2268
|
chapter_scores = [] # [{chapter,title,means,passed,rounds,words}]
|
|
2205
2269
|
issues_all = []
|
|
2206
2270
|
|
|
@@ -2209,6 +2273,20 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
2209
2273
|
i = start + k - 1 # 全书章号:文件名/步骤角色/评分记录都按全书编号
|
|
2210
2274
|
ch = outline["chapters"][k - 1]
|
|
2211
2275
|
ch_file = "chapter-%02d.md" % i
|
|
2276
|
+
# 分卷上下文:评审时也带着「这是第几卷第几章、是不是卷末」——
|
|
2277
|
+
# 卷末章要按卷弧度收束,不符的章节不该靠全书评分才发现。
|
|
2278
|
+
_vpos, _vtotal = volumes.position(vol_plan, i) if vol_plan else (0, 0)
|
|
2279
|
+
vol_review_block = ""
|
|
2280
|
+
if vol_plan and _vpos:
|
|
2281
|
+
_vent = volumes.find(vol_plan, i)
|
|
2282
|
+
_vt = ("《%s》" % _vent["title"]) if (_vent and _vent.get("title")) else ""
|
|
2283
|
+
vol_review_block = (
|
|
2284
|
+
"本卷上下文:第 %d 卷%s,本章是卷内第 %d%s 章。%s\n"
|
|
2285
|
+
% (_vent["vol"], _vt, _vpos,
|
|
2286
|
+
("/%d" % _vtotal) if _vtotal else "",
|
|
2287
|
+
"**卷末章**:须核对本卷主线是否收束、高潮是否到位、卷末钩子是否立住。"
|
|
2288
|
+
if (_vtotal and _vpos == _vtotal)
|
|
2289
|
+
else "核对本章是否服务于本卷主线弧光。"))
|
|
2212
2290
|
# 逐项目标审稿(借鉴 AI-Novel-Writer):本章大纲要点随评审下发,评审
|
|
2213
2291
|
# 以 <event_check> 回逐项判定;资源账本(借鉴角色资源账本)以 <ledger>
|
|
2214
2292
|
# 回本章道具/伤情/承诺/伏笔增量,评审达标后追加账本文件供下章注入。
|
|
@@ -2220,8 +2298,9 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
2220
2298
|
"「铺垫了但没发生」不算已完成;未完成的项必须反映到对应维度评分。\n"
|
|
2221
2299
|
"另在 <event_check> 块之后输出 <ledger> 块(没有新变化就整个省略):"
|
|
2222
2300
|
"逐行列出本章新出现或状态变化的 道具/伤情/承诺/伏笔,格式:"
|
|
2223
|
-
"类型|名称|现状(一句话)。\n\n"
|
|
2224
|
-
% (ch.get("beats") or "按大纲推进", ch.get("hook") or "留下悬念"
|
|
2301
|
+
"类型|名称|现状(一句话)。\n%s\n"
|
|
2302
|
+
% (ch.get("beats") or "按大纲推进", ch.get("hook") or "留下悬念",
|
|
2303
|
+
vol_review_block))
|
|
2225
2304
|
ev_check_lines = [[]] # 每章重置:第一份非空评审的逐项判定
|
|
2226
2305
|
ledger_lines = [] # 本章全部评审的账本增量并集
|
|
2227
2306
|
ledger_txt = _read_ledger(workdir) # 截至上一章的资源账本(起草注入)
|
|
@@ -2384,6 +2463,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
2384
2463
|
return (SERIAL_CHAPTER_PROMPT
|
|
2385
2464
|
.replace("__SKILLS__", sk_block)
|
|
2386
2465
|
.replace("__SCOPE__", scope)
|
|
2466
|
+
.replace("__VOLUME__", vol_block_for(i))
|
|
2387
2467
|
.replace("__I__", str(i)).replace("__FILE__", vfile)
|
|
2388
2468
|
.replace("__GOAL__", task["goal"])
|
|
2389
2469
|
.replace("__OUTLINE__", outline_txt)
|
|
@@ -2632,6 +2712,11 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
2632
2712
|
cs = dict(inh_scores[i])
|
|
2633
2713
|
cs.setdefault("chapter", i)
|
|
2634
2714
|
cs["reused"] = True
|
|
2715
|
+
_vent = volumes.find(vol_plan, i) if vol_plan else None
|
|
2716
|
+
if _vent:
|
|
2717
|
+
cs["vol"] = _vent["vol"]
|
|
2718
|
+
if _vent.get("title"):
|
|
2719
|
+
cs["vol_title"] = _vent["title"]
|
|
2635
2720
|
chapter_scores.append(cs)
|
|
2636
2721
|
store.update_run(run_id, chapter_scores=chapter_scores)
|
|
2637
2722
|
continue
|
|
@@ -2695,6 +2780,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
2695
2780
|
prompt = (SERIAL_REVISE_PROMPT
|
|
2696
2781
|
.replace("__I__", str(i)).replace("__FILE__", ch_file)
|
|
2697
2782
|
.replace("__GOAL__", task["goal"])
|
|
2783
|
+
.replace("__VOLUME__", vol_block_for(i))
|
|
2698
2784
|
.replace("__CRITIQUE__", "\n".join(crit_lines))
|
|
2699
2785
|
.replace("__WORDS__", str(wpc)))
|
|
2700
2786
|
prompt = attachments.append_task_context(prompt, task)
|
|
@@ -2710,6 +2796,11 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
2710
2796
|
"passed": bool(means) and all(v >= threshold_ch for v in means.values()),
|
|
2711
2797
|
"rounds": rounds_used,
|
|
2712
2798
|
"words": _wc(_read_chapter(workdir, i))}
|
|
2799
|
+
_vent = volumes.find(vol_plan, i) if vol_plan else None
|
|
2800
|
+
if _vent:
|
|
2801
|
+
cs_new["vol"] = _vent["vol"] # 章节卡按卷分组用
|
|
2802
|
+
if _vent.get("title"):
|
|
2803
|
+
cs_new["vol_title"] = _vent["title"]
|
|
2713
2804
|
if ev_check_lines[0]:
|
|
2714
2805
|
cs_new["event_check"] = ev_check_lines[0][:8] # 逐项目标审稿结果
|
|
2715
2806
|
chapter_scores.append(cs_new)
|
|
@@ -2838,6 +2929,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
2838
2929
|
prompt = (SERIAL_REVISE_PROMPT
|
|
2839
2930
|
.replace("__I__", str(i)).replace("__FILE__", "chapter-%02d.md" % i)
|
|
2840
2931
|
.replace("__GOAL__", task["goal"])
|
|
2932
|
+
.replace("__VOLUME__", vol_block_for(i))
|
|
2841
2933
|
.replace("__CRITIQUE__", crit)
|
|
2842
2934
|
.replace("__WORDS__", str(wpc)))
|
|
2843
2935
|
prompt = attachments.append_task_context(prompt, task)
|
|
@@ -2918,14 +3010,29 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
2918
3010
|
ms_name = _ms_name(task.get("manuscript"))
|
|
2919
3011
|
book_title = outline.get("book_title") or task["title"]
|
|
2920
3012
|
parts = ["# %s" % book_title, ""]
|
|
3013
|
+
# 分卷标题:卷首章之前插「第 X 卷 《卷名》」分隔(全书 1..end 都插,续写
|
|
3014
|
+
# 批次合并时上一批的卷标题也一并补上,不会只在首批出现)
|
|
3015
|
+
vol_heads = {}
|
|
3016
|
+
if vol_plan:
|
|
3017
|
+
for _ent in vol_plan:
|
|
3018
|
+
if _ent["first"] <= end:
|
|
3019
|
+
vol_heads[_ent["first"]] = _ent
|
|
2921
3020
|
for i in range(1, end + 1): # 合并全书:续写时包含上一批已写好的章
|
|
3021
|
+
head = vol_heads.get(i)
|
|
3022
|
+
if head:
|
|
3023
|
+
vol_title = ("第 %d 卷 《%s》" % (head["vol"], head["title"])) \
|
|
3024
|
+
if head.get("title") else ("第 %d 卷" % head["vol"])
|
|
3025
|
+
parts.append("## %s" % vol_title)
|
|
3026
|
+
parts.append("")
|
|
2922
3027
|
parts.append(_read_chapter(workdir, i).strip())
|
|
2923
3028
|
parts.append("")
|
|
2924
3029
|
with _ms_io(workdir, ms_name, "w") as f:
|
|
2925
3030
|
f.write("\n".join(parts))
|
|
2926
3031
|
total_words = _wc("\n".join(parts))
|
|
2927
3032
|
store.finish_step(run_id, step["n"], "done",
|
|
2928
|
-
summary="已合并 %d 章为 %s(约 %d
|
|
3033
|
+
summary="已合并 %d 章为 %s(约 %d 字%s)" % (
|
|
3034
|
+
n, ms_name, total_words,
|
|
3035
|
+
",分 %d 卷" % len(vol_heads) if vol_heads else ""),
|
|
2929
3036
|
duration_s=0.1)
|
|
2930
3037
|
|
|
2931
3038
|
chapters_pass = all(c["passed"] for c in chapter_scores)
|
|
@@ -2940,11 +3047,19 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
|
|
|
2940
3047
|
"chapter_scores": chapter_scores, "global_scores": global_means,
|
|
2941
3048
|
"global_pass": global_pass, "route": route,
|
|
2942
3049
|
}
|
|
3050
|
+
if vol_plan:
|
|
3051
|
+
verdict["volumes"] = vol_plan
|
|
2943
3052
|
scope_txt = ("续写第 %d–%d 章,衔接前文 %d 章" % (start, end, start - 1)) if start > 1 \
|
|
2944
3053
|
else ("共 %d 章" % n)
|
|
2945
3054
|
lines = ["# 连载小说评审报告:%s" % task["title"], "",
|
|
2946
3055
|
"- 书名:%s(%s / 约 %d 字,合并为 `%s`)" % (
|
|
2947
3056
|
book_title, scope_txt, total_words, ms_name),
|
|
3057
|
+
"- 分卷:%s" % (";".join(
|
|
3058
|
+
("第 %d 卷《%s》第 %d–%d 章" % (v["vol"], v.get("title") or "未命名",
|
|
3059
|
+
v["first"],
|
|
3060
|
+
v["last"] if v["last"] is not None else end))
|
|
3061
|
+
for v in vol_heads.values()) if vol_heads
|
|
3062
|
+
else ("未分卷(可设置「每卷章数」,或在目标里写明卷结构)")),
|
|
2948
3063
|
"- 结论:**%s**(各章门禁 %s / 全局评审 %s)" % (
|
|
2949
3064
|
"✅ 达到发布标准" if publishable else "❌ 未达标",
|
|
2950
3065
|
"通过" if chapters_pass else "未通过",
|
|
@@ -3244,10 +3359,8 @@ def _run_content_review(run, task, agents, ev, stats, mode):
|
|
|
3244
3359
|
if task.get("context"):
|
|
3245
3360
|
crit_prompt += "\n\n## 原始任务背景与附件参考\n" + task["context"]
|
|
3246
3361
|
# AI 味确定性检测(借鉴 oh-story 去AI味):客观参考线随评审下发,
|
|
3247
|
-
#
|
|
3248
|
-
|
|
3249
|
-
if _aiflavor_line:
|
|
3250
|
-
crit_prompt += "\n\n## 确定性检测结果(供评审参考)\n" + _aiflavor_line
|
|
3362
|
+
# 命中才追加——评审官结合上下文判断是否真问题,脚本不直接扣分。
|
|
3363
|
+
crit_prompt = _inject_detection(crit_prompt, manuscript, task)
|
|
3251
3364
|
for agent in critics:
|
|
3252
3365
|
role = "critique-r%d" % r
|
|
3253
3366
|
if agent.get("mode") == "mock":
|
|
@@ -3698,6 +3811,20 @@ def execute_run(run_id):
|
|
|
3698
3811
|
})
|
|
3699
3812
|
mode = task.get("mode") or ("manual" if task.get("implementer") else "auto")
|
|
3700
3813
|
store.update_run(run_id, mode=mode)
|
|
3814
|
+
# 任务生命周期钩子(task_start,2026-09-22):stdout 注入文本并入任务上下文
|
|
3815
|
+
# ——context 在全引擎提示词都有 __CONTEXT__ 占位,一处并入全覆盖
|
|
3816
|
+
try:
|
|
3817
|
+
hook_txt = hooks.run_event("task_start",
|
|
3818
|
+
ctx={"title": task.get("title") or "",
|
|
3819
|
+
"type": task.get("type") or "",
|
|
3820
|
+
"mode": mode, "goal": task.get("goal") or ""},
|
|
3821
|
+
task_id=task.get("id") or "", run_id=run_id)
|
|
3822
|
+
if hook_txt:
|
|
3823
|
+
task["context"] = ((task.get("context") or "") + "\n\n" +
|
|
3824
|
+
t("## 项目钩子注入(task_start)\n") + hook_txt).strip()
|
|
3825
|
+
store.update_run(run_id, hook_inject=hook_txt[:400])
|
|
3826
|
+
except Exception:
|
|
3827
|
+
pass # 外部钩子故障绝不挡主流程
|
|
3701
3828
|
# engine 决定流水线:code=实现/验证/评审/修复;review=起草/多维评审/修订/门禁;
|
|
3702
3829
|
# direct=单 CLI 直达(无拆解/评审,信箱续轮即对话)
|
|
3703
3830
|
engine = task.get("engine") or ("code" if task["type"] == "code" else "review")
|
package/app/core/planner.py
CHANGED
|
@@ -12,7 +12,7 @@ import os
|
|
|
12
12
|
import re
|
|
13
13
|
import time
|
|
14
14
|
|
|
15
|
-
from . import knowledge, modelhub, runner, skills, usage
|
|
15
|
+
from . import knowledge, modelhub, runner, skills, usage, volumes
|
|
16
16
|
|
|
17
17
|
MAX_SUBTASKS = 4
|
|
18
18
|
DEFAULT_OUTLINE_TIMEOUT = 900 # 8 章大纲 + 经验包注入是重生成任务,300s 实测不够
|
|
@@ -155,12 +155,13 @@ SERIAL_OUTLINE_PROMPT = """你是网文主编,熟悉签约平台(番茄/七
|
|
|
155
155
|
__SKILLS__
|
|
156
156
|
请为下面的小说目标设计一份连载大纲:共 __N__ 章,每章约 __W__ 字。
|
|
157
157
|
只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
|
|
158
|
-
{"book_title": "书名", "chapters": [{"title": "章节标题", "beats": "本章剧情要点(50-120字:事件/冲突/推进)", "hook": "章末钩子(一句话)"}]}
|
|
158
|
+
{"book_title": "书名", "chapters": [{"title": "章节标题", "beats": "本章剧情要点(50-120字:事件/冲突/推进)", "hook": "章末钩子(一句话)"}]__VOLUMES_KEY__}
|
|
159
159
|
硬性要求:
|
|
160
160
|
- 第 1-3 章是黄金三章:第 1 章开篇即冲突+人设立住,第 3 章末留大钩子;
|
|
161
161
|
- 每章有明确冲突与剧情推进,禁止水字数的日常流水账;
|
|
162
162
|
- 结局必须闭环(完本感),主角有成长弧光;
|
|
163
163
|
- 题材健康,无违规内容,符合平台签约调性。
|
|
164
|
+
__VOLUMES__
|
|
164
165
|
|
|
165
166
|
## 小说目标
|
|
166
167
|
__GOAL__
|
|
@@ -173,12 +174,13 @@ SERIAL_CONTINUE_OUTLINE_PROMPT = """你是网文主编,熟悉签约平台(
|
|
|
173
174
|
__SKILLS__
|
|
174
175
|
这是一部长篇连载的续写:全书已完成前 __DONE__ 章,现在请规划第 __START__–__END__ 章
|
|
175
176
|
(本批共 __N__ 章,每章约 __W__ 字)。只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
|
|
176
|
-
{"book_title": "书名(与前文保持一致)", "chapters": [{"title": "章节标题", "beats": "本章剧情要点(50-120字:事件/冲突/推进)", "hook": "章末钩子(一句话)"}]}
|
|
177
|
+
{"book_title": "书名(与前文保持一致)", "chapters": [{"title": "章节标题", "beats": "本章剧情要点(50-120字:事件/冲突/推进)", "hook": "章末钩子(一句话)"}]__VOLUMES_KEY__}
|
|
177
178
|
硬性要求:
|
|
178
179
|
- 第 1 章直接衔接前文(见下方前情),不得跳线、不得重启设定、不得复述前文;
|
|
179
180
|
- 主线沿既有脉络推进,新冲突尽量从已埋伏笔中生长,人物性格与前文一致;
|
|
180
181
|
- 每章有明确冲突与剧情推进,禁止水字数的日常流水账;
|
|
181
182
|
- 题材健康,无违规内容,符合平台签约调性。
|
|
183
|
+
__VOLUMES__
|
|
182
184
|
|
|
183
185
|
## 前情大纲(已完成章节,章号为全书章号)
|
|
184
186
|
__PREV_OUTLINE__
|
|
@@ -192,8 +194,23 @@ __GOAL__
|
|
|
192
194
|
## 背景与上下文
|
|
193
195
|
__CONTEXT__"""
|
|
194
196
|
|
|
197
|
+
# 分卷指令块:插进大纲提示词。卷边界(哪几章属于哪一卷)已由全书章号确定,
|
|
198
|
+
# 是本书的不变量——模型只负责起卷名、写卷弧光,不得改动边界。不分卷时整块为空。
|
|
199
|
+
_VOLUMES_BLOCK = """
|
|
200
|
+
## 分卷结构(必须遵守)
|
|
201
|
+
本书按下列卷组织(**卷边界已按全书章号确定,是本书的不变量:不要改动章号区间、
|
|
202
|
+
不要新增或合并卷**):
|
|
203
|
+
__VOL_PLAN__
|
|
195
204
|
|
|
196
|
-
|
|
205
|
+
请为**本批写作涉及的卷**补全卷名与卷弧光(已给卷名的必须原样沿用,一个字都不要改):
|
|
206
|
+
- 卷名:4-12 字;已给的(如《卷一 少年初入江湖》)照抄,未给的由你命名。
|
|
207
|
+
- 卷弧光:本卷的主线冲突、人物成长、卷末必须达到的高潮或转折(50-150 字)。
|
|
208
|
+
- 各卷弧光要能串成全书主线:前卷埋的钩子在后卷回收,卷末是本卷最高潮。
|
|
209
|
+
JSON 增加 "volumes" 字段,键为卷号字符串:
|
|
210
|
+
"volumes": {"1": {"title": "卷一 少年初入江湖", "arc": "…"}, "2": {"title": "…", "arc": "…"}}"""
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _norm_chapters(data, n, vol_per=0, vol_start=1, vol_plan=None):
|
|
197
214
|
"""规范化连载大纲输出;不合规返回 None。"""
|
|
198
215
|
if not isinstance(data, dict):
|
|
199
216
|
return None
|
|
@@ -215,12 +232,20 @@ def _norm_chapters(data, n):
|
|
|
215
232
|
while len(out) < n: # 缺的章补模板位
|
|
216
233
|
out.append({"title": "第 %d 章" % (len(out) + 1), "beats": "按全书目标推进剧情",
|
|
217
234
|
"hook": ""})
|
|
218
|
-
|
|
235
|
+
res = {"book_title": str(data.get("book_title") or "").strip()[:40], "chapters": out[:n]}
|
|
236
|
+
# 分卷:只收本批真正用到的卷的卷名/卷弧光(模型可能多写或乱写卷号,一律按
|
|
237
|
+
# 本批章号范围过滤)。卷边界不在这里——它由 volumes.build_plan 从章号推导。
|
|
238
|
+
if vol_plan:
|
|
239
|
+
named = volumes.norm_volumes(data.get("volumes"), vol_per, vol_start, n)
|
|
240
|
+
if named:
|
|
241
|
+
res["volumes"] = named
|
|
242
|
+
return res
|
|
219
243
|
|
|
220
244
|
|
|
221
245
|
def _prev_serial_story(task):
|
|
222
246
|
"""续写大纲的前情素材:沿 serial.continues 链收集已完成各章大纲(标全书章号)
|
|
223
|
-
+ 最新一章结尾(衔接锚点)+
|
|
247
|
+
+ 最新一章结尾(衔接锚点)+ 既有书名 + 既有卷名。
|
|
248
|
+
返回 (前情文本, 已完成章数, 书名, 最新章结尾, 卷名表)。"""
|
|
224
249
|
from . import store # 惰性导入:store 不依赖 planner,避免测试环境导入顺序问题
|
|
225
250
|
chain, seen, cur = [], set(), task
|
|
226
251
|
while len(chain) < 10:
|
|
@@ -231,7 +256,7 @@ def _prev_serial_story(task):
|
|
|
231
256
|
seen.add(prev["id"])
|
|
232
257
|
chain.append(prev)
|
|
233
258
|
cur = prev
|
|
234
|
-
lines, book_title = [], ""
|
|
259
|
+
lines, book_title, volnames = [], "", {}
|
|
235
260
|
for prev in reversed(chain): # 旧 → 新,前情按章号顺序铺开
|
|
236
261
|
try:
|
|
237
262
|
ps = int((prev.get("serial") or {}).get("start_chapter") or 1)
|
|
@@ -246,6 +271,7 @@ def _prev_serial_story(task):
|
|
|
246
271
|
if not outline:
|
|
247
272
|
continue
|
|
248
273
|
book_title = book_title or str(outline.get("book_title") or "")
|
|
274
|
+
volnames.update(outline.get("volumes") or {})
|
|
249
275
|
for k, c in enumerate(outline["chapters"]):
|
|
250
276
|
lines.append("第 %d 章《%s》:%s" % (ps + k, c.get("title", ""),
|
|
251
277
|
str(c.get("beats") or "")[:120]))
|
|
@@ -269,7 +295,52 @@ def _prev_serial_story(task):
|
|
|
269
295
|
tail = b.decode("utf-8", "replace")[-500:].strip()
|
|
270
296
|
except OSError:
|
|
271
297
|
pass
|
|
272
|
-
return "\n".join(lines), max(best_i, 0), book_title, tail
|
|
298
|
+
return "\n".join(lines), max(best_i, 0), book_title, tail, volnames
|
|
299
|
+
|
|
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)
|
|
273
344
|
|
|
274
345
|
|
|
275
346
|
def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path=None):
|
|
@@ -289,15 +360,36 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
|
|
|
289
360
|
if kb_block:
|
|
290
361
|
sk_block = (sk_block + "\n\n" + kb_block) if sk_block else kb_block
|
|
291
362
|
prev_title = ""
|
|
363
|
+
# 分卷规划表:显式卷表 → 每卷章数,皆无则不分卷(整块提示词为空)。
|
|
364
|
+
# 卷边界由章号确定性推导,本批写不到的高章号也要铺出来(upto=start+n-1)。
|
|
365
|
+
vol_per = volumes.norm_per(serial.get("volume_chapters"))
|
|
366
|
+
vol_plan = volumes.build_plan(serial.get("volumes"), vol_per,
|
|
367
|
+
upto=start + n - 1)
|
|
368
|
+
prev_volnames = {}
|
|
292
369
|
if start > 1:
|
|
293
|
-
prev_lines, done, prev_title, prev_tail = _prev_serial_story(task)
|
|
370
|
+
prev_lines, done, prev_title, prev_tail, prev_volnames = _prev_serial_story(task)
|
|
294
371
|
done = max(done, start - 1)
|
|
372
|
+
# 前批已命名的卷名回填进规划表:续写批次沿用同一卷名,不会给同一卷改名
|
|
373
|
+
if vol_plan:
|
|
374
|
+
vol_plan = volumes.merge_titles(vol_plan, prev_volnames)
|
|
375
|
+
else:
|
|
376
|
+
prev_lines = prev_tail = ""
|
|
377
|
+
if vol_plan:
|
|
378
|
+
vol_block = (_VOLUMES_BLOCK
|
|
379
|
+
.replace("__VOL_PLAN__", volumes.fmt_plan(vol_plan,
|
|
380
|
+
upto=start + n - 1)))
|
|
381
|
+
vol_key = ', "volumes": {"<卷号>": {"title": "<卷名>", "arc": "<卷弧光>"}}'
|
|
382
|
+
else:
|
|
383
|
+
vol_block, vol_key = "", ""
|
|
384
|
+
if start > 1:
|
|
295
385
|
prompt = (SERIAL_CONTINUE_OUTLINE_PROMPT
|
|
296
386
|
.replace("__SKILLS__", sk_block)
|
|
297
387
|
.replace("__DONE__", str(done))
|
|
298
388
|
.replace("__START__", str(start))
|
|
299
389
|
.replace("__END__", str(start + n - 1))
|
|
300
390
|
.replace("__N__", str(n)).replace("__W__", str(wpc))
|
|
391
|
+
.replace("__VOLUMES_KEY__", vol_key)
|
|
392
|
+
.replace("__VOLUMES__", vol_block)
|
|
301
393
|
.replace("__PREV_OUTLINE__", prev_lines or "(无大纲记录,请依据下方最新一章结尾与小说目标衔接)")
|
|
302
394
|
.replace("__PREV_TAIL__", prev_tail or "(无)")
|
|
303
395
|
.replace("__GOAL__", task["goal"])
|
|
@@ -305,8 +397,25 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
|
|
|
305
397
|
else:
|
|
306
398
|
prompt = (SERIAL_OUTLINE_PROMPT.replace("__SKILLS__", sk_block)
|
|
307
399
|
.replace("__N__", str(n)).replace("__W__", str(wpc))
|
|
400
|
+
.replace("__VOLUMES_KEY__", vol_key)
|
|
401
|
+
.replace("__VOLUMES__", vol_block)
|
|
308
402
|
.replace("__GOAL__", task["goal"])
|
|
309
403
|
.replace("__CONTEXT__", task.get("context") or "(无)"))
|
|
404
|
+
|
|
405
|
+
def _norm(data):
|
|
406
|
+
"""规范化大纲 + 收卷名。卷边界不采信模型,只采信它给的卷名/卷弧光。"""
|
|
407
|
+
o = _norm_chapters(data, n, vol_per=vol_per, vol_start=start,
|
|
408
|
+
vol_plan=vol_plan)
|
|
409
|
+
if o and vol_plan and not o.get("volumes"):
|
|
410
|
+
# 模型没给卷名时,用规划表里已有的(用户给的/前批命名的)兜底,
|
|
411
|
+
# 保证成书卷标题不空
|
|
412
|
+
have = {str(e["vol"]): {"title": e.get("title") or "",
|
|
413
|
+
"arc": e.get("arc") or ""}
|
|
414
|
+
for e in vol_plan if (e.get("title") or e.get("arc"))}
|
|
415
|
+
if have:
|
|
416
|
+
o["volumes"] = have
|
|
417
|
+
return o
|
|
418
|
+
|
|
310
419
|
# 续写批次打上全书章号标记;书名缺省时沿用前文
|
|
311
420
|
def _mark(o):
|
|
312
421
|
if o and start > 1:
|
|
@@ -347,7 +456,7 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
|
|
|
347
456
|
% (_attempt, (res.get("error") or "未知错误")[:300]))
|
|
348
457
|
orch_errors.append(str(res.get("error") or "返回内容无法解析为大纲"))
|
|
349
458
|
data = runner.extract_json(res.get("text") or "") if res["ok"] else None
|
|
350
|
-
outline =
|
|
459
|
+
outline = _norm(data)
|
|
351
460
|
if outline:
|
|
352
461
|
outline["source"] = "编排者(%s)" % label
|
|
353
462
|
return _mark(outline)
|
|
@@ -367,7 +476,7 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
|
|
|
367
476
|
if not res["ok"]:
|
|
368
477
|
_append_log(log_path, "作者 CLI 失败:%s" % (res.get("error") or "")[:300])
|
|
369
478
|
reason_tail = (res.get("error") or reason_tail)[:200]
|
|
370
|
-
outline =
|
|
479
|
+
outline = _norm(runner.extract_json(res.get("text") or ""))
|
|
371
480
|
if outline:
|
|
372
481
|
outline["source"] = "llm(%s)" % author_agent["id"]
|
|
373
482
|
return _mark(outline)
|
|
@@ -380,6 +489,13 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
|
|
|
380
489
|
chapters = [{"title": "第 %d 章" % (start + i), "beats": "按全书目标推进剧情,保持冲突与钩子",
|
|
381
490
|
"hook": ""} for i in range(n)]
|
|
382
491
|
out = {"book_title": prev_title, "chapters": chapters, "source": "template"}
|
|
492
|
+
if vol_plan:
|
|
493
|
+
# 分卷是结构性的、与情节无关:兜底模板也给卷名(用户给的/前批命名的),
|
|
494
|
+
# 这样成书卷标题与章-卷归属在降级情况下依然成立。
|
|
495
|
+
have = {str(e["vol"]): {"title": e.get("title") or "", "arc": e.get("arc") or ""}
|
|
496
|
+
for e in vol_plan if (e.get("title") or e.get("arc"))}
|
|
497
|
+
if have:
|
|
498
|
+
out["volumes"] = have
|
|
383
499
|
if author_agent and author_agent.get("mode") != "mock":
|
|
384
500
|
out["degraded"] = True
|
|
385
501
|
out["degraded_reason"] = "编排者/作者模型均未返回可用大纲(%s)" % reason_tail
|
package/app/core/store.py
CHANGED
|
@@ -18,7 +18,7 @@ import threading
|
|
|
18
18
|
import time
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
|
|
21
|
-
from . import paths, runner
|
|
21
|
+
from . import paths, runner, volumes
|
|
22
22
|
|
|
23
23
|
LOCK = threading.RLock()
|
|
24
24
|
_TASKS = {}
|
|
@@ -212,6 +212,22 @@ def create_task(payload):
|
|
|
212
212
|
v = 1
|
|
213
213
|
if v > 1:
|
|
214
214
|
s["variants"] = v
|
|
215
|
+
# 分卷:显式卷表优先(新建任务时用户明确给出的分卷信息),
|
|
216
|
+
# 其次每卷章数等长切卷。卷边界一律由 volumes 模块从「章号」
|
|
217
|
+
# 确定性推导——续写批次沿用同一份卷表/每卷章数即自动接上同一卷,
|
|
218
|
+
# 不会因分批而错位。
|
|
219
|
+
vol_spec = volumes.norm_spec(serial.get("volumes"))
|
|
220
|
+
if not vol_spec:
|
|
221
|
+
# 否则从文字里识别:表单「指定卷结构」框 > 任务目标 > 上下文。
|
|
222
|
+
# 保守识别——认不出返回空,绝不把普通文字误判成分卷。
|
|
223
|
+
vol_spec = volumes.parse_spec_text(serial.get("volumes_text")) \
|
|
224
|
+
or volumes.parse_spec_text(goal) \
|
|
225
|
+
or volumes.parse_spec_text(task.get("context") or "")
|
|
226
|
+
if vol_spec:
|
|
227
|
+
s["volumes"] = vol_spec
|
|
228
|
+
vper = volumes.norm_per(serial.get("volume_chapters"))
|
|
229
|
+
if vper:
|
|
230
|
+
s["volume_chapters"] = vper
|
|
215
231
|
task["serial"] = s
|
|
216
232
|
critics = payload.get("critics")
|
|
217
233
|
if isinstance(critics, list) and critics:
|
|
@@ -708,6 +724,19 @@ def update_run(run_id, expected_status=None, **fields):
|
|
|
708
724
|
if task:
|
|
709
725
|
task["status"] = st
|
|
710
726
|
_save_json(paths.TASKS_DIR / (tid + ".json"), task)
|
|
727
|
+
if st in ("done", "failed", "cancelled"):
|
|
728
|
+
# run_end 钩子(2026-09-22):终态即触发,后台跑副作用型脚本
|
|
729
|
+
# (回写知识库等);失败/超时静默,绝不拖慢收尾路径
|
|
730
|
+
try:
|
|
731
|
+
import threading
|
|
732
|
+
from . import hooks
|
|
733
|
+
threading.Thread(
|
|
734
|
+
target=hooks.run_event, args=("run_end",), daemon=True,
|
|
735
|
+
kwargs={"task_id": run.get("task_id") or "", "run_id": run_id,
|
|
736
|
+
"ctx": {"status": st, "title": run.get("title") or "",
|
|
737
|
+
"error": str(run.get("error") or "")[:300]}}).start()
|
|
738
|
+
except Exception:
|
|
739
|
+
pass
|
|
711
740
|
return run
|
|
712
741
|
|
|
713
742
|
|
|
@@ -1371,6 +1400,11 @@ def continue_task(task_id, chapters=None):
|
|
|
1371
1400
|
}
|
|
1372
1401
|
if serial.get("variants"): # 赛马配置随链条沿用
|
|
1373
1402
|
payload["serial"]["variants"] = serial["variants"]
|
|
1403
|
+
# 分卷配置随链条沿用:卷边界必须跨批次稳定,否则同一卷会被续写批次重切
|
|
1404
|
+
if serial.get("volumes"):
|
|
1405
|
+
payload["serial"]["volumes"] = serial["volumes"]
|
|
1406
|
+
if serial.get("volume_chapters"):
|
|
1407
|
+
payload["serial"]["volume_chapters"] = serial["volume_chapters"]
|
|
1374
1408
|
for key in ("draft_prompt", "critique_prompt"): # 自定义提示词覆盖一并沿用
|
|
1375
1409
|
if task.get(key):
|
|
1376
1410
|
payload[key] = task[key]
|
|
@@ -1534,6 +1568,38 @@ def _ensure_messages(run):
|
|
|
1534
1568
|
return run["messages"]
|
|
1535
1569
|
|
|
1536
1570
|
|
|
1571
|
+
def append_run_inject(run_id, text):
|
|
1572
|
+
"""追加钩子注入文本到 run.hook_inject(message_submit 钩子通道)。
|
|
1573
|
+
直连轮间组装提示词时消费一次并清零。返回 bool。"""
|
|
1574
|
+
text = str(text or "").strip()
|
|
1575
|
+
if not text:
|
|
1576
|
+
return False
|
|
1577
|
+
with LOCK:
|
|
1578
|
+
run = _RUNS.get(run_id)
|
|
1579
|
+
if not run:
|
|
1580
|
+
return False
|
|
1581
|
+
old = str(run.get("hook_inject") or "")
|
|
1582
|
+
run["hook_inject"] = (old + ("\n\n" if old and text else "") + text)[:4000]
|
|
1583
|
+
_save_json(paths.RUNS_DIR / run_id / "run.json", run)
|
|
1584
|
+
return True
|
|
1585
|
+
|
|
1586
|
+
|
|
1587
|
+
def pop_run_inject(run_id):
|
|
1588
|
+
"""取出并清空 hook_inject(直连轮间提示词头一次性消费)。"""
|
|
1589
|
+
with LOCK:
|
|
1590
|
+
run = _RUNS.get(run_id)
|
|
1591
|
+
if not run:
|
|
1592
|
+
return ""
|
|
1593
|
+
txt = str(run.get("hook_inject") or "")
|
|
1594
|
+
if txt and "hook_inject" in run:
|
|
1595
|
+
run["hook_inject"] = ""
|
|
1596
|
+
try:
|
|
1597
|
+
_save_json(paths.RUNS_DIR / run_id / "run.json", run)
|
|
1598
|
+
except Exception:
|
|
1599
|
+
pass
|
|
1600
|
+
return txt
|
|
1601
|
+
|
|
1602
|
+
|
|
1537
1603
|
def add_message(run_id, text, sender="本机", attachments=None):
|
|
1538
1604
|
"""往运行信箱追加一条用户指令。attachments 为已落盘到 workdir 的
|
|
1539
1605
|
附件相对路径清单(由 main.py 提交后传入)。返回消息 dict 或 None。"""
|