codebee 0.1.98 → 0.1.100
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 -1
- package/README.md +4 -4
- package/app/core/acp_client.py +426 -0
- package/app/core/backend_profiles.py +188 -0
- package/app/core/builtin_agent.py +48 -37
- package/app/core/command_auth.py +302 -0
- package/app/core/gitmod.py +6 -2
- package/app/core/jobs.py +3 -2
- package/app/core/manager.py +59 -4
- package/app/core/market_remote.py +20 -7
- package/app/core/modelhub.py +21 -5
- package/app/core/openapi.py +32 -0
- package/app/core/pipeline.py +34 -9
- package/app/core/project_memory.py +73 -6
- package/app/core/publish/auto.py +91 -20
- package/app/core/publish/fanqie.py +30 -0
- package/app/core/publish/flows-fanqie-calibrated.json +81 -0
- package/app/core/publish/flows-qimao-calibrated.json +58 -0
- package/app/core/publish/ledger.py +24 -1
- package/app/core/publish/manager.py +36 -9
- package/app/core/publish/qimao.py +17 -0
- package/app/core/quality_gate.py +7 -0
- package/app/core/registry.py +13 -0
- package/app/core/runner.py +13 -1
- package/app/main.py +75 -2
- package/app/ui/app.js +167 -15
- package/app/ui/board.css +5 -1
- package/app/ui/board.js +16 -0
- package/app/ui/i18n.js +26 -1
- package/app/ui/index.html +20 -1
- package/package.json +1 -1
package/app/core/openapi.py
CHANGED
|
@@ -51,6 +51,15 @@ def document():
|
|
|
51
51
|
"items": {"type": "string", "maxLength": 80}},
|
|
52
52
|
"task_id": {"type": "string", "maxLength": 100}, "status": {"type": "string", "maxLength": 30},
|
|
53
53
|
"metadata": {"type": "object"}}},
|
|
54
|
+
"BackendProfile": {"type": "object", "required": ["label", "command"], "properties": {
|
|
55
|
+
"id": {"type": "string"}, "agent_id": {"type": "string"},
|
|
56
|
+
"label": {"type": "string", "maxLength": 80}, "command": {"type": "string", "maxLength": 500},
|
|
57
|
+
"args": {"type": "array", "maxItems": 80, "items": {"type": "string"}},
|
|
58
|
+
"env": {"type": "object", "additionalProperties": {"type": "string"}},
|
|
59
|
+
"workspace_path": {"type": "string"}, "enabled": {"type": "boolean"}}},
|
|
60
|
+
"CommandAuth": {"type": "object", "properties": {
|
|
61
|
+
"mode": {"type": "string", "enum": ["legacy", "restricted"]},
|
|
62
|
+
"pending": {"type": "array"}, "rules": {"type": "array"}, "audit": {"type": "array"}}},
|
|
54
63
|
}
|
|
55
64
|
|
|
56
65
|
def body(schema):
|
|
@@ -106,6 +115,29 @@ def document():
|
|
|
106
115
|
"post": {"summary": "Agent 心跳", "security": security, "requestBody": body(ref("Presence")),
|
|
107
116
|
"responses": {"200": response("心跳已记录", {"type": "object"}), "400": errors["400"],
|
|
108
117
|
"401": errors["401"], "423": errors["423"]}}},
|
|
118
|
+
"/api/backend-profiles": {
|
|
119
|
+
"get": {"summary": "读取 ACP Backend Profiles(密钥脱敏)", "security": security,
|
|
120
|
+
"responses": {"200": response("Profiles", {"type": "object"}), "401": errors["401"]}},
|
|
121
|
+
"post": {"summary": "保存 ACP Backend Profile", "security": security,
|
|
122
|
+
"requestBody": body(ref("BackendProfile")),
|
|
123
|
+
"responses": {"200": response("Profile", {"type": "object"}), "400": errors["400"], "401": errors["401"]}}},
|
|
124
|
+
"/api/backend-profiles/{profile_id}/delete": {
|
|
125
|
+
"post": {"summary": "删除 ACP Backend Profile", "security": security,
|
|
126
|
+
"responses": {"200": ok, "400": errors["400"], "401": errors["401"], "404": errors["404"]}}},
|
|
127
|
+
"/api/command-auth": {
|
|
128
|
+
"get": {"summary": "读取命令授权策略与待处理请求", "security": security,
|
|
129
|
+
"responses": {"200": response("Authorization state", ref("CommandAuth")), "401": errors["401"]}},
|
|
130
|
+
"post": {"summary": "切换命令授权策略", "security": security,
|
|
131
|
+
"requestBody": body({"type": "object", "properties": {"mode": {"type": "string", "enum": ["legacy", "restricted"]}}}),
|
|
132
|
+
"responses": {"200": response("Authorization state", ref("CommandAuth")), "400": errors["400"], "401": errors["401"]}}},
|
|
133
|
+
"/api/command-auth/requests/{request_id}": {
|
|
134
|
+
"post": {"summary": "批准或拒绝等待中的命令", "security": security,
|
|
135
|
+
"requestBody": body({"type": "object", "required": ["approve"], "properties": {
|
|
136
|
+
"approve": {"type": "boolean"}, "scope": {"type": "string"}, "match": {"type": "string"}, "prefix": {"type": "string"}}}),
|
|
137
|
+
"responses": {"200": ok, "400": errors["400"], "401": errors["401"], "404": errors["404"]}}},
|
|
138
|
+
"/api/command-auth/rules/{rule_id}/revoke": {
|
|
139
|
+
"post": {"summary": "撤销命令授权规则", "security": security,
|
|
140
|
+
"responses": {"200": ok, "400": errors["400"], "401": errors["401"], "404": errors["404"]}}},
|
|
109
141
|
"/api/retrieval/search": {"get": {"summary": "本地检索", "security": security,
|
|
110
142
|
"parameters": [{"name": "q", "in": "query", "required": True,
|
|
111
143
|
"schema": {"type": "string", "maxLength": 500}},
|
package/app/core/pipeline.py
CHANGED
|
@@ -596,6 +596,16 @@ def _external_sandbox_block_reason(agent, workdir, sandbox):
|
|
|
596
596
|
if normalized.get("env_allowlist"):
|
|
597
597
|
return "沙箱拒绝:该 CLI 后端未接入环境变量白名单,拒绝运行受限任务"
|
|
598
598
|
return ""
|
|
599
|
+
if (agent or {}).get("kind") == "acp":
|
|
600
|
+
if normalized.get("network") is False:
|
|
601
|
+
return "沙箱拒绝:ACP 后端进程未接入网络隔离,拒绝运行网络受限任务"
|
|
602
|
+
if normalized.get("env_allowlist"):
|
|
603
|
+
return "沙箱拒绝:ACP 后端进程未接入环境变量白名单,拒绝运行受限任务"
|
|
604
|
+
roots = [os.path.realpath(x) for x in normalized.get("allowed_roots") or []]
|
|
605
|
+
if roots and not any(root == allowed or root.startswith(allowed + os.sep)
|
|
606
|
+
for allowed in roots):
|
|
607
|
+
return "沙箱拒绝:任务目录不在 ACP 允许目录内"
|
|
608
|
+
return ""
|
|
599
609
|
# 非 codex 外部 CLI:默认策略放行,收窄策略拒绝
|
|
600
610
|
if normalized.get("network") is False:
|
|
601
611
|
return "沙箱拒绝:该任务要求禁网,外部 CLI 无法执行网络受限任务"
|
|
@@ -628,14 +638,16 @@ def _create_task_from_builtin(payload):
|
|
|
628
638
|
|
|
629
639
|
|
|
630
640
|
def _run_builtin_step(run_id, role, bi, prompt, workdir, ev, note="", images=None,
|
|
631
|
-
followups=False):
|
|
641
|
+
followups=False, sandbox=None):
|
|
632
642
|
"""内置智能体步骤:直连模型 API + 工具循环(builtin_agent),不经 CLI 进程。
|
|
633
643
|
|
|
634
644
|
与 _run_step 对齐的三件事:暂停/取消闸门、运行中指令 drain 注入、重复调用
|
|
635
645
|
守门;结果同样经 _finish_step_result 落步骤(output=干净回答)并入用量台账。
|
|
636
646
|
日志只有「迭代/工具」摘要行——对话视图吃 output,日志抽屉看工具轨迹。
|
|
637
647
|
followups=True 时从回答末尾解析「建议追问」块(直连对话专用协议):
|
|
638
|
-
剥离出结构化列表落步骤记录,正文保持干净。
|
|
648
|
+
剥离出结构化列表落步骤记录,正文保持干净。
|
|
649
|
+
sandbox:任务沙箱策略由调用方透传(本函数无 task 上下文),builtin_agent
|
|
650
|
+
内部 normalize 后在工具执行期强制。"""
|
|
639
651
|
_wait_gate(run_id, ev)
|
|
640
652
|
deadline = _ensure_budget(run_id)
|
|
641
653
|
step, log_abs = store.add_step(run_id, role, "builtin", "CodeBee", note=note,
|
|
@@ -689,7 +701,7 @@ def _run_builtin_step(run_id, role, bi, prompt, workdir, ev, note="", images=Non
|
|
|
689
701
|
on_reason=_on_reason, on_stream=_on_stream,
|
|
690
702
|
on_activity=_on_activity,
|
|
691
703
|
task_creator=_create_task_from_builtin,
|
|
692
|
-
sandbox=
|
|
704
|
+
sandbox=sandbox, checkpoint_run_id=run_id)
|
|
693
705
|
if followups and res.get("ok"):
|
|
694
706
|
clean, fups = _parse_followups(res.get("text") or "")
|
|
695
707
|
if fups:
|
|
@@ -816,7 +828,13 @@ def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
|
|
|
816
828
|
llm_caller = _make_llm_caller(agent, workdir, deadline=deadline)
|
|
817
829
|
call_kwargs = dict(workdir=workdir, readonly=readonly,
|
|
818
830
|
timeout=timeout, cancel_event=ev, log_path=str(log_abs),
|
|
819
|
-
images=images, require_tools=require_tools, deadline=deadline
|
|
831
|
+
images=images, require_tools=require_tools, deadline=deadline,
|
|
832
|
+
command_context={"run_id": session_run_id,
|
|
833
|
+
"sandbox": (store.get_task(
|
|
834
|
+
(store.get_run(session_run_id) or {}).get("task_id")
|
|
835
|
+
) or {}).get("sandbox") or {},
|
|
836
|
+
"allow_terminal": not readonly},
|
|
837
|
+
checkpoint_run_id=session_run_id)
|
|
820
838
|
|
|
821
839
|
def _call(p, **kw):
|
|
822
840
|
nonlocal usage_recorded
|
|
@@ -844,7 +862,13 @@ def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
|
|
|
844
862
|
res = runner.run_agent(agent, prompt, workdir=workdir, readonly=readonly,
|
|
845
863
|
timeout=timeout, cancel_event=ev, log_path=str(log_abs),
|
|
846
864
|
resume=resume, images=images, require_tools=require_tools,
|
|
847
|
-
deadline=deadline
|
|
865
|
+
deadline=deadline,
|
|
866
|
+
command_context={"run_id": session_run_id,
|
|
867
|
+
"sandbox": (store.get_task(
|
|
868
|
+
(store.get_run(session_run_id) or {}).get("task_id")
|
|
869
|
+
) or {}).get("sandbox") or {},
|
|
870
|
+
"allow_terminal": not readonly},
|
|
871
|
+
checkpoint_run_id=session_run_id)
|
|
848
872
|
# 默认关闭压缩和 resume 都走直通分支,也必须把真实 usage 送进预算表;否则
|
|
849
873
|
# 下一步永远看到 used=0,max_tokens_per_run 只是一个无效设置。
|
|
850
874
|
if not usage_recorded:
|
|
@@ -1559,7 +1583,8 @@ def _run_code(run, task, agents, ev, stats, mode):
|
|
|
1559
1583
|
|
|
1560
1584
|
# 项目记忆既给规划器,也给快速路径的实现者;不能因省掉独立规划而漏掉。
|
|
1561
1585
|
user_context_len = len(task.get("context") or "")
|
|
1562
|
-
project_memory = _read_project_memory(
|
|
1586
|
+
project_memory = _read_project_memory(
|
|
1587
|
+
workdir, query=task.get("goal") or task.get("title") or "")
|
|
1563
1588
|
if project_memory:
|
|
1564
1589
|
task = dict(task, context=(task.get("context") or "") + "\n\n" + project_memory)
|
|
1565
1590
|
|
|
@@ -2334,7 +2359,7 @@ def _run_direct(run, task, agents, ev, stats, mode):
|
|
|
2334
2359
|
if bi is not None:
|
|
2335
2360
|
res = _run_builtin_step(run_id, "direct" if first else "chat", bi, prompt,
|
|
2336
2361
|
step_wd, ev=ev, note=note, images=images,
|
|
2337
|
-
followups=True)
|
|
2362
|
+
followups=True, sandbox=task.get("sandbox"))
|
|
2338
2363
|
else:
|
|
2339
2364
|
res = _run_step(run_id, "direct" if first else "chat", impl, prompt, step_wd,
|
|
2340
2365
|
readonly=False, ev=ev, note=note,
|
|
@@ -5380,11 +5405,11 @@ def _write_project_memory(task, workdir, lines, source_run=""):
|
|
|
5380
5405
|
return ""
|
|
5381
5406
|
|
|
5382
5407
|
|
|
5383
|
-
def _read_project_memory(workdir, cap=4000):
|
|
5408
|
+
def _read_project_memory(workdir, cap=4000, query=""):
|
|
5384
5409
|
"""Only inject approved, unexpired and fingerprint-verified memory."""
|
|
5385
5410
|
try:
|
|
5386
5411
|
from . import project_memory
|
|
5387
|
-
return project_memory.active_text(workdir, cap=cap)
|
|
5412
|
+
return project_memory.active_text(workdir, cap=cap, query=query)
|
|
5388
5413
|
except (OSError, ValueError, TypeError):
|
|
5389
5414
|
return ""
|
|
5390
5415
|
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import hashlib
|
|
6
6
|
import json
|
|
7
7
|
import os
|
|
8
|
+
import re
|
|
8
9
|
import stat
|
|
9
10
|
import time
|
|
10
11
|
from pathlib import Path
|
|
@@ -95,6 +96,8 @@ def list_entries(workdir, *, now=None):
|
|
|
95
96
|
raw = row.get("content") or ""
|
|
96
97
|
row["verified"] = hashlib.sha256(raw.encode("utf-8")).hexdigest() == row.get("content_sha256")
|
|
97
98
|
row["expired"] = float(row.get("expires_at") or 0) <= stamp
|
|
99
|
+
row["feedback_score"] = int(row.get("feedback_score") or 0)
|
|
100
|
+
row["feedback_count"] = int(row.get("feedback_count") or 0)
|
|
98
101
|
return sorted(rows, key=lambda x: (x.get("created_at", 0), x.get("id", "")), reverse=True)
|
|
99
102
|
|
|
100
103
|
|
|
@@ -126,14 +129,78 @@ def decide(workdir, entry_id, action, *, expected_version=None, now=None):
|
|
|
126
129
|
return row
|
|
127
130
|
|
|
128
131
|
|
|
129
|
-
def
|
|
132
|
+
def feedback(workdir, entry_id, *, useful, expected_version=None, now=None):
|
|
133
|
+
"""Record explicit retrieval feedback as a versioned append-only update."""
|
|
134
|
+
if not isinstance(useful, bool):
|
|
135
|
+
raise ValueError("useful 必须是布尔值")
|
|
136
|
+
path = _file(workdir)
|
|
137
|
+
row = _read(path).get(str(entry_id or ""))
|
|
138
|
+
if not row:
|
|
139
|
+
raise KeyError("记忆条目不存在")
|
|
140
|
+
if expected_version is not None and int(expected_version) != int(row.get("version") or 1):
|
|
141
|
+
raise ValueError("记忆条目版本已变化,请刷新后重试")
|
|
142
|
+
raw = row.get("content") or ""
|
|
143
|
+
if hashlib.sha256(raw.encode("utf-8")).hexdigest() != row.get("content_sha256"):
|
|
144
|
+
raise ValueError("记忆内容指纹校验失败")
|
|
145
|
+
if row.get("status") != "approved":
|
|
146
|
+
raise ValueError("只有已批准的记忆条目可以反馈")
|
|
130
147
|
stamp = float(now if now is not None else time.time())
|
|
148
|
+
row = dict(row, feedback_score=int(row.get("feedback_score") or 0) +
|
|
149
|
+
(1 if useful else -1),
|
|
150
|
+
feedback_count=int(row.get("feedback_count") or 0) + 1,
|
|
151
|
+
feedback_at=stamp, version=int(row.get("version") or 1) + 1,
|
|
152
|
+
updated_at=stamp)
|
|
153
|
+
_append(path, row)
|
|
154
|
+
return row
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _query_terms(query):
|
|
158
|
+
query = str(query or "").strip().lower()
|
|
159
|
+
words = re.findall(r"[a-z0-9_]+|[\u3400-\u9fff]+", query)
|
|
160
|
+
terms = set()
|
|
161
|
+
for word in words:
|
|
162
|
+
if len(word) > 2 and re.fullmatch(r"[\u3400-\u9fff]+", word):
|
|
163
|
+
terms.update(word[i:i + 2] for i in range(len(word) - 1))
|
|
164
|
+
else:
|
|
165
|
+
terms.add(word)
|
|
166
|
+
return terms
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _relevance(row, terms):
|
|
170
|
+
if not terms:
|
|
171
|
+
return 0
|
|
172
|
+
title = str(row.get("title") or "").lower()
|
|
173
|
+
content = str(row.get("content") or "").lower()
|
|
174
|
+
title_terms = _query_terms(title)
|
|
175
|
+
content_terms = _query_terms(content)
|
|
176
|
+
return 4 * len(terms & title_terms) + len(terms & content_terms)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def active_text(workdir, *, cap=4000, query="", now=None):
|
|
180
|
+
stamp = float(now if now is not None else time.time())
|
|
181
|
+
try:
|
|
182
|
+
cap = max(0, int(cap))
|
|
183
|
+
except (TypeError, ValueError):
|
|
184
|
+
cap = 4000
|
|
185
|
+
heading = "## 已批准项目记忆(内容已校验,过期条目不注入)\n\n"
|
|
186
|
+
cap = max(0, cap - len(heading) - 2)
|
|
187
|
+
terms = _query_terms(query)
|
|
131
188
|
items = []
|
|
132
189
|
for row in list_entries(workdir, now=stamp):
|
|
133
190
|
if row.get("status") != "approved" or row.get("expired") or not row.get("verified"):
|
|
134
191
|
continue
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
192
|
+
block = "### %s\n%s" % (row.get("title") or "项目记忆", row.get("content") or "")
|
|
193
|
+
items.append((row, block))
|
|
194
|
+
items.sort(key=lambda item: (_relevance(item[0], terms),
|
|
195
|
+
item[0].get("feedback_score", 0),
|
|
196
|
+
item[0].get("created_at", 0)), reverse=True)
|
|
197
|
+
selected = []
|
|
198
|
+
used = 0
|
|
199
|
+
for _row, block in items:
|
|
200
|
+
cost = len(block) + (2 if selected else 0)
|
|
201
|
+
if used + cost > cap:
|
|
202
|
+
continue
|
|
203
|
+
selected.append(block)
|
|
204
|
+
used += cost
|
|
205
|
+
text = "\n\n".join(selected)
|
|
206
|
+
return (heading + text + "\n\n") if text else ""
|
package/app/core/publish/auto.py
CHANGED
|
@@ -135,7 +135,9 @@ def pending(task_id, platform):
|
|
|
135
135
|
"""待发章节清单:任务工作目录里的章节文件 − 已发章号,按章号升序。
|
|
136
136
|
|
|
137
137
|
已发口径连载链合并(含校准所得平台实况),所以任何一个批次任务上发
|
|
138
|
-
|
|
138
|
+
起发布,看到的都是整本书的待发清单。已存草稿的章同样剔除(内容已在
|
|
139
|
+
平台草稿箱,再跑只会造重复稿;提交发布走平台草稿箱由用户手工完成)。
|
|
140
|
+
返回 (list, err);list 项
|
|
139
141
|
{chapter_no, file, size},file 为工作目录相对路径。章号解析不出的
|
|
140
142
|
文件不进自动发布(防同章多文件误发),API 单章发(manager 直调)
|
|
141
143
|
不受此限。
|
|
@@ -154,7 +156,8 @@ def pending(task_id, platform):
|
|
|
154
156
|
if fs:
|
|
155
157
|
files = fs # 任一 run 的成品口径都从任务首跑起,取到即够
|
|
156
158
|
break
|
|
157
|
-
done = ledger.published_chapters(task_id, platform)
|
|
159
|
+
done = ledger.published_chapters(task_id, platform) \
|
|
160
|
+
| ledger.drafted_chapters(task_id, platform)
|
|
158
161
|
# 分卷标注(2026-10-08 用户需求:待发清单按卷展示):卷计划解析不出时
|
|
159
162
|
# volume 为空串,前端不分组平铺。
|
|
160
163
|
plan = []
|
|
@@ -182,7 +185,12 @@ def pending(task_id, platform):
|
|
|
182
185
|
|
|
183
186
|
|
|
184
187
|
def status(task_id):
|
|
185
|
-
"""前端视图:待发清单 + 护栏状态 + 自动发布进度。
|
|
188
|
+
"""前端视图:待发清单 + 护栏状态 + 自动发布进度。
|
|
189
|
+
|
|
190
|
+
护栏分两层给前端:guard_ok/guard_reason=完整发布闸(硬护栏+质量闸),
|
|
191
|
+
管「发布」类按钮;draft_ok/draft_reason=硬护栏(每日上限/连败退避/建书
|
|
192
|
+
确认),管「存草稿」按钮——草稿不上线,质量闸拦它只会堵死「先落平台
|
|
193
|
+
再人工把关」的通路;quality_blockers 单独带出去做提示性展示。"""
|
|
186
194
|
from .. import store
|
|
187
195
|
from . import ledger, manager
|
|
188
196
|
ent = ledger.books_for(str(task_id)) # 沿连载链继承:续写批次同书同账
|
|
@@ -190,19 +198,25 @@ def status(task_id):
|
|
|
190
198
|
books = []
|
|
191
199
|
for plat, info in ent.items():
|
|
192
200
|
pend, err = pending(task_id, plat)
|
|
193
|
-
|
|
201
|
+
hard_ok, hard_why = guards(task_id, plat)
|
|
194
202
|
if not _book_ready(info):
|
|
195
|
-
|
|
196
|
-
|
|
203
|
+
hard_ok, hard_why = False, "该平台建书结果未确认(缺少作品 ID),请重试创建作品或登记已有作品"
|
|
204
|
+
blockers = []
|
|
205
|
+
if task and _book_ready(info):
|
|
197
206
|
quality = manager._quality_release_guard(task, plat, action="publish")
|
|
198
207
|
if not quality.get("allowed"):
|
|
199
|
-
|
|
200
|
-
|
|
208
|
+
blockers = [str(b) for b in (quality.get("blockers") or [])]
|
|
209
|
+
ok, why = hard_ok, hard_why
|
|
210
|
+
if ok and blockers:
|
|
211
|
+
ok, why = False, "质量门禁拦截:%s" % ";".join(blockers)
|
|
201
212
|
books.append({"platform": plat, "bound": True,
|
|
202
213
|
"title": info.get("title") or "",
|
|
203
214
|
"pending": len(pend),
|
|
204
215
|
"items": pend[:200],
|
|
205
216
|
"guard_ok": ok, "guard_reason": why,
|
|
217
|
+
"draft_ok": hard_ok, "draft_reason": hard_why,
|
|
218
|
+
"quality_blockers": blockers,
|
|
219
|
+
"drafted": len(ledger.drafted_chapters(task_id, plat)),
|
|
206
220
|
"calibrated": calibrated(plat)})
|
|
207
221
|
run = _running.get(task_id) or None
|
|
208
222
|
if run:
|
|
@@ -236,8 +250,27 @@ def calibrated(platform):
|
|
|
236
250
|
return (paths.PUBLISH_DIR / ("flows-%s.json" % platform)).is_file()
|
|
237
251
|
|
|
238
252
|
|
|
253
|
+
def flow_calibrated(plat, action):
|
|
254
|
+
"""某动作的步骤表是否有真机校准来源(用户校准文件或仓库校准模板里
|
|
255
|
+
有该动作的表)。内置推测表不算——存草稿虽不上线,批量乱点照样惹
|
|
256
|
+
风控/攒一箱废稿,无人值守跑之前必须有验证过的步骤表。"""
|
|
257
|
+
import json as _json
|
|
258
|
+
from pathlib import Path as _Path
|
|
259
|
+
from .. import paths as _paths
|
|
260
|
+
for fp in (_paths.PUBLISH_DIR / ("flows-%s.json" % plat),
|
|
261
|
+
_Path(__file__).with_name("flows-%s-calibrated.json" % plat)):
|
|
262
|
+
try:
|
|
263
|
+
data = _json.loads(fp.read_text(encoding="utf-8"))
|
|
264
|
+
if isinstance(data, dict) and isinstance(data.get(action), list):
|
|
265
|
+
return True
|
|
266
|
+
except Exception:
|
|
267
|
+
pass
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
|
|
239
271
|
def publish_pending_async(task_id, platform, auto_submit=False, only=None,
|
|
240
|
-
force=False, force_confirmed=False, force_reason=""
|
|
272
|
+
force=False, force_confirmed=False, force_reason="",
|
|
273
|
+
as_draft=False):
|
|
241
274
|
"""把任务的待发章节按章号顺序发出(后台线程)。返回 (ok, err)。
|
|
242
275
|
|
|
243
276
|
only=章号清单(批量选择发布):从待发清单里挑出所选章号,保持章号
|
|
@@ -245,12 +278,22 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
|
|
|
245
278
|
auto_submit=True(直发)逐章提交走完全程;False(人工确认)每轮只填
|
|
246
279
|
**一章**就停在 manual_pause——表单填好后提交权在用户,walker 若直接
|
|
247
280
|
填下一章会导航离开未提交的编辑器,把上一章内容丢掉(平台草稿自动
|
|
248
|
-
保存不可依赖)。用户在浏览器提交后再次发起即发下一章。
|
|
281
|
+
保存不可依赖)。用户在浏览器提交后再次发起即发下一章。
|
|
282
|
+
as_draft=True(全部发草稿,2026-10-09):逐章自动填稿并点「存草稿」,
|
|
283
|
+
不上线、不受质量闸拦、无需人工确认,一章接一章连跑到清空待发清单
|
|
284
|
+
(章间仍按 PACE_S 防风控节奏);提交发布由用户到平台草稿箱手工完成,
|
|
285
|
+
届时质量闸照常把关。与 auto_submit 互斥(草稿模式忽略 auto_submit)。"""
|
|
249
286
|
from .. import store
|
|
250
287
|
from . import ledger, manager
|
|
251
288
|
if platform not in manager.PLATFORMS:
|
|
252
289
|
return False, "未知平台"
|
|
253
|
-
if
|
|
290
|
+
if as_draft:
|
|
291
|
+
auto_submit = False # 草稿模式没有「提交」语义
|
|
292
|
+
if not flow_calibrated(platform, "upload_chapter_draft"):
|
|
293
|
+
return False, ("存草稿模式需要该平台有 upload_chapter_draft 步骤表"
|
|
294
|
+
"(仓库校准模板或 data/publish/flows-%s.json);"
|
|
295
|
+
"未校准不许无人值守批量动表单" % platform)
|
|
296
|
+
elif auto_submit and not calibrated(platform):
|
|
254
297
|
return False, ("自动提交模式需要先校准该平台发布流程:用「探测」按钮 dump "
|
|
255
298
|
"表单后把真实步骤写进 data/publish/flows-%s.json(缺省选择器"
|
|
256
299
|
"只是推测,未校准不许无人值守直发)" % platform)
|
|
@@ -271,9 +314,13 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
|
|
|
271
314
|
return False, "该任务尚未在此平台建书,请先「创建作品」"
|
|
272
315
|
if not _book_ready(book):
|
|
273
316
|
return False, "该平台建书结果未确认(缺少作品 ID),请重试创建作品或登记已有作品"
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
317
|
+
if as_draft:
|
|
318
|
+
# 草稿不上线:质量闸不适用(同 manager 口径),硬护栏照查
|
|
319
|
+
quality = {"allowed": True, "status": "draft_only"}
|
|
320
|
+
else:
|
|
321
|
+
quality = manager._quality_release_guard(
|
|
322
|
+
task, platform, action="publish", force=force,
|
|
323
|
+
force_confirmed=force_confirmed, force_reason=force_reason)
|
|
277
324
|
if not quality.get("allowed"):
|
|
278
325
|
return False, "质量门禁拦截:%s" % ";".join(quality.get("blockers") or [])
|
|
279
326
|
ok, why = guards(task_id, platform)
|
|
@@ -298,7 +345,8 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
|
|
|
298
345
|
wd = task.get("workdir") or ""
|
|
299
346
|
st = {"platform": platform, "at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
300
347
|
"done": 0, "total": len(pend), "status": "running", "error": "",
|
|
301
|
-
"auto_submit": bool(auto_submit), "
|
|
348
|
+
"auto_submit": bool(auto_submit), "as_draft": bool(as_draft),
|
|
349
|
+
"last_chapter": 0}
|
|
302
350
|
with _LOCK:
|
|
303
351
|
_running[task_id] = st
|
|
304
352
|
|
|
@@ -310,7 +358,7 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
|
|
|
310
358
|
st["status"] = "error"
|
|
311
359
|
st["error"] = "第 %d 章前护栏拦截:%s" % (item["chapter_no"], why)
|
|
312
360
|
return
|
|
313
|
-
upload_kwargs = {"auto_submit": auto_submit}
|
|
361
|
+
upload_kwargs = {"auto_submit": auto_submit, "as_draft": as_draft}
|
|
314
362
|
if force or force_confirmed or force_reason:
|
|
315
363
|
upload_kwargs.update(force=force,
|
|
316
364
|
force_confirmed=force_confirmed,
|
|
@@ -324,10 +372,29 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
|
|
|
324
372
|
return
|
|
325
373
|
if not _wait_idle(platform):
|
|
326
374
|
st["status"] = "error"
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
375
|
+
if as_draft:
|
|
376
|
+
st["error"] = ("第 %d 章存草稿等待超时(%.0f 分钟),后续"
|
|
377
|
+
"章节未存" % (item["chapter_no"],
|
|
378
|
+
IDLE_TIMEOUT_S / 60))
|
|
379
|
+
else:
|
|
380
|
+
st["error"] = ("第 %d 章发布等待超时(%.0f 分钟);若在等人工提交,"
|
|
381
|
+
"请提交后重跑剩余章节" % (item["chapter_no"],
|
|
382
|
+
IDLE_TIMEOUT_S / 60))
|
|
330
383
|
return
|
|
384
|
+
if as_draft:
|
|
385
|
+
# manager 走完草稿流程(含平台侧验证)才返回;台账落了
|
|
386
|
+
# upload_chapter_draft 才算数,防「流程跑完但平台没存上」。
|
|
387
|
+
if item["chapter_no"] not in ledger.drafted_chapters(
|
|
388
|
+
task_id, platform):
|
|
389
|
+
st["status"] = "error"
|
|
390
|
+
st["error"] = ("第 %d 章存草稿未确认,后续章节未存(详见"
|
|
391
|
+
"发布台账与截图存证)" % item["chapter_no"])
|
|
392
|
+
return
|
|
393
|
+
st["done"] += 1
|
|
394
|
+
st["last_chapter"] = item["chapter_no"]
|
|
395
|
+
if st["done"] < st["total"]:
|
|
396
|
+
time.sleep(PACE_S)
|
|
397
|
+
continue
|
|
331
398
|
if not auto_submit:
|
|
332
399
|
# The manager stopped before the submit step. There is no
|
|
333
400
|
# remote success receipt until the user clicks submit.
|
|
@@ -348,7 +415,11 @@ def publish_pending_async(task_id, platform, auto_submit=False, only=None,
|
|
|
348
415
|
if st["done"] < st["total"]:
|
|
349
416
|
time.sleep(PACE_S)
|
|
350
417
|
st["status"] = "done"
|
|
351
|
-
if
|
|
418
|
+
if as_draft:
|
|
419
|
+
st["message"] = ("已存草稿 %d 章(未发布,不受质量闸拦不代表可直发);"
|
|
420
|
+
"请到平台章节管理/草稿箱逐章检查后提交发布"
|
|
421
|
+
% st["done"])
|
|
422
|
+
elif not auto_submit:
|
|
352
423
|
st["message"] = "第 %d 章已填好,请在浏览器里确认提交,再重新校准" % st["last_chapter"]
|
|
353
424
|
except Exception as e: # 线程内绝不能悬挂无终态
|
|
354
425
|
st["status"] = "error"
|
|
@@ -110,6 +110,35 @@ UPLOAD_CHAPTER = [
|
|
|
110
110
|
"optional": True},
|
|
111
111
|
]
|
|
112
112
|
|
|
113
|
+
# 发章存草稿(2026-10-09 全部发草稿):与 UPLOAD_CHAPTER 同一张填表前段,
|
|
114
|
+
# 终点不同——点编辑器头部「存草稿」直接落草稿箱,不走「下一步→内容检测→
|
|
115
|
+
# 发布提示→提交」发布链。草稿不上线,批量连跑无需人工确认;提交发布由
|
|
116
|
+
# 用户到章节管理/草稿箱手工完成。verify 沿用章节管理页断言(草稿行同样
|
|
117
|
+
# 出现在列表里,标题/章号标记可命中);若平台改版把草稿挪去独立草稿箱页,
|
|
118
|
+
# 首轮失败的 step 截图会指出来,照 flows-fanqie.json 校准即可。
|
|
119
|
+
UPLOAD_CHAPTER_DRAFT = [
|
|
120
|
+
{"do": "navigate", "url": "{editor_url}"},
|
|
121
|
+
{"do": "url_any", "any": ["fanqienovel.com"]},
|
|
122
|
+
{"do": "wait", "sel": "[contenteditable=true],textarea[class*=content],div[class*=editor]", "timeout": 10},
|
|
123
|
+
{"do": "volume", "key": "volume_name", "optional": True,
|
|
124
|
+
"open_sel": "input[type=number],input[class*=serial],div[class*=volume]",
|
|
125
|
+
"modal_sel": "[class*=volume][class*=modal],[class*=modal][class*=volume]",
|
|
126
|
+
"item_sel": "[class*=volume] li,[class*=volume-item]",
|
|
127
|
+
"add_sel": "[class*=add-volume],[class*=volume] button",
|
|
128
|
+
"confirm_sel": "[class*=confirm]", "settle": 1.5},
|
|
129
|
+
{"do": "fill", "sel": "input[type=number],input[class*=serial],input[class*=byte]", "key": "chapter_no"},
|
|
130
|
+
{"do": "fill", "sel": "input[placeholder*='章节'],input[placeholder*='标题']", "key": "chapter_name"},
|
|
131
|
+
{"do": "wait", "sel": "[contenteditable=true],textarea[class*=content],div[class*=editor]", "timeout": 10},
|
|
132
|
+
{"do": "fill", "sel": "[contenteditable=true],textarea[class*=content]", "key": "chapter_body"},
|
|
133
|
+
{"do": "shot", "name": "chapter-filled-draft"},
|
|
134
|
+
{"do": "submit", "text": "存草稿", "scope": "button", "tries": 15},
|
|
135
|
+
{"do": "js_click", "text": "存草稿", "scope": "button", "tries": 10,
|
|
136
|
+
"optional": True, "skip_if_modal": True},
|
|
137
|
+
{"do": "sleep", "s": 2.5},
|
|
138
|
+
{"do": "verify", "url": "{chapter_manage_url}",
|
|
139
|
+
"any": ["第{chapter_no}章", "{chapter_name}"], "settle": 6, "new_tab": True},
|
|
140
|
+
]
|
|
141
|
+
|
|
113
142
|
# 登录态探测:打开后台首页,URL 被踢到登录页 → 未登录
|
|
114
143
|
CHECK_LOGIN = [
|
|
115
144
|
{"do": "navigate", "url": "{home}"},
|
|
@@ -125,6 +154,7 @@ PROBE_FORM = [
|
|
|
125
154
|
]
|
|
126
155
|
|
|
127
156
|
FLOWS = {"create_book": CREATE_BOOK, "upload_chapter": UPLOAD_CHAPTER,
|
|
157
|
+
"upload_chapter_draft": UPLOAD_CHAPTER_DRAFT,
|
|
128
158
|
"check_login": CHECK_LOGIN, "probe_form": PROBE_FORM}
|
|
129
159
|
|
|
130
160
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_note": "番茄真机校准(2026-09-19 定稿,2026-09-24 加固):编辑器=/main/writer/<book_id>/publish/ 直达(章节号 input.serial-input 纯数字;标题 div.serial-editor-input-hint input ≥5字/30上限;正文 .ProseMirror ≥1000字;「下一步」保存校验后按钮组右缘箭头展开下拉含「定时发布」)。建书=/main/writer/create 直达表单:radio 签约模式/目标读者(label 真实点击)→ 阅读标签 select(y<430)弹层 arco-anchor 切主分类/主题/角色/情节 + 标签云点击 + 确认 → 内容标签 select(y>430)弹层 情感/人设/世界观 同构 → 名称/主角1/主角2/简介 → 立即创建(submit 步骤:auto_submit=false 时填好即停,留给人工提交) → 跳 book-info/<id>。锚点切换渲染慢(0924 三连败):弹层打开后 settle,锚点 tries=12。0928 再加固(弹层没开连败案):签约/读者 radio 点击后断言选中态(expect_checked);两处标签触发器点完 wait .category-modal 存在替代盲 sleep;real_click_text 落点 elementFromPoint 守卫,找不到目标时报错自带定位范围可见项诊断。2026-10-08 发章三修:values 补 chapter_no/chapter_name 键(此前序号框从未被填、被平台以「章节序号只支持阿拉伯数字」打回);标题位改填 chapter_name(read_chapter 拆掉「第X章」前缀,序号/标题分开填);verify 标记改「第{chapter_no}章」或「{chapter_name}」任一命中(用户手工改过标题时按名对版必假失败)。。2026-10-08 批量自动发布·自动分卷:upload_chapter 增加 volume 步骤(values.volume_name 由 manager 按工作目录卷计划解析:分卷.json 优先,其次 大纲/series-outline.md 的「## 第X卷:名」+「依次48、56…章」;平台卷弹窗 = .publish-header-volume-name 开 → .editor-volume-list-item 按去前缀名匹配选中,缺卷走 .editor-volume-footer-add-volume 新建→行内 input 输名→i.tomato-confirm 落名→选中→确定;新建卷名会追加进 values._volumes 由 manager 回写 books.json 缓存)。。2026-10-08 晚:平台发布确认 UI 已改——下一步(保存校验)后弹「发布提示」错别字确认框(取消/提交),不再有「定时发布」下拉组;流程撤 click_arrow/定时发布/发布,改点确认框「提交」(optional:无错别字时可能不弹,直接 verify)。。verify 改 new_tab(开新页签验证、编辑器页签不动):编辑器页签内导航触发 beforeunload 原生确认,渲染挂起+发布请求被取消——直发章全卡死在验证导航上的实案修法。。1008深夜终版:publish链=submit(下一步,gate)→js_click下一步兜底(skip_if_modal)→js_click仅基础检测(检测方式弹窗,tries60)→sleep5→js_click提交(错别字确认,tries75)→verify new_tab;真实鼠标在 headless/后台页签不触发平台处理器,DOM click 可靠。",
|
|
3
|
+
"upload_chapter_draft": "2026-10-09 全部发草稿:前段(volume/序号/标题/正文填表)与 upload_chapter 同源选择器;终点改点编辑器头部「存草稿」直接落草稿箱,不走下一步/检测/发布提示发布链。verify 沿用章节管理页断言(草稿行同样在列表里);若平台把草稿挪去独立草稿箱页,首轮失败截图会指出,届时按此校准。「存草稿」按钮文本未真机实证(校准时浏览器离线),首次批量跑请盯首章截图。",
|
|
3
4
|
"check_login": [
|
|
4
5
|
{
|
|
5
6
|
"do": "navigate",
|
|
@@ -111,6 +112,86 @@
|
|
|
111
112
|
"new_tab": true
|
|
112
113
|
}
|
|
113
114
|
],
|
|
115
|
+
"upload_chapter_draft": [
|
|
116
|
+
{
|
|
117
|
+
"do": "navigate",
|
|
118
|
+
"url": "about:blank"
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
"do": "navigate",
|
|
122
|
+
"url": "{editor_url}"
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
"do": "url_any",
|
|
126
|
+
"any": [
|
|
127
|
+
"fanqienovel.com"
|
|
128
|
+
]
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"do": "wait",
|
|
132
|
+
"sel": ".ProseMirror",
|
|
133
|
+
"timeout": 20
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
"do": "volume",
|
|
137
|
+
"key": "volume_name",
|
|
138
|
+
"optional": true,
|
|
139
|
+
"open_sel": ".publish-header-volume-name",
|
|
140
|
+
"modal_sel": ".serial-modal.editor-volume",
|
|
141
|
+
"item_sel": ".editor-volume-list-item",
|
|
142
|
+
"add_sel": ".editor-volume-footer-add-volume",
|
|
143
|
+
"input_sel": ".editor-volume-list-item input",
|
|
144
|
+
"confirm_sel": "i.tomato-confirm",
|
|
145
|
+
"settle": 1.5
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
"do": "fill",
|
|
149
|
+
"sel": "input.serial-input, input.byte-input",
|
|
150
|
+
"key": "chapter_no"
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"do": "fill",
|
|
154
|
+
"sel": "div.serial-editor-input-hint input",
|
|
155
|
+
"key": "chapter_name"
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
"do": "fill",
|
|
159
|
+
"sel": ".ProseMirror",
|
|
160
|
+
"key": "chapter_body"
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
"do": "shot",
|
|
164
|
+
"name": "chapter-filled-draft"
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
"do": "submit",
|
|
168
|
+
"text": "存草稿",
|
|
169
|
+
"scope": "button",
|
|
170
|
+
"tries": 15
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
"do": "js_click",
|
|
174
|
+
"text": "存草稿",
|
|
175
|
+
"scope": "button",
|
|
176
|
+
"tries": 10,
|
|
177
|
+
"optional": true,
|
|
178
|
+
"skip_if_modal": true
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
"do": "sleep",
|
|
182
|
+
"s": 2.5
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
"do": "verify",
|
|
186
|
+
"url": "{chapter_manage_url}",
|
|
187
|
+
"any": [
|
|
188
|
+
"第{chapter_no}章",
|
|
189
|
+
"{chapter_name}"
|
|
190
|
+
],
|
|
191
|
+
"settle": 6,
|
|
192
|
+
"new_tab": true
|
|
193
|
+
}
|
|
194
|
+
],
|
|
114
195
|
"probe_form": [
|
|
115
196
|
{
|
|
116
197
|
"do": "navigate",
|