codebee 0.1.95 → 0.1.96

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.
Files changed (138) hide show
  1. package/CHANGELOG.md +725 -720
  2. package/LICENSE +21 -21
  3. package/README.md +515 -509
  4. package/app/core/acceptance.py +100 -100
  5. package/app/core/agent_context.py +100 -0
  6. package/app/core/aiflavor.py +197 -197
  7. package/app/core/analytics.py +38 -38
  8. package/app/core/artifacts.py +148 -148
  9. package/app/core/assets.py +110 -110
  10. package/app/core/attachments.py +623 -623
  11. package/app/core/audit.py +30 -30
  12. package/app/core/backup.py +470 -470
  13. package/app/core/beekeeper.py +183 -183
  14. package/app/core/benchstore.py +102 -95
  15. package/app/core/board.py +209 -209
  16. package/app/core/bookmeta.py +1036 -1036
  17. package/app/core/bookmeta_catalog.py +180 -180
  18. package/app/core/branching.py +137 -137
  19. package/app/core/builtin_agent.py +1680 -1680
  20. package/app/core/capability.py +144 -144
  21. package/app/core/chaptersafe.py +32 -32
  22. package/app/core/checkpoints.py +143 -0
  23. package/app/core/cleanup.py +303 -303
  24. package/app/core/compaction.py +201 -201
  25. package/app/core/contracts.py +255 -255
  26. package/app/core/covergen.py +356 -356
  27. package/app/core/defectretro.py +43 -43
  28. package/app/core/diagnostics.py +114 -114
  29. package/app/core/dispatch.py +198 -191
  30. package/app/core/dispatch_log.py +128 -128
  31. package/app/core/env_scrub.py +85 -85
  32. package/app/core/errorlog.py +137 -137
  33. package/app/core/eval_matrix.py +82 -0
  34. package/app/core/evalbench.py +184 -91
  35. package/app/core/evaluation.py +174 -174
  36. package/app/core/failure_notify.py +290 -290
  37. package/app/core/flow_graph.py +33 -0
  38. package/app/core/goal_service.py +167 -167
  39. package/app/core/health.py +389 -389
  40. package/app/core/history.py +38 -38
  41. package/app/core/hooks.py +162 -162
  42. package/app/core/jobs.py +25 -4
  43. package/app/core/knowledge_pipeline.py +86 -0
  44. package/app/core/manager.py +2232 -2232
  45. package/app/core/mocks.py +64 -64
  46. package/app/core/notify.py +201 -201
  47. package/app/core/novel_quality.py +181 -189
  48. package/app/core/openapi.py +177 -154
  49. package/app/core/operations.py +228 -228
  50. package/app/core/paihang.py +145 -145
  51. package/app/core/pipeline.py +43 -19
  52. package/app/core/planner.py +17 -11
  53. package/app/core/policy.py +52 -0
  54. package/app/core/portguard.py +111 -111
  55. package/app/core/presence.py +77 -77
  56. package/app/core/preview.py +293 -293
  57. package/app/core/prompt_guard.py +78 -78
  58. package/app/core/publish/__init__.py +7 -7
  59. package/app/core/publish/auto.py +26 -3
  60. package/app/core/publish/browser.py +655 -655
  61. package/app/core/publish/chaprows.py +138 -0
  62. package/app/core/publish/fanqie.py +210 -165
  63. package/app/core/publish/flow.py +753 -640
  64. package/app/core/publish/flows-fanqie-calibrated.json +349 -336
  65. package/app/core/publish/ledger.py +13 -1
  66. package/app/core/publish/manager.py +1150 -1118
  67. package/app/core/publish/qimao.py +176 -176
  68. package/app/core/publish/volumes.py +108 -0
  69. package/app/core/publish/ws.py +139 -139
  70. package/app/core/quality_gate.py +194 -194
  71. package/app/core/redact.py +38 -38
  72. package/app/core/repeat_guard.py +129 -129
  73. package/app/core/retrieval.py +137 -137
  74. package/app/core/router.py +398 -398
  75. package/app/core/runner.py +2238 -2238
  76. package/app/core/selfupdate.py +384 -384
  77. package/app/core/session_log.py +161 -161
  78. package/app/core/sessions.py +312 -312
  79. package/app/core/settings.py +20 -0
  80. package/app/core/settings_schema.py +270 -270
  81. package/app/core/share_page.py +114 -114
  82. package/app/core/skill_scan.py +139 -139
  83. package/app/core/skillpacks/fanqie-novel.md +8 -6
  84. package/app/core/skillpacks/market/character-bible.md +66 -66
  85. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  86. package/app/core/skillpacks/market/git-workflow.md +57 -57
  87. package/app/core/skillpacks/market/release-notes.md +72 -72
  88. package/app/core/skillpacks/market/weekly-report.md +71 -71
  89. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  90. package/app/core/skillpacks/qimao-signing.md +14 -11
  91. package/app/core/skills.py +32 -21
  92. package/app/core/step_runner.py +132 -132
  93. package/app/core/store.py +9 -0
  94. package/app/core/story_tracking.py +275 -275
  95. package/app/core/task_compile.py +199 -199
  96. package/app/core/telemetry.py +292 -292
  97. package/app/core/tlsctx.py +64 -64
  98. package/app/core/token_meter.py +147 -147
  99. package/app/core/tracing.py +207 -144
  100. package/app/core/upstream.py +41 -41
  101. package/app/core/usage.py +1038 -1038
  102. package/app/core/volumes.py +389 -389
  103. package/app/core/webhooks.py +89 -0
  104. package/app/core/wxdigest.py +858 -858
  105. package/app/core/zentao.py +2692 -2692
  106. package/app/main.py +104 -9
  107. package/app/mcp_server.py +244 -244
  108. package/app/pick_dialog.py +110 -110
  109. package/app/ui/app.js +286 -18
  110. package/app/ui/artdiff.js +74 -74
  111. package/app/ui/assets/community-qr.png +0 -0
  112. package/app/ui/assets/donate-alipay.jpg +0 -0
  113. package/app/ui/assets/donate-qr.jpg +0 -0
  114. package/app/ui/board.html +162 -162
  115. package/app/ui/board.js +305 -305
  116. package/app/ui/i18n.js +33 -0
  117. package/app/ui/icons/bee.svg +79 -79
  118. package/app/ui/index.html +32 -3
  119. package/app/ui/manifest.json +16 -16
  120. package/app/ui/qrcode.js +2297 -2297
  121. package/app/ui/style.css +196 -2
  122. package/bin/tutti.js +147 -147
  123. package/package.json +48 -48
  124. package/tools/wx_reader.py +261 -261
  125. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  126. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  127. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  128. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  129. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  130. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  131. package/app/.playwright-cli/run-detail-after.png +0 -0
  132. package/app/output/_createbook_body.json +0 -1
  133. package/app/output/_probe_fq_click.py +0 -50
  134. package/app/output/_probe_fq_form.py +0 -42
  135. package/app/output/_probe_fq_modal.py +0 -55
  136. package/app/output/_probe_fq_points.py +0 -71
  137. package/app/output/_probe_fq_submit.py +0 -34
  138. package/app/output/_probe_fq_watch.py +0 -62
@@ -1,356 +1,356 @@
1
- # -*- coding: utf-8 -*-
2
- """封面图生成:调供应商图像 API 产出竖版封面插画,curl 直接落盘到运行目录。
3
-
4
- 接口形状:OpenAI 兼容 POST {base}/images/generations(Z.ai cogview 系列、
5
- 多数聚合网关都支持,默认返回图片 URL)。端点选取:编排者供应商的 openai 面
6
- (显式协议或 wire_caps 实测出的另一协议面)优先,其余开了 openai 面的供应商
7
- 按优先级自动跟上——编排者走 anthropic 面不再挡封面。已知主机会补规范图像面
8
- (智谱 anthropic 面 key 同源,图像只走 /api/paas/v4;实测 face 对图像回
9
- HTTP 200+失败信封,规范面放前面省一轮空试)。错误信息带响应体真因(聚合
10
- 网关「无可用渠道」不再吞成光秃 HTTPError)。
11
- 图像模型:环境变量
12
- CODEBEE_IMAGE_MODEL 优先,其次供应商模型清单里认得出的图像模型(关键词),
13
- 否则逐个试默认候选(cogview-3-flash → cogview-4),第一个 2xx 的胜出;
14
- 同一模型先试竖版尺寸再回落方图。
15
- 状态机与建书生成同款(running/done/failed 写任务 cover_gen 字段,
16
- bump_state 推 SSE)。
17
-
18
- 安全边界:
19
- - 图像 URL 仅接受 https,解析后 IP 命中私网/环回/链路本地一律拒绝
20
- (防 SSRF 打内网与云元数据);禁用重定向(curl -L 不加)。
21
- - Python 不经手图像字节:下载与落盘由 curl 子进程 -o 一步完成。
22
- - 产物只落 paths.RUNS_DIR 受管路径(与 report.md 同款),不写任务工作目录。
23
- """
24
- from __future__ import annotations
25
-
26
- import json
27
- import logging
28
- import os
29
- import socket
30
- import threading
31
- import time
32
- import urllib.error
33
- import urllib.request
34
- from pathlib import Path
35
-
36
- log = logging.getLogger(__name__)
37
-
38
- _IMAGE_CANDIDATES = ("cogview-3-flash", "cogview-4")
39
- # 模型清单里认得出的图像模型关键词(命中即列为候选,保持清单序)
40
- _IMAGE_MODEL_KEYWORDS = ("cogview", "dall-e", "gpt-image", "flux", "kolors",
41
- "seedream", "stable-diffusion", "hidream", "imagen")
42
- _SIZES = ("768x1344", "1024x1024") # 竖版优先,方图兜底
43
-
44
-
45
- def _chapter_facts(workdir, limit=3):
46
- """从最新章节抽取视觉事实(借鉴 poster-v2 的 novel fact extraction):
47
- 人物/场景/关键意象的原文片段——封面元素来自真实剧情而非干瘪题材词。
48
- 读不到章节(单稿/未开写)返回空串,提示词回落题材+简介。"""
49
- if not workdir:
50
- return ""
51
- import glob as _glob
52
- try:
53
- chapters = sorted(_glob.glob(str(workdir) + "/chapter-*.md"))
54
- if not chapters:
55
- return ""
56
- facts = []
57
- for p in chapters[-limit:]:
58
- try:
59
- with open(p, encoding="utf-8", errors="replace") as f:
60
- txt = f.read(4000)
61
- # 取开头(多为场景/人物动作)+ 关键句粗筛
62
- head = " ".join(txt.split("\n")[1:6])
63
- if head.strip():
64
- facts.append(head.strip()[:200])
65
- except OSError:
66
- continue
67
- return "\n".join(facts)[:900]
68
- except Exception:
69
- return ""
70
-
71
-
72
- def _cover_prompt(task):
73
- """从任务与建书资料拼图像提示词:场景氛围向,不要文字(平台会自行压字)。
74
-
75
- 素材优先级:章节事实抽取(真实剧情的人物/场景/意象)> 题材+简介。
76
- poster-v2 借鉴:视觉元素来自 novel facts 才有「这是这本书的封面」的辨识度。"""
77
- bm = ((task.get("book_meta") or {}).get("fanqie") or {}).get("data") or {}
78
- if not isinstance(bm, dict):
79
- bm = {}
80
- title = bm.get("书名") or task.get("title") or ""
81
- genre = bm.get("类型") or bm.get("分类") or ""
82
- brief = (bm.get("一句话简介") or bm.get("简介") or task.get("goal") or "")
83
- facts = _chapter_facts(task.get("workdir") or "")
84
- base = ("竖版小说封面插画,画面中不要出现任何文字。题材:%s %s。故事梗概:%s。"
85
- % (genre, title, str(brief)[:300]))
86
- if facts:
87
- base += ("\n\n以下是最新的剧情片段(从中提取主要人物形象、标志性场景与核心意象"
88
- "作为画面主体——让封面一眼可辨「这是这本书」):\n%s" % facts)
89
- base += ("\n商业网文封面质感:主体人物或核心场景突出,色彩浓郁有冲击力,"
90
- "构图上方留白便于后期压标题。")
91
- return base
92
-
93
-
94
- def _pick_key(prov):
95
- from . import modelhub
96
- keys = modelhub._chain_keys(prov) or []
97
- if keys and keys[0].get("key"):
98
- return keys[0]["key"]
99
- return prov.get("api_key") or ""
100
-
101
-
102
- # 已知主机的规范图像面:wire_caps 记的 openai 面只保 chat 可用,图像端点在
103
- # 各家规范路径上(真案:智谱 anthropic 面 key 同源可出图,但 wire_caps 面的
104
- # /api/anthropic 路径对图像返回 HTTP 200+失败信封,必须走 /api/paas/v4)。
105
- _CANON_IMAGE_FACE = {
106
- "open.bigmodel.cn": "https://open.bigmodel.cn/api/paas/v4",
107
- }
108
-
109
-
110
- def _openai_face(prov):
111
- """该供应商可用的 openai 面地址:显式 openai 协议用本体;其余只认 wire_caps
112
- 里实测通过的 openai 面——两协议面的路径不同(Z.ai:/api/anthropic vs
113
- /api/paas/v4),靠猜必错,与绑定链「只挑实测 wire」同纪律。"""
114
- proto = str(prov.get("protocol") or "openai")
115
- base = str(prov.get("base_url") or "").rstrip("/")
116
- if proto == "openai" and base:
117
- return base
118
- cap = (prov.get("wire_caps") or {}).get("openai") or {}
119
- return str(cap.get("base") or "").rstrip("/")
120
-
121
-
122
- def _image_bases(prov):
123
- """该供应商可试的图像端点(去重保序):已知主机的规范图像面在前——
124
- 实测 face 若恰好是 chat 代理路径(智谱 anthropic 面),对图像必是假 200;
125
- 规范面直接出图省一轮空试。无规范表的其余主机仍用实测 face。"""
126
- from urllib.parse import urlparse
127
- face = _openai_face(prov)
128
- host = urlparse(face or str(prov.get("base_url") or "")).hostname or ""
129
- canon = _CANON_IMAGE_FACE.get(host) or ""
130
- bases = [b for b in (canon, face) if b]
131
- seen, out = set(), []
132
- for b in bases:
133
- if b not in seen:
134
- seen.add(b)
135
- out.append(b)
136
- return out
137
-
138
-
139
- def _image_models(prov):
140
- """图像模型候选序:环境变量 CODEBEE_IMAGE_MODEL > 供应商模型清单里关键词
141
- 命中的图像模型(保持清单序)> 默认候选 cogview(Z.ai 免费档先行)。"""
142
- models = []
143
- env_model = os.environ.get("CODEBEE_IMAGE_MODEL", "").strip()
144
- if env_model:
145
- models.append(env_model)
146
- for m in (prov.get("models") or []):
147
- name = str((m.get("name") if isinstance(m, dict) else m) or "").strip()
148
- low = name.lower()
149
- if name and any(k in low for k in _IMAGE_MODEL_KEYWORDS) and name not in models:
150
- models.append(name)
151
- models.extend(m for m in _IMAGE_CANDIDATES if m not in models)
152
- return models
153
-
154
-
155
- def _candidates():
156
- """图像端点候选 [{label, bases, key, allow_private, prov}]:编排者供应商排
157
- 最前,其余启用且配了密钥的供应商按列表优先级跟上;每家可试端点见
158
- _image_bases(实测 face + 已知主机规范面);同一(端点组+密钥)只收一次。"""
159
- from . import modelhub
160
- orch = modelhub.resolve_orchestrator()
161
- first = [orch[0]] if orch else []
162
- first_ids = {p.get("id") for p in first}
163
- rest = [p for p in modelhub.providers() if p.get("id") not in first_ids]
164
- out, seen = [], set()
165
- for prov in first + rest:
166
- if not prov.get("enabled", True) or not prov.get("api_key"):
167
- continue
168
- bases = _image_bases(prov)
169
- key = _pick_key(prov)
170
- if not bases or not key:
171
- continue
172
- sig = (tuple(bases), key)
173
- if sig in seen:
174
- continue
175
- seen.add(sig)
176
- out.append({"label": prov.get("name") or prov.get("id") or "供应商",
177
- "bases": bases, "key": key,
178
- "allow_private": bool(prov.get("allow_private")),
179
- "prov": prov})
180
- return out
181
-
182
-
183
- def _safe_image_url(url):
184
- """SSRF 校验:仅 https;解析主机全部 IP,私网/环回/链路本地/保留段拒绝。"""
185
- import ipaddress
186
- from urllib.parse import urlparse
187
- u = urlparse(url)
188
- if u.scheme != "https":
189
- raise ValueError("仅允许 https 图像地址")
190
- host = u.hostname or ""
191
- if not host:
192
- raise ValueError("图像地址缺少主机")
193
- infos = socket.getaddrinfo(host, u.port or 443, proto=socket.IPPROTO_TCP)
194
- if not infos:
195
- raise ValueError("图像主机无法解析")
196
- for info in infos:
197
- ip = ipaddress.ip_address(info[4][0])
198
- if (ip.is_private or ip.is_loopback or ip.is_link_local
199
- or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
200
- raise ValueError("图像主机解析到受限地址,已拒绝")
201
- return url
202
-
203
-
204
- def _curl_to(url, out_path):
205
- """curl 子进程下载落盘(Python 不经手图像字节;不加 -L 禁重定向)。"""
206
- from . import runner
207
- r = runner.run_process(
208
- argv=["curl", "-sS", "--max-time", "180",
209
- "--proto", "=https", "--fail",
210
- "-o", str(out_path), url],
211
- timeout=200)
212
- if not r["ok"]:
213
- raise RuntimeError((r.get("stderr") or r.get("stdout") or "下载失败")[:200])
214
- p = Path(out_path)
215
- if not p.is_file() or p.stat().st_size < 1024:
216
- raise RuntimeError("下载内容过小或为空")
217
-
218
-
219
- def _post_images(url, key, body, allow_private, timeout):
220
- """图像接口专用 POST:非 2xx 也读响应体真因。_post_json_http 对 HTTPError
221
- 直接返回 (0, None, repr),聚合网关的「无可用渠道」会被吞成光秃
222
- HTTPError 503;复用同一套 SSRF 校验与出站 TLS 上下文。"""
223
- from . import modelhub, tlsctx
224
- _host, herr = modelhub._validate_host(url, allow_private)
225
- if _host is None:
226
- return 0, None, herr
227
- try:
228
- req = urllib.request.Request(url, method="POST",
229
- headers={"Authorization": "Bearer " + key,
230
- "Content-Type": "application/json"},
231
- data=json.dumps(body).encode("utf-8"))
232
- with modelhub._opener().open(req, timeout=timeout) as resp:
233
- return resp.status, json.loads(resp.read(1024 * 1024).decode("utf-8", "replace")), ""
234
- except urllib.error.HTTPError as e:
235
- raw = e.read(65536).decode("utf-8", "replace")
236
- try:
237
- data = json.loads(raw)
238
- except Exception:
239
- data = None
240
- msg = ""
241
- if isinstance(data, dict):
242
- msg = str((data.get("error") or {}).get("message")
243
- or data.get("message") or data.get("msg") or "")
244
- if not msg:
245
- msg = "HTTP %s %s" % (e.code, getattr(e, "reason", "") or "")
246
- return e.code, data, msg[:300]
247
- except Exception as e:
248
- return 0, None, tlsctx.humanize(repr(e)[:300])
249
-
250
-
251
- def _call_images(base, key, model, prompt, size, allow_private):
252
- """POST /images/generations。返回 (data_item dict, 错误串);data_item 含 url 或 b64_json。"""
253
- url = base.rstrip("/") + "/images/generations"
254
- body = {"model": model, "prompt": prompt, "size": size}
255
- if os.environ.get("CODEBEE_IMAGE_WATERMARK", "").strip() != "1":
256
- # 智谱缺省 watermark=true,图上强制「AI生成」显式角标+隐式数字水印;
257
- # 实测传 false 被 200 接受。CODEBEE_IMAGE_WATERMARK=1 恢复供应商默认。
258
- body["watermark"] = False
259
- status, data, err = _post_images(url, key, body, allow_private, 180)
260
- if status == 0:
261
- return None, err or "网络错误"
262
- if 200 <= status < 300:
263
- if isinstance(data, dict) and data.get("success") is False:
264
- # 智谱 anthropic 面对未知子路径回 HTTP 200+失败信封,按失败处理
265
- return None, str(data.get("msg") or "供应商返回失败信封")
266
- items = (data or {}).get("data") or []
267
- if items and isinstance(items[0], dict):
268
- return items[0], ""
269
- return None, "响应缺少 data[0]"
270
- return None, err or ("HTTP %s" % status)
271
-
272
-
273
- def make_cover(run_id, task):
274
- """同步生成封面到运行目录。成功/失败返回 entry dict(写任务 cover_gen 用)。"""
275
- from . import builtin_agent, paths
276
- try:
277
- cands = _candidates()
278
- if not cands:
279
- raise RuntimeError(
280
- "没有可用的 openai 协议图像接口(扫了全部供应商的 openai 面,"
281
- "含适配测试实测出的)。可到模型管理页对网关跑一次适配测试补出 "
282
- "openai 面,或设环境变量 CODEBEE_IMAGE_MODEL 指定图像模型后重试")
283
- prompt = _cover_prompt(task)
284
- last_err = ""
285
- for cand in cands:
286
- for base in cand["bases"]:
287
- for model in _image_models(cand["prov"]):
288
- for size in _SIZES:
289
- item, err = _call_images(base, cand["key"], model,
290
- prompt, size, cand["allow_private"])
291
- if item is None:
292
- last_err = err
293
- continue
294
- img_url = str(item.get("url") or "")
295
- if not img_url:
296
- last_err = "该供应商未返回图片 URL(仅内嵌数据),暂不支持"
297
- continue
298
- try:
299
- safe_url = _safe_image_url(img_url)
300
- except ValueError as e:
301
- last_err = str(e)
302
- continue
303
- out_dir = paths.RUNS_DIR / str(run_id)
304
- out_dir.mkdir(parents=True, exist_ok=True)
305
- out = out_dir / "cover.png"
306
- try:
307
- _curl_to(safe_url, out)
308
- except RuntimeError as e:
309
- last_err = str(e)
310
- continue
311
- return {"status": "done", "file": "cover.png", "run_id": str(run_id),
312
- "provider": cand["label"], "model": model, "size": size,
313
- "at": time.strftime("%Y-%m-%d %H:%M:%S")}
314
- raise RuntimeError(last_err or "图像接口无可用模型")
315
- except Exception as e:
316
- return {"status": "failed", "error": str(e)[:300],
317
- "at": time.strftime("%Y-%m-%d %H:%M:%S")}
318
-
319
-
320
- def generate_async(run_id, task_id, task):
321
- """后台线程入口:生成封面并把终态写回任务 cover_gen(仿建书 generate_async)。"""
322
- from . import store
323
- if not store.get_task(task_id):
324
- return
325
- entry = make_cover(run_id, task)
326
- cur = store.get_task(task_id)
327
- if not cur:
328
- return
329
- prev = cur.get("cover_gen") or {}
330
- if prev.get("status") != "running": # 期间被删/重置:丢弃结果
331
- return
332
- store.set_cover_gen(task_id, entry)
333
-
334
-
335
- def start(task_id, run_id=None):
336
- """起后台封面生成线程。run_id 缺省用该任务最近一次 run。返回 (ok, err)。running 幂等拒绝。"""
337
- from . import store
338
- task = store.get_task(task_id)
339
- if not task:
340
- return False, "任务不存在"
341
- cur = task.get("cover_gen") or {}
342
- if cur.get("status") == "running":
343
- return True, ""
344
- rid = run_id
345
- if not rid:
346
- runs = store.task_runs(task_id)
347
- rid = (runs[-1].get("id") if runs else "")
348
- if not rid:
349
- return False, "没有可归属的运行记录(先跑一次任务再生成封面)"
350
- if not store.set_cover_gen(task_id, {"status": "running",
351
- "at": time.strftime("%Y-%m-%d %H:%M:%S")}):
352
- return False, "任务不存在"
353
- task = dict(task, cover_gen={"status": "running"})
354
- threading.Thread(target=generate_async, daemon=True,
355
- name="cover-gen-%s" % task_id, args=(rid, task_id, task)).start()
356
- return True, ""
1
+ # -*- coding: utf-8 -*-
2
+ """封面图生成:调供应商图像 API 产出竖版封面插画,curl 直接落盘到运行目录。
3
+
4
+ 接口形状:OpenAI 兼容 POST {base}/images/generations(Z.ai cogview 系列、
5
+ 多数聚合网关都支持,默认返回图片 URL)。端点选取:编排者供应商的 openai 面
6
+ (显式协议或 wire_caps 实测出的另一协议面)优先,其余开了 openai 面的供应商
7
+ 按优先级自动跟上——编排者走 anthropic 面不再挡封面。已知主机会补规范图像面
8
+ (智谱 anthropic 面 key 同源,图像只走 /api/paas/v4;实测 face 对图像回
9
+ HTTP 200+失败信封,规范面放前面省一轮空试)。错误信息带响应体真因(聚合
10
+ 网关「无可用渠道」不再吞成光秃 HTTPError)。
11
+ 图像模型:环境变量
12
+ CODEBEE_IMAGE_MODEL 优先,其次供应商模型清单里认得出的图像模型(关键词),
13
+ 否则逐个试默认候选(cogview-3-flash → cogview-4),第一个 2xx 的胜出;
14
+ 同一模型先试竖版尺寸再回落方图。
15
+ 状态机与建书生成同款(running/done/failed 写任务 cover_gen 字段,
16
+ bump_state 推 SSE)。
17
+
18
+ 安全边界:
19
+ - 图像 URL 仅接受 https,解析后 IP 命中私网/环回/链路本地一律拒绝
20
+ (防 SSRF 打内网与云元数据);禁用重定向(curl -L 不加)。
21
+ - Python 不经手图像字节:下载与落盘由 curl 子进程 -o 一步完成。
22
+ - 产物只落 paths.RUNS_DIR 受管路径(与 report.md 同款),不写任务工作目录。
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import logging
28
+ import os
29
+ import socket
30
+ import threading
31
+ import time
32
+ import urllib.error
33
+ import urllib.request
34
+ from pathlib import Path
35
+
36
+ log = logging.getLogger(__name__)
37
+
38
+ _IMAGE_CANDIDATES = ("cogview-3-flash", "cogview-4")
39
+ # 模型清单里认得出的图像模型关键词(命中即列为候选,保持清单序)
40
+ _IMAGE_MODEL_KEYWORDS = ("cogview", "dall-e", "gpt-image", "flux", "kolors",
41
+ "seedream", "stable-diffusion", "hidream", "imagen")
42
+ _SIZES = ("768x1344", "1024x1024") # 竖版优先,方图兜底
43
+
44
+
45
+ def _chapter_facts(workdir, limit=3):
46
+ """从最新章节抽取视觉事实(借鉴 poster-v2 的 novel fact extraction):
47
+ 人物/场景/关键意象的原文片段——封面元素来自真实剧情而非干瘪题材词。
48
+ 读不到章节(单稿/未开写)返回空串,提示词回落题材+简介。"""
49
+ if not workdir:
50
+ return ""
51
+ import glob as _glob
52
+ try:
53
+ chapters = sorted(_glob.glob(str(workdir) + "/chapter-*.md"))
54
+ if not chapters:
55
+ return ""
56
+ facts = []
57
+ for p in chapters[-limit:]:
58
+ try:
59
+ with open(p, encoding="utf-8", errors="replace") as f:
60
+ txt = f.read(4000)
61
+ # 取开头(多为场景/人物动作)+ 关键句粗筛
62
+ head = " ".join(txt.split("\n")[1:6])
63
+ if head.strip():
64
+ facts.append(head.strip()[:200])
65
+ except OSError:
66
+ continue
67
+ return "\n".join(facts)[:900]
68
+ except Exception:
69
+ return ""
70
+
71
+
72
+ def _cover_prompt(task):
73
+ """从任务与建书资料拼图像提示词:场景氛围向,不要文字(平台会自行压字)。
74
+
75
+ 素材优先级:章节事实抽取(真实剧情的人物/场景/意象)> 题材+简介。
76
+ poster-v2 借鉴:视觉元素来自 novel facts 才有「这是这本书的封面」的辨识度。"""
77
+ bm = ((task.get("book_meta") or {}).get("fanqie") or {}).get("data") or {}
78
+ if not isinstance(bm, dict):
79
+ bm = {}
80
+ title = bm.get("书名") or task.get("title") or ""
81
+ genre = bm.get("类型") or bm.get("分类") or ""
82
+ brief = (bm.get("一句话简介") or bm.get("简介") or task.get("goal") or "")
83
+ facts = _chapter_facts(task.get("workdir") or "")
84
+ base = ("竖版小说封面插画,画面中不要出现任何文字。题材:%s %s。故事梗概:%s。"
85
+ % (genre, title, str(brief)[:300]))
86
+ if facts:
87
+ base += ("\n\n以下是最新的剧情片段(从中提取主要人物形象、标志性场景与核心意象"
88
+ "作为画面主体——让封面一眼可辨「这是这本书」):\n%s" % facts)
89
+ base += ("\n商业网文封面质感:主体人物或核心场景突出,色彩浓郁有冲击力,"
90
+ "构图上方留白便于后期压标题。")
91
+ return base
92
+
93
+
94
+ def _pick_key(prov):
95
+ from . import modelhub
96
+ keys = modelhub._chain_keys(prov) or []
97
+ if keys and keys[0].get("key"):
98
+ return keys[0]["key"]
99
+ return prov.get("api_key") or ""
100
+
101
+
102
+ # 已知主机的规范图像面:wire_caps 记的 openai 面只保 chat 可用,图像端点在
103
+ # 各家规范路径上(真案:智谱 anthropic 面 key 同源可出图,但 wire_caps 面的
104
+ # /api/anthropic 路径对图像返回 HTTP 200+失败信封,必须走 /api/paas/v4)。
105
+ _CANON_IMAGE_FACE = {
106
+ "open.bigmodel.cn": "https://open.bigmodel.cn/api/paas/v4",
107
+ }
108
+
109
+
110
+ def _openai_face(prov):
111
+ """该供应商可用的 openai 面地址:显式 openai 协议用本体;其余只认 wire_caps
112
+ 里实测通过的 openai 面——两协议面的路径不同(Z.ai:/api/anthropic vs
113
+ /api/paas/v4),靠猜必错,与绑定链「只挑实测 wire」同纪律。"""
114
+ proto = str(prov.get("protocol") or "openai")
115
+ base = str(prov.get("base_url") or "").rstrip("/")
116
+ if proto == "openai" and base:
117
+ return base
118
+ cap = (prov.get("wire_caps") or {}).get("openai") or {}
119
+ return str(cap.get("base") or "").rstrip("/")
120
+
121
+
122
+ def _image_bases(prov):
123
+ """该供应商可试的图像端点(去重保序):已知主机的规范图像面在前——
124
+ 实测 face 若恰好是 chat 代理路径(智谱 anthropic 面),对图像必是假 200;
125
+ 规范面直接出图省一轮空试。无规范表的其余主机仍用实测 face。"""
126
+ from urllib.parse import urlparse
127
+ face = _openai_face(prov)
128
+ host = urlparse(face or str(prov.get("base_url") or "")).hostname or ""
129
+ canon = _CANON_IMAGE_FACE.get(host) or ""
130
+ bases = [b for b in (canon, face) if b]
131
+ seen, out = set(), []
132
+ for b in bases:
133
+ if b not in seen:
134
+ seen.add(b)
135
+ out.append(b)
136
+ return out
137
+
138
+
139
+ def _image_models(prov):
140
+ """图像模型候选序:环境变量 CODEBEE_IMAGE_MODEL > 供应商模型清单里关键词
141
+ 命中的图像模型(保持清单序)> 默认候选 cogview(Z.ai 免费档先行)。"""
142
+ models = []
143
+ env_model = os.environ.get("CODEBEE_IMAGE_MODEL", "").strip()
144
+ if env_model:
145
+ models.append(env_model)
146
+ for m in (prov.get("models") or []):
147
+ name = str((m.get("name") if isinstance(m, dict) else m) or "").strip()
148
+ low = name.lower()
149
+ if name and any(k in low for k in _IMAGE_MODEL_KEYWORDS) and name not in models:
150
+ models.append(name)
151
+ models.extend(m for m in _IMAGE_CANDIDATES if m not in models)
152
+ return models
153
+
154
+
155
+ def _candidates():
156
+ """图像端点候选 [{label, bases, key, allow_private, prov}]:编排者供应商排
157
+ 最前,其余启用且配了密钥的供应商按列表优先级跟上;每家可试端点见
158
+ _image_bases(实测 face + 已知主机规范面);同一(端点组+密钥)只收一次。"""
159
+ from . import modelhub
160
+ orch = modelhub.resolve_orchestrator()
161
+ first = [orch[0]] if orch else []
162
+ first_ids = {p.get("id") for p in first}
163
+ rest = [p for p in modelhub.providers() if p.get("id") not in first_ids]
164
+ out, seen = [], set()
165
+ for prov in first + rest:
166
+ if not prov.get("enabled", True) or not prov.get("api_key"):
167
+ continue
168
+ bases = _image_bases(prov)
169
+ key = _pick_key(prov)
170
+ if not bases or not key:
171
+ continue
172
+ sig = (tuple(bases), key)
173
+ if sig in seen:
174
+ continue
175
+ seen.add(sig)
176
+ out.append({"label": prov.get("name") or prov.get("id") or "供应商",
177
+ "bases": bases, "key": key,
178
+ "allow_private": bool(prov.get("allow_private")),
179
+ "prov": prov})
180
+ return out
181
+
182
+
183
+ def _safe_image_url(url):
184
+ """SSRF 校验:仅 https;解析主机全部 IP,私网/环回/链路本地/保留段拒绝。"""
185
+ import ipaddress
186
+ from urllib.parse import urlparse
187
+ u = urlparse(url)
188
+ if u.scheme != "https":
189
+ raise ValueError("仅允许 https 图像地址")
190
+ host = u.hostname or ""
191
+ if not host:
192
+ raise ValueError("图像地址缺少主机")
193
+ infos = socket.getaddrinfo(host, u.port or 443, proto=socket.IPPROTO_TCP)
194
+ if not infos:
195
+ raise ValueError("图像主机无法解析")
196
+ for info in infos:
197
+ ip = ipaddress.ip_address(info[4][0])
198
+ if (ip.is_private or ip.is_loopback or ip.is_link_local
199
+ or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
200
+ raise ValueError("图像主机解析到受限地址,已拒绝")
201
+ return url
202
+
203
+
204
+ def _curl_to(url, out_path):
205
+ """curl 子进程下载落盘(Python 不经手图像字节;不加 -L 禁重定向)。"""
206
+ from . import runner
207
+ r = runner.run_process(
208
+ argv=["curl", "-sS", "--max-time", "180",
209
+ "--proto", "=https", "--fail",
210
+ "-o", str(out_path), url],
211
+ timeout=200)
212
+ if not r["ok"]:
213
+ raise RuntimeError((r.get("stderr") or r.get("stdout") or "下载失败")[:200])
214
+ p = Path(out_path)
215
+ if not p.is_file() or p.stat().st_size < 1024:
216
+ raise RuntimeError("下载内容过小或为空")
217
+
218
+
219
+ def _post_images(url, key, body, allow_private, timeout):
220
+ """图像接口专用 POST:非 2xx 也读响应体真因。_post_json_http 对 HTTPError
221
+ 直接返回 (0, None, repr),聚合网关的「无可用渠道」会被吞成光秃
222
+ HTTPError 503;复用同一套 SSRF 校验与出站 TLS 上下文。"""
223
+ from . import modelhub, tlsctx
224
+ _host, herr = modelhub._validate_host(url, allow_private)
225
+ if _host is None:
226
+ return 0, None, herr
227
+ try:
228
+ req = urllib.request.Request(url, method="POST",
229
+ headers={"Authorization": "Bearer " + key,
230
+ "Content-Type": "application/json"},
231
+ data=json.dumps(body).encode("utf-8"))
232
+ with modelhub._opener().open(req, timeout=timeout) as resp:
233
+ return resp.status, json.loads(resp.read(1024 * 1024).decode("utf-8", "replace")), ""
234
+ except urllib.error.HTTPError as e:
235
+ raw = e.read(65536).decode("utf-8", "replace")
236
+ try:
237
+ data = json.loads(raw)
238
+ except Exception:
239
+ data = None
240
+ msg = ""
241
+ if isinstance(data, dict):
242
+ msg = str((data.get("error") or {}).get("message")
243
+ or data.get("message") or data.get("msg") or "")
244
+ if not msg:
245
+ msg = "HTTP %s %s" % (e.code, getattr(e, "reason", "") or "")
246
+ return e.code, data, msg[:300]
247
+ except Exception as e:
248
+ return 0, None, tlsctx.humanize(repr(e)[:300])
249
+
250
+
251
+ def _call_images(base, key, model, prompt, size, allow_private):
252
+ """POST /images/generations。返回 (data_item dict, 错误串);data_item 含 url 或 b64_json。"""
253
+ url = base.rstrip("/") + "/images/generations"
254
+ body = {"model": model, "prompt": prompt, "size": size}
255
+ if os.environ.get("CODEBEE_IMAGE_WATERMARK", "").strip() != "1":
256
+ # 智谱缺省 watermark=true,图上强制「AI生成」显式角标+隐式数字水印;
257
+ # 实测传 false 被 200 接受。CODEBEE_IMAGE_WATERMARK=1 恢复供应商默认。
258
+ body["watermark"] = False
259
+ status, data, err = _post_images(url, key, body, allow_private, 180)
260
+ if status == 0:
261
+ return None, err or "网络错误"
262
+ if 200 <= status < 300:
263
+ if isinstance(data, dict) and data.get("success") is False:
264
+ # 智谱 anthropic 面对未知子路径回 HTTP 200+失败信封,按失败处理
265
+ return None, str(data.get("msg") or "供应商返回失败信封")
266
+ items = (data or {}).get("data") or []
267
+ if items and isinstance(items[0], dict):
268
+ return items[0], ""
269
+ return None, "响应缺少 data[0]"
270
+ return None, err or ("HTTP %s" % status)
271
+
272
+
273
+ def make_cover(run_id, task):
274
+ """同步生成封面到运行目录。成功/失败返回 entry dict(写任务 cover_gen 用)。"""
275
+ from . import builtin_agent, paths
276
+ try:
277
+ cands = _candidates()
278
+ if not cands:
279
+ raise RuntimeError(
280
+ "没有可用的 openai 协议图像接口(扫了全部供应商的 openai 面,"
281
+ "含适配测试实测出的)。可到模型管理页对网关跑一次适配测试补出 "
282
+ "openai 面,或设环境变量 CODEBEE_IMAGE_MODEL 指定图像模型后重试")
283
+ prompt = _cover_prompt(task)
284
+ last_err = ""
285
+ for cand in cands:
286
+ for base in cand["bases"]:
287
+ for model in _image_models(cand["prov"]):
288
+ for size in _SIZES:
289
+ item, err = _call_images(base, cand["key"], model,
290
+ prompt, size, cand["allow_private"])
291
+ if item is None:
292
+ last_err = err
293
+ continue
294
+ img_url = str(item.get("url") or "")
295
+ if not img_url:
296
+ last_err = "该供应商未返回图片 URL(仅内嵌数据),暂不支持"
297
+ continue
298
+ try:
299
+ safe_url = _safe_image_url(img_url)
300
+ except ValueError as e:
301
+ last_err = str(e)
302
+ continue
303
+ out_dir = paths.RUNS_DIR / str(run_id)
304
+ out_dir.mkdir(parents=True, exist_ok=True)
305
+ out = out_dir / "cover.png"
306
+ try:
307
+ _curl_to(safe_url, out)
308
+ except RuntimeError as e:
309
+ last_err = str(e)
310
+ continue
311
+ return {"status": "done", "file": "cover.png", "run_id": str(run_id),
312
+ "provider": cand["label"], "model": model, "size": size,
313
+ "at": time.strftime("%Y-%m-%d %H:%M:%S")}
314
+ raise RuntimeError(last_err or "图像接口无可用模型")
315
+ except Exception as e:
316
+ return {"status": "failed", "error": str(e)[:300],
317
+ "at": time.strftime("%Y-%m-%d %H:%M:%S")}
318
+
319
+
320
+ def generate_async(run_id, task_id, task):
321
+ """后台线程入口:生成封面并把终态写回任务 cover_gen(仿建书 generate_async)。"""
322
+ from . import store
323
+ if not store.get_task(task_id):
324
+ return
325
+ entry = make_cover(run_id, task)
326
+ cur = store.get_task(task_id)
327
+ if not cur:
328
+ return
329
+ prev = cur.get("cover_gen") or {}
330
+ if prev.get("status") != "running": # 期间被删/重置:丢弃结果
331
+ return
332
+ store.set_cover_gen(task_id, entry)
333
+
334
+
335
+ def start(task_id, run_id=None):
336
+ """起后台封面生成线程。run_id 缺省用该任务最近一次 run。返回 (ok, err)。running 幂等拒绝。"""
337
+ from . import store
338
+ task = store.get_task(task_id)
339
+ if not task:
340
+ return False, "任务不存在"
341
+ cur = task.get("cover_gen") or {}
342
+ if cur.get("status") == "running":
343
+ return True, ""
344
+ rid = run_id
345
+ if not rid:
346
+ runs = store.task_runs(task_id)
347
+ rid = (runs[-1].get("id") if runs else "")
348
+ if not rid:
349
+ return False, "没有可归属的运行记录(先跑一次任务再生成封面)"
350
+ if not store.set_cover_gen(task_id, {"status": "running",
351
+ "at": time.strftime("%Y-%m-%d %H:%M:%S")}):
352
+ return False, "任务不存在"
353
+ task = dict(task, cover_gen={"status": "running"})
354
+ threading.Thread(target=generate_async, daemon=True,
355
+ name="cover-gen-%s" % task_id, args=(rid, task_id, task)).start()
356
+ return True, ""