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 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.15
25
-
26
- - **禅道集成**:定时扫描激活 Bug 自动建修复任务 跑完自动合并 + resolve + 报告回写 + 群通知
27
- - **扫榜选材(双源)**:七猫+番茄排行榜抓取聚合,AI 提炼热门题材/人设套路/差异化切入,跨平台重合题材单独标记
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 固定
@@ -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
- subs = channel.get(c_main)
278
- if subs and c_sub not in subs:
279
- c_sub = "" # 级联不匹配清空(二级必须挂在一级下)
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
  "风格": ["热血", "轻松", "暗黑", "现实题材", "虐恋", "治愈", "甜宠", "悬疑", "种田", "清穿", "宅斗", "宫斗", "中短篇", "青春", "军事", "爽文", "恐怖", "烧脑", "正剧", "商战", "权谋", "励志", "搞笑", "日常", "短篇", "短故事", "买股文", "第一人称", "纯爱", "快节奏", "反套路", "新疆现实题材", "国安现实题材"],
@@ -2,11 +2,18 @@
2
2
  """封面图生成:调供应商图像 API 产出竖版封面插画,curl 直接落盘到运行目录。
3
3
 
4
4
  接口形状:OpenAI 兼容 POST {base}/images/generations(Z.ai cogview 系列、
5
- 多数聚合网关都支持,默认返回图片 URL)。模型解析:环境变量
6
- CODEBEE_IMAGE_MODEL 优先,否则逐个试候选(cogview-3-flash cogview-4),
7
- 第一个 2xx 的胜出;同一模型先试竖版尺寸再回落方图。
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)。仅支持 openai 协议供应商。
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
- status, data, err = builtin_agent._post_json(
94
- url,
95
- {"Authorization": "Bearer " + key, "Content-Type": "application/json"},
96
- {"model": model, "prompt": prompt, "size": size},
97
- allow_private, 180)
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
- last = str((data or {}).get("error", {}).get("message", "") if isinstance(data, dict) else "") \
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, modelhub, paths
239
+ from . import builtin_agent, paths
113
240
  try:
114
- orch = modelhub.resolve_orchestrator()
115
- if not orch:
116
- raise RuntimeError("未配置编排者供应商(编排设置)")
117
- prov, _m = orch
118
- if str(prov.get("protocol") or "openai") not in ("openai", ""):
119
- raise RuntimeError("封面生成仅支持 openai 协议供应商")
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 model in models:
133
- for size in _SIZES:
134
- item, err = _call_images(base, key, model, prompt, size, allow_private)
135
- if item is None:
136
- last_err = err
137
- continue
138
- img_url = str(item.get("url") or "")
139
- if not img_url:
140
- last_err = "该供应商未返回图片 URL(仅内嵌数据),暂不支持"
141
- continue
142
- try:
143
- safe_url = _safe_image_url(img_url)
144
- except ValueError as e:
145
- last_err = str(e)
146
- continue
147
- out_dir = paths.RUNS_DIR / str(run_id)
148
- out_dir.mkdir(parents=True, exist_ok=True)
149
- out = out_dir / "cover.png"
150
- try:
151
- _curl_to(safe_url, out)
152
- except RuntimeError as e:
153
- last_err = str(e)
154
- continue
155
- return {"status": "done", "file": "cover.png", "run_id": str(run_id),
156
- "model": model, "size": size,
157
- "at": time.strftime("%Y-%m-%d %H:%M:%S")}
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
- if run.get("status") != "queued" or run.get("kind") != "orchestration":
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 _task_active_run(run["task_id"], exclude_run_id=run["id"]):
412
- continue # 同任务已有更活跃的运行,别再排一份
413
- _QUEUE.put({"kind": "orchestration", "run_id": run["id"],
414
- "task_id": run["task_id"]})
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
@@ -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
- last_err = (url + " 无模型列表接口(HTTP 404)——"
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
- return None, last_err
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}
@@ -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
 
@@ -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