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 +15 -0
- package/README.md +22 -8
- package/app/core/automation.py +7 -0
- package/app/core/bookmeta.py +10 -3
- package/app/core/bookmeta_catalog.py +16 -0
- package/app/core/catalog.py +9 -1
- package/app/core/covergen.py +176 -56
- package/app/core/env_scrub.py +5 -3
- package/app/core/jobs.py +33 -11
- package/app/core/modelhub.py +27 -7
- package/app/core/notify.py +11 -1
- package/app/core/paihang.py +66 -22
- package/app/core/pipeline.py +77 -4
- package/app/core/publish/browser.py +62 -5
- package/app/core/publish/fanqie.py +32 -2
- package/app/core/publish/flow.py +48 -2
- package/app/core/publish/flows-fanqie-calibrated.json +324 -0
- 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/share_page.py +111 -0
- package/app/core/telemetry.py +3 -2
- package/app/core/tlsctx.py +16 -0
- package/app/core/zentao.py +1302 -0
- package/app/main.py +87 -4
- package/app/ui/app.js +525 -25
- package/app/ui/i18n.js +124 -1
- package/app/ui/index.html +53 -26
- package/app/ui/style.css +133 -24
- package/package.json +1 -1
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.
|
|
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
|
package/app/core/automation.py
CHANGED
|
@@ -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():
|
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/catalog.py
CHANGED
|
@@ -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
|
|
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/env_scrub.py
CHANGED
|
@@ -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
|
-
|
|
21
|
-
"
|
|
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 = """你是环境工程师。在
|
|
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
|
-
|
|
48
|
-
|
|
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
|
|
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(
|
|
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:
|
|
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("
|
|
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,
|
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
|
-
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
|
-
|
|
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}
|