codebee 0.1.15 → 0.1.17
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 +14 -0
- package/README.md +9 -8
- package/app/core/bookmeta.py +10 -3
- package/app/core/bookmeta_catalog.py +16 -0
- package/app/core/covergen.py +176 -56
- package/app/core/jobs.py +23 -10
- package/app/core/modelhub.py +23 -3
- package/app/core/notify.py +11 -1
- package/app/core/paihang.py +5 -2
- package/app/core/pipeline.py +159 -10
- package/app/core/publish/browser.py +21 -7
- package/app/core/publish/fanqie.py +16 -2
- package/app/core/publish/flow.py +25 -1
- package/app/core/publish/flows-fanqie-calibrated.json +324 -37
- package/app/core/publish/manager.py +25 -9
- package/app/core/publish/qimao.py +18 -3
- package/app/core/runner.py +19 -4
- package/app/core/settings.py +3 -1
- package/app/core/store.py +13 -1
- package/app/core/zentao.py +26 -26
- package/app/main.py +20 -0
- package/app/ui/app.js +441 -60
- package/app/ui/i18n.js +243 -9
- package/app/ui/index.html +34 -52
- package/app/ui/style.css +116 -24
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@ CodeBee 的用户可感知变更记录。发布新版时:最上面加一节,
|
|
|
4
4
|
`<!-- relnotes:start -->…<!-- relnotes:end -->` 段(那段会被 `npm view` 的
|
|
5
5
|
README 元数据带回,供老版本在「发现新版本」时展示新版更新内容)。
|
|
6
6
|
|
|
7
|
+
## v0.1.17(2026-09-20)
|
|
8
|
+
|
|
9
|
+
- 任务宪章:工作目录 .codebee/constitution.md 一次定义质量原则(代码规范/文风/测试要求),每次运行自动注入所有智能体——注入体系五层就位(宪章>圣经>模块库>经验>记忆)
|
|
10
|
+
- 代码评审证据锚定:每个问题必须带「文件名:行号」+一行关键代码引用,无证据不报(借鉴 pr-af)
|
|
11
|
+
- 调研报告证据链:同结论双源交叉验证、来源行内标明、事实与观点分离、结论先行+风险局限结构(借鉴 gpt-researcher)
|
|
12
|
+
- 评审维度写前自查:起草提示词亮出评审打分维度,作者提前对齐重点
|
|
13
|
+
- 圣经连续性锁:跨章设定原样沿用不问义改写,每章用词一致读者不出戏(借鉴 drama-skills)
|
|
14
|
+
|
|
15
|
+
## v0.1.16(2026-09-19)
|
|
16
|
+
|
|
17
|
+
- 帮助中心:首启欢迎引导升级为「左目录 + 右内容」多章帮助中心,八章全就绪——快速上手、模型接入与绑定(多 Key 轮换 / 协议徽章解读 / 报错速查 / 免费档起步)、功能一览、连载创作流程、发布上架、代码任务与版本、自动化与技能市场、常见问题(9 条踩坑 FAQ)。首启仍只自动弹一次落在「快速上手」;手动打开(设置导航/关于页/Ctrl+K/**F1**)徽章与标题显示「帮助中心」,原三处「使用引导」入口统一更名
|
|
18
|
+
- 就地帮助问号:模型接入、CLI 绑定、智能体目录、自动化、插件市场、禅道、用量统计、经验库八个设置页头 + Git 工作台/代码版本隔离/蜂巢工作台/作品信息四处工作台头部共十二处新增小「?」,点击直达帮助中心对应章节;供应商空状态与「还没有装好的 CLI」空状态附「看帮助」链接
|
|
19
|
+
- 帮助中心新增「代码任务与版本」章(任务分支隔离 / 版本裁决合并与丢弃 / 蜂巢与步骤阅读指引),「自动化与技能市场」章补禅道 Bug 自动修复小节
|
|
20
|
+
|
|
7
21
|
## v0.1.15(2026-09-19)
|
|
8
22
|
|
|
9
23
|
- 禅道集成:定时扫描激活 Bug → 自动建修复任务 → 跑完自动合并 + resolve(fixed) + 报告回写 + 群通知(REST v1,SSRF 守卫;设置页「禅道」子页)
|
package/README.md
CHANGED
|
@@ -21,14 +21,10 @@ Kimi Code、MiMo Code、Grok Build、Pi、DeepSeek Harness……),提供
|
|
|
21
21
|
不会把你的任务内容交给任何第三方。
|
|
22
22
|
|
|
23
23
|
<!-- relnotes:start -->
|
|
24
|
-
### 最新版更新内容(v0.1.
|
|
25
|
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
- **可分享报告页**:运行报告一键导出自包含单文件 HTML
|
|
29
|
-
- **引擎调参卡**:设置页直接调压缩阈值/goal 轮数等引擎参数(防并发覆盖)
|
|
30
|
-
- 出站 HTTPS 证书校验加固(修复部分 Mac 上的全外联失败);发布浏览器 profile 迁出仓库目录
|
|
31
|
-
- 群推送(钉钉/飞书/企微自动适配)与「扫榜选材」任务类型见 v0.1.14
|
|
24
|
+
### 最新版更新内容(v0.1.16)
|
|
25
|
+
|
|
26
|
+
- **帮助中心**:首启欢迎引导升级为多章帮助中心——快速上手 / 模型接入与绑定 / 连载创作流程 / 发布上架 / 代码任务与版本 / 自动化与技能市场 / 功能一览 / 常见问题八章全就绪;设置各子页与蜂巢、Git 工作台、作品信息等十二处「?」就地问号直达对应章节;**F1** 或设置导航、关于页、Ctrl+K 随时可重开
|
|
27
|
+
- 禅道集成、扫榜选材(双源)、可分享报告页、引擎调参卡见 v0.1.15
|
|
32
28
|
<!-- relnotes:end -->
|
|
33
29
|
|
|
34
30
|
---
|
|
@@ -106,6 +102,11 @@ OpenCode / Continue / Cursor / Trae 十种工具的配置,一次带入全部
|
|
|
106
102
|
- **运行中指挥**:任务跑着也能递话——文字 / 截图 / 附件注入下一轮;
|
|
107
103
|
- **对话模式**:不拆步骤、直接执行,时间线式对话页,随时追话续上下文;
|
|
108
104
|
- **自动化**:定时任务复用真实运行链,到点自动开跑(错过的一次性任务不补跑);
|
|
105
|
+
- **禅道 Bug 自动修复**:多产品档案(每产品配我方端/前后端仓库/负责人),定时扫描
|
|
106
|
+
激活 Bug 先排查定责(模块路由规则 > AI 判端 > 留人工)——我方端自动建修复任务
|
|
107
|
+
跑完整代码链,修完自动合并;双端问题修完我方部分后带修复报告转派另一端负责人,
|
|
108
|
+
非我方转回报告人;测试指错人也按排查结论改派;修复失败自动升级转派;群机器人
|
|
109
|
+
全程播报(设置页「禅道」子页配置);
|
|
109
110
|
- **多任务并发**:worker 池默认 3(1-6 可调),互不打扰;连载任务超时/中断自动
|
|
110
111
|
断点续跑;
|
|
111
112
|
- **远程访问**:手机同一 WiFi 直开、Tailscale 外网可达、Cloudflare Tunnel 固定
|
package/app/core/bookmeta.py
CHANGED
|
@@ -274,9 +274,16 @@ def _norm(data, platform, goal=""):
|
|
|
274
274
|
channel = cat.QIMAO_CATS.get(reader) or {}
|
|
275
275
|
c_main = _in_table(data.get("category_main"), list(channel))
|
|
276
276
|
c_sub = _name(data.get("category_sub"), 10)
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
277
|
+
if c_sub and not (c_main and c_sub in (channel.get(c_main) or [])):
|
|
278
|
+
# 一级/二级不自洽(一级空、或二级不属于该频道——如女生专属的
|
|
279
|
+
# 「现实故事」配了男生频道):跨频道反查官方目录纠正,频道与
|
|
280
|
+
# 一级跟着二级走(表单级联的选项集以此为准);全目录都没有
|
|
281
|
+
# 才清空——表单选不出的词不硬填
|
|
282
|
+
loc = cat.qimao_locate_sub(c_sub)
|
|
283
|
+
if loc:
|
|
284
|
+
reader, c_main = loc
|
|
285
|
+
else:
|
|
286
|
+
c_sub = ""
|
|
280
287
|
# 作品标签四组(官方每组必选 1-3):逐组过滤 + 截到 3;老数据 tags 并入
|
|
281
288
|
legacy_tags = data.get("tags") if isinstance(data.get("tags"), list) else []
|
|
282
289
|
groups = {}
|
|
@@ -155,6 +155,22 @@ QIMAO_CATS = {
|
|
|
155
155
|
QIMAO_MAIN_CATEGORIES = [m for ch in QIMAO_CATS.values() for m in ch]
|
|
156
156
|
QIMAO_CASCADE = {m: subs for ch in QIMAO_CATS.values() for m, subs in ch.items()}
|
|
157
157
|
|
|
158
|
+
|
|
159
|
+
def qimao_locate_sub(sub):
|
|
160
|
+
"""二级分类跨频道反查:返回 (频道, 一级分类),目录里没有则 None。
|
|
161
|
+
|
|
162
|
+
级联表单的二级选项集随频道变化,「现实故事」(女生·现实主义)
|
|
163
|
+
等词被编排者配到男生频道时,只能从官方全目录反查出真实归属,
|
|
164
|
+
供规整层纠正 target_reader/category_main 错配。"""
|
|
165
|
+
sub = (sub or "").strip()
|
|
166
|
+
if not sub:
|
|
167
|
+
return None
|
|
168
|
+
for ch, mains in QIMAO_CATS.items():
|
|
169
|
+
for main, subs in mains.items():
|
|
170
|
+
if sub in subs:
|
|
171
|
+
return ch, main
|
|
172
|
+
return None
|
|
173
|
+
|
|
158
174
|
# 七猫·作品标签四组(男女生频道同池),每组必选 1-3 个
|
|
159
175
|
QIMAO_TAG_GROUPS = {
|
|
160
176
|
"风格": ["热血", "轻松", "暗黑", "现实题材", "虐恋", "治愈", "甜宠", "悬疑", "种田", "清穿", "宅斗", "宫斗", "中短篇", "青春", "军事", "爽文", "恐怖", "烧脑", "正剧", "商战", "权谋", "励志", "搞笑", "日常", "短篇", "短故事", "买股文", "第一人称", "纯爱", "快节奏", "反套路", "新疆现实题材", "国安现实题材"],
|
package/app/core/covergen.py
CHANGED
|
@@ -2,11 +2,18 @@
|
|
|
2
2
|
"""封面图生成:调供应商图像 API 产出竖版封面插画,curl 直接落盘到运行目录。
|
|
3
3
|
|
|
4
4
|
接口形状:OpenAI 兼容 POST {base}/images/generations(Z.ai cogview 系列、
|
|
5
|
-
多数聚合网关都支持,默认返回图片 URL
|
|
6
|
-
|
|
7
|
-
|
|
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
|
+
同一模型先试竖版尺寸再回落方图。
|
|
8
15
|
状态机与建书生成同款(running/done/failed 写任务 cover_gen 字段,
|
|
9
|
-
bump_state 推 SSE
|
|
16
|
+
bump_state 推 SSE)。
|
|
10
17
|
|
|
11
18
|
安全边界:
|
|
12
19
|
- 图像 URL 仅接受 https,解析后 IP 命中私网/环回/链路本地一律拒绝
|
|
@@ -16,16 +23,22 @@ bump_state 推 SSE)。仅支持 openai 协议供应商。
|
|
|
16
23
|
"""
|
|
17
24
|
from __future__ import annotations
|
|
18
25
|
|
|
26
|
+
import json
|
|
19
27
|
import logging
|
|
20
28
|
import os
|
|
21
29
|
import socket
|
|
22
30
|
import threading
|
|
23
31
|
import time
|
|
32
|
+
import urllib.error
|
|
33
|
+
import urllib.request
|
|
24
34
|
from pathlib import Path
|
|
25
35
|
|
|
26
36
|
log = logging.getLogger(__name__)
|
|
27
37
|
|
|
28
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")
|
|
29
42
|
_SIZES = ("768x1344", "1024x1024") # 竖版优先,方图兜底
|
|
30
43
|
|
|
31
44
|
|
|
@@ -50,6 +63,87 @@ def _pick_key(prov):
|
|
|
50
63
|
return prov.get("api_key") or ""
|
|
51
64
|
|
|
52
65
|
|
|
66
|
+
# 已知主机的规范图像面:wire_caps 记的 openai 面只保 chat 可用,图像端点在
|
|
67
|
+
# 各家规范路径上(真案:智谱 anthropic 面 key 同源可出图,但 wire_caps 面的
|
|
68
|
+
# /api/anthropic 路径对图像返回 HTTP 200+失败信封,必须走 /api/paas/v4)。
|
|
69
|
+
_CANON_IMAGE_FACE = {
|
|
70
|
+
"open.bigmodel.cn": "https://open.bigmodel.cn/api/paas/v4",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _openai_face(prov):
|
|
75
|
+
"""该供应商可用的 openai 面地址:显式 openai 协议用本体;其余只认 wire_caps
|
|
76
|
+
里实测通过的 openai 面——两协议面的路径不同(Z.ai:/api/anthropic vs
|
|
77
|
+
/api/paas/v4),靠猜必错,与绑定链「只挑实测 wire」同纪律。"""
|
|
78
|
+
proto = str(prov.get("protocol") or "openai")
|
|
79
|
+
base = str(prov.get("base_url") or "").rstrip("/")
|
|
80
|
+
if proto == "openai" and base:
|
|
81
|
+
return base
|
|
82
|
+
cap = (prov.get("wire_caps") or {}).get("openai") or {}
|
|
83
|
+
return str(cap.get("base") or "").rstrip("/")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _image_bases(prov):
|
|
87
|
+
"""该供应商可试的图像端点(去重保序):已知主机的规范图像面在前——
|
|
88
|
+
实测 face 若恰好是 chat 代理路径(智谱 anthropic 面),对图像必是假 200;
|
|
89
|
+
规范面直接出图省一轮空试。无规范表的其余主机仍用实测 face。"""
|
|
90
|
+
from urllib.parse import urlparse
|
|
91
|
+
face = _openai_face(prov)
|
|
92
|
+
host = urlparse(face or str(prov.get("base_url") or "")).hostname or ""
|
|
93
|
+
canon = _CANON_IMAGE_FACE.get(host) or ""
|
|
94
|
+
bases = [b for b in (canon, face) if b]
|
|
95
|
+
seen, out = set(), []
|
|
96
|
+
for b in bases:
|
|
97
|
+
if b not in seen:
|
|
98
|
+
seen.add(b)
|
|
99
|
+
out.append(b)
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _image_models(prov):
|
|
104
|
+
"""图像模型候选序:环境变量 CODEBEE_IMAGE_MODEL > 供应商模型清单里关键词
|
|
105
|
+
命中的图像模型(保持清单序)> 默认候选 cogview(Z.ai 免费档先行)。"""
|
|
106
|
+
models = []
|
|
107
|
+
env_model = os.environ.get("CODEBEE_IMAGE_MODEL", "").strip()
|
|
108
|
+
if env_model:
|
|
109
|
+
models.append(env_model)
|
|
110
|
+
for m in (prov.get("models") or []):
|
|
111
|
+
name = str((m.get("name") if isinstance(m, dict) else m) or "").strip()
|
|
112
|
+
low = name.lower()
|
|
113
|
+
if name and any(k in low for k in _IMAGE_MODEL_KEYWORDS) and name not in models:
|
|
114
|
+
models.append(name)
|
|
115
|
+
models.extend(m for m in _IMAGE_CANDIDATES if m not in models)
|
|
116
|
+
return models
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _candidates():
|
|
120
|
+
"""图像端点候选 [{label, bases, key, allow_private, prov}]:编排者供应商排
|
|
121
|
+
最前,其余启用且配了密钥的供应商按列表优先级跟上;每家可试端点见
|
|
122
|
+
_image_bases(实测 face + 已知主机规范面);同一(端点组+密钥)只收一次。"""
|
|
123
|
+
from . import modelhub
|
|
124
|
+
orch = modelhub.resolve_orchestrator()
|
|
125
|
+
first = [orch[0]] if orch else []
|
|
126
|
+
first_ids = {p.get("id") for p in first}
|
|
127
|
+
rest = [p for p in modelhub.providers() if p.get("id") not in first_ids]
|
|
128
|
+
out, seen = [], set()
|
|
129
|
+
for prov in first + rest:
|
|
130
|
+
if not prov.get("enabled", True) or not prov.get("api_key"):
|
|
131
|
+
continue
|
|
132
|
+
bases = _image_bases(prov)
|
|
133
|
+
key = _pick_key(prov)
|
|
134
|
+
if not bases or not key:
|
|
135
|
+
continue
|
|
136
|
+
sig = (tuple(bases), key)
|
|
137
|
+
if sig in seen:
|
|
138
|
+
continue
|
|
139
|
+
seen.add(sig)
|
|
140
|
+
out.append({"label": prov.get("name") or prov.get("id") or "供应商",
|
|
141
|
+
"bases": bases, "key": key,
|
|
142
|
+
"allow_private": bool(prov.get("allow_private")),
|
|
143
|
+
"prov": prov})
|
|
144
|
+
return out
|
|
145
|
+
|
|
146
|
+
|
|
53
147
|
def _safe_image_url(url):
|
|
54
148
|
"""SSRF 校验:仅 https;解析主机全部 IP,私网/环回/链路本地/保留段拒绝。"""
|
|
55
149
|
import ipaddress
|
|
@@ -86,75 +180,101 @@ def _curl_to(url, out_path):
|
|
|
86
180
|
raise RuntimeError("下载内容过小或为空")
|
|
87
181
|
|
|
88
182
|
|
|
183
|
+
def _post_images(url, key, body, allow_private, timeout):
|
|
184
|
+
"""图像接口专用 POST:非 2xx 也读响应体真因。_post_json_http 对 HTTPError
|
|
185
|
+
直接返回 (0, None, repr),聚合网关的「无可用渠道」会被吞成光秃
|
|
186
|
+
HTTPError 503;复用同一套 SSRF 校验与出站 TLS 上下文。"""
|
|
187
|
+
from . import modelhub, tlsctx
|
|
188
|
+
_host, herr = modelhub._validate_host(url, allow_private)
|
|
189
|
+
if _host is None:
|
|
190
|
+
return 0, None, herr
|
|
191
|
+
try:
|
|
192
|
+
req = urllib.request.Request(url, method="POST",
|
|
193
|
+
headers={"Authorization": "Bearer " + key,
|
|
194
|
+
"Content-Type": "application/json"},
|
|
195
|
+
data=json.dumps(body).encode("utf-8"))
|
|
196
|
+
with modelhub._opener().open(req, timeout=timeout) as resp:
|
|
197
|
+
return resp.status, json.loads(resp.read(1024 * 1024).decode("utf-8", "replace")), ""
|
|
198
|
+
except urllib.error.HTTPError as e:
|
|
199
|
+
raw = e.read(65536).decode("utf-8", "replace")
|
|
200
|
+
try:
|
|
201
|
+
data = json.loads(raw)
|
|
202
|
+
except Exception:
|
|
203
|
+
data = None
|
|
204
|
+
msg = ""
|
|
205
|
+
if isinstance(data, dict):
|
|
206
|
+
msg = str((data.get("error") or {}).get("message")
|
|
207
|
+
or data.get("message") or data.get("msg") or "")
|
|
208
|
+
if not msg:
|
|
209
|
+
msg = "HTTP %s %s" % (e.code, getattr(e, "reason", "") or "")
|
|
210
|
+
return e.code, data, msg[:300]
|
|
211
|
+
except Exception as e:
|
|
212
|
+
return 0, None, tlsctx.humanize(repr(e)[:300])
|
|
213
|
+
|
|
214
|
+
|
|
89
215
|
def _call_images(base, key, model, prompt, size, allow_private):
|
|
90
216
|
"""POST /images/generations。返回 (data_item dict, 错误串);data_item 含 url 或 b64_json。"""
|
|
91
|
-
from . import builtin_agent
|
|
92
217
|
url = base.rstrip("/") + "/images/generations"
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
218
|
+
body = {"model": model, "prompt": prompt, "size": size}
|
|
219
|
+
if os.environ.get("CODEBEE_IMAGE_WATERMARK", "").strip() != "1":
|
|
220
|
+
# 智谱缺省 watermark=true,图上强制「AI生成」显式角标+隐式数字水印;
|
|
221
|
+
# 实测传 false 被 200 接受。CODEBEE_IMAGE_WATERMARK=1 恢复供应商默认。
|
|
222
|
+
body["watermark"] = False
|
|
223
|
+
status, data, err = _post_images(url, key, body, allow_private, 180)
|
|
98
224
|
if status == 0:
|
|
99
225
|
return None, err or "网络错误"
|
|
100
226
|
if 200 <= status < 300:
|
|
227
|
+
if isinstance(data, dict) and data.get("success") is False:
|
|
228
|
+
# 智谱 anthropic 面对未知子路径回 HTTP 200+失败信封,按失败处理
|
|
229
|
+
return None, str(data.get("msg") or "供应商返回失败信封")
|
|
101
230
|
items = (data or {}).get("data") or []
|
|
102
231
|
if items and isinstance(items[0], dict):
|
|
103
232
|
return items[0], ""
|
|
104
233
|
return None, "响应缺少 data[0]"
|
|
105
|
-
|
|
106
|
-
or err or ("HTTP %s" % status)
|
|
107
|
-
return None, last
|
|
234
|
+
return None, err or ("HTTP %s" % status)
|
|
108
235
|
|
|
109
236
|
|
|
110
237
|
def make_cover(run_id, task):
|
|
111
238
|
"""同步生成封面到运行目录。成功/失败返回 entry dict(写任务 cover_gen 用)。"""
|
|
112
|
-
from . import builtin_agent,
|
|
239
|
+
from . import builtin_agent, paths
|
|
113
240
|
try:
|
|
114
|
-
|
|
115
|
-
if not
|
|
116
|
-
raise RuntimeError(
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
base = str(prov.get("base_url") or "").rstrip("/")
|
|
121
|
-
key = _pick_key(prov)
|
|
122
|
-
if not base or not key:
|
|
123
|
-
raise RuntimeError("供应商缺少 base_url 或密钥")
|
|
124
|
-
allow_private = bool(prov.get("allow_private"))
|
|
241
|
+
cands = _candidates()
|
|
242
|
+
if not cands:
|
|
243
|
+
raise RuntimeError(
|
|
244
|
+
"没有可用的 openai 协议图像接口(扫了全部供应商的 openai 面,"
|
|
245
|
+
"含适配测试实测出的)。可到模型管理页对网关跑一次适配测试补出 "
|
|
246
|
+
"openai 面,或设环境变量 CODEBEE_IMAGE_MODEL 指定图像模型后重试")
|
|
125
247
|
prompt = _cover_prompt(task)
|
|
126
|
-
models = []
|
|
127
|
-
env_model = os.environ.get("CODEBEE_IMAGE_MODEL", "").strip()
|
|
128
|
-
if env_model:
|
|
129
|
-
models.append(env_model)
|
|
130
|
-
models.extend(m for m in _IMAGE_CANDIDATES if m not in models)
|
|
131
248
|
last_err = ""
|
|
132
|
-
for
|
|
133
|
-
for
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
249
|
+
for cand in cands:
|
|
250
|
+
for base in cand["bases"]:
|
|
251
|
+
for model in _image_models(cand["prov"]):
|
|
252
|
+
for size in _SIZES:
|
|
253
|
+
item, err = _call_images(base, cand["key"], model,
|
|
254
|
+
prompt, size, cand["allow_private"])
|
|
255
|
+
if item is None:
|
|
256
|
+
last_err = err
|
|
257
|
+
continue
|
|
258
|
+
img_url = str(item.get("url") or "")
|
|
259
|
+
if not img_url:
|
|
260
|
+
last_err = "该供应商未返回图片 URL(仅内嵌数据),暂不支持"
|
|
261
|
+
continue
|
|
262
|
+
try:
|
|
263
|
+
safe_url = _safe_image_url(img_url)
|
|
264
|
+
except ValueError as e:
|
|
265
|
+
last_err = str(e)
|
|
266
|
+
continue
|
|
267
|
+
out_dir = paths.RUNS_DIR / str(run_id)
|
|
268
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
269
|
+
out = out_dir / "cover.png"
|
|
270
|
+
try:
|
|
271
|
+
_curl_to(safe_url, out)
|
|
272
|
+
except RuntimeError as e:
|
|
273
|
+
last_err = str(e)
|
|
274
|
+
continue
|
|
275
|
+
return {"status": "done", "file": "cover.png", "run_id": str(run_id),
|
|
276
|
+
"provider": cand["label"], "model": model, "size": size,
|
|
277
|
+
"at": time.strftime("%Y-%m-%d %H:%M:%S")}
|
|
158
278
|
raise RuntimeError(last_err or "图像接口无可用模型")
|
|
159
279
|
except Exception as e:
|
|
160
280
|
return {"status": "failed", "error": str(e)[:300],
|
package/app/core/jobs.py
CHANGED
|
@@ -382,11 +382,15 @@ def _in_resume_backoff(run, now=None):
|
|
|
382
382
|
|
|
383
383
|
|
|
384
384
|
def requeue_pending(limit=10, max_age_s=None):
|
|
385
|
-
"""把遗留的 queued
|
|
385
|
+
"""把遗留的 queued 运行重新入队(启动补队与运行期巡检共用)。
|
|
386
386
|
|
|
387
387
|
队列在内存里,进程一死排队项就没人管了(2026-09-18 七猫 r-162724
|
|
388
388
|
排队僵尸案:续跑副本 created 后服务重启,Timer 随进程蒸发,运行永远
|
|
389
389
|
停在「排队中」)。同任务已有在跑/排队的不重复补。
|
|
390
|
+
mgmt 同样纳入:CLI 安装/升级 job 也只存在于内存队列,worker 线程
|
|
391
|
+
起失败(杀软挂起 Thread.start)或入队丢失后永远「排队中」,还堵住
|
|
392
|
+
同条目去重闸(2026-09-19 三连 CLI 升级排队无人接案)。selfupgrade
|
|
393
|
+
不补——升级本体有进程替换语义,自动重排不可控。
|
|
390
394
|
|
|
391
395
|
max_age_s:巡检模式只补「卡了超过该秒数」的,刚入队的正常排队不掺和;
|
|
392
396
|
None(启动模式)全量补。resume_enqueue_at 未到点的续跑副本两种模式都
|
|
@@ -400,18 +404,27 @@ def requeue_pending(limit=10, max_age_s=None):
|
|
|
400
404
|
for run in store.list_runs(200):
|
|
401
405
|
if n >= limit:
|
|
402
406
|
break
|
|
403
|
-
|
|
407
|
+
kind = run.get("kind")
|
|
408
|
+
if run.get("status") != "queued" or kind not in ("orchestration", "mgmt"):
|
|
404
409
|
continue
|
|
405
|
-
if not run.get("task_id") or not _recent(run):
|
|
406
|
-
continue
|
|
407
|
-
if _in_resume_backoff(run):
|
|
408
|
-
continue # 退避窗口内的续跑副本:到点 Timer 自会入队
|
|
409
410
|
if max_age_s is not None and _age_s(run) < max_age_s:
|
|
410
411
|
continue
|
|
411
|
-
if
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
412
|
+
if kind == "orchestration":
|
|
413
|
+
if not run.get("task_id") or not _recent(run):
|
|
414
|
+
continue
|
|
415
|
+
if _in_resume_backoff(run):
|
|
416
|
+
continue # 退避窗口内的续跑副本:到点 Timer 自会入队
|
|
417
|
+
if _task_active_run(run["task_id"], exclude_run_id=run["id"]):
|
|
418
|
+
continue # 同任务已有更活跃的运行,别再排一份
|
|
419
|
+
else:
|
|
420
|
+
eid = run.get("entry_id")
|
|
421
|
+
if eid:
|
|
422
|
+
other = store.active_mgmt_run(eid)
|
|
423
|
+
if other and other.get("id") != run["id"]:
|
|
424
|
+
continue # 同条目已有更活跃的 run,去重闸语义收敛
|
|
425
|
+
_QUEUE.put({"kind": kind, "run_id": run["id"],
|
|
426
|
+
"task_id": run.get("task_id"),
|
|
427
|
+
"entry_id": run.get("entry_id"), "op": run.get("op")})
|
|
415
428
|
n += 1
|
|
416
429
|
except Exception:
|
|
417
430
|
pass
|
package/app/core/modelhub.py
CHANGED
|
@@ -192,6 +192,18 @@ def _validate_host(url, allow_private):
|
|
|
192
192
|
return host, ""
|
|
193
193
|
|
|
194
194
|
|
|
195
|
+
# 「候选列表端点全部 404」的良性提示固定后缀。zcode-plan 等 anthropic 形网关
|
|
196
|
+
# 没有 GET /models:404 与密钥无关、不影响对话调用。调用方用 is_no_list_note()
|
|
197
|
+
# 把它归为预期情况(成功 + 提示),不算失败、不记 KEY 错误。
|
|
198
|
+
_NO_LIST_SUFFIX = ("无模型列表接口(HTTP 404)——"
|
|
199
|
+
"模型以导入/手动添加为准,不影响对话调用")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def is_no_list_note(err):
|
|
203
|
+
"""404≠密钥错≠网络断:网关本来就没有 /models 列表接口,属良性结果。"""
|
|
204
|
+
return bool(err) and err.endswith(_NO_LIST_SUFFIX)
|
|
205
|
+
|
|
206
|
+
|
|
195
207
|
def _fetch_models_http(base_url, api_key, protocol, allow_private=False):
|
|
196
208
|
"""GET {base}/models 拉取模型列表。返回 (names, err)。
|
|
197
209
|
|
|
@@ -199,6 +211,8 @@ def _fetch_models_http(base_url, api_key, protocol, allow_private=False):
|
|
|
199
211
|
google 走 /v1beta/models,返回项形如 {"name": "models/gemini-x"}。
|
|
200
212
|
protocol="auto"(导入时未指定格式)两条 URL 形状都试——anthropic 与 openai
|
|
201
213
|
的取列表路径本就相同,多试 google 那条只是让 auto 名副其实。
|
|
214
|
+
全部候选端点皆 404 → err 携带 _NO_LIST_SUFFIX 的良性提示(is_no_list_note
|
|
215
|
+
判真),不是失败;混入任何真错误(500/401/连不上)则报真错误。
|
|
202
216
|
"""
|
|
203
217
|
import urllib.parse
|
|
204
218
|
base = (base_url or "").rstrip("/")
|
|
@@ -213,6 +227,7 @@ def _fetch_models_http(base_url, api_key, protocol, allow_private=False):
|
|
|
213
227
|
else:
|
|
214
228
|
urls = std
|
|
215
229
|
last_err = ""
|
|
230
|
+
last_404 = "" # 最后一条 404;全部尝试皆 404 时它是良性结果而非失败
|
|
216
231
|
opener = _opener()
|
|
217
232
|
for url in urls:
|
|
218
233
|
host, herr = _validate_host(url, allow_private)
|
|
@@ -248,13 +263,13 @@ def _fetch_models_http(base_url, api_key, protocol, allow_private=False):
|
|
|
248
263
|
if e.code == 404:
|
|
249
264
|
# zcode-plan / open.bigmodel.cn 等 anthropic 形网关没有
|
|
250
265
|
# GET /models——404 与密钥无关,模型以导入/手填为准
|
|
251
|
-
|
|
252
|
-
"模型以导入/手动添加为准,不影响对话调用")
|
|
266
|
+
last_404 = url + " " + _NO_LIST_SUFFIX
|
|
253
267
|
else:
|
|
254
268
|
last_err = tlsctx.humanize("%s → HTTP %s" % (url, e.code))
|
|
255
269
|
except Exception as e:
|
|
256
270
|
last_err = tlsctx.humanize("%s → %r" % (url, e))
|
|
257
|
-
|
|
271
|
+
# 真错误压过良性 404:500/401/连不上任一出现,就不能报「无列表接口」
|
|
272
|
+
return None, last_err or last_404
|
|
258
273
|
|
|
259
274
|
|
|
260
275
|
def refresh_models(provider_id):
|
|
@@ -2533,6 +2548,11 @@ def test_provider(provider_id):
|
|
|
2533
2548
|
note_key_ok(provider_id, kk.get("id") or "")
|
|
2534
2549
|
return {"ok": True, "latency_ms": int((_t.time() - t0) * 1000),
|
|
2535
2550
|
"count": len(names), "error": "", "key_id": kk.get("id") or ""}
|
|
2551
|
+
if is_no_list_note(err):
|
|
2552
|
+
# 网关没有 /models:HTTP 往返已证连通,与 KEY 无关——不计失败、
|
|
2553
|
+
# 不记 KEY 错误;密钥真伪交「单模型测试」的 1 token 真对话去验
|
|
2554
|
+
return {"ok": True, "latency_ms": int((_t.time() - t0) * 1000),
|
|
2555
|
+
"count": 0, "error": "", "note": err, "key_id": kk.get("id") or ""}
|
|
2536
2556
|
last = err
|
|
2537
2557
|
note_key_error(provider_id, kk.get("id") or "", err)
|
|
2538
2558
|
return {"ok": False, "latency_ms": int((_t.time() - t0) * 1000), "error": last}
|
package/app/core/notify.py
CHANGED
|
@@ -71,7 +71,10 @@ def push_run_async(run_id):
|
|
|
71
71
|
|
|
72
72
|
|
|
73
73
|
def push_run(run_id):
|
|
74
|
-
"""组装 run 结果摘要并推送(webhook 未配置时静默跳过)。
|
|
74
|
+
"""组装 run 结果摘要并推送(webhook 未配置时静默跳过)。
|
|
75
|
+
|
|
76
|
+
摘要末尾附分享页路径(notify_base_url 设置非空时拼完整链接,否则只给
|
|
77
|
+
run_id 供在本机 CodeBee 界面查找)。"""
|
|
75
78
|
hook = _webhook()
|
|
76
79
|
if not hook:
|
|
77
80
|
return False
|
|
@@ -90,6 +93,13 @@ def push_run(run_id):
|
|
|
90
93
|
if v.get("overall") is not None:
|
|
91
94
|
lines.append("综合评分 %.1f(%s)" % (
|
|
92
95
|
float(v["overall"]), "达标" if v.get("publishable") else "未达标"))
|
|
96
|
+
try:
|
|
97
|
+
from .settings import load as _sload
|
|
98
|
+
base = str(_sload().get("notify_base_url") or "").strip().rstrip("/")
|
|
99
|
+
if base:
|
|
100
|
+
lines.append("📄 详情:%s/api/runs/%s/share" % (base, run_id))
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
93
103
|
return push_text("\n".join(lines))
|
|
94
104
|
|
|
95
105
|
|
package/app/core/paihang.py
CHANGED
|
@@ -22,10 +22,13 @@ FANQIE_RANK_URL = "https://fanqienovel.com/rank"
|
|
|
22
22
|
_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
23
23
|
"(KHTML, like Gecko) Chrome/126 Safari/537.36")
|
|
24
24
|
|
|
25
|
-
# 页面 UI
|
|
25
|
+
# 页面 UI 噪音词(非书目/分类):页面 chrome、导航、tab 名、登录/搜索等
|
|
26
26
|
_NOISE = {"加入书架", "立即阅读", "开始阅读", "免费阅读", "全部", "分类", "排行",
|
|
27
27
|
"排行榜", "完本", "连载中", "阅读", "书城", "男生", "女生",
|
|
28
|
-
"帮助中心", "作家助手", "客服", "登录", "注册"
|
|
28
|
+
"帮助中心", "作家助手", "客服", "登录", "注册", "立即登录",
|
|
29
|
+
"搜索", "热门作品排行", "大热榜", "新书榜", "完结榜", "收藏榜",
|
|
30
|
+
"更新榜", "日榜", "月榜", "蝉联", "榜首", "热度", "免费", "畅销",
|
|
31
|
+
"下载", "客户端", "首页", "我的", "设置", "更多", "返回", "关于"}
|
|
29
32
|
|
|
30
33
|
_CJK_RE = re.compile(r">([\u4e00-\u9fa5]{2,12})<")
|
|
31
34
|
|