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
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""分卷(网文卷结构)纯函数:把「用户给的卷表」或「每卷章数」解析成一张
|
|
3
|
+
确定性的卷规划表(plan),再据此回答「某章属于哪一卷、是不是卷末章、本批
|
|
4
|
+
覆盖哪几卷」。
|
|
5
|
+
|
|
6
|
+
为什么卷边界必须确定且可复现:本书是分批写成的(每批 1-20 章,续写批次
|
|
7
|
+
共用同一工作目录、按全书章号衔接)。若让模型每批自由切卷,同一卷会在下一
|
|
8
|
+
批被重新划分、甚至被劈成两半。本模块把卷归属变成章号的纯函数——同一份
|
|
9
|
+
卷表/每卷章数下,任何一批、任何一次续写,卷归属完全一致。模型只负责卷名
|
|
10
|
+
与卷弧光(用户已给卷名时原样沿用)。
|
|
11
|
+
|
|
12
|
+
两种来源(优先级从高到低):
|
|
13
|
+
1. 显式卷表 spec —— 新建任务时用户明确给出的分卷信息(表单字段或目标/
|
|
14
|
+
上下文文字里的「第一卷 少年初入江湖 第1-20章」式描述)。
|
|
15
|
+
2. 每卷章数 per —— 没给显式卷表时,按固定章数等长切卷。
|
|
16
|
+
|
|
17
|
+
两者都没有 → 空 plan = 不分卷(向后兼容:旧任务 serial 里没有这两个字段,
|
|
18
|
+
行为与分卷前完全一致)。
|
|
19
|
+
|
|
20
|
+
纯函数、零依赖,故不参与分层(未分层模块按最高层处理,只被禁不禁人)。
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
|
|
26
|
+
MAX_PER = 200 # 每卷章数上限:再大就不是分卷而是「一卷到底」
|
|
27
|
+
MIN_PER = 2 # 每卷至少 2 章
|
|
28
|
+
MAX_VOLS = 200 # 卷数上限:防模型/用户给出病态长表
|
|
29
|
+
MAX_TITLE = 60
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def norm_per(value):
|
|
33
|
+
"""规范化每卷章数:非数字/<2/非法 → 0(不分卷);否则夹到 [2, 200]。"""
|
|
34
|
+
try:
|
|
35
|
+
n = int(value)
|
|
36
|
+
except (TypeError, ValueError):
|
|
37
|
+
return 0
|
|
38
|
+
if n < MIN_PER:
|
|
39
|
+
return 0
|
|
40
|
+
return min(MAX_PER, n)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _to_int(value):
|
|
44
|
+
try:
|
|
45
|
+
return int(value)
|
|
46
|
+
except (TypeError, ValueError):
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
_RANGE_RE = re.compile(r"(\d{1,5})\s*[-~—-到至]\s*(\d{1,5})")
|
|
51
|
+
_COUNT_RE = re.compile(r"(\d{1,5})\s*章")
|
|
52
|
+
# 章号表达式的完整形态:第1-20章 / 第1章 / 1-20章 / 20章(用于从卷名里剔除)
|
|
53
|
+
_CH_EXPR_RE = re.compile(
|
|
54
|
+
r"第\s*\d{1,5}\s*[-~—-到至]\s*\d{1,5}\s*章?"
|
|
55
|
+
r"|第\s*\d{1,5}\s*章"
|
|
56
|
+
r"|\d{1,5}\s*[-~—-到至]\s*\d{1,5}\s*章"
|
|
57
|
+
r"|\d{1,5}\s*章")
|
|
58
|
+
# 开放卷:「第21章起 / 21章开始 / 21章以后」= 该卷从 21 章铺到书末
|
|
59
|
+
_OPEN_RE = re.compile(r"第?\s*(\d{1,5})\s*章\s*(?:起|开始|以上|以后|往后|及以后)")
|
|
60
|
+
_VOL_RE = re.compile(r"第\s*([0-9一二三四五六七八九十百零两]+)\s*(?:卷|部|篇)"
|
|
61
|
+
r"|(?:^|[\s。;;,,::、])(?:卷|部|篇)\s*([0-9一二三四五六七八九十百零两]+)")
|
|
62
|
+
_BRACKETS_RE = re.compile(r"[《》〈〉「」『』\[\]【】()()\"'“”‘’]")
|
|
63
|
+
_TITLE_TRIM = " \t::,,、。;;!!??-—~~·/"
|
|
64
|
+
# 开放卷的残词:「21章起」被剔除章号后只剩「起」,整词丢弃;卷名里的「起」
|
|
65
|
+
# (如「风云再起」)因是词内字符不受影响
|
|
66
|
+
_OPEN_WORD_RE = re.compile(r"(?:^|\s)(?:起|开始|以上|以后|往后|及以后)(?=\s|$)")
|
|
67
|
+
|
|
68
|
+
_CN_NUM = {"零": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5,
|
|
69
|
+
"六": 6, "七": 7, "八": 8, "九": 9, "十": 10, "百": 100}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _cn2int(text):
|
|
73
|
+
"""中文数字 → int(支持「一」「十二」「二十三」「一百」这类常见写法);
|
|
74
|
+
失败返回 None。仅用于识别卷号,识别不出也不影响正文章节内容。"""
|
|
75
|
+
text = str(text or "").strip()
|
|
76
|
+
if not text:
|
|
77
|
+
return None
|
|
78
|
+
if text.isdigit():
|
|
79
|
+
return int(text)
|
|
80
|
+
total, section, seen = 0, 0, False
|
|
81
|
+
for ch in text:
|
|
82
|
+
if ch not in _CN_NUM:
|
|
83
|
+
return None
|
|
84
|
+
seen = True
|
|
85
|
+
val = _CN_NUM[ch]
|
|
86
|
+
if val == 10:
|
|
87
|
+
section = (section or 1) * 10
|
|
88
|
+
elif val == 100:
|
|
89
|
+
total += (section or 1) * 100
|
|
90
|
+
section = 0
|
|
91
|
+
else:
|
|
92
|
+
section += val
|
|
93
|
+
if not seen:
|
|
94
|
+
return None
|
|
95
|
+
return total + section
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def norm_spec(raw):
|
|
99
|
+
"""规范化显式卷表 → [{"title": str, "start": int|None, "end": int|None,
|
|
100
|
+
"chapters": int|None}]。
|
|
101
|
+
|
|
102
|
+
接受多种形态(模型/前端/用户手写各有习惯):
|
|
103
|
+
[{"title": "卷一 少年初入江湖", "chapters": 20}, ...]
|
|
104
|
+
[{"title": "...", "start": 1, "end": 20}, ...]
|
|
105
|
+
[{"title": "...", "range": "1-20"}, ...]
|
|
106
|
+
[{"title": "...", "count": 20}, ...]
|
|
107
|
+
["卷一 少年初入江湖", ...](纯字符串:只有卷名,边界靠 per 推)
|
|
108
|
+
无 title 也无边界的条目丢弃;整体不可用返回 []。"""
|
|
109
|
+
if isinstance(raw, dict):
|
|
110
|
+
raw = raw.get("volumes") if isinstance(raw.get("volumes"), list) else None
|
|
111
|
+
if not isinstance(raw, list):
|
|
112
|
+
return []
|
|
113
|
+
out = []
|
|
114
|
+
for item in raw[:MAX_VOLS]:
|
|
115
|
+
if isinstance(item, str):
|
|
116
|
+
title = item.strip()[:MAX_TITLE]
|
|
117
|
+
if title:
|
|
118
|
+
out.append({"title": title, "start": None, "end": None,
|
|
119
|
+
"chapters": None})
|
|
120
|
+
continue
|
|
121
|
+
if not isinstance(item, dict):
|
|
122
|
+
continue
|
|
123
|
+
title = str(item.get("title") or item.get("name") or "").strip()[:MAX_TITLE]
|
|
124
|
+
start = _to_int(item.get("start") or item.get("from"))
|
|
125
|
+
end = _to_int(item.get("end") or item.get("to"))
|
|
126
|
+
rng = item.get("range")
|
|
127
|
+
if isinstance(rng, str):
|
|
128
|
+
m = _RANGE_RE.search(rng)
|
|
129
|
+
if m:
|
|
130
|
+
start, end = _to_int(m.group(1)), _to_int(m.group(2))
|
|
131
|
+
chapters = _to_int(item.get("chapters") or item.get("count")
|
|
132
|
+
or item.get("n"))
|
|
133
|
+
if chapters is not None and chapters < 1:
|
|
134
|
+
chapters = None
|
|
135
|
+
if start is not None and start < 1:
|
|
136
|
+
start = None
|
|
137
|
+
if end is not None and (end < 1 or (start is not None and end < start)):
|
|
138
|
+
end = None
|
|
139
|
+
if start is not None and end is None and chapters:
|
|
140
|
+
end = start + chapters - 1
|
|
141
|
+
if start is None and end is not None:
|
|
142
|
+
start = 1
|
|
143
|
+
if not (title or start or chapters):
|
|
144
|
+
continue
|
|
145
|
+
out.append({"title": title, "start": start, "end": end,
|
|
146
|
+
"chapters": chapters})
|
|
147
|
+
return out
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _clean_title(seg, m):
|
|
151
|
+
"""从卷段里抠出卷名:优先取卷标记之后的文字(「第一卷 少年初入江湖 第1-20章」
|
|
152
|
+
→ 少年初入江湖),剔除章号表达式、书名号与修饰标点;标记后为空才回退到
|
|
153
|
+
标记之前(「少年初入江湖 第一卷 1-20章」这类倒装写法)。前后都空返回 "",
|
|
154
|
+
交给模型/大纲补卷名。"""
|
|
155
|
+
def _tidy(s):
|
|
156
|
+
s = _BRACKETS_RE.sub(" ", s)
|
|
157
|
+
s = _CH_EXPR_RE.sub(" ", s)
|
|
158
|
+
s = _OPEN_WORD_RE.sub(" ", s)
|
|
159
|
+
s = re.sub(r"\s+", " ", s)
|
|
160
|
+
return s.strip(_TITLE_TRIM)
|
|
161
|
+
after = _tidy(seg[m.end():])
|
|
162
|
+
if after:
|
|
163
|
+
return after[:MAX_TITLE]
|
|
164
|
+
return _tidy(seg[:m.start()])[:MAX_TITLE]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def parse_spec_text(text):
|
|
168
|
+
"""从目标/上下文文字里识别显式分卷信息(新建任务时用户在描述里直接写
|
|
169
|
+
「第一卷 少年初入江湖 第1-20章;卷二 风云再起 21-40章」)。
|
|
170
|
+
|
|
171
|
+
保守策略:必须出现 ≥1 个卷标记(第X卷/卷X/第X部/第X篇)才认为用户在
|
|
172
|
+
分卷,且除末卷外每个卷段都要有章号范围或章数(末卷可省 = 开放到书末)。
|
|
173
|
+
识别不到就返回 [],绝不猜——误判会把普通文字里的「第一卷」当成分卷要求。
|
|
174
|
+
|
|
175
|
+
切段方式:按**卷标记出现位置**切(而非按行/分号),这样「卷一 甲 20章,
|
|
176
|
+
卷二 乙 25章」这种同一行多卷也能正确拆开。"""
|
|
177
|
+
text = str(text or "")
|
|
178
|
+
if not text.strip():
|
|
179
|
+
return []
|
|
180
|
+
marks = list(_VOL_RE.finditer(text))
|
|
181
|
+
if not marks:
|
|
182
|
+
return []
|
|
183
|
+
# 同一卷号被重复提及时只认首次出现;能解析出卷号且全为升序时按卷号排序
|
|
184
|
+
segs, seen = [], set()
|
|
185
|
+
for i, m in enumerate(marks):
|
|
186
|
+
num = _cn2int(m.group(1) or m.group(2))
|
|
187
|
+
key = num if num else len(segs) + 1
|
|
188
|
+
if key in seen:
|
|
189
|
+
continue
|
|
190
|
+
seen.add(key)
|
|
191
|
+
end = marks[i + 1].start() if i + 1 < len(marks) else len(text)
|
|
192
|
+
segs.append((text[m.start():end], key))
|
|
193
|
+
out = []
|
|
194
|
+
for idx, (seg, _num) in enumerate(segs):
|
|
195
|
+
m = _VOL_RE.match(seg) # seg 以卷标记起头,相对位置 0
|
|
196
|
+
if not m:
|
|
197
|
+
continue
|
|
198
|
+
body = seg[m.end():]
|
|
199
|
+
op = _OPEN_RE.search(body) or _OPEN_RE.search(seg[:m.start()])
|
|
200
|
+
rng = _RANGE_RE.search(body)
|
|
201
|
+
if op:
|
|
202
|
+
start, end = _to_int(op.group(1)), None # 开放卷:铺到书末
|
|
203
|
+
chapters = None
|
|
204
|
+
elif rng:
|
|
205
|
+
start, end = _to_int(rng.group(1)), _to_int(rng.group(2))
|
|
206
|
+
chapters = (end - start + 1) if (start and end and end >= start) else None
|
|
207
|
+
else:
|
|
208
|
+
m2 = _COUNT_RE.search(body) or _COUNT_RE.search(seg[:m.start()])
|
|
209
|
+
chapters = _to_int(m2.group(1)) if m2 else None
|
|
210
|
+
start, end = None, None
|
|
211
|
+
if idx < len(segs) - 1 and not (chapters or (start and end)):
|
|
212
|
+
# 非末卷却没有边界:整段解析不可信,放弃(避免半数卷无边界的错切)
|
|
213
|
+
return []
|
|
214
|
+
out.append({"title": _clean_title(seg, m), "start": start,
|
|
215
|
+
"end": end, "chapters": chapters})
|
|
216
|
+
return out if out else []
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def build_plan(spec, per, upto=0):
|
|
220
|
+
"""卷规划表:按顺序铺开各卷的章号区间,返回
|
|
221
|
+
[{"vol": 1, "title": str, "first": 1, "last": 20}, ...](last=None 表示开放
|
|
222
|
+
到书末)。
|
|
223
|
+
|
|
224
|
+
spec 优先(显式卷表);spec 用尽后若 per>0 继续按 per 等长续卷,否则把最后
|
|
225
|
+
一卷视为开放卷(不再新开卷)。upto>0 时保证表至少覆盖到第 upto 章(续写批次
|
|
226
|
+
需要的章号范围),不足则按 per(无 per 用末卷长度)继续补卷。
|
|
227
|
+
不分卷返回 []。"""
|
|
228
|
+
per = norm_per(per)
|
|
229
|
+
spec = norm_spec(spec)
|
|
230
|
+
if not spec and not per:
|
|
231
|
+
return []
|
|
232
|
+
plan, prev_last = [], 0
|
|
233
|
+
def _push(title, first, last):
|
|
234
|
+
plan.append({"vol": len(plan) + 1, "title": title or "",
|
|
235
|
+
"first": first, "last": last})
|
|
236
|
+
for item in spec:
|
|
237
|
+
if len(plan) >= MAX_VOLS:
|
|
238
|
+
break
|
|
239
|
+
if item.get("start"):
|
|
240
|
+
first = item["start"]
|
|
241
|
+
else:
|
|
242
|
+
first = prev_last + 1
|
|
243
|
+
if item.get("end"):
|
|
244
|
+
last = item["end"]
|
|
245
|
+
elif item.get("chapters"):
|
|
246
|
+
last = first + item["chapters"] - 1
|
|
247
|
+
else:
|
|
248
|
+
last = None
|
|
249
|
+
if last is not None and last < first:
|
|
250
|
+
last = first
|
|
251
|
+
_push(item.get("title"), first, last)
|
|
252
|
+
prev_last = last if last is not None else first
|
|
253
|
+
if last is None:
|
|
254
|
+
# 开放卷:后面不再有卷(用户没给结尾),直接收工
|
|
255
|
+
return plan
|
|
256
|
+
# spec 之外的续卷:per 优先,否则沿用末卷长度(可预测,不会突然变成巨卷)
|
|
257
|
+
if plan:
|
|
258
|
+
fallback = per or (plan[-1]["last"] - plan[-1]["first"] + 1
|
|
259
|
+
if plan[-1]["last"] is not None else 0)
|
|
260
|
+
else:
|
|
261
|
+
fallback = per
|
|
262
|
+
if fallback and len(plan) < MAX_VOLS:
|
|
263
|
+
cursor = prev_last + 1
|
|
264
|
+
while (upto and cursor <= upto) or (not upto and not plan):
|
|
265
|
+
if len(plan) >= MAX_VOLS:
|
|
266
|
+
break
|
|
267
|
+
_push("", cursor, cursor + fallback - 1)
|
|
268
|
+
cursor += fallback
|
|
269
|
+
# upto 已知时,保证覆盖到 upto;未知时至少一卷
|
|
270
|
+
if upto and plan and plan[-1]["last"] is not None and plan[-1]["last"] < upto:
|
|
271
|
+
_push("", cursor, cursor + fallback - 1)
|
|
272
|
+
return plan
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def find(plan, chapter):
|
|
276
|
+
"""章号 → 所在卷条目(dict);不在任何卷内返回 None。"""
|
|
277
|
+
c = _to_int(chapter)
|
|
278
|
+
if c is None or c < 1 or not plan:
|
|
279
|
+
return None
|
|
280
|
+
for ent in plan:
|
|
281
|
+
last = ent.get("last")
|
|
282
|
+
if ent["first"] <= c and (last is None or c <= last):
|
|
283
|
+
return ent
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def plan_volumes(plan, start, n):
|
|
288
|
+
"""本批 [start, start+n-1] 覆盖到的卷条目(升序去重);不分卷返回 []。"""
|
|
289
|
+
s, n = _to_int(start), _to_int(n)
|
|
290
|
+
if s is None or n is None or n <= 0 or not plan:
|
|
291
|
+
return []
|
|
292
|
+
out, seen = [], set()
|
|
293
|
+
for i in range(s, s + n):
|
|
294
|
+
ent = find(plan, i)
|
|
295
|
+
if ent and ent["vol"] not in seen:
|
|
296
|
+
seen.add(ent["vol"])
|
|
297
|
+
out.append(ent)
|
|
298
|
+
return out
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def position(plan, chapter):
|
|
302
|
+
"""章在本卷内的位置:(卷内第几章, 本卷共几章)。末卷开放时总数返回 0
|
|
303
|
+
(未知)。不分卷返回 (0, 0)。"""
|
|
304
|
+
ent = find(plan, chapter)
|
|
305
|
+
if not ent:
|
|
306
|
+
return (0, 0)
|
|
307
|
+
total = (ent["last"] - ent["first"] + 1) if ent["last"] is not None else 0
|
|
308
|
+
return (_to_int(chapter) - ent["first"] + 1, total)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def is_vol_end(plan, chapter):
|
|
312
|
+
"""本章是否为卷末章(卷末要有本卷大高潮与卷末钩子)。开放末卷恒为 False。"""
|
|
313
|
+
ent = find(plan, chapter)
|
|
314
|
+
if not ent or ent["last"] is None:
|
|
315
|
+
return False
|
|
316
|
+
return _to_int(chapter) == ent["last"]
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def fmt_plan(plan, upto=None):
|
|
320
|
+
"""卷规划表 → 提示词/日志可读文本。upto 限定只列到该章为止的卷。"""
|
|
321
|
+
if not plan:
|
|
322
|
+
return ""
|
|
323
|
+
lines = []
|
|
324
|
+
for ent in plan:
|
|
325
|
+
if upto and ent["first"] > upto:
|
|
326
|
+
break
|
|
327
|
+
last = ent["last"]
|
|
328
|
+
rng = ("第 %d 章起(开放至书末)" % ent["first"]) if last is None \
|
|
329
|
+
else ("第 %d–%d 章" % (ent["first"], last))
|
|
330
|
+
title = ("《%s》" % ent["title"]) if ent.get("title") else "(未命名)"
|
|
331
|
+
lines.append("第 %d 卷 %s:%s" % (ent["vol"], title, rng))
|
|
332
|
+
return "\n".join(lines)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def merge_titles(plan, named):
|
|
336
|
+
"""把模型/大纲给出的卷名合并进规划表(named 形如 {"1": {"title","arc"}} 或
|
|
337
|
+
{"1": "卷名"})。用户显式给的卷名优先,不被模型覆盖。返回新表。"""
|
|
338
|
+
if not isinstance(named, dict) or not plan:
|
|
339
|
+
return plan
|
|
340
|
+
out = []
|
|
341
|
+
for ent in plan:
|
|
342
|
+
e = dict(ent)
|
|
343
|
+
item = named.get(str(e["vol"])) if str(e["vol"]) in named else named.get(e["vol"])
|
|
344
|
+
if isinstance(item, str):
|
|
345
|
+
item = {"title": item}
|
|
346
|
+
if isinstance(item, dict):
|
|
347
|
+
if not e.get("title") and str(item.get("title") or "").strip():
|
|
348
|
+
e["title"] = str(item["title"]).strip()[:MAX_TITLE]
|
|
349
|
+
arc = str(item.get("arc") or "").strip()
|
|
350
|
+
if arc:
|
|
351
|
+
e["arc"] = arc[:400]
|
|
352
|
+
out.append(e)
|
|
353
|
+
return out
|
package/app/core/zentao.py
CHANGED
|
@@ -849,15 +849,20 @@ def fetch_modules(product_id):
|
|
|
849
849
|
except ZenError as e:
|
|
850
850
|
return {"ok": False,
|
|
851
851
|
"error": "%s——也可能你的禅道没有该接口:请在禅道产品视图 URL 里查模块 ID 手工填写" % e}
|
|
852
|
-
|
|
852
|
+
# 形状兼容器({_list} / {modules:[...]} / {id:{...}} 字典 / 裸数组)
|
|
853
|
+
items = _list_items(d, "modules")
|
|
854
|
+
if items is None:
|
|
855
|
+
items = d.get("_list") if isinstance(d.get("_list"), list) else None
|
|
853
856
|
out = []
|
|
854
857
|
for m in items or []:
|
|
855
858
|
if isinstance(m, dict) and m.get("id"):
|
|
856
859
|
out.append({"id": m.get("id"), "name": str(m.get("name") or "")})
|
|
857
|
-
elif isinstance(m, dict) and str(m.get("id") or "") == "" and m.get("name"):
|
|
858
|
-
continue
|
|
859
860
|
if not out:
|
|
860
|
-
|
|
861
|
+
# 带响应形状摘要帮排查(顶层键名,不吐正文——用户实测反馈
|
|
862
|
+
# 「响应形状不认识」却看不到真实形状,没法报修)
|
|
863
|
+
keys = ",".join(sorted(str(k) for k in d.keys())) if isinstance(d, dict) else type(d).__name__
|
|
864
|
+
return {"ok": False,
|
|
865
|
+
"error": "模块清单为空或响应形状不认识(响应顶层键:%s)——请手工填模块 ID" % (keys or "非字典")}
|
|
861
866
|
return {"ok": True, "modules": out[:200]}
|
|
862
867
|
|
|
863
868
|
|
package/app/main.py
CHANGED
|
@@ -318,6 +318,11 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
318
318
|
# 偏好记忆:上次创建任务的类型/参数(新建表单预填用)
|
|
319
319
|
from core import prefs
|
|
320
320
|
return self._json(200, {"prefs": prefs.load()})
|
|
321
|
+
if path == "/api/hooks":
|
|
322
|
+
# 任务生命周期钩子配置(本机设置;只读列表)
|
|
323
|
+
from core import hooks
|
|
324
|
+
return self._json(200, {"hooks": hooks.load_list(),
|
|
325
|
+
"events": list(hooks.EVENTS)})
|
|
321
326
|
if path == "/api/wxdigest/pythons":
|
|
322
327
|
# 64 位 Python 候选(蜜蜂坞微信直连配置):扫描后台跑,此端点轮询
|
|
323
328
|
from core import wxdigest
|
|
@@ -577,6 +582,21 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
577
582
|
return deny
|
|
578
583
|
if path == "/api/tasks":
|
|
579
584
|
return self._api_create_task()
|
|
585
|
+
if path == "/api/hooks/save":
|
|
586
|
+
# 生命周期钩子配置整表保存(本机设置)
|
|
587
|
+
from core import hooks
|
|
588
|
+
try:
|
|
589
|
+
items = hooks.save_list((self._body() or {}).get("hooks"))
|
|
590
|
+
return self._json(200, {"ok": True, "hooks": items})
|
|
591
|
+
except ValueError as e:
|
|
592
|
+
return self._json(400, {"error": str(e)})
|
|
593
|
+
if path == "/api/hooks/test":
|
|
594
|
+
# 设置页「测试」:手动跑一条钩子看输出(不写配置)
|
|
595
|
+
from core import hooks
|
|
596
|
+
body = self._body() or {}
|
|
597
|
+
ok, out = hooks.test_one(body.get("cmd") or "", body.get("event") or "task_start",
|
|
598
|
+
body.get("timeout_s"))
|
|
599
|
+
return self._json(200, {"ok": ok, "output": out})
|
|
580
600
|
if path == "/api/tasks/clarify":
|
|
581
601
|
return self._api_task_clarify()
|
|
582
602
|
if path == "/api/hooks/run":
|
|
@@ -1778,6 +1798,15 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1778
1798
|
attachments=saved_paths)
|
|
1779
1799
|
if not msg:
|
|
1780
1800
|
return self._json(400, {"error": "运行不存在或消息非法"})
|
|
1801
|
+
# message_submit 钩子(2026-09-22):stdout 注入文本随下一轮提示词头送达
|
|
1802
|
+
try:
|
|
1803
|
+
from core import hooks as _hooks
|
|
1804
|
+
hook_txt = _hooks.run_event("message_submit", text=text,
|
|
1805
|
+
task_id=run.get("task_id") or "", run_id=run_id)
|
|
1806
|
+
if hook_txt:
|
|
1807
|
+
store.append_run_inject(run_id, hook_txt)
|
|
1808
|
+
except Exception:
|
|
1809
|
+
pass # 钩子故障绝不挡消息投递
|
|
1781
1810
|
# 终态连载 run 收到递话:自动起答疑轮(op=qa)。此前消息只会躺在信箱里
|
|
1782
1811
|
# 无人消费——向已完结的连载任务「下达指令」= 石沉大海(2026-09-18 实案)
|
|
1783
1812
|
task = store.get_task(run.get("task_id") or "") if run.get("task_id") else None
|
|
@@ -1980,6 +2009,15 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1980
2009
|
ok, err, new_run = store.retry_task(task["id"])
|
|
1981
2010
|
if not ok:
|
|
1982
2011
|
return self._json(400, {"error": err or "无法续跑"})
|
|
2012
|
+
# message_submit 钩子(2026-09-22):注入文本转入新 run 的提示词头通道
|
|
2013
|
+
try:
|
|
2014
|
+
from core import hooks as _hooks
|
|
2015
|
+
hook_txt = _hooks.run_event("message_submit", text=text,
|
|
2016
|
+
task_id=task["id"], run_id=new_run["id"])
|
|
2017
|
+
if hook_txt:
|
|
2018
|
+
store.append_run_inject(new_run["id"], hook_txt)
|
|
2019
|
+
except Exception:
|
|
2020
|
+
pass
|
|
1983
2021
|
if is_serial:
|
|
1984
2022
|
# 答疑档:问题文本显式带上(真实调用失败会把信箱消息 drain 掉,
|
|
1985
2023
|
# 换将重试时不能丢问题);retry_task 只认终态任务,active 已挡
|