codebee 0.1.12 → 0.1.14
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 +13 -0
- package/README.md +21 -0
- package/app/core/covergen.py +200 -0
- package/app/core/flows.py +3 -0
- package/app/core/goal_service.py +11 -2
- package/app/core/jobs.py +5 -0
- package/app/core/market_remote.py +6 -3
- package/app/core/modelhub.py +42 -15
- package/app/core/notify.py +96 -0
- package/app/core/paihang.py +65 -0
- package/app/core/pipeline.py +26 -12
- package/app/core/publish/browser.py +72 -10
- package/app/core/publish/flow.py +81 -2
- package/app/core/publish/flows-qimao-calibrated.json +233 -0
- package/app/core/publish/manager.py +83 -13
- package/app/core/publish/qimao.py +29 -0
- package/app/core/settings.py +3 -1
- package/app/core/settings_schema.py +15 -2
- package/app/core/step_runner.py +23 -1
- package/app/core/store.py +16 -0
- package/app/core/tlsctx.py +48 -0
- package/app/main.py +27 -0
- package/app/ui/app.js +101 -0
- package/app/ui/i18n.js +10 -0
- package/app/ui/index.html +1 -0
- package/app/ui/style.css +10 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,19 @@ CodeBee 的用户可感知变更记录。发布新版时:最上面加一节,
|
|
|
4
4
|
`<!-- relnotes:start -->…<!-- relnotes:end -->` 段(那段会被 `npm view` 的
|
|
5
5
|
README 元数据带回,供老版本在「发现新版本」时展示新版更新内容)。
|
|
6
6
|
|
|
7
|
+
## v0.1.14(2026-09-19)
|
|
8
|
+
|
|
9
|
+
- 新任务类型「扫榜选材」:抓取七猫排行榜公开数据,AI 提炼热门题材 Top3/高频人设套路/差异化切入建议,直出选题洞察报告
|
|
10
|
+
- 运行结果群推送:设置群机器人 webhook(钉钉/飞书/企微自动适配),任务跑完自动汇报状态与评分
|
|
11
|
+
|
|
12
|
+
## v0.1.13(2026-09-19)
|
|
13
|
+
|
|
14
|
+
- 封面图生成:连载任务建书面板新增「生成封面」——调编排者供应商图像 API(cogview 候选,竖版优先)产出 cover.png 到运行目录,SSRF 边界校验 + curl 子进程落盘(Python 不经手图像字节)
|
|
15
|
+
- 需求拷问采访态:目标描述过短时先问 1-3 个澄清问题(选项芯片点选),创建更准
|
|
16
|
+
- 代码任务 Best-of-N:单路实现失败后多候选 worktree 隔离赛马择优
|
|
17
|
+
- 计划落盘任务档案:.codebee/ 三件套(spec.md 意图 / task_plan.md 计划 / evidence.md 验证证据)
|
|
18
|
+
- 步骤卡「实际派发模型」徽章 + 类型菜单成本预估行
|
|
19
|
+
|
|
7
20
|
## v0.1.12(2026-09-19)
|
|
8
21
|
|
|
9
22
|
- 代码任务计划落盘:每次运行把编排计划写进工作目录 .codebee/task_plan.md,与任务规格(spec.md)、验证证据(evidence.md)同居「任务档案」,断点续跑/复盘全程可查
|
package/README.md
CHANGED
|
@@ -136,6 +136,27 @@ codebee
|
|
|
136
136
|
数据存放在用户目录(Windows `%APPDATA%\CodeBee`,macOS/Linux `~/.codebee`),
|
|
137
137
|
升级/重装不影响;老版本 Tutti 目录(`%APPDATA%\Tutti`)会被自动沿用,无需迁移。
|
|
138
138
|
|
|
139
|
+
### macOS:npm 全局安装报 EACCES(permission denied)
|
|
140
|
+
|
|
141
|
+
官方 pkg 安装的 Node,全局目录 `/usr/local/lib/node_modules` 归 root,直接
|
|
142
|
+
`npm install -g codebee` 会报
|
|
143
|
+
`EACCES: permission denied, mkdir '/usr/local/lib/node_modules/codebee'`。
|
|
144
|
+
**不要用 sudo 装**:装完目录归 root,应用内「一键升级」(以普通用户跑
|
|
145
|
+
`npm install -g codebee@latest`)之后每次都会撞同样的权限错误。
|
|
146
|
+
正确做法是把 npm 全局目录改到用户目录下,一次配好、安装与自动升级都畅通:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
mkdir -p ~/.npm-global
|
|
150
|
+
npm config set prefix "~/.npm-global"
|
|
151
|
+
echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.zshrc
|
|
152
|
+
source ~/.zshrc
|
|
153
|
+
npm install -g codebee
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
默认 shell 是 bash 的话,把第 3 行的 `~/.zshrc` 换成 `~/.bash_profile`。
|
|
157
|
+
用 Homebrew 装的 Node(prefix 在 `/opt/homebrew`)没有这个问题,可直接安装。
|
|
158
|
+
验证 `which codebee` 指向 `~/.npm-global/bin/codebee` 即配置成功。
|
|
159
|
+
|
|
139
160
|
**方式二:源码运行(开发者)**
|
|
140
161
|
|
|
141
162
|
```bat
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""封面图生成:调供应商图像 API 产出竖版封面插画,curl 直接落盘到运行目录。
|
|
3
|
+
|
|
4
|
+
接口形状:OpenAI 兼容 POST {base}/images/generations(Z.ai cogview 系列、
|
|
5
|
+
多数聚合网关都支持,默认返回图片 URL)。模型解析:环境变量
|
|
6
|
+
CODEBEE_IMAGE_MODEL 优先,否则逐个试候选(cogview-3-flash → cogview-4),
|
|
7
|
+
第一个 2xx 的胜出;同一模型先试竖版尺寸再回落方图。
|
|
8
|
+
状态机与建书生成同款(running/done/failed 写任务 cover_gen 字段,
|
|
9
|
+
bump_state 推 SSE)。仅支持 openai 协议供应商。
|
|
10
|
+
|
|
11
|
+
安全边界:
|
|
12
|
+
- 图像 URL 仅接受 https,解析后 IP 命中私网/环回/链路本地一律拒绝
|
|
13
|
+
(防 SSRF 打内网与云元数据);禁用重定向(curl -L 不加)。
|
|
14
|
+
- Python 不经手图像字节:下载与落盘由 curl 子进程 -o 一步完成。
|
|
15
|
+
- 产物只落 paths.RUNS_DIR 受管路径(与 report.md 同款),不写任务工作目录。
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import socket
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
log = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
_IMAGE_CANDIDATES = ("cogview-3-flash", "cogview-4")
|
|
29
|
+
_SIZES = ("768x1344", "1024x1024") # 竖版优先,方图兜底
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _cover_prompt(task):
|
|
33
|
+
"""从任务与建书资料拼图像提示词:场景氛围向,不要文字(平台会自行压字)。"""
|
|
34
|
+
bm = ((task.get("book_meta") or {}).get("fanqie") or {}).get("data") or {}
|
|
35
|
+
if not isinstance(bm, dict):
|
|
36
|
+
bm = {}
|
|
37
|
+
title = bm.get("书名") or task.get("title") or ""
|
|
38
|
+
genre = bm.get("类型") or bm.get("分类") or ""
|
|
39
|
+
brief = (bm.get("一句话简介") or bm.get("简介") or task.get("goal") or "")
|
|
40
|
+
return ("竖版小说封面插画,画面中不要出现任何文字。题材:%s %s。故事梗概:%s。"
|
|
41
|
+
"商业网文封面质感:主体人物或核心场景突出,色彩浓郁有冲击力,"
|
|
42
|
+
"构图上方留白便于后期压标题。" % (genre, title, str(brief)[:300]))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _pick_key(prov):
|
|
46
|
+
from . import modelhub
|
|
47
|
+
keys = modelhub._chain_keys(prov) or []
|
|
48
|
+
if keys and keys[0].get("key"):
|
|
49
|
+
return keys[0]["key"]
|
|
50
|
+
return prov.get("api_key") or ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _safe_image_url(url):
|
|
54
|
+
"""SSRF 校验:仅 https;解析主机全部 IP,私网/环回/链路本地/保留段拒绝。"""
|
|
55
|
+
import ipaddress
|
|
56
|
+
from urllib.parse import urlparse
|
|
57
|
+
u = urlparse(url)
|
|
58
|
+
if u.scheme != "https":
|
|
59
|
+
raise ValueError("仅允许 https 图像地址")
|
|
60
|
+
host = u.hostname or ""
|
|
61
|
+
if not host:
|
|
62
|
+
raise ValueError("图像地址缺少主机")
|
|
63
|
+
infos = socket.getaddrinfo(host, u.port or 443, proto=socket.IPPROTO_TCP)
|
|
64
|
+
if not infos:
|
|
65
|
+
raise ValueError("图像主机无法解析")
|
|
66
|
+
for info in infos:
|
|
67
|
+
ip = ipaddress.ip_address(info[4][0])
|
|
68
|
+
if (ip.is_private or ip.is_loopback or ip.is_link_local
|
|
69
|
+
or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
|
|
70
|
+
raise ValueError("图像主机解析到受限地址,已拒绝")
|
|
71
|
+
return url
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _curl_to(url, out_path):
|
|
75
|
+
"""curl 子进程下载落盘(Python 不经手图像字节;不加 -L 禁重定向)。"""
|
|
76
|
+
from . import runner
|
|
77
|
+
r = runner.run_process(
|
|
78
|
+
argv=["curl", "-sS", "--max-time", "180",
|
|
79
|
+
"--proto", "=https", "--fail",
|
|
80
|
+
"-o", str(out_path), url],
|
|
81
|
+
timeout=200)
|
|
82
|
+
if not r["ok"]:
|
|
83
|
+
raise RuntimeError((r.get("stderr") or r.get("stdout") or "下载失败")[:200])
|
|
84
|
+
p = Path(out_path)
|
|
85
|
+
if not p.is_file() or p.stat().st_size < 1024:
|
|
86
|
+
raise RuntimeError("下载内容过小或为空")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _call_images(base, key, model, prompt, size, allow_private):
|
|
90
|
+
"""POST /images/generations。返回 (data_item dict, 错误串);data_item 含 url 或 b64_json。"""
|
|
91
|
+
from . import builtin_agent
|
|
92
|
+
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)
|
|
98
|
+
if status == 0:
|
|
99
|
+
return None, err or "网络错误"
|
|
100
|
+
if 200 <= status < 300:
|
|
101
|
+
items = (data or {}).get("data") or []
|
|
102
|
+
if items and isinstance(items[0], dict):
|
|
103
|
+
return items[0], ""
|
|
104
|
+
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
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def make_cover(run_id, task):
|
|
111
|
+
"""同步生成封面到运行目录。成功/失败返回 entry dict(写任务 cover_gen 用)。"""
|
|
112
|
+
from . import builtin_agent, modelhub, paths
|
|
113
|
+
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"))
|
|
125
|
+
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
|
+
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")}
|
|
158
|
+
raise RuntimeError(last_err or "图像接口无可用模型")
|
|
159
|
+
except Exception as e:
|
|
160
|
+
return {"status": "failed", "error": str(e)[:300],
|
|
161
|
+
"at": time.strftime("%Y-%m-%d %H:%M:%S")}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def generate_async(run_id, task_id, task):
|
|
165
|
+
"""后台线程入口:生成封面并把终态写回任务 cover_gen(仿建书 generate_async)。"""
|
|
166
|
+
from . import store
|
|
167
|
+
if not store.get_task(task_id):
|
|
168
|
+
return
|
|
169
|
+
entry = make_cover(run_id, task)
|
|
170
|
+
cur = store.get_task(task_id)
|
|
171
|
+
if not cur:
|
|
172
|
+
return
|
|
173
|
+
prev = cur.get("cover_gen") or {}
|
|
174
|
+
if prev.get("status") != "running": # 期间被删/重置:丢弃结果
|
|
175
|
+
return
|
|
176
|
+
store.set_cover_gen(task_id, entry)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def start(task_id, run_id=None):
|
|
180
|
+
"""起后台封面生成线程。run_id 缺省用该任务最近一次 run。返回 (ok, err)。running 幂等拒绝。"""
|
|
181
|
+
from . import store
|
|
182
|
+
task = store.get_task(task_id)
|
|
183
|
+
if not task:
|
|
184
|
+
return False, "任务不存在"
|
|
185
|
+
cur = task.get("cover_gen") or {}
|
|
186
|
+
if cur.get("status") == "running":
|
|
187
|
+
return True, ""
|
|
188
|
+
rid = run_id
|
|
189
|
+
if not rid:
|
|
190
|
+
runs = store.task_runs(task_id)
|
|
191
|
+
rid = (runs[-1].get("id") if runs else "")
|
|
192
|
+
if not rid:
|
|
193
|
+
return False, "没有可归属的运行记录(先跑一次任务再生成封面)"
|
|
194
|
+
if not store.set_cover_gen(task_id, {"status": "running",
|
|
195
|
+
"at": time.strftime("%Y-%m-%d %H:%M:%S")}):
|
|
196
|
+
return False, "任务不存在"
|
|
197
|
+
task = dict(task, cover_gen={"status": "running"})
|
|
198
|
+
threading.Thread(target=generate_async, daemon=True,
|
|
199
|
+
name="cover-gen-%s" % task_id, args=(rid, task_id, task)).start()
|
|
200
|
+
return True, ""
|
package/app/core/flows.py
CHANGED
|
@@ -74,6 +74,9 @@ BUILTIN_FLOWS = [
|
|
|
74
74
|
"threshold": 7.0, "rounds": 2,
|
|
75
75
|
"goal_hint": "翻译什么(源文本位置 / 目标语言 / 要求)",
|
|
76
76
|
"note": "起草 → 多维评审 → 修订循环 → 发布门禁"},
|
|
77
|
+
{"id": "rank_scan", "name": "扫榜选材", "icon": "i-chart", "engine": "direct", "builtin": True,
|
|
78
|
+
"goal_hint": "想写哪个方向(一句话,可留空默认分析总榜热门题材)",
|
|
79
|
+
"note": "抓取七猫排行榜公开数据 → AI 提炼热门题材/人设/差异化切入点(快档直出报告)"},
|
|
77
80
|
{"id": "research", "name": "调研报告", "icon": "i-file-search", "engine": "review", "builtin": True,
|
|
78
81
|
"manuscript": "report.md",
|
|
79
82
|
"rubric": ["全面性", "深度", "论据可靠", "可读性", "结论质量"],
|
package/app/core/goal_service.py
CHANGED
|
@@ -86,9 +86,18 @@ def get(goal_id: str):
|
|
|
86
86
|
return dict(g) if g else None
|
|
87
87
|
|
|
88
88
|
|
|
89
|
-
def create(title: str, description: str = "", *, rounds_max: int =
|
|
89
|
+
def create(title: str, description: str = "", *, rounds_max: int | None = None,
|
|
90
90
|
metadata: dict | None = None) -> dict:
|
|
91
|
-
"""创建新 goal。已有未完结 goal 时拒绝(单一当前目标,仿 dsh)。
|
|
91
|
+
"""创建新 goal。已有未完结 goal 时拒绝(单一当前目标,仿 dsh)。
|
|
92
|
+
|
|
93
|
+
rounds_max 缺省读 settings_v2 orchestrator.max_goal_rounds(读不到回落 5)。"""
|
|
94
|
+
if rounds_max is None:
|
|
95
|
+
try:
|
|
96
|
+
from .settings_schema import get as ss_get, register_default_namespaces
|
|
97
|
+
register_default_namespaces()
|
|
98
|
+
rounds_max = int(ss_get("orchestrator", "max_goal_rounds") or 5)
|
|
99
|
+
except Exception:
|
|
100
|
+
rounds_max = 5
|
|
92
101
|
with _LOCK:
|
|
93
102
|
cur = current()
|
|
94
103
|
if cur is not None:
|
package/app/core/jobs.py
CHANGED
|
@@ -449,6 +449,11 @@ def _worker():
|
|
|
449
449
|
_maybe_auto_resume(run_id) # 连载失败自动续跑(继承已完成章)
|
|
450
450
|
except Exception:
|
|
451
451
|
pass
|
|
452
|
+
try:
|
|
453
|
+
from . import notify
|
|
454
|
+
notify.push_run_async(run_id) # 结果推群(借鉴 agency-orchestrator --notify)
|
|
455
|
+
except Exception:
|
|
456
|
+
pass
|
|
452
457
|
_QUEUE.task_done()
|
|
453
458
|
finally:
|
|
454
459
|
with _pool_lock:
|
|
@@ -36,7 +36,7 @@ import zipfile
|
|
|
36
36
|
from pathlib import Path
|
|
37
37
|
from urllib.parse import urlparse
|
|
38
38
|
|
|
39
|
-
from . import market
|
|
39
|
+
from . import market, tlsctx
|
|
40
40
|
|
|
41
41
|
_LOCK = market._LOCK # 安装/记账与 market 共用一把锁,避免交叉写 market.json
|
|
42
42
|
|
|
@@ -157,10 +157,13 @@ def _fetch(url, cap=_CAP_MANIFEST):
|
|
|
157
157
|
return b"".join(chunks)
|
|
158
158
|
|
|
159
159
|
try:
|
|
160
|
-
return _read(lambda: urllib.request.urlopen(
|
|
160
|
+
return _read(lambda: urllib.request.urlopen(
|
|
161
|
+
req, timeout=30, context=tlsctx.context())) # 默认 opener:含系统代理
|
|
161
162
|
except (urllib.error.HTTPError, urllib.error.URLError):
|
|
162
163
|
# 直连重试:ProxyHandler({}) 显式清空代理
|
|
163
|
-
opener = urllib.request.build_opener(
|
|
164
|
+
opener = urllib.request.build_opener(
|
|
165
|
+
urllib.request.ProxyHandler({}),
|
|
166
|
+
urllib.request.HTTPSHandler(context=tlsctx.context()))
|
|
164
167
|
return _read(lambda: opener.open(req, timeout=30))
|
|
165
168
|
|
|
166
169
|
|
package/app/core/modelhub.py
CHANGED
|
@@ -41,7 +41,7 @@ import threading
|
|
|
41
41
|
import time
|
|
42
42
|
import urllib.request
|
|
43
43
|
|
|
44
|
-
from . import paths
|
|
44
|
+
from . import paths, tlsctx
|
|
45
45
|
|
|
46
46
|
|
|
47
47
|
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
@@ -50,6 +50,13 @@ class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
|
50
50
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
51
51
|
return None
|
|
52
52
|
|
|
53
|
+
|
|
54
|
+
def _opener():
|
|
55
|
+
"""出站 opener:禁重定向 + tlsctx 兜底 CA(macOS python.org 版 Python
|
|
56
|
+
缺系统证书,默认上下文验证必挂——见 tlsctx 模块 docstring)。"""
|
|
57
|
+
return urllib.request.build_opener(
|
|
58
|
+
_NoRedirect, urllib.request.HTTPSHandler(context=tlsctx.context()))
|
|
59
|
+
|
|
53
60
|
_LOCK = threading.RLock()
|
|
54
61
|
_FILE = paths.DATA_DIR / "models.json"
|
|
55
62
|
|
|
@@ -206,11 +213,11 @@ def _fetch_models_http(base_url, api_key, protocol, allow_private=False):
|
|
|
206
213
|
else:
|
|
207
214
|
urls = std
|
|
208
215
|
last_err = ""
|
|
209
|
-
opener =
|
|
216
|
+
opener = _opener()
|
|
210
217
|
for url in urls:
|
|
211
|
-
|
|
212
|
-
if
|
|
213
|
-
last_err =
|
|
218
|
+
host, herr = _validate_host(url, allow_private)
|
|
219
|
+
if host is None:
|
|
220
|
+
last_err = herr
|
|
214
221
|
continue
|
|
215
222
|
# auto 不知道是哪条 wire,鉴权头也按两种都试(google 那种单独补上)
|
|
216
223
|
hdrs = _auth_header_variants(api_key, protocol)
|
|
@@ -237,6 +244,14 @@ def _fetch_models_http(base_url, api_key, protocol, allow_private=False):
|
|
|
237
244
|
if names:
|
|
238
245
|
return names, ""
|
|
239
246
|
last_err = url + " 返回 200 但未解析到模型"
|
|
247
|
+
except urllib.error.HTTPError as e:
|
|
248
|
+
if e.code == 404:
|
|
249
|
+
# zcode-plan / open.bigmodel.cn 等 anthropic 形网关没有
|
|
250
|
+
# GET /models——404 与密钥无关,模型以导入/手填为准
|
|
251
|
+
last_err = (url + " 无模型列表接口(HTTP 404)——"
|
|
252
|
+
"模型以导入/手动添加为准,不影响对话调用")
|
|
253
|
+
else:
|
|
254
|
+
last_err = "%s → HTTP %s" % (url, e.code)
|
|
240
255
|
except Exception as e:
|
|
241
256
|
last_err = "%s → %r" % (url, e)
|
|
242
257
|
return None, last_err
|
|
@@ -1512,6 +1527,7 @@ def _src_zcode():
|
|
|
1512
1527
|
default_model = str(d.get("model") or "")
|
|
1513
1528
|
tail = default_model.split("/", 1)[1] if "/" in default_model else default_model
|
|
1514
1529
|
bad = []
|
|
1530
|
+
nokey = []
|
|
1515
1531
|
for pid, p in (d.get("provider") or {}).items():
|
|
1516
1532
|
if not isinstance(p, dict):
|
|
1517
1533
|
continue
|
|
@@ -1523,13 +1539,23 @@ def _src_zcode():
|
|
|
1523
1539
|
"zcode", "zcode:%s" % pid,
|
|
1524
1540
|
model=(tail if tail in names else ""), models=names)
|
|
1525
1541
|
if pr:
|
|
1542
|
+
if not pr.get("api_key"):
|
|
1543
|
+
nokey.append(str(p.get("name") or pid))
|
|
1526
1544
|
out["providers"].append(pr)
|
|
1527
1545
|
else:
|
|
1528
1546
|
bad.append(str(p.get("name") or pid))
|
|
1547
|
+
notes = []
|
|
1529
1548
|
if bad:
|
|
1530
|
-
|
|
1531
|
-
len(bad), "、".join(bad[:4]))
|
|
1532
|
-
|
|
1549
|
+
notes.append("跳过 {0} 个(缺合法 baseURL):{1}".format(
|
|
1550
|
+
len(bad), "、".join(bad[:4])))
|
|
1551
|
+
if nokey:
|
|
1552
|
+
# ZCode 的 builtin Plan 条目(zcode.z.ai/api/v1/zcode-plan 等)密钥不入
|
|
1553
|
+
# config.json(实测 credentials.json 里的 OAuth token 也不能直接当 API
|
|
1554
|
+
# key 用)——静默导入空密钥条目只会让用户在拉列表/适配测试时一头雾水
|
|
1555
|
+
notes.append("{0} 个未带出密钥——请在「模型接入」页手填:{1}".format(
|
|
1556
|
+
len(nokey), "、".join(nokey[:4])))
|
|
1557
|
+
if notes:
|
|
1558
|
+
out["note"] = ";".join(notes)
|
|
1533
1559
|
return out
|
|
1534
1560
|
|
|
1535
1561
|
|
|
@@ -2368,14 +2394,14 @@ def _post_json_http(url, headers, body, allow_private, timeout=20):
|
|
|
2368
2394
|
p = urllib.parse.urlsplit(url)
|
|
2369
2395
|
if p.scheme not in ("http", "https"):
|
|
2370
2396
|
return 0, None, "协议必须是 http/https"
|
|
2371
|
-
|
|
2372
|
-
if
|
|
2373
|
-
return 0, None,
|
|
2397
|
+
host, herr = _validate_host(url, allow_private)
|
|
2398
|
+
if host is None:
|
|
2399
|
+
return 0, None, herr
|
|
2374
2400
|
try:
|
|
2375
2401
|
req = urllib.request.Request(url, method="POST",
|
|
2376
2402
|
headers=dict(headers, **{"Content-Type": "application/json"}),
|
|
2377
2403
|
data=json.dumps(body).encode("utf-8"))
|
|
2378
|
-
with
|
|
2404
|
+
with _opener().open(req, timeout=timeout) as resp:
|
|
2379
2405
|
raw = resp.read(1024 * 1024)
|
|
2380
2406
|
return resp.status, json.loads(raw.decode("utf-8", "replace")), ""
|
|
2381
2407
|
except Exception as e:
|
|
@@ -2440,15 +2466,16 @@ def _post_sse_http(url, headers, body, allow_private, timeout, proto, on_delta):
|
|
|
2440
2466
|
p = urllib.parse.urlsplit(url)
|
|
2441
2467
|
if p.scheme not in ("http", "https"):
|
|
2442
2468
|
return 0, "", None, "协议必须是 http/https"
|
|
2443
|
-
|
|
2444
|
-
|
|
2469
|
+
host, herr = _validate_host(url, allow_private)
|
|
2470
|
+
if host is None:
|
|
2471
|
+
return 0, "", None, herr
|
|
2445
2472
|
parts, usage = [], {}
|
|
2446
2473
|
resp_status = 0
|
|
2447
2474
|
try:
|
|
2448
2475
|
req = urllib.request.Request(url, method="POST",
|
|
2449
2476
|
headers=dict(headers, **{"Content-Type": "application/json"}),
|
|
2450
2477
|
data=json.dumps(body).encode("utf-8"))
|
|
2451
|
-
with
|
|
2478
|
+
with _opener().open(req, timeout=timeout) as resp:
|
|
2452
2479
|
resp_status = resp.status
|
|
2453
2480
|
if not 200 <= resp.status < 300:
|
|
2454
2481
|
raw = resp.read(65536).decode("utf-8", "replace")
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""运行结果群推送(借鉴 agency-orchestrator 的 --notify):任务跑完把结果摘要
|
|
3
|
+
推到钉钉/飞书/企业微信群机器人,配合定时自动化就是「AI 团队每天定点交活」。
|
|
4
|
+
|
|
5
|
+
webhook 一个地址全包——按域名自动适配三种机器人格式(同 agency-orchestrator
|
|
6
|
+
思路);也接受任意 https 地址(按钉钉 text 形状发,自建 n8n 等自选)。
|
|
7
|
+
推送在后台线程、失败只记日志——通知永远不影响任务本身。
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _payload_for(webhook, text):
|
|
17
|
+
"""按 webhook 域名适配群机器人消息体。返回 None = 无法识别的地址。"""
|
|
18
|
+
from urllib.parse import urlparse
|
|
19
|
+
host = (urlparse(webhook).hostname or "").lower()
|
|
20
|
+
if "dingtalk" in host:
|
|
21
|
+
return {"msgtype": "text", "text": {"content": text}}
|
|
22
|
+
if "feishu" in host or "larksuite" in host:
|
|
23
|
+
return {"msg_type": "text", "content": {"text": text}}
|
|
24
|
+
if "weixin" in host or "wechat" in host or "work.weixin" in host:
|
|
25
|
+
return {"msgtype": "text", "text": {"content": text}}
|
|
26
|
+
if host:
|
|
27
|
+
return {"msgtype": "text", "text": {"content": text}} # 未知域名按钉钉形状
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _webhook():
|
|
32
|
+
try:
|
|
33
|
+
from . import settings
|
|
34
|
+
return str(settings.load().get("notify_webhook") or "").strip()
|
|
35
|
+
except Exception:
|
|
36
|
+
return ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _post(hook, text):
|
|
40
|
+
"""curl 子进程 POST(Python 不经手响应体);网络失败只记日志。"""
|
|
41
|
+
from . import runner
|
|
42
|
+
import json as _json
|
|
43
|
+
body = _json.dumps(_payload_for(hook, text), ensure_ascii=False)
|
|
44
|
+
r = runner.run_process(
|
|
45
|
+
argv=["curl", "-sS", "--max-time", "20",
|
|
46
|
+
"-H", "Content-Type: application/json",
|
|
47
|
+
"-d", body, hook],
|
|
48
|
+
timeout=30)
|
|
49
|
+
return bool(r["ok"])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def push_text(text):
|
|
53
|
+
"""推一条文本到群。配置了 webhook 才推;失败返回 False 不抛错。"""
|
|
54
|
+
hook = _webhook()
|
|
55
|
+
if not hook:
|
|
56
|
+
return False
|
|
57
|
+
if not hook.startswith("https://"):
|
|
58
|
+
log.warning("notify webhook 必须是 https")
|
|
59
|
+
return False
|
|
60
|
+
try:
|
|
61
|
+
return _post(hook, text)
|
|
62
|
+
except Exception as e:
|
|
63
|
+
log.warning("notify push failed: %s", e)
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def push_run_async(run_id):
|
|
68
|
+
"""任务收尾后异步推送结果摘要(jobs 层调用;绝不阻塞/影响任务)。"""
|
|
69
|
+
threading.Thread(target=push_run, daemon=True,
|
|
70
|
+
name="notify-%s" % run_id, args=(run_id,)).start()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def push_run(run_id):
|
|
74
|
+
"""组装 run 结果摘要并推送(webhook 未配置时静默跳过)。"""
|
|
75
|
+
hook = _webhook()
|
|
76
|
+
if not hook:
|
|
77
|
+
return False
|
|
78
|
+
from . import store
|
|
79
|
+
run = store.get_run(run_id) or {}
|
|
80
|
+
if not run:
|
|
81
|
+
return False
|
|
82
|
+
status = str(run.get("status") or "")
|
|
83
|
+
mark = {"done": "✅", "failed": "❌", "cancelled": "⚪"}.get(status, "🔔")
|
|
84
|
+
lines = ["%s CodeBee 任务%s" % (mark, {"done": "完成", "failed": "失败",
|
|
85
|
+
"cancelled": "已取消"}.get(status, status))]
|
|
86
|
+
lines.append("任务:%s" % (run.get("title") or run_id))
|
|
87
|
+
if run.get("error"):
|
|
88
|
+
lines.append("错误:%s" % str(run["error"])[:200])
|
|
89
|
+
v = run.get("verdict") or {}
|
|
90
|
+
if v.get("overall") is not None:
|
|
91
|
+
lines.append("综合评分 %.1f(%s)" % (
|
|
92
|
+
float(v["overall"]), "达标" if v.get("publishable") else "未达标"))
|
|
93
|
+
return push_text("\n".join(lines))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
import threading # noqa: E402 (push_run_async 依赖;置底避免顶部循环导入)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""扫榜选材:抓取七猫排行榜公开页,产出选题分析素材。
|
|
3
|
+
|
|
4
|
+
数据源:www.qimao.com/paihang/(公开可抓,2026-09-19 实测 200/106KB,
|
|
5
|
+
书名与分类在 a 标签中文文本里)。下载走 curl 子进程(与 covergen 同款,
|
|
6
|
+
Python 不经手响应体以外的东西),解析用宽松正则——页面结构变了宁可
|
|
7
|
+
返回空(调用方回落普通直连提示词),不做脆弱的强解析。
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
RANK_URL = "https://www.qimao.com/paihang/"
|
|
17
|
+
_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
18
|
+
"(KHTML, like Gecko) Chrome/126 Safari/537.36")
|
|
19
|
+
|
|
20
|
+
# 页面 UI 噪音词(非书目/分类)
|
|
21
|
+
_NOISE = {"加入书架", "立即阅读", "开始阅读", "免费阅读", "全部", "分类", "排行",
|
|
22
|
+
"排行榜", "完本", "连载中", "阅读", "书城", "男生", "女生"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def fetch_rank_items(limit=40):
|
|
26
|
+
"""抓七猫排行榜页并提取中文元素材(书名/分类混排,去 UI 噪音)。
|
|
27
|
+
|
|
28
|
+
返回元素列表(保序去重,最多 limit 个);抓取失败返回 []——调用方
|
|
29
|
+
回落普通直连提示词,扫榜永远不挡任务。"""
|
|
30
|
+
from . import runner
|
|
31
|
+
try:
|
|
32
|
+
r = runner.run_process(
|
|
33
|
+
argv=["curl", "-sS", "--max-time", "20", "-A", _UA, RANK_URL],
|
|
34
|
+
timeout=30)
|
|
35
|
+
except Exception:
|
|
36
|
+
return []
|
|
37
|
+
if not r.get("ok"):
|
|
38
|
+
return []
|
|
39
|
+
items, seen = [], set()
|
|
40
|
+
for t in re.findall(r">([\u4e00-\u9fa5]{2,12})<", r.get("stdout") or ""):
|
|
41
|
+
if t in _NOISE or t in seen:
|
|
42
|
+
continue
|
|
43
|
+
seen.add(t)
|
|
44
|
+
items.append(t)
|
|
45
|
+
if len(items) >= limit:
|
|
46
|
+
break
|
|
47
|
+
return items
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def rank_scan_prompt(goal):
|
|
51
|
+
"""组装扫榜选材分析提示词。榜单抓取失败返回 None(调用方回落直连提示词)。"""
|
|
52
|
+
items = fetch_rank_items()
|
|
53
|
+
if not items:
|
|
54
|
+
return None
|
|
55
|
+
material = "、".join(items)
|
|
56
|
+
return (
|
|
57
|
+
"你是网文选题分析师。以下是刚刚抓取的七猫排行榜页面的书目与分类素材:\n\n"
|
|
58
|
+
"## 榜单元素材\n%s\n\n"
|
|
59
|
+
"## 用户想写的方向\n%s\n\n"
|
|
60
|
+
"## 你的产出(Markdown 报告)\n"
|
|
61
|
+
"1. **热门题材 Top3**:各自的共同特征与上榜代表书目\n"
|
|
62
|
+
"2. **高频人设/套路总结**:3-5 条,点名反复出现的元素\n"
|
|
63
|
+
"3. **差异化切入建议**:2-3 个,结合用户方向给出「题材+人设」组合与一句话理由\n"
|
|
64
|
+
"直接输出分析报告,不要复述素材清单,不要输出与报告无关的内容。"
|
|
65
|
+
% (material, goal or "(用户未指定方向,按大盘热门分析)"))
|