codebee 0.1.13 → 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 CHANGED
@@ -4,6 +4,11 @@ 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
+
7
12
  ## v0.1.13(2026-09-19)
8
13
 
9
14
  - 封面图生成:连载任务建书面板新增「生成封面」——调编排者供应商图像 API(cogview 候选,竖版优先)产出 cover.png 到运行目录,SSRF 边界校验 + curl 子进程落盘(Python 不经手图像字节)
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
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": ["全面性", "深度", "论据可靠", "可读性", "结论质量"],
@@ -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 = 5,
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(req, timeout=30)) # 默认 opener:含系统代理
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(urllib.request.ProxyHandler({}))
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
 
@@ -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 = urllib.request.build_opener(_NoRedirect)
216
+ opener = _opener()
210
217
  for url in urls:
211
- host_info = _validate_host(url, allow_private)
212
- if host_info is None:
213
- last_err = host_info[1]
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
- out["note"] = "跳过 {0} 个(缺合法 baseURL):{1}".format(
1531
- len(bad), "、".join(bad[:4]))
1532
- out["note_args"] = [len(bad), "、".join(bad[:4])]
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
- host_info = _validate_host(url, allow_private)
2372
- if host_info is None:
2373
- return 0, None, host_info[1]
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 urllib.request.build_opener(_NoRedirect).open(req, timeout=timeout) as resp:
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
- if _validate_host(url, allow_private) is None:
2444
- return 0, "", None, "目标地址校验未通过"
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 urllib.request.build_opener(_NoRedirect).open(req, timeout=timeout) as resp:
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 "(用户未指定方向,按大盘热门分析)"))
@@ -19,7 +19,7 @@ import re
19
19
  import threading
20
20
  import time
21
21
 
22
- from . import aiflavor, catalog, history, jobs, manager, modelhub, mocks, planner, registry, router, runner, skills, store, usage
22
+ from . import aiflavor, catalog, history, jobs, manager, modelhub, mocks, paihang, planner, registry, router, runner, skills, store, usage
23
23
  from . import builtin_agent
24
24
  from . import diagnostics
25
25
  from . import paths as paths_mod
@@ -168,8 +168,16 @@ def _resume_workdir(resume_ctx, fallback):
168
168
 
169
169
 
170
170
  def _compaction_enabled():
171
- """Phase 2 灰度开关:环境变量 TUTTI_COMPACTION=1 启用上下文压缩(默认关)。"""
172
- return os.environ.get("TUTTI_COMPACTION") == "1"
171
+ """Phase 2 灰度开关:环境变量 TUTTI_COMPACTION=1 或设置页
172
+ settings_v2 orchestrator.compaction.enabled 任一开启即生效(默认关)。"""
173
+ if os.environ.get("TUTTI_COMPACTION") == "1":
174
+ return True
175
+ try:
176
+ from .settings_schema import get as ss_get, register_default_namespaces
177
+ register_default_namespaces()
178
+ return bool(ss_get("orchestrator", "compaction.enabled"))
179
+ except Exception:
180
+ return False
173
181
 
174
182
 
175
183
  _sessions_cache = {}
@@ -1284,15 +1292,21 @@ def _run_direct(run, task, agents, ev, stats, mode):
1284
1292
  while True:
1285
1293
  _wait_gate(run_id, ev)
1286
1294
  if first:
1287
- if bi is not None:
1288
- prompt = (BUILTIN_DIRECT_PROMPT
1289
- .replace("__GOAL__", task["goal"])
1290
- .replace("__CONTEXT__", task.get("context") or "(无)")
1291
- .replace("__FOLLOWUPS__", FOLLOWUPS_PROTOCOL))
1292
- else:
1293
- prompt = (DIRECT_PROMPT
1294
- .replace("__GOAL__", task["goal"])
1295
- .replace("__CONTEXT__", task.get("context") or "(无)"))
1295
+ prompt = ""
1296
+ if task.get("type") == "rank_scan" and bi is not None:
1297
+ # 扫榜选材(借鉴 oh-story 扫榜):抓七猫排行榜公开数据注入,
1298
+ # AI 做选题洞察;抓取失败回落普通直连提示词
1299
+ prompt = paihang.rank_scan_prompt(task.get("goal") or "") or ""
1300
+ if not prompt:
1301
+ if bi is not None:
1302
+ prompt = (BUILTIN_DIRECT_PROMPT
1303
+ .replace("__GOAL__", task["goal"])
1304
+ .replace("__CONTEXT__", task.get("context") or "(无)")
1305
+ .replace("__FOLLOWUPS__", FOLLOWUPS_PROTOCOL))
1306
+ else:
1307
+ prompt = (DIRECT_PROMPT
1308
+ .replace("__GOAL__", task["goal"])
1309
+ .replace("__CONTEXT__", task.get("context") or "(无)"))
1296
1310
  note = route.get("implementer", "")
1297
1311
  images = _task_images(task, workdir)
1298
1312
  else:
@@ -276,15 +276,32 @@ class Page:
276
276
  return self.evaluate("location.href")
277
277
 
278
278
  def navigate(self, url, timeout=30.0):
279
- """导航并等文档就绪。SPA 路由可能不触发完整 load,readyState 轮询兜底。"""
279
+ """导航并等文档就绪。SPA 路由可能不触发完整 load,readyState 轮询兜底。
280
+
281
+ about:blank 起跳时 readyState 本就 complete——必须同时等 location
282
+ 真正到达目标域,否则后续步骤打在空白页上(url_any 误报)。"""
283
+ from urllib.parse import urlparse as _up
284
+ if url == "about:blank": # 中转页:无需等加载(页面忙时会假超时)
285
+ try:
286
+ self.send("Page.navigate", {"url": url}, timeout=5.0)
287
+ except BrowserError:
288
+ pass
289
+ time.sleep(0.4)
290
+ return
280
291
  try:
281
292
  self.send("Page.navigate", {"url": url}, timeout=timeout)
282
293
  except BrowserError:
283
294
  pass # 老页面销毁时连接报错属正常,轮询兜底
295
+ host = _up(url).netloc
284
296
  deadline = time.time() + timeout
297
+ seen_url = False
285
298
  while time.time() < deadline:
286
299
  try:
287
- if self.evaluate("document.readyState", timeout=3.0) == "complete":
300
+ href = self.evaluate("location.href", timeout=3.0) or ""
301
+ if not host or host in href:
302
+ seen_url = True
303
+ if seen_url and self.evaluate("document.readyState", timeout=3.0) == "complete":
304
+ time.sleep(0.5) # SPA 首帧渲染余量
288
305
  return
289
306
  except BrowserError:
290
307
  pass # 导航间隙 evaluate 会短暂失败
@@ -106,6 +106,8 @@ def run_flow(page, steps, values=None, config=None, auto_submit=False,
106
106
  url = st["url"]
107
107
  for k, v in config.items():
108
108
  url = url.replace("{%s}" % k, str(v))
109
+ for k, v in (values or {}).items(): # editor_url/draft_url 等任务级占位
110
+ url = url.replace("{%s}" % k, str(v))
109
111
  note(i, "打开 %s" % url)
110
112
  page.navigate(url)
111
113
  elif act == "wait":
@@ -0,0 +1,233 @@
1
+ {
2
+ "_note": "七猫真机校准(2026-09-19 凌晨定稿):建书=book-manage「新建小说」→站点弹层(click_match+确认)→information 表单(fill_label/radio/级联分类/tags 四组各1-3个+确定/主角名平铺/简介);书名仅允许,:!?中文标点(·被拦)。发章=编辑器填好后「立即发布」落草稿箱(未签约书限制)→草稿行「立即发布」→「更正序号去发布」→「确认发布」三层弹窗→章节待审核。正文下限 1000 字(min_chapter_chars)。book-upload?id=<书id> 直达编辑器。",
3
+ "probe_form": [
4
+ {
5
+ "do": "navigate",
6
+ "url": "{book_manage}"
7
+ },
8
+ {
9
+ "do": "url_any",
10
+ "any": [
11
+ "qimao.com"
12
+ ]
13
+ },
14
+ {
15
+ "do": "click_text",
16
+ "text": "新建小说",
17
+ "contains": true
18
+ },
19
+ {
20
+ "do": "click_match",
21
+ "any": [
22
+ "七猫中文网",
23
+ "网站特色"
24
+ ],
25
+ "max_len": 160
26
+ },
27
+ {
28
+ "do": "probe",
29
+ "note": "建书表单"
30
+ }
31
+ ],
32
+ "create_book": [
33
+ {
34
+ "do": "navigate",
35
+ "url": "{book_manage}"
36
+ },
37
+ {
38
+ "do": "url_any",
39
+ "any": [
40
+ "qimao.com"
41
+ ]
42
+ },
43
+ {
44
+ "do": "click_text",
45
+ "text": "新建小说",
46
+ "contains": true
47
+ },
48
+ {
49
+ "do": "click_match",
50
+ "any": [
51
+ "七猫中文网",
52
+ "网站特色"
53
+ ],
54
+ "max_len": 400
55
+ },
56
+ {
57
+ "do": "click_text",
58
+ "text": "确认",
59
+ "scope": "button,[class*=btn]"
60
+ },
61
+ {
62
+ "do": "wait",
63
+ "sel": "textarea[placeholder*='作品名称']",
64
+ "timeout": 15
65
+ },
66
+ {
67
+ "do": "fill_label",
68
+ "label": "作品名称",
69
+ "key": "title"
70
+ },
71
+ {
72
+ "do": "radio",
73
+ "key": "target_reader",
74
+ "map": {
75
+ "男生": "0",
76
+ "女生": "1"
77
+ }
78
+ },
79
+ {
80
+ "do": "click",
81
+ "sel": "input[placeholder='请选择一级分类']"
82
+ },
83
+ {
84
+ "do": "click_text",
85
+ "text": "{category_main}",
86
+ "contains": true,
87
+ "scope": "li,span,[class*=dropdown] *,[class*=popper] *"
88
+ },
89
+ {
90
+ "do": "click",
91
+ "sel": "input[placeholder='请选择二级分类']"
92
+ },
93
+ {
94
+ "do": "click_text",
95
+ "text": "{category_sub}",
96
+ "contains": true,
97
+ "scope": "li,span,[class*=dropdown] *,[class*=popper] *"
98
+ },
99
+ {
100
+ "do": "click_text",
101
+ "text": "添加标签"
102
+ },
103
+ {
104
+ "do": "tags"
105
+ },
106
+ {
107
+ "do": "click_text",
108
+ "text": "确定",
109
+ "scope": "button,[class*=btn]"
110
+ },
111
+ {
112
+ "do": "fill_label",
113
+ "label": "主角名",
114
+ "key": "protagonist"
115
+ },
116
+ {
117
+ "do": "radio",
118
+ "key": "status",
119
+ "map": {
120
+ "连载中": "0",
121
+ "已完结": "1"
122
+ }
123
+ },
124
+ {
125
+ "do": "fill_label",
126
+ "label": "作品简介",
127
+ "key": "summary"
128
+ },
129
+ {
130
+ "do": "shot",
131
+ "name": "create-book-filled"
132
+ },
133
+ {
134
+ "do": "submit",
135
+ "text": "确认创建"
136
+ }
137
+ ],
138
+ "upload_chapter": [
139
+ {
140
+ "do": "navigate",
141
+ "url": "about:blank"
142
+ },
143
+ {
144
+ "do": "navigate",
145
+ "url": "{editor_url}"
146
+ },
147
+ {
148
+ "do": "url_any",
149
+ "any": [
150
+ "qimao.com"
151
+ ]
152
+ },
153
+ {
154
+ "do": "wait",
155
+ "sel": "textarea[placeholder*='章节名称']",
156
+ "timeout": 20
157
+ },
158
+ {
159
+ "do": "fill",
160
+ "sel": "textarea[placeholder*='章节名称']",
161
+ "key": "chapter_title"
162
+ },
163
+ {
164
+ "do": "wait",
165
+ "sel": ".q-contenteditable",
166
+ "timeout": 12
167
+ },
168
+ {
169
+ "do": "fill",
170
+ "sel": ".q-contenteditable",
171
+ "key": "chapter_body"
172
+ },
173
+ {
174
+ "do": "shot",
175
+ "name": "chapter-filled"
176
+ },
177
+ {
178
+ "do": "click_real",
179
+ "text": "立即发布",
180
+ "tries": 25
181
+ },
182
+ {
183
+ "do": "navigate",
184
+ "url": "about:blank"
185
+ },
186
+ {
187
+ "do": "navigate",
188
+ "url": "{draft_url}"
189
+ },
190
+ {
191
+ "do": "wait",
192
+ "sel": ".el-table__row, tr",
193
+ "timeout": 25
194
+ },
195
+ {
196
+ "do": "click_in",
197
+ "scope_text": "{chapter_title}",
198
+ "text": "立即发布"
199
+ },
200
+ {
201
+ "do": "click_real",
202
+ "text": "更正序号去发布",
203
+ "tries": 20,
204
+ "optional": true
205
+ },
206
+ {
207
+ "do": "click_real",
208
+ "text": "确认发布",
209
+ "tries": 20,
210
+ "optional": true
211
+ },
212
+ {
213
+ "do": "verify",
214
+ "url": "{chapter_manage_url}",
215
+ "any": [
216
+ "{chapter_title}"
217
+ ],
218
+ "settle": 6
219
+ }
220
+ ],
221
+ "check_login": [
222
+ {
223
+ "do": "navigate",
224
+ "url": "{home}"
225
+ },
226
+ {
227
+ "do": "url_any",
228
+ "any": [
229
+ "qimao.com"
230
+ ]
231
+ }
232
+ ]
233
+ }
@@ -8,9 +8,10 @@
8
8
  busy 发布动作进行中(结束回 connected / error)
9
9
  error 最后一次动作失败(error 字段带人话原因,可重试)
10
10
 
11
- 浏览器生命周期:每平台一个持久化 profile(data/publish/profiles/<id>),
12
- 登录态落在 profile 里。服务重启后按 state.json 记住的调试端口 attach
13
- 旧实例;实例已死才重新 launch——用户登录一次,之后无感。
11
+ 浏览器生命周期:每平台一个持久化 profile(~/.codebee/publish_profiles/<id>,
12
+ 仓库外——GB 级浏览器运行时数据不进仓库目录),登录态落在 profile 里。
13
+ 服务重启后按 state.json 记住的调试端口 attach 旧实例;实例已死才重新
14
+ launch——用户登录一次,之后无感。
14
15
 
15
16
  与 bookmeta.generate_async 同款线程纪律:动作起后台线程即返回,前端靠
16
17
  view()(SSE/轮询)看进度;线程内任何异常都落终态,绝不悬挂。
@@ -80,7 +81,7 @@ def view():
80
81
  out[pid] = {"label": mod.CONFIG["label"], "status": s.get("status") or "none",
81
82
  "at": s.get("at") or "", "error": s.get("error") or "",
82
83
  "last_action": s.get("last_action") or "",
83
- "profile": str(paths.PUBLISH_DIR / "profiles" / pid)}
84
+ "profile": str(_profile_dir(pid))}
84
85
  return {"platforms": out, "browser_found": bool(find_browser())}
85
86
 
86
87
 
@@ -96,8 +97,49 @@ def recover_orphans():
96
97
 
97
98
 
98
99
  # ---------------------------------------------------------------- 浏览器会话
100
+ def _profiles_base():
101
+ """profile 存放根:用户主目录 ~/.codebee/publish_profiles(仓库外)。
102
+
103
+ 浏览器 profile 是 GB 级运行时数据(缓存/扩展/字体),放仓库 data/ 里
104
+ 会拖垮静态扫描/备份(639M→986M 实测淹没整仓安全扫描),且登录态
105
+ cookie 没必要进任何仓库周边流程。"""
106
+ from pathlib import Path as _P
107
+ return _P.home() / ".codebee" / "publish_profiles"
108
+
109
+
110
+ def _migrate_legacy_profiles():
111
+ """一次性迁移:老位置 data/publish/profiles/<plat> 整体搬到新根(保登录态)。
112
+
113
+ 新位置已有同名平台目录时跳过(老数据视为已废弃);搬家失败静默——
114
+ 大不了用户重扫一次码。"""
115
+ from pathlib import Path as _P
116
+ legacy = _P(paths.PUBLISH_DIR) / "profiles"
117
+ if not legacy.is_dir():
118
+ return
119
+ base = _profiles_base()
120
+ try:
121
+ base.mkdir(parents=True, exist_ok=True)
122
+ except OSError:
123
+ return
124
+ for plat_dir in legacy.iterdir():
125
+ if not plat_dir.is_dir():
126
+ continue
127
+ dst = base / plat_dir.name
128
+ if dst.exists():
129
+ continue
130
+ try:
131
+ import shutil
132
+ shutil.move(str(plat_dir), str(dst)) # 跨盘(仓库盘→系统盘)也能搬
133
+ except OSError:
134
+ continue
135
+ try:
136
+ legacy.rmdir() # 空了才删得掉;还有残留就留给下次
137
+ except OSError:
138
+ pass
139
+
140
+
99
141
  def _profile_dir(plat):
100
- return paths.PUBLISH_DIR / "profiles" / plat
142
+ return _profiles_base() / plat
101
143
 
102
144
 
103
145
  def _ensure_browser(plat):
@@ -122,6 +164,18 @@ def _ensure_browser(plat):
122
164
 
123
165
  def _open_page(plat):
124
166
  b = _ensure_browser(plat)
167
+ # 单标签纪律:平台点击(如「上传章节」)会开新 tab,流程引擎的 page
168
+ # 对象可能留在旧 tab 上填错页面(0 字草稿案的乱源)。每次动作前收拢
169
+ # 到一个 tab——发布是串行作业,多 tab 只会串台。
170
+ try:
171
+ for t in b.pages()[1:]:
172
+ try:
173
+ from .browser import _http_json
174
+ _http_json("http://127.0.0.1:%d/json/close/%s" % (b.port, t["id"]))
175
+ except Exception:
176
+ pass
177
+ except Exception:
178
+ pass
125
179
  return b, b.first_page(create=True)
126
180
 
127
181
 
@@ -145,14 +199,18 @@ def _check_login(plat, page):
145
199
 
146
200
  # ---------------------------------------------------------------- 流程加载
147
201
  def load_flow(plat, action):
148
- """流程表:data/publish/flows-<plat>.json 覆盖内置默认(校准不改代码)。"""
149
- fp = paths.PUBLISH_DIR / ("flows-%s.json" % plat)
150
- try:
151
- data = json.loads(fp.read_text(encoding="utf-8"))
152
- if isinstance(data, dict) and isinstance(data.get(action), list):
153
- return data[action]
154
- except Exception:
155
- pass
202
+ """流程表三层:data/publish/flows-<plat>.json(用户校准)→ 仓库校准模板
203
+ flows-<plat>-calibrated.json(真机验证过的基线,随代码分发)→ 平台模块
204
+ 内置默认(待校准推测)。"""
205
+ from pathlib import Path
206
+ for fp in (paths.PUBLISH_DIR / ("flows-%s.json" % plat),
207
+ Path(__file__).with_name("flows-%s-calibrated.json" % plat)):
208
+ try:
209
+ data = json.loads(fp.read_text(encoding="utf-8"))
210
+ if isinstance(data, dict) and isinstance(data.get(action), list):
211
+ return data[action]
212
+ except Exception:
213
+ pass
156
214
  return PLATFORMS[plat].FLOWS[action]
157
215
 
158
216
 
@@ -403,6 +461,7 @@ def upload_chapter_async(task_id, plat, chapter_file, auto_submit=False):
403
461
  try: # verify/draft 页 URL(流程占位)
404
462
  values["chapter_manage_url"] = mod.chapter_manage_url(book)
405
463
  values["draft_url"] = mod.draft_url(book)
464
+ values["editor_url"] = mod.editor_url(book)
406
465
  except AttributeError:
407
466
  pass
408
467
  logs = []
@@ -435,3 +494,4 @@ def history(task_id=None, plat=None, limit=50):
435
494
 
436
495
 
437
496
  _load()
497
+ _migrate_legacy_profiles()
@@ -111,3 +111,16 @@ def draft_url(book):
111
111
  if bid:
112
112
  return CONFIG["book_manage"] + "/draft?id=" + bid
113
113
  return CONFIG["book_manage"]
114
+
115
+
116
+ def editor_url(book):
117
+ """章节编辑器直达(绕开会开新 tab 的「上传章节」点击)。
118
+
119
+ 缺 title 参数会被平台重定向回首页(真机实测),必须带上。"""
120
+ bid = str((book or {}).get("book_id") or "")
121
+ title = str((book or {}).get("title") or "")
122
+ if bid:
123
+ from urllib.parse import quote
124
+ return (CONFIG["book_manage"].rsplit("/", 1)[0]
125
+ + "/book-upload?id=" + bid + "&title=" + quote(title))
126
+ return CONFIG["book_manage"]
@@ -18,7 +18,7 @@ _FILE = paths.DATA_DIR / "settings.json"
18
18
  # 成功发章上限、平台连续失败几次后暂停自动发布(publish/auto.py 读取)
19
19
  DEFAULTS = {"max_concurrent_jobs": 6, "default_workdir": "", "hooks_token": "",
20
20
  "telemetry_errors": True, "publish_daily_cap": 10,
21
- "publish_fail_streak": 3}
21
+ "publish_fail_streak": 3, "notify_webhook": ""}
22
22
  # 并发上限 12:worker 只是拉起 CLI 子进程的调度位,跨任务无共享资源;
23
23
  # 同任务单飞守卫在 jobs 层。默认 6 对齐「多任务并行不排队」的使用预期。
24
24
  MIN_WORKERS, MAX_WORKERS = 1, 12
@@ -99,6 +99,8 @@ def save(patch):
99
99
  cur["publish_fail_streak"] = max(1, min(10, int(patch.get("publish_fail_streak"))))
100
100
  except (TypeError, ValueError):
101
101
  return cur, "publish_fail_streak 必须是 1-10 的整数"
102
+ if "notify_webhook" in patch:
103
+ cur["notify_webhook"] = str(patch.get("notify_webhook") or "").strip()[:300]
102
104
  _FILE.parent.mkdir(parents=True, exist_ok=True)
103
105
  tmp = _FILE.with_suffix(".tmp")
104
106
  tmp.write_text(json.dumps(cur, ensure_ascii=False, indent=2), encoding="utf-8")
@@ -149,6 +149,12 @@ def revision(ns):
149
149
  return _REVISIONS.get(ns, 0)
150
150
 
151
151
 
152
+ def names():
153
+ """已注册 namespace 清单(GET /api/settings-v2 用)。"""
154
+ with _LOCK:
155
+ return sorted(_NAMESPACES)
156
+
157
+
152
158
  def mutate(ns, ops, expected_revision=None):
153
159
  """写一组操作。ops = [{"op":"set","path":..., "value":...}, ...]。
154
160
 
@@ -189,15 +195,22 @@ def mutate(ns, ops, expected_revision=None):
189
195
 
190
196
 
191
197
  def describe(ns, redact_secrets=True):
192
- """给 UI 的视图:默认把 redact 字段替换为 __REDACTED__(不泄露真值)。"""
198
+ """给 UI 的视图:默认把 redact 字段替换为 __REDACTED__(不泄露真值)。
199
+
200
+ fields 带回字段元数据(类型/说明/choices/clamp),前端调参卡按 schema
201
+ 渲染控件,schema 变更零前端改动。"""
193
202
  with _LOCK:
194
203
  vals = json.loads(json.dumps(_VALUES.get(ns, {}))) # deep copy
195
204
  ndef = _NAMESPACES.get(ns) or {"fields": {}}
205
+ fields = [{"path": f.path, "type": f.ftype, "description": f.description,
206
+ "choices": f.choices, "clamp": f.clamp}
207
+ for f in ndef["fields"].values()]
196
208
  for f in ndef["fields"].values():
197
209
  if f.redact and redact_secrets:
198
210
  if _get_path(vals, f.path) not in (None, ""):
199
211
  _set_path(vals, f.path, "__REDACTED__")
200
- return {"ns": ns, "revision": _REVISIONS.get(ns, 0), "values": vals}
212
+ return {"ns": ns, "revision": _REVISIONS.get(ns, 0), "values": vals,
213
+ "fields": fields}
201
214
 
202
215
 
203
216
  # ---- 默认 namespace(编排阈值的 schema 化存放处;pipeline 可渐进接入) ----
@@ -14,7 +14,7 @@ from __future__ import annotations
14
14
 
15
15
  import logging
16
16
 
17
- from .compaction import maybe_compact
17
+ from .compaction import maybe_compact, DEFAULT_PRESSURE_THRESHOLD
18
18
  from .error_codes import ErrorCode
19
19
 
20
20
  log = logging.getLogger(__name__)
@@ -28,6 +28,23 @@ _OVERFLOW_CODES = {ErrorCode.CONTEXT_OVERFLOW, ErrorCode.MAX_TOKENS}
28
28
  _PRECHECK_RATIO = 0.9
29
29
 
30
30
 
31
+ def _v2_compaction_tuning():
32
+ """settings_v2 orchestrator.compaction 微调 → (threshold|None, retain|None)。
33
+
34
+ None = 未配置(走 maybe_compact 的模块默认);读取/注册失败静默回落。
35
+ retain 允许 0(尾部不保留),与 None(未配置)语义不同,勿合并。"""
36
+ try:
37
+ from .settings_schema import get as ss_get, register_default_namespaces
38
+ register_default_namespaces()
39
+ c = (ss_get("orchestrator") or {}).get("compaction") or {}
40
+ th = c.get("pressure_threshold")
41
+ rt = c.get("retain_tail_tokens")
42
+ return (float(th) if th else None,
43
+ int(rt) if rt is not None else None)
44
+ except Exception:
45
+ return None, None
46
+
47
+
31
48
  def execute_step(session, run_agent_fn, prompt, *, model: str = "",
32
49
  llm_caller=None, retain_tail_tokens=None, **kwargs):
33
50
  """执行一次 step;撑爆时压缩并守门重试一次。
@@ -47,9 +64,14 @@ def execute_step(session, run_agent_fn, prompt, *, model: str = "",
47
64
  Returns:
48
65
  (result, retried: bool)
49
66
  """
67
+ v2_th, v2_rt = _v2_compaction_tuning()
50
68
  compact_kwargs = {}
51
69
  if retain_tail_tokens is not None:
52
70
  compact_kwargs["retain_tail_tokens"] = retain_tail_tokens
71
+ elif v2_rt is not None:
72
+ compact_kwargs["retain_tail_tokens"] = v2_rt
73
+ if v2_th is not None:
74
+ compact_kwargs["threshold"] = v2_th
53
75
  if llm_caller is not None and model:
54
76
  try:
55
77
  from .token_meter import token_meter
@@ -0,0 +1,48 @@
1
+ # -*- coding: utf-8 -*-
2
+ """HTTPS 证书校验上下文:补齐 macOS 上 Python 缺失的 CA 源(不关校验)。
3
+
4
+ 现象:用官方 pkg 装 Node 的 Mac 上往往再装 python.org 的 Python,它不读
5
+ 系统钥匙串,默认验证路径下没有根证书,所有 HTTPS 请求报
6
+ CERTIFICATE_VERIFY_FAILED(unable to get local issuer certificate)。
7
+
8
+ 修法是给默认上下文**追加**可用 CA 源,校验语义只增不减:
9
+ 1. certifi(装了就用,跨平台最全);
10
+ 2. /etc/ssl/cert.pem(macOS 系统自带 CA 束,Catalina 起就有)。
11
+ 两个都拿不到时返回默认上下文——报错与旧行为一致,绝不静默关校验。
12
+ """
13
+ import ssl
14
+ import sys
15
+
16
+
17
+ def cafiles():
18
+ """候选 CA 束路径;坏路径/不存在由 load_verify_locations 抛错后被吞。"""
19
+ out = []
20
+ try:
21
+ import certifi
22
+ out.append(certifi.where())
23
+ except Exception:
24
+ pass
25
+ if sys.platform == "darwin":
26
+ out.append("/etc/ssl/cert.pem")
27
+ return out
28
+
29
+
30
+ def _build():
31
+ ctx = ssl.create_default_context()
32
+ for f in cafiles():
33
+ try:
34
+ ctx.load_verify_locations(cafile=f)
35
+ except Exception:
36
+ pass
37
+ return ctx
38
+
39
+
40
+ _CTX = None
41
+
42
+
43
+ def context():
44
+ """带兜底 CA 源的 ssl.SSLContext(进程内缓存;校验语义与默认一致)。"""
45
+ global _CTX
46
+ if _CTX is None:
47
+ _CTX = _build()
48
+ return _CTX
package/app/main.py CHANGED
@@ -235,6 +235,12 @@ class Handler(BaseHTTPRequestHandler):
235
235
  return self._json(200, skills.view())
236
236
  if path == "/api/settings":
237
237
  return self._json(200, dict(settings.load(), **jobs.workers_info()))
238
+ if path == "/api/settings-v2":
239
+ # schema 化设置全貌(secret 已脱敏;前端调参卡直读)
240
+ from core import settings_schema as ss2
241
+ ss2.register_default_namespaces()
242
+ return self._json(200, {"namespaces": {
243
+ ns: ss2.describe(ns) for ns in ss2.names()}})
238
244
  if path == "/api/selfupdate":
239
245
  from core import selfupdate
240
246
  return self._json(200, selfupdate.check(
@@ -814,6 +820,22 @@ class Handler(BaseHTTPRequestHandler):
814
820
  return self._json(400, {"error": err, "settings": view})
815
821
  n = jobs.configure(view["max_concurrent_jobs"])
816
822
  return self._json(200, {"ok": True, "settings": view, "workers": n})
823
+ m = re.match(r"^/api/settings-v2/([a-z_-]+)$", path)
824
+ if m:
825
+ # schema 化设置写入口:{ops:[{op:"set",path,value}], expected_revision?}
826
+ # 带 expected_revision 做 CAS,冲突 409(前端据此重拉重试)
827
+ from core import settings_schema as ss2
828
+ ns = m.group(1)
829
+ body = self._body() or {}
830
+ try:
831
+ rev = ss2.mutate(ns, body.get("ops") or [],
832
+ expected_revision=body.get("expected_revision"))
833
+ except ss2.SettingsConflictError as e:
834
+ return self._json(409, {"error": str(e), "revision": ss2.revision(ns)})
835
+ except ValueError as e:
836
+ return self._json(400, {"error": str(e)})
837
+ return self._json(200, {"ok": True, "revision": rev,
838
+ "values": ss2.describe(ns)["values"]})
817
839
  if path == "/api/settings/default-workdir":
818
840
  body = self._body()
819
841
  old = settings.default_workdir()
package/app/ui/app.js CHANGED
@@ -26,6 +26,7 @@ function flowIconHtml(f) {
26
26
  }
27
27
 
28
28
  function flowDesc(f) {
29
+ if (f.id === "rank_scan") return t("抓七猫榜 → AI 选题洞察");
29
30
  if (f.engine === "direct") return t("单智能体直达(快)");
30
31
  if (f.engine === "code") return t("实现 → 验证 → 评审");
31
32
  // 连载与单稿件同引擎,描述必须区分:连载强调逐章与断点续跑
@@ -8007,8 +8008,77 @@ async function loadSettings() {
8007
8008
  queueGitProbe(); // 目录在场即探测代码版本,点亮分支胶囊
8008
8009
  }
8009
8010
  } catch (e) { /* 忽略 */ }
8011
+ loadSettingsV2(); // 引擎调参卡独立拉取(挂了不影响基础设置)
8010
8012
  }
8011
8013
 
8014
+ /* ---------------- settings_v2 引擎调参卡(schema 驱动) ----------------
8015
+ * /api/settings-v2 拉 describe(含字段元数据),按 namespace 渲染控件;
8016
+ * 保存走 /api/settings-v2/<ns> mutate(expected_revision CAS,409=别处已改,
8017
+ * 自动重拉最新值)。schema 变更零前端改动。 */
8018
+ async function loadSettingsV2() {
8019
+ const box = $("set-v2-card");
8020
+ if (!box) return;
8021
+ let v2 = null;
8022
+ try { v2 = await api("/api/settings-v2"); } catch (e) { box.innerHTML = ""; return; }
8023
+ S.settingsV2 = v2;
8024
+ const NS_LABEL = { orchestrator: t("编排引擎"), budget: t("预算"), cascade: t("级联路由") };
8025
+ let html = "<label>" + esc(t("引擎调参(schema 化设置,立即生效)")) + "</label>";
8026
+ for (const ns of Object.keys(v2.namespaces || {})) {
8027
+ const d = v2.namespaces[ns] || {};
8028
+ html += '<div class="set-v2-ns" data-ns="' + esc(ns) + '" data-rev="' + (d.revision || 0) + '">' +
8029
+ '<div class="set-v2-ns-h">' + esc(NS_LABEL[ns] || ns) +
8030
+ '<span class="flex1"></span><span class="set-v2-rev">rev ' + (d.revision || 0) + "</span></div>";
8031
+ for (const f of (d.fields || [])) {
8032
+ const iid = "setv2-" + esc(ns) + "-" + f.path.replace(/\./g, "-");
8033
+ const val = f.path.split(".").reduce((o, k) => (o && o[k] !== undefined) ? o[k] : undefined, d.values || {});
8034
+ const cur = val === undefined ? "" : val;
8035
+ let inp;
8036
+ if (f.type === "bool") {
8037
+ inp = '<input id="' + iid + '" type="checkbox"' + (cur ? " checked" : "") + ">";
8038
+ } else if (f.type === "int" || f.type === "float") {
8039
+ const step = f.type === "float" ? "0.01" : "1";
8040
+ inp = '<input id="' + iid + '" type="number" step="' + step + '" value="' + esc(String(cur)) + '"' +
8041
+ (f.clamp ? ' min="' + f.clamp[0] + '" max="' + f.clamp[1] + '"' : "") + ' style="max-width:140px">';
8042
+ } else {
8043
+ inp = '<input id="' + iid + '" type="text" value="' + esc(String(cur)) + '">';
8044
+ }
8045
+ html += '<div class="set-v2-row">' + inp +
8046
+ '<span class="set-v2-lb" title="' + esc(f.description || f.path) + '">' + esc(f.description || f.path) + "</span></div>";
8047
+ }
8048
+ html += '<div class="input-row" style="margin-top:6px"><button class="ghost small" onclick="saveSettingsV2(\'' + esc(ns) + '\')">' + t("保存") + "</button>" +
8049
+ '<span class="set-v2-msg msg"></span></div></div>';
8050
+ }
8051
+ box.innerHTML = html;
8052
+ }
8053
+
8054
+ window.saveSettingsV2 = async function (ns) {
8055
+ const v2 = S.settingsV2 || {};
8056
+ const d = (v2.namespaces || {})[ns];
8057
+ const box = document.querySelector('.set-v2-ns[data-ns="' + ns + '"]');
8058
+ if (!d || !box) return;
8059
+ const ops = [];
8060
+ for (const f of (d.fields || [])) {
8061
+ const iid = "setv2-" + ns + "-" + f.path.replace(/\./g, "-");
8062
+ const el = document.getElementById(iid);
8063
+ if (!el) continue;
8064
+ if (f.type === "bool") ops.push({ op: "set", path: f.path, value: el.checked });
8065
+ else if (f.type === "int" || f.type === "float") ops.push({ op: "set", path: f.path, value: Number(el.value) });
8066
+ else ops.push({ op: "set", path: f.path, value: el.value });
8067
+ }
8068
+ const msg = box.querySelector(".set-v2-msg");
8069
+ try {
8070
+ const r = await api("/api/settings-v2/" + ns, {
8071
+ method: "POST", body: JSON.stringify({ ops, expected_revision: d.revision || 0 }) });
8072
+ if (msg) { msg.className = "set-v2-msg msg ok"; msg.textContent = t("已保存 rev ") + r.revision; }
8073
+ loadSettingsV2();
8074
+ } catch (e) {
8075
+ if (/期望 rev/.test(String(e.message))) {
8076
+ if (msg) { msg.className = "set-v2-msg msg err"; msg.textContent = t("配置已被别处修改,已刷新,请重试"); }
8077
+ loadSettingsV2();
8078
+ } else if (msg) { msg.className = "set-v2-msg msg err"; msg.textContent = e.message; }
8079
+ }
8080
+ };
8081
+
8012
8082
  /* 保存默认保存路径;可选把旧默认路径下的现有任务目录迁移到新路径 */
8013
8083
  async function saveDefaultWorkdir() {
8014
8084
  const msg = $("settings-msg");
package/app/ui/i18n.js CHANGED
@@ -900,6 +900,7 @@
900
900
  "启用中": "Enabled",
901
901
  "设为主模型": "Set as primary model",
902
902
  "单智能体直达(快)": "Single-agent direct (fast)",
903
+ "抓七猫榜 → AI 选题洞察": "Scan Qimao rankings → AI topic insights",
903
904
  "新增函数补用例": "Add tests for new functions",
904
905
  "同类加函数任务,只要verify_pass=false即不得因review_pass=true或高分判定通过;必须将verify失败原因作为修复输入。": "For similar add-a-function tasks, verify_pass=false blocks acceptance regardless of review_pass or high scores; the verify failure details are the input to the next fix.",
905
906
  "凡主角获取越权信息或关键证据(系统记录、录音、账目),必须当章或前文落实来源链(人脉、留底、委托调查),并让角色当场追问一句'东西哪来的';无来源的特权查询与来历不明的证据一律禁止上稿。": "Whenever the protagonist obtains privileged information or key evidence (system records, recordings, ledgers), establish the source chain in the same or an earlier chapter and have a character ask \"where did this come from\" on the spot; unsourced privileged queries and evidence of unknown origin are banned from the manuscript.",
package/app/ui/index.html CHANGED
@@ -691,6 +691,7 @@
691
691
  <label class="toggle" style="margin-top:6px"><input id="set-workdir-migrate" type="checkbox"> <span data-i18n="保存时把「旧默认路径下」的现有任务目录迁移到新路径(运行中的跳过;手动指定的目录不受影响)">保存时把「旧默认路径下」的现有任务目录迁移到新路径(运行中的跳过;手动指定的目录不受影响)</span></label>
692
692
  <p class="hint" id="set-workdir-hint"></p>
693
693
  </div>
694
+ <div id="set-v2-card" class="field"></div>
694
695
  <div id="settings-msg" class="msg"></div>
695
696
  </div>
696
697
  </div>
package/app/ui/style.css CHANGED
@@ -5113,3 +5113,13 @@ body.welcome-open { overflow: hidden; }
5113
5113
  }
5114
5114
  .cl-opt:hover { border-color: var(--accent); color: var(--accent); opacity: 1; }
5115
5115
  .cl-opt.picked { border-color: var(--accent); color: var(--accent); opacity: .75; cursor: default; }
5116
+
5117
+ /* ---------------- settings_v2 引擎调参卡(schema 驱动,追加于文件尾防并行重排) ---------------- */
5118
+ #set-v2-card { margin-top: 14px; }
5119
+ .set-v2-ns { border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px; margin: 8px 0; }
5120
+ .set-v2-ns-h { display: flex; align-items: center; gap: 8px; font-weight: 600; margin-bottom: 6px; }
5121
+ .set-v2-rev { font-size: 11px; color: var(--muted); font-weight: 400; }
5122
+ .set-v2-row { display: flex; align-items: center; gap: 10px; margin: 5px 0; }
5123
+ .set-v2-row input[type="number"], .set-v2-row input[type="text"] { width: 140px; }
5124
+ .set-v2-lb { font-size: 12px; color: var(--muted); }
5125
+ .set-v2-msg { font-size: 12px; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebee",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "CodeBee · 多智能体编排台 — 统一编排本机 AI 编码 CLI(Codex / Claude Code / QwenCode / OpenCode / Aider…):目标→拆解→路由→执行→验证→跨厂商评审→修复→报告。纯 Python 标准库实现,本地 Web UI。",
5
5
  "license": "MIT",
6
6
  "bin": {