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 +5 -0
- package/README.md +21 -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 +19 -2
- package/app/core/publish/flow.py +2 -0
- package/app/core/publish/flows-qimao-calibrated.json +233 -0
- package/app/core/publish/manager.py +73 -13
- package/app/core/publish/qimao.py +13 -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/tlsctx.py +48 -0
- package/app/main.py +22 -0
- package/app/ui/app.js +70 -0
- package/app/ui/i18n.js +1 -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,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": ["全面性", "深度", "论据可靠", "可读性", "结论质量"],
|
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 "(用户未指定方向,按大盘热门分析)"))
|
package/app/core/pipeline.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
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
|
-
|
|
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 会短暂失败
|
package/app/core/publish/flow.py
CHANGED
|
@@ -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
|
|
12
|
-
|
|
13
|
-
旧实例;实例已死才重新
|
|
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(
|
|
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
|
|
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
|
-
"""
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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"]
|
package/app/core/settings.py
CHANGED
|
@@ -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 可渐进接入) ----
|
package/app/core/step_runner.py
CHANGED
|
@@ -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