codebee 0.1.47 → 0.1.48
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/flows.py +12 -3
- package/app/core/hooks.py +156 -0
- package/app/core/pipeline.py +149 -4
- package/app/core/planner.py +82 -11
- package/app/core/store.py +68 -1
- package/app/core/usage.py +290 -286
- package/app/core/volumes.py +353 -0
- package/app/core/zentao.py +9 -4
- package/app/main.py +38 -0
- package/app/ui/app.js +405 -16
- package/app/ui/i18n.js +26 -0
- package/app/ui/index.html +72 -0
- package/app/ui/style.css +160 -0
- package/package.json +1 -1
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,7 @@ 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
|
|
273
299
|
|
|
274
300
|
|
|
275
301
|
def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path=None):
|
|
@@ -289,15 +315,36 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
|
|
|
289
315
|
if kb_block:
|
|
290
316
|
sk_block = (sk_block + "\n\n" + kb_block) if sk_block else kb_block
|
|
291
317
|
prev_title = ""
|
|
318
|
+
# 分卷规划表:显式卷表 → 每卷章数,皆无则不分卷(整块提示词为空)。
|
|
319
|
+
# 卷边界由章号确定性推导,本批写不到的高章号也要铺出来(upto=start+n-1)。
|
|
320
|
+
vol_per = volumes.norm_per(serial.get("volume_chapters"))
|
|
321
|
+
vol_plan = volumes.build_plan(serial.get("volumes"), vol_per,
|
|
322
|
+
upto=start + n - 1)
|
|
323
|
+
prev_volnames = {}
|
|
292
324
|
if start > 1:
|
|
293
|
-
prev_lines, done, prev_title, prev_tail = _prev_serial_story(task)
|
|
325
|
+
prev_lines, done, prev_title, prev_tail, prev_volnames = _prev_serial_story(task)
|
|
294
326
|
done = max(done, start - 1)
|
|
327
|
+
# 前批已命名的卷名回填进规划表:续写批次沿用同一卷名,不会给同一卷改名
|
|
328
|
+
if vol_plan:
|
|
329
|
+
vol_plan = volumes.merge_titles(vol_plan, prev_volnames)
|
|
330
|
+
else:
|
|
331
|
+
prev_lines = prev_tail = ""
|
|
332
|
+
if vol_plan:
|
|
333
|
+
vol_block = (_VOLUMES_BLOCK
|
|
334
|
+
.replace("__VOL_PLAN__", volumes.fmt_plan(vol_plan,
|
|
335
|
+
upto=start + n - 1)))
|
|
336
|
+
vol_key = ', "volumes": {"<卷号>": {"title": "<卷名>", "arc": "<卷弧光>"}}'
|
|
337
|
+
else:
|
|
338
|
+
vol_block, vol_key = "", ""
|
|
339
|
+
if start > 1:
|
|
295
340
|
prompt = (SERIAL_CONTINUE_OUTLINE_PROMPT
|
|
296
341
|
.replace("__SKILLS__", sk_block)
|
|
297
342
|
.replace("__DONE__", str(done))
|
|
298
343
|
.replace("__START__", str(start))
|
|
299
344
|
.replace("__END__", str(start + n - 1))
|
|
300
345
|
.replace("__N__", str(n)).replace("__W__", str(wpc))
|
|
346
|
+
.replace("__VOLUMES_KEY__", vol_key)
|
|
347
|
+
.replace("__VOLUMES__", vol_block)
|
|
301
348
|
.replace("__PREV_OUTLINE__", prev_lines or "(无大纲记录,请依据下方最新一章结尾与小说目标衔接)")
|
|
302
349
|
.replace("__PREV_TAIL__", prev_tail or "(无)")
|
|
303
350
|
.replace("__GOAL__", task["goal"])
|
|
@@ -305,8 +352,25 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
|
|
|
305
352
|
else:
|
|
306
353
|
prompt = (SERIAL_OUTLINE_PROMPT.replace("__SKILLS__", sk_block)
|
|
307
354
|
.replace("__N__", str(n)).replace("__W__", str(wpc))
|
|
355
|
+
.replace("__VOLUMES_KEY__", vol_key)
|
|
356
|
+
.replace("__VOLUMES__", vol_block)
|
|
308
357
|
.replace("__GOAL__", task["goal"])
|
|
309
358
|
.replace("__CONTEXT__", task.get("context") or "(无)"))
|
|
359
|
+
|
|
360
|
+
def _norm(data):
|
|
361
|
+
"""规范化大纲 + 收卷名。卷边界不采信模型,只采信它给的卷名/卷弧光。"""
|
|
362
|
+
o = _norm_chapters(data, n, vol_per=vol_per, vol_start=start,
|
|
363
|
+
vol_plan=vol_plan)
|
|
364
|
+
if o and vol_plan and not o.get("volumes"):
|
|
365
|
+
# 模型没给卷名时,用规划表里已有的(用户给的/前批命名的)兜底,
|
|
366
|
+
# 保证成书卷标题不空
|
|
367
|
+
have = {str(e["vol"]): {"title": e.get("title") or "",
|
|
368
|
+
"arc": e.get("arc") or ""}
|
|
369
|
+
for e in vol_plan if (e.get("title") or e.get("arc"))}
|
|
370
|
+
if have:
|
|
371
|
+
o["volumes"] = have
|
|
372
|
+
return o
|
|
373
|
+
|
|
310
374
|
# 续写批次打上全书章号标记;书名缺省时沿用前文
|
|
311
375
|
def _mark(o):
|
|
312
376
|
if o and start > 1:
|
|
@@ -347,7 +411,7 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
|
|
|
347
411
|
% (_attempt, (res.get("error") or "未知错误")[:300]))
|
|
348
412
|
orch_errors.append(str(res.get("error") or "返回内容无法解析为大纲"))
|
|
349
413
|
data = runner.extract_json(res.get("text") or "") if res["ok"] else None
|
|
350
|
-
outline =
|
|
414
|
+
outline = _norm(data)
|
|
351
415
|
if outline:
|
|
352
416
|
outline["source"] = "编排者(%s)" % label
|
|
353
417
|
return _mark(outline)
|
|
@@ -367,7 +431,7 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
|
|
|
367
431
|
if not res["ok"]:
|
|
368
432
|
_append_log(log_path, "作者 CLI 失败:%s" % (res.get("error") or "")[:300])
|
|
369
433
|
reason_tail = (res.get("error") or reason_tail)[:200]
|
|
370
|
-
outline =
|
|
434
|
+
outline = _norm(runner.extract_json(res.get("text") or ""))
|
|
371
435
|
if outline:
|
|
372
436
|
outline["source"] = "llm(%s)" % author_agent["id"]
|
|
373
437
|
return _mark(outline)
|
|
@@ -380,6 +444,13 @@ def make_serial_outline(task, author_agent=None, workdir=None, ev=None, log_path
|
|
|
380
444
|
chapters = [{"title": "第 %d 章" % (start + i), "beats": "按全书目标推进剧情,保持冲突与钩子",
|
|
381
445
|
"hook": ""} for i in range(n)]
|
|
382
446
|
out = {"book_title": prev_title, "chapters": chapters, "source": "template"}
|
|
447
|
+
if vol_plan:
|
|
448
|
+
# 分卷是结构性的、与情节无关:兜底模板也给卷名(用户给的/前批命名的),
|
|
449
|
+
# 这样成书卷标题与章-卷归属在降级情况下依然成立。
|
|
450
|
+
have = {str(e["vol"]): {"title": e.get("title") or "", "arc": e.get("arc") or ""}
|
|
451
|
+
for e in vol_plan if (e.get("title") or e.get("arc"))}
|
|
452
|
+
if have:
|
|
453
|
+
out["volumes"] = have
|
|
383
454
|
if author_agent and author_agent.get("mode") != "mock":
|
|
384
455
|
out["degraded"] = True
|
|
385
456
|
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,23 @@ 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
|
+
# 「第一卷 少年初入江湖 第1-20章」式分卷信息(保守识别,
|
|
223
|
+
# 认不出就返回空,绝不会把普通文字误判成分卷)。
|
|
224
|
+
vol_spec = volumes.parse_spec_text(goal) \
|
|
225
|
+
or volumes.parse_spec_text(serial.get("volumes_text")) \
|
|
226
|
+
or volumes.parse_spec_text(task.get("context") or "")
|
|
227
|
+
if vol_spec:
|
|
228
|
+
s["volumes"] = vol_spec
|
|
229
|
+
vper = volumes.norm_per(serial.get("volume_chapters"))
|
|
230
|
+
if vper:
|
|
231
|
+
s["volume_chapters"] = vper
|
|
215
232
|
task["serial"] = s
|
|
216
233
|
critics = payload.get("critics")
|
|
217
234
|
if isinstance(critics, list) and critics:
|
|
@@ -708,6 +725,19 @@ def update_run(run_id, expected_status=None, **fields):
|
|
|
708
725
|
if task:
|
|
709
726
|
task["status"] = st
|
|
710
727
|
_save_json(paths.TASKS_DIR / (tid + ".json"), task)
|
|
728
|
+
if st in ("done", "failed", "cancelled"):
|
|
729
|
+
# run_end 钩子(2026-09-22):终态即触发,后台跑副作用型脚本
|
|
730
|
+
# (回写知识库等);失败/超时静默,绝不拖慢收尾路径
|
|
731
|
+
try:
|
|
732
|
+
import threading
|
|
733
|
+
from . import hooks
|
|
734
|
+
threading.Thread(
|
|
735
|
+
target=hooks.run_event, args=("run_end",), daemon=True,
|
|
736
|
+
kwargs={"task_id": run.get("task_id") or "", "run_id": run_id,
|
|
737
|
+
"ctx": {"status": st, "title": run.get("title") or "",
|
|
738
|
+
"error": str(run.get("error") or "")[:300]}}).start()
|
|
739
|
+
except Exception:
|
|
740
|
+
pass
|
|
711
741
|
return run
|
|
712
742
|
|
|
713
743
|
|
|
@@ -1371,6 +1401,11 @@ def continue_task(task_id, chapters=None):
|
|
|
1371
1401
|
}
|
|
1372
1402
|
if serial.get("variants"): # 赛马配置随链条沿用
|
|
1373
1403
|
payload["serial"]["variants"] = serial["variants"]
|
|
1404
|
+
# 分卷配置随链条沿用:卷边界必须跨批次稳定,否则同一卷会被续写批次重切
|
|
1405
|
+
if serial.get("volumes"):
|
|
1406
|
+
payload["serial"]["volumes"] = serial["volumes"]
|
|
1407
|
+
if serial.get("volume_chapters"):
|
|
1408
|
+
payload["serial"]["volume_chapters"] = serial["volume_chapters"]
|
|
1374
1409
|
for key in ("draft_prompt", "critique_prompt"): # 自定义提示词覆盖一并沿用
|
|
1375
1410
|
if task.get(key):
|
|
1376
1411
|
payload[key] = task[key]
|
|
@@ -1534,6 +1569,38 @@ def _ensure_messages(run):
|
|
|
1534
1569
|
return run["messages"]
|
|
1535
1570
|
|
|
1536
1571
|
|
|
1572
|
+
def append_run_inject(run_id, text):
|
|
1573
|
+
"""追加钩子注入文本到 run.hook_inject(message_submit 钩子通道)。
|
|
1574
|
+
直连轮间组装提示词时消费一次并清零。返回 bool。"""
|
|
1575
|
+
text = str(text or "").strip()
|
|
1576
|
+
if not text:
|
|
1577
|
+
return False
|
|
1578
|
+
with LOCK:
|
|
1579
|
+
run = _RUNS.get(run_id)
|
|
1580
|
+
if not run:
|
|
1581
|
+
return False
|
|
1582
|
+
old = str(run.get("hook_inject") or "")
|
|
1583
|
+
run["hook_inject"] = (old + ("\n\n" if old and text else "") + text)[:4000]
|
|
1584
|
+
_save_json(paths.RUNS_DIR / run_id / "run.json", run)
|
|
1585
|
+
return True
|
|
1586
|
+
|
|
1587
|
+
|
|
1588
|
+
def pop_run_inject(run_id):
|
|
1589
|
+
"""取出并清空 hook_inject(直连轮间提示词头一次性消费)。"""
|
|
1590
|
+
with LOCK:
|
|
1591
|
+
run = _RUNS.get(run_id)
|
|
1592
|
+
if not run:
|
|
1593
|
+
return ""
|
|
1594
|
+
txt = str(run.get("hook_inject") or "")
|
|
1595
|
+
if txt and "hook_inject" in run:
|
|
1596
|
+
run["hook_inject"] = ""
|
|
1597
|
+
try:
|
|
1598
|
+
_save_json(paths.RUNS_DIR / run_id / "run.json", run)
|
|
1599
|
+
except Exception:
|
|
1600
|
+
pass
|
|
1601
|
+
return txt
|
|
1602
|
+
|
|
1603
|
+
|
|
1537
1604
|
def add_message(run_id, text, sender="本机", attachments=None):
|
|
1538
1605
|
"""往运行信箱追加一条用户指令。attachments 为已落盘到 workdir 的
|
|
1539
1606
|
附件相对路径清单(由 main.py 提交后传入)。返回消息 dict 或 None。"""
|