codebee 0.1.14 → 0.1.16

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,21 @@ CodeBee 的用户可感知变更记录。发布新版时:最上面加一节,
4
4
  `<!-- relnotes:start -->…<!-- relnotes:end -->` 段(那段会被 `npm view` 的
5
5
  README 元数据带回,供老版本在「发现新版本」时展示新版更新内容)。
6
6
 
7
+ ## v0.1.16(2026-09-19)
8
+
9
+ - 帮助中心:首启欢迎引导升级为「左目录 + 右内容」多章帮助中心,七章全就绪——快速上手、模型接入与绑定(多 Key 轮换 / 协议徽章解读 / 报错速查 / 免费档起步)、功能一览、连载创作流程、发布上架、自动化与技能市场、常见问题(9 条踩坑 FAQ)。首启仍只自动弹一次落在「快速上手」,手动打开(设置导航/关于页/Ctrl+K)徽章显示「帮助中心」
10
+ - 就地帮助问号:模型接入、CLI 绑定、自动化、插件市场四个页头新增小「?」,点击直达帮助中心对应章节;供应商空状态与「还没有装好的 CLI」空状态附「看帮助」链接
11
+
12
+ ## v0.1.15(2026-09-19)
13
+
14
+ - 禅道集成:定时扫描激活 Bug → 自动建修复任务 → 跑完自动合并 + resolve(fixed) + 报告回写 + 群通知(REST v1,SSRF 守卫;设置页「禅道」子页)
15
+ - 扫榜双源聚合:七猫+番茄 Web 版排行榜合并分析,跨平台重合题材单独标记(信号更强)
16
+ - 可分享报告页:运行报告导出为自包含单文件 HTML,发给任何人浏览器直接打开
17
+ - 出站 HTTPS 加固:统一证书校验上下文,修复 macOS python.org 发行版缺 CA 导致的全部外联失败(校验只增不减)
18
+ - 引擎调参卡:设置页可直接调压缩阈值/尾部保留/goal 轮数等引擎参数(schema 驱动,revision CAS 防并发覆盖)
19
+ - 发布浏览器 profile 迁至 ~/.codebee(登录态自动迁移),不再占用仓库目录与拖慢扫描
20
+ - 连载发布真机校准续:番茄编辑器结构校准、发章导航超时放宽、真实鼠标事件提交(合成点击被平台检测)
21
+
7
22
  ## v0.1.14(2026-09-19)
8
23
 
9
24
  - 新任务类型「扫榜选材」:抓取七猫排行榜公开数据,AI 提炼热门题材 Top3/高频人设套路/差异化切入建议,直出选题洞察报告
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.11
25
-
26
- - **连载发布闭环**:番茄/七猫作家后台一键发布——连接扫码后自动建书/发章(默认填好表单人工确认提交),出版台面板+发布台账+每日上限/连续失败暂停护栏
27
- - **定时自动发布**:任务到点自动批量发章,直发模式需流程校准文件解锁
28
- - 代码任务 Best-of-N 赛马兜底;goal 模糊时先问 1-3 个澄清问题再开跑
29
- - 任务队列自愈(看门狗+排队回填);同因连撞不再重复退避付费,退避期明示「将于 HH:MM 自动续跑」
30
- - 运行前自动同步 CLI 配置:绑定换供应商后不再撞上旧配置空跑
31
- - 评审新增「AI 味」参考线;任务留 .codebee/spec.md 规格档案;报告区空态可见
24
+ ### 最新版更新内容(v0.1.16
25
+
26
+ - **帮助中心**:首启欢迎引导升级为多章帮助中心——快速上手 / 模型接入与绑定 / 连载创作流程 / 发布上架 / 自动化与技能市场 / 功能一览 / 常见问题七章全就绪;模型接入、CLI 绑定、自动化、插件市场页头有「?」就地问号直达对应章节;设置导航、关于页、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 固定
@@ -157,6 +158,19 @@ npm install -g codebee
157
158
  用 Homebrew 装的 Node(prefix 在 `/opt/homebrew`)没有这个问题,可直接安装。
158
159
  验证 `which codebee` 指向 `~/.npm-global/bin/codebee` 即配置成功。
159
160
 
161
+ ### macOS:HTTPS 请求报 SSL 证书错误(CERTIFICATE_VERIFY_FAILED)
162
+
163
+ python.org 安装的 Python 不读 macOS 系统钥匙串,本机又没有可用的 CA 束时,
164
+ 「获取模型列表 / 适配测试 / 插件市场」等所有 HTTPS 检测会报
165
+ `certificate verify failed: unable to get local issuer certificate`。
166
+ CodeBee 已内置兜底:出站请求自动加载 certifi 与 macOS 系统证书束
167
+ (`/etc/ssl/cert.pem`),多数机器无需任何操作。若仍报错,补齐证书后重试:
168
+
169
+ ```bash
170
+ python3 -m pip install --upgrade certifi # CodeBee 自动采用,装完即生效
171
+ # 或:Finder → 应用程序 → Python 3.x → 双击 Install Certificates.command
172
+ ```
173
+
160
174
  **方式二:源码运行(开发者)**
161
175
 
162
176
  ```bat
@@ -387,6 +387,13 @@ def _tick():
387
387
  log.info("automation: 定时发布触发 %d 个任务", n)
388
388
  except Exception:
389
389
  log.debug("automation: 定时发布检查跳过", exc_info=True)
390
+ # 禅道 Bug 扫描联动:fire_due 内部自节流(未启用/没到点零开销),
391
+ # 对账回写也在同一入口,这里只当调度宿主(同定时发布模式)。
392
+ try:
393
+ from . import zentao as _zentao
394
+ _zentao.fire_due()
395
+ except Exception:
396
+ log.debug("automation: 禅道扫描跳过", exc_info=True)
390
397
 
391
398
 
392
399
  def _loop():
@@ -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
  "风格": ["热血", "轻松", "暗黑", "现实题材", "虐恋", "治愈", "甜宠", "悬疑", "种田", "清穿", "宅斗", "宫斗", "中短篇", "青春", "军事", "爽文", "恐怖", "烧脑", "正剧", "商战", "权谋", "励志", "搞笑", "日常", "短篇", "短故事", "买股文", "第一人称", "纯爱", "快节奏", "反套路", "新疆现实题材", "国安现实题材"],
@@ -248,7 +248,7 @@ def npm_pkg_name(cmd):
248
248
 
249
249
 
250
250
  def derive_uninstall(cmd):
251
- """从安装命令推导卸载命令(npm / winget / pip 三种本机渠道)。
251
+ """从安装命令推导卸载命令(npm / winget / pip / uv / brew 本机渠道)。
252
252
 
253
253
  卸载命令不单独维护一份,避免与安装命令不同步;认不出渠道返回 None,
254
254
  此时条目可显式配置 uninstall 字段覆盖。
@@ -275,6 +275,14 @@ def derive_uninstall(cmd):
275
275
  pkgs = [t for t in m.group(1).split() if not t.startswith("-")]
276
276
  if pkgs:
277
277
  return "uv tool uninstall %s" % pkgs[-1]
278
+ m = re.search(r"brew\s+install\b(.*)$", c)
279
+ if m:
280
+ # brew uninstall 按名字自动识别 formula/cask,--cask 这类 flag 丢了也
281
+ # 能卸对(Mac 渠道,AI 修复白名单已放行 brew install,编辑安装命令
282
+ # 也会填这种形态)
283
+ pkgs = [t for t in m.group(1).split() if not t.startswith("-")]
284
+ if pkgs:
285
+ return "brew uninstall %s" % pkgs[0]
278
286
  return None
279
287
 
280
288
 
@@ -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],
@@ -16,9 +16,10 @@ _DENY_RE = re.compile(r"(KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL)", re.IGNOR
16
16
 
17
17
  # 白名单:精确匹配(不分大小写),用于覆盖黑名单。
18
18
  _ALLOW_EXACT = frozenset({
19
- # 系统/环境
20
- "PATH", "PATHEXT", "SYSTEMROOT", "TEMP", "TMP", "USERPROFILE", "USERDNSDOMAIN",
21
- "HOMEPATH", "HOMEDRIVE",
19
+ # 系统/环境(TEMP/TMP/PATHEXT 等是 Windows 名,TMPDIR 是 macOS 每用户
20
+ # 临时目录——三边各留各的,多出的名字在别的平台上不存在、无副作用)
21
+ "PATH", "PATHEXT", "SYSTEMROOT", "TEMP", "TMP", "TMPDIR",
22
+ "USERPROFILE", "USERDNSDOMAIN", "HOMEPATH", "HOMEDRIVE",
22
23
  "LANG", "LC_ALL", "LC_CTYPE",
23
24
  "HOME", "SHELL", "TERM", "PYTHONIOENCODING",
24
25
  "PYTHONPATH", "PYTHONUNBUFFERED",
@@ -38,6 +39,7 @@ _ALLOW_PREFIX = (
38
39
  "TUTTI_", "ALLINONE_", "DSH_",
39
40
  "LANG", "LC_", # locale 系列
40
41
  "PYTHON", # PYTHONPATH / PYTHONIOENCODING 等
42
+ "XDG_", # XDG_CONFIG_HOME 等(macOS/Linux CLI 认它找配置/缓存落点)
41
43
  "CHROME", "EDGE", "PLAYWRIGHT", # 本机测试工具
42
44
  )
43
45
 
package/app/core/jobs.py CHANGED
@@ -5,11 +5,13 @@
5
5
  隔离,store 层有全局锁。目标并发数可在设置页调整;调小后多余线程在取到
6
6
  新任务前自行退出,调大即时补齐。安装/升级失败时自动触发 AI 诊断修复:
7
7
  由真实智能体读取失败日志与本机环境给出修正命令;仅当命令命中白名单前缀
8
- (npm/winget/pip 安装类)才自动执行,否则把建议命令记录在运行记录里等人工确认。
8
+ (npm/winget/brew/pip 安装类,按平台取对应渠道)才自动执行,否则把建议命令
9
+ 记录在运行记录里等人工确认。
9
10
  """
10
11
  from __future__ import annotations
11
12
 
12
13
  import queue
14
+ import sys
13
15
  import threading
14
16
  import traceback
15
17
 
@@ -27,10 +29,11 @@ WATCHDOG_INTERVAL_S = 60 # 队列看门狗巡检周期
27
29
  WATCHDOG_STALE_S = 120 # queued 超过该秒数视为掉队(正常入队到被拿起 ≤5s)
28
30
  _watchdog_started = False
29
31
 
30
- AI_REPAIR_PROMPT = """你是环境工程师。在 Windows 上执行下面的安装命令失败了,请诊断原因并给出修正命令。
32
+ AI_REPAIR_PROMPT = """你是环境工程师。在 __OS__ 上执行下面的安装命令失败了,请诊断原因并给出修正命令。
31
33
  只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
32
34
  {"diagnosis": "失败原因(一句话)", "command": "修正后的完整安装命令", "safe": true/false}
33
- 硬性约束:command 只能是本机包管理器的安装命令,前缀必须是 __ALLOW__ 之一。
35
+ 硬性约束:command 只能是本机包管理器的安装命令,前缀必须是 __ALLOW__ 之一,且不要带 sudo
36
+ (命令非交互执行,sudo 会挂死;brew/npm 本身也拒绝 sudo)。
34
37
  给不出符合约束的安全命令时,safe 设为 false 且 command 留空。
35
38
 
36
39
  ## 失败的命令
@@ -43,14 +46,29 @@ __LOG__
43
46
  __ENV__"""
44
47
 
45
48
  # AI 修复命令白名单:只放行包管理器的安装类命令(提示词里的前缀清单由它生成,
46
- # 两处永远不会漂移)
47
- AI_REPAIR_ALLOW = ("npm install ", "winget install", "py -3.13 -m pip install",
48
- "uv tool install")
49
+ # 两处永远不会漂移)。按平台分组:winget/py 启动器是 Windows 独有,brew/python3
50
+ # macOS/Linux 渠道——Mac 上不放行 brew 的话,AI 给出正确的修正命令也会被拒。
51
+ _AI_REPAIR_ALLOW_WIN = ("npm install ", "winget install", "py -3.13 -m pip install",
52
+ "uv tool install")
53
+ _AI_REPAIR_ALLOW_UNIX = ("npm install ", "brew install", "python3 -m pip install",
54
+ "uv tool install")
55
+ AI_REPAIR_ALLOW = _AI_REPAIR_ALLOW_WIN # 兼容旧引用:Windows 本机即此表
49
56
 
50
57
 
51
- def _repair_command_allowed(cmd):
58
+ def _repair_os_label(platform=None):
59
+ return {"win32": "Windows", "darwin": "macOS"}.get(platform or sys.platform, "Linux")
60
+
61
+
62
+ def _repair_allow(platform=None):
63
+ if (platform or sys.platform) == "win32":
64
+ return _AI_REPAIR_ALLOW_WIN
65
+ return _AI_REPAIR_ALLOW_UNIX
66
+
67
+
68
+ def _repair_command_allowed(cmd, platform=None):
52
69
  cmd = (cmd or "").strip()
53
- return cmd.startswith(AI_REPAIR_ALLOW) and "|" not in cmd and "&" not in cmd and ">" not in cmd
70
+ return (cmd.startswith(_repair_allow(platform)) and "|" not in cmd
71
+ and "&" not in cmd and ">" not in cmd)
54
72
 
55
73
 
56
74
  def configure(max_workers):
@@ -592,17 +610,21 @@ def _ai_repair(run_id, entry, ev, failed_cmd, orig_log):
592
610
  except Exception:
593
611
  log_tail = "(日志不可读)"
594
612
  import shutil
613
+ is_win = sys.platform == "win32"
595
614
  env_lines = [
596
- "OS: Windows",
615
+ "OS: %s" % _repair_os_label(),
597
616
  "node: %s" % (shutil.which("node") or "缺失"),
598
617
  "npm: %s" % (shutil.which("npm") or "缺失"),
599
618
  "pnpm: %s" % (shutil.which("pnpm") or "缺失"),
600
- "python: %s" % (shutil.which("python") or "缺失"),
619
+ "python: %s" % (shutil.which("python3" if not is_win else "python") or "缺失"),
601
620
  ]
621
+ if not is_win:
622
+ env_lines.append("brew: %s" % (shutil.which("brew") or "缺失"))
602
623
  prompt = (AI_REPAIR_PROMPT.replace("__CMD__", failed_cmd or "(未知)")
603
624
  .replace("__LOG__", log_tail)
604
625
  .replace("__ENV__", "\n".join(env_lines))
605
- .replace("__ALLOW__", " / ".join(p.strip() for p in AI_REPAIR_ALLOW)))
626
+ .replace("__OS__", _repair_os_label())
627
+ .replace("__ALLOW__", " / ".join(p.strip() for p in _repair_allow())))
606
628
  step, log_abs = store.add_step(run_id, "ai-repair", agent["id"], agent.get("label"),
607
629
  note="自动诊断修复")
608
630
  res = runner.run_agent(agent, prompt, readonly=True, timeout=300,
@@ -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
- last_err = "%s → HTTP %s" % (url, e.code)
268
+ last_err = tlsctx.humanize("%s → HTTP %s" % (url, e.code))
255
269
  except Exception as e:
256
- last_err = "%s → %r" % (url, e)
257
- return None, last_err
270
+ last_err = tlsctx.humanize("%s → %r" % (url, e))
271
+ # 真错误压过良性 404:500/401/连不上任一出现,就不能报「无列表接口」
272
+ return None, last_err or last_404
258
273
 
259
274
 
260
275
  def refresh_models(provider_id):
@@ -2405,7 +2420,7 @@ def _post_json_http(url, headers, body, allow_private, timeout=20):
2405
2420
  raw = resp.read(1024 * 1024)
2406
2421
  return resp.status, json.loads(raw.decode("utf-8", "replace")), ""
2407
2422
  except Exception as e:
2408
- return 0, None, repr(e)[:300]
2423
+ return 0, None, tlsctx.humanize(repr(e)[:300])
2409
2424
 
2410
2425
 
2411
2426
  def _sse_parse(proto, obj):
@@ -2506,7 +2521,7 @@ def _post_sse_http(url, headers, body, allow_private, timeout, proto, on_delta):
2506
2521
  if sum(len(s) for s in parts) > 2 * 1024 * 1024: # 防失控
2507
2522
  break
2508
2523
  except Exception as e:
2509
- return 0, "", None, repr(e)[:300]
2524
+ return 0, "", None, tlsctx.humanize(repr(e)[:300])
2510
2525
  text = "".join(parts)
2511
2526
  if not usage.get("total"):
2512
2527
  usage["total"] = usage.get("input", 0) + usage.get("output", 0) + usage.get("cached", 0)
@@ -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}