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.
- package/CHANGELOG.md +725 -720
- package/LICENSE +21 -21
- package/README.md +515 -509
- package/app/core/acceptance.py +100 -100
- package/app/core/agent_context.py +100 -0
- package/app/core/aiflavor.py +197 -197
- package/app/core/analytics.py +38 -38
- package/app/core/artifacts.py +148 -148
- package/app/core/assets.py +110 -110
- package/app/core/attachments.py +623 -623
- package/app/core/audit.py +30 -30
- package/app/core/backup.py +470 -470
- package/app/core/beekeeper.py +183 -183
- package/app/core/benchstore.py +102 -95
- package/app/core/board.py +209 -209
- package/app/core/bookmeta.py +1036 -1036
- package/app/core/bookmeta_catalog.py +180 -180
- package/app/core/branching.py +137 -137
- package/app/core/builtin_agent.py +1680 -1680
- package/app/core/capability.py +144 -144
- package/app/core/chaptersafe.py +32 -32
- package/app/core/checkpoints.py +143 -0
- package/app/core/cleanup.py +303 -303
- package/app/core/compaction.py +201 -201
- package/app/core/contracts.py +255 -255
- package/app/core/covergen.py +356 -356
- package/app/core/defectretro.py +43 -43
- package/app/core/diagnostics.py +114 -114
- package/app/core/dispatch.py +198 -191
- package/app/core/dispatch_log.py +128 -128
- package/app/core/env_scrub.py +85 -85
- package/app/core/errorlog.py +137 -137
- package/app/core/eval_matrix.py +82 -0
- package/app/core/evalbench.py +184 -91
- package/app/core/evaluation.py +174 -174
- package/app/core/failure_notify.py +290 -290
- package/app/core/flow_graph.py +33 -0
- package/app/core/goal_service.py +167 -167
- package/app/core/health.py +389 -389
- package/app/core/history.py +38 -38
- package/app/core/hooks.py +162 -162
- package/app/core/jobs.py +25 -4
- package/app/core/knowledge_pipeline.py +86 -0
- package/app/core/manager.py +2232 -2232
- package/app/core/mocks.py +64 -64
- package/app/core/notify.py +201 -201
- package/app/core/novel_quality.py +181 -189
- package/app/core/openapi.py +177 -154
- package/app/core/operations.py +228 -228
- package/app/core/paihang.py +145 -145
- package/app/core/pipeline.py +43 -19
- package/app/core/planner.py +17 -11
- package/app/core/policy.py +52 -0
- package/app/core/portguard.py +111 -111
- package/app/core/presence.py +77 -77
- package/app/core/preview.py +293 -293
- package/app/core/prompt_guard.py +78 -78
- package/app/core/publish/__init__.py +7 -7
- package/app/core/publish/auto.py +26 -3
- package/app/core/publish/browser.py +655 -655
- package/app/core/publish/chaprows.py +138 -0
- package/app/core/publish/fanqie.py +210 -165
- package/app/core/publish/flow.py +753 -640
- package/app/core/publish/flows-fanqie-calibrated.json +349 -336
- package/app/core/publish/ledger.py +13 -1
- package/app/core/publish/manager.py +1150 -1118
- package/app/core/publish/qimao.py +176 -176
- package/app/core/publish/volumes.py +108 -0
- package/app/core/publish/ws.py +139 -139
- package/app/core/quality_gate.py +194 -194
- package/app/core/redact.py +38 -38
- package/app/core/repeat_guard.py +129 -129
- package/app/core/retrieval.py +137 -137
- package/app/core/router.py +398 -398
- package/app/core/runner.py +2238 -2238
- package/app/core/selfupdate.py +384 -384
- package/app/core/session_log.py +161 -161
- package/app/core/sessions.py +312 -312
- package/app/core/settings.py +20 -0
- package/app/core/settings_schema.py +270 -270
- package/app/core/share_page.py +114 -114
- package/app/core/skill_scan.py +139 -139
- package/app/core/skillpacks/fanqie-novel.md +8 -6
- package/app/core/skillpacks/market/character-bible.md +66 -66
- package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
- package/app/core/skillpacks/market/git-workflow.md +57 -57
- package/app/core/skillpacks/market/release-notes.md +72 -72
- package/app/core/skillpacks/market/weekly-report.md +71 -71
- package/app/core/skillpacks/market/worldview-consistency.md +70 -70
- package/app/core/skillpacks/qimao-signing.md +14 -11
- package/app/core/skills.py +32 -21
- package/app/core/step_runner.py +132 -132
- package/app/core/store.py +9 -0
- package/app/core/story_tracking.py +275 -275
- package/app/core/task_compile.py +199 -199
- package/app/core/telemetry.py +292 -292
- package/app/core/tlsctx.py +64 -64
- package/app/core/token_meter.py +147 -147
- package/app/core/tracing.py +207 -144
- package/app/core/upstream.py +41 -41
- package/app/core/usage.py +1038 -1038
- package/app/core/volumes.py +389 -389
- package/app/core/webhooks.py +89 -0
- package/app/core/wxdigest.py +858 -858
- package/app/core/zentao.py +2692 -2692
- package/app/main.py +104 -9
- package/app/mcp_server.py +244 -244
- package/app/pick_dialog.py +110 -110
- package/app/ui/app.js +286 -18
- package/app/ui/artdiff.js +74 -74
- package/app/ui/assets/community-qr.png +0 -0
- package/app/ui/assets/donate-alipay.jpg +0 -0
- package/app/ui/assets/donate-qr.jpg +0 -0
- package/app/ui/board.html +162 -162
- package/app/ui/board.js +305 -305
- package/app/ui/i18n.js +33 -0
- package/app/ui/icons/bee.svg +79 -79
- package/app/ui/index.html +32 -3
- package/app/ui/manifest.json +16 -16
- package/app/ui/qrcode.js +2297 -2297
- package/app/ui/style.css +196 -2
- package/bin/tutti.js +147 -147
- package/package.json +48 -48
- package/tools/wx_reader.py +261 -261
- package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
- package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
- package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
- package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
- package/app/.playwright-cli/run-detail-after.png +0 -0
- package/app/output/_createbook_body.json +0 -1
- package/app/output/_probe_fq_click.py +0 -50
- package/app/output/_probe_fq_form.py +0 -42
- package/app/output/_probe_fq_modal.py +0 -55
- package/app/output/_probe_fq_points.py +0 -71
- package/app/output/_probe_fq_submit.py +0 -34
- package/app/output/_probe_fq_watch.py +0 -62
package/app/core/covergen.py
CHANGED
|
@@ -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, ""
|