codebee 0.1.69 → 0.1.71
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 +589 -543
- package/LICENSE +21 -21
- package/README.md +509 -508
- package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +88 -0
- package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +126 -0
- package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +210 -0
- package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
- package/app/.playwright-cli/run-detail-after.png +0 -0
- package/app/browser-final-1440.png +0 -0
- package/app/browser-final-390.png +0 -0
- package/app/browser-google-fallback.png +0 -0
- package/app/browser-google-test.png +0 -0
- package/app/browser-workbench-1440.png +0 -0
- package/app/browser-workbench-390.png +0 -0
- package/app/browser-workspace-google.png +0 -0
- package/app/browser-workspace-smoke.png +0 -0
- package/app/browser-workspace.png +0 -0
- package/app/core/acceptance.py +100 -100
- package/app/core/aiflavor.py +197 -197
- package/app/core/artifacts.py +148 -148
- package/app/core/assets.py +110 -110
- package/app/core/attachments.py +623 -623
- package/app/core/audit.py +30 -0
- package/app/core/automation.py +107 -12
- package/app/core/backup.py +470 -470
- package/app/core/beekeeper.py +183 -183
- package/app/core/benchstore.py +95 -0
- package/app/core/board.py +209 -209
- package/app/core/bookmeta.py +935 -930
- package/app/core/bookmeta_catalog.py +180 -180
- package/app/core/branching.py +137 -137
- package/app/core/builtin_agent.py +1673 -1234
- package/app/core/capability.py +144 -144
- package/app/core/catalog.py +7 -1
- package/app/core/chaptersafe.py +32 -32
- package/app/core/cleanup.py +303 -303
- package/app/core/compaction.py +201 -201
- package/app/core/covergen.py +356 -356
- package/app/core/defectretro.py +43 -43
- package/app/core/diagnostics.py +114 -114
- package/app/core/dispatch.py +190 -173
- package/app/core/dispatch_log.py +128 -128
- package/app/core/env_scrub.py +85 -85
- package/app/core/error_codes.py +11 -2
- package/app/core/errorlog.py +137 -137
- package/app/core/evalbench.py +720 -0
- package/app/core/evaluation.py +174 -174
- package/app/core/failure_notify.py +290 -290
- package/app/core/flows.py +234 -9
- package/app/core/goal_service.py +167 -167
- package/app/core/health.py +374 -374
- package/app/core/history.py +38 -38
- package/app/core/hooks.py +162 -162
- package/app/core/jobs.py +22 -1
- package/app/core/manager.py +2143 -2122
- package/app/core/market_remote.py +10 -0
- package/app/core/mcp_client.py +287 -0
- package/app/core/mocks.py +64 -64
- package/app/core/modelhub.py +185 -2
- package/app/core/notify.py +201 -106
- package/app/core/novel_quality.py +189 -0
- package/app/core/operations.py +228 -228
- package/app/core/paihang.py +138 -138
- package/app/core/paths.py +4 -0
- package/app/core/pipeline.py +426 -28
- package/app/core/planner.py +243 -22
- package/app/core/plugins.py +525 -0
- package/app/core/portguard.py +111 -111
- package/app/core/preview.py +293 -293
- package/app/core/publish/__init__.py +7 -7
- package/app/core/publish/auto.py +367 -367
- package/app/core/publish/browser.py +655 -539
- package/app/core/publish/fanqie.py +165 -159
- package/app/core/publish/flow.py +589 -440
- package/app/core/publish/flows-fanqie-calibrated.json +336 -332
- package/app/core/publish/ledger.py +229 -203
- package/app/core/publish/manager.py +824 -692
- package/app/core/publish/qimao.py +146 -141
- package/app/core/publish/ws.py +139 -139
- package/app/core/redact.py +38 -38
- package/app/core/remotesync.py +121 -0
- package/app/core/repeat_guard.py +129 -129
- package/app/core/router.py +398 -398
- package/app/core/runner.py +2142 -2067
- package/app/core/selfupdate.py +384 -384
- package/app/core/session_log.py +161 -161
- package/app/core/sessions.py +312 -312
- package/app/core/settings.py +42 -4
- package/app/core/settings_schema.py +270 -262
- package/app/core/share_page.py +114 -114
- package/app/core/skill_scan.py +122 -122
- package/app/core/skillpacks/fanqie-novel.md +80 -80
- package/app/core/skillpacks/market/character-bible.md +66 -66
- package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
- package/app/core/skillpacks/market/git-workflow.md +57 -57
- package/app/core/skillpacks/market/release-notes.md +72 -72
- package/app/core/skillpacks/market/weekly-report.md +71 -71
- package/app/core/skillpacks/market/worldview-consistency.md +70 -70
- package/app/core/step_runner.py +132 -132
- package/app/core/store.py +30 -8
- package/app/core/task_compile.py +199 -199
- package/app/core/telemetry.py +292 -292
- package/app/core/tlsctx.py +64 -64
- package/app/core/token_meter.py +147 -147
- package/app/core/tracing.py +144 -144
- package/app/core/upstream.py +41 -41
- package/app/core/usage.py +1035 -862
- package/app/core/volumes.py +389 -389
- package/app/core/wxdigest.py +858 -858
- package/app/core/zentao.py +2614 -2465
- package/app/current-browser.png +0 -0
- package/app/main.py +316 -17
- package/app/mcp_server.py +175 -0
- package/app/pet.py +83 -22
- package/app/pick_dialog.py +110 -110
- package/app/plugins/code-review-helper/.codex-plugin/plugin.json +18 -0
- package/app/plugins/code-review-helper/skills/review.md +16 -0
- package/app/ui/app.js +1391 -77
- package/app/ui/artdiff.js +74 -74
- package/app/ui/board.html +162 -162
- package/app/ui/board.js +305 -305
- package/app/ui/i18n.js +249 -7
- package/app/ui/icons/bee.svg +79 -79
- package/app/ui/index.html +277 -13
- package/app/ui/manifest.json +16 -16
- package/app/ui/qrcode.js +2297 -2297
- package/app/ui/style.css +845 -12
- package/app//344/275/234/345/223/201/344/277/241/346/201/257-/344/270/203/347/214/253.md +18 -0
- package/bin/tutti.js +147 -147
- package/package.json +47 -46
- package/tools/wx_reader.py +261 -261
|
@@ -1039,6 +1039,15 @@ def preview_remote(entry_id):
|
|
|
1039
1039
|
_PREVIEW_CACHE[token] = {"entry_id": entry_id, "files": files,
|
|
1040
1040
|
"stripped": stripped, "ts": time.time()}
|
|
1041
1041
|
file_list = sorted(files.items(), key=lambda kv: kv[0])
|
|
1042
|
+
# 内容安检(ClawHavoc 投毒案借鉴):对全部技能文本跑危险模式扫描,
|
|
1043
|
+
# 风险随预览返回——UI 高风险红字确认;发现列表给行号可定位。
|
|
1044
|
+
try:
|
|
1045
|
+
from . import skill_scan
|
|
1046
|
+
risk_findings = skill_scan.scan_text(
|
|
1047
|
+
"\n".join(str(v) for v in files.values()))
|
|
1048
|
+
risk_label = skill_scan.risk_label(risk_findings)
|
|
1049
|
+
except Exception:
|
|
1050
|
+
risk_findings, risk_label = [], ""
|
|
1042
1051
|
return {
|
|
1043
1052
|
"token": token, "id": entry_id, "title": entry["title"],
|
|
1044
1053
|
"source": entry["source_name"], "version": entry["version"],
|
|
@@ -1048,6 +1057,7 @@ def preview_remote(entry_id):
|
|
|
1048
1057
|
"total_chars": sum(len(v) for v in files.values()),
|
|
1049
1058
|
"file_list": [{"path": k, "chars": len(v)} for k, v in file_list[:60]],
|
|
1050
1059
|
"stripped": sorted(stripped)[:40], "stripped_total": len(stripped),
|
|
1060
|
+
"risk_label": risk_label, "risk_findings": risk_findings[:8],
|
|
1051
1061
|
"expires_in": _PREVIEW_TTL,
|
|
1052
1062
|
}, None
|
|
1053
1063
|
except ValueError as e:
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""内置智能体的 MCP 客户端(Model Context Protocol,stdio 形态)。
|
|
3
|
+
|
|
4
|
+
零依赖:JSON-RPC 2.0 走子进程 stdin/stdout(按行分帧——MCP stdio 传输约定)。
|
|
5
|
+
每次工具调用重新拉起服务器进程:没有长驻会话要管,冷启动成本对内置智能体
|
|
6
|
+
的低频工具循环可接受;进程用完即关(finally 杀树),绝不留孤儿。
|
|
7
|
+
|
|
8
|
+
配置(settings.mcp_servers,JSON 数组字符串,≤4 个服务器):
|
|
9
|
+
[{"name": "fs", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-fs", "C:/x"],
|
|
10
|
+
"env": {"KEY": "v"}}]
|
|
11
|
+
name 用于工具名前缀(mcp__<name>__<tool>),必须 [a-z0-9_-]。
|
|
12
|
+
|
|
13
|
+
安全口径:服务器命令是用户在本机自己配置的(与内置智能体 run_command 同一
|
|
14
|
+
自主权级别);本模块只做传输与解析,不校验工具语义。工具清单按服务器缓存
|
|
15
|
+
(TTL 300s)——每次对话请求重建 tools 列表时不能每次都冷启动全量服务器。
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import subprocess
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
|
|
26
|
+
_LOCK = threading.RLock()
|
|
27
|
+
_TOOLS_CACHE = {} # server_key → (ts, tools)
|
|
28
|
+
_TOOLS_TTL = 300.0
|
|
29
|
+
|
|
30
|
+
MAX_SERVERS = 4
|
|
31
|
+
MAX_MCP_TOOLS = 24 # 全部服务器合计注入内置智能体的工具数上限
|
|
32
|
+
_NAME_RE = re.compile(r"^[a-z0-9_-]{1,24}$")
|
|
33
|
+
_INIT_TIMEOUT = 15.0
|
|
34
|
+
_CALL_TIMEOUT = 60.0
|
|
35
|
+
_CALL_TIMEOUT_MAX = 300.0
|
|
36
|
+
|
|
37
|
+
_CLIENT_INFO = {"name": "CodeBee", "version": "1.0"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def invalidate_tools_cache():
|
|
41
|
+
"""服务器配置变化后丢弃已发现工具(插件启停等场景调用)。"""
|
|
42
|
+
with _LOCK:
|
|
43
|
+
_TOOLS_CACHE.clear()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_servers(text):
|
|
47
|
+
"""settings.mcp_servers 文本 → 校验后的服务器配置列表。返回 (servers, 错误)。"""
|
|
48
|
+
raw = str(text or "").strip()
|
|
49
|
+
if not raw:
|
|
50
|
+
return [], None
|
|
51
|
+
try:
|
|
52
|
+
arr = json.loads(raw)
|
|
53
|
+
except Exception:
|
|
54
|
+
return None, "mcp_servers 不是合法 JSON"
|
|
55
|
+
if not isinstance(arr, list):
|
|
56
|
+
return None, "mcp_servers 必须是数组"
|
|
57
|
+
if len(arr) > MAX_SERVERS:
|
|
58
|
+
return None, "最多 %d 个 MCP 服务器" % MAX_SERVERS
|
|
59
|
+
out = []
|
|
60
|
+
for item in arr:
|
|
61
|
+
if not isinstance(item, dict):
|
|
62
|
+
return None, "MCP 服务器配置必须是对象"
|
|
63
|
+
name = str(item.get("name") or "").strip()
|
|
64
|
+
command = str(item.get("command") or "").strip()
|
|
65
|
+
if not _NAME_RE.match(name):
|
|
66
|
+
return None, "服务器名 %r 只能是小写字母/数字/-/_(≤24 位)" % name[:24]
|
|
67
|
+
if not command:
|
|
68
|
+
return None, "服务器 %s 缺 command" % name
|
|
69
|
+
args = item.get("args") if isinstance(item.get("args"), list) else []
|
|
70
|
+
args = [str(a) for a in args][:16]
|
|
71
|
+
env = item.get("env") if isinstance(item.get("env"), dict) else {}
|
|
72
|
+
env = {str(k): str(v) for k, v in list(env.items())[:16]}
|
|
73
|
+
out.append({"name": name, "command": command, "args": args, "env": env})
|
|
74
|
+
return out, None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class _Session:
|
|
78
|
+
"""一次性的 MCP 服务器会话:spawn → initialize → (list/call)→ 杀树。"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, server):
|
|
81
|
+
self.server = server
|
|
82
|
+
self.proc = None
|
|
83
|
+
self.lines = None # reader 线程产出的行队列
|
|
84
|
+
self.err = None
|
|
85
|
+
|
|
86
|
+
def _pump_err(self):
|
|
87
|
+
try:
|
|
88
|
+
for _ in self.proc.stderr:
|
|
89
|
+
pass
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
def _pump_out(self):
|
|
94
|
+
try:
|
|
95
|
+
for raw in self.proc.stdout:
|
|
96
|
+
self.lines.put(raw.decode("utf-8", errors="replace").rstrip("\r\n"))
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
finally:
|
|
100
|
+
self.lines.put(None) # EOF 哨兵
|
|
101
|
+
|
|
102
|
+
def __enter__(self):
|
|
103
|
+
try:
|
|
104
|
+
self.proc = subprocess.Popen(
|
|
105
|
+
[self.server["command"]] + list(self.server.get("args") or []),
|
|
106
|
+
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
107
|
+
stderr=subprocess.PIPE, text=False,
|
|
108
|
+
env={**os.environ, **(self.server.get("env") or {})})
|
|
109
|
+
except Exception as e:
|
|
110
|
+
raise RuntimeError("MCP 服务器启动失败: %s" % e)
|
|
111
|
+
import queue as _queue
|
|
112
|
+
self.lines = _queue.Queue()
|
|
113
|
+
threading.Thread(target=self._pump_out, daemon=True).start()
|
|
114
|
+
threading.Thread(target=self._pump_err, daemon=True).start()
|
|
115
|
+
self._request("initialize", {
|
|
116
|
+
"protocolVersion": "2024-11-05", "capabilities": {},
|
|
117
|
+
"clientInfo": _CLIENT_INFO}, timeout=_INIT_TIMEOUT)
|
|
118
|
+
self._notify("notifications/initialized")
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
def __exit__(self, *exc):
|
|
122
|
+
try:
|
|
123
|
+
if self.proc and self.proc.stdin:
|
|
124
|
+
try:
|
|
125
|
+
self.proc.stdin.close()
|
|
126
|
+
except Exception:
|
|
127
|
+
pass
|
|
128
|
+
finally:
|
|
129
|
+
try:
|
|
130
|
+
if self.proc:
|
|
131
|
+
self.proc.kill()
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
def _send(self, obj):
|
|
137
|
+
data = json.dumps(obj, ensure_ascii=False).encode("utf-8") + b"\n"
|
|
138
|
+
self.proc.stdin.write(data)
|
|
139
|
+
self.proc.stdin.flush()
|
|
140
|
+
|
|
141
|
+
def _notify(self, method, params=None):
|
|
142
|
+
self._send({"jsonrpc": "2.0", "method": method, "params": params or {}})
|
|
143
|
+
|
|
144
|
+
def _request(self, method, params, timeout):
|
|
145
|
+
"""单请求单响应;忽略通知与未知 id 的服务端请求。"""
|
|
146
|
+
rid = id({})
|
|
147
|
+
self._send({"jsonrpc": "2.0", "id": 1 if method == "initialize" else rid,
|
|
148
|
+
"method": method, "params": params or {}})
|
|
149
|
+
deadline = time.time() + max(1.0, timeout)
|
|
150
|
+
while True:
|
|
151
|
+
remain = deadline - time.time()
|
|
152
|
+
if remain <= 0:
|
|
153
|
+
raise RuntimeError("%s 超时(%.0fs)" % (method, timeout))
|
|
154
|
+
try:
|
|
155
|
+
line = self.lines.get(timeout=remain)
|
|
156
|
+
except Exception:
|
|
157
|
+
raise RuntimeError("%s 超时(%.0fs)" % (method, timeout))
|
|
158
|
+
if line is None:
|
|
159
|
+
raise RuntimeError("MCP 服务器提前退出")
|
|
160
|
+
line = line.strip()
|
|
161
|
+
if not line.startswith("{"):
|
|
162
|
+
continue
|
|
163
|
+
try:
|
|
164
|
+
msg = json.loads(line)
|
|
165
|
+
except Exception:
|
|
166
|
+
continue
|
|
167
|
+
if not isinstance(msg, dict) or msg.get("id") is None:
|
|
168
|
+
continue # 通知/服务端请求:不匹配本调用
|
|
169
|
+
if msg.get("id") != (1 if method == "initialize" else rid):
|
|
170
|
+
continue
|
|
171
|
+
if msg.get("error"):
|
|
172
|
+
raise RuntimeError("MCP %s 失败: %s" % (
|
|
173
|
+
method, json.dumps(msg["error"], ensure_ascii=False)[:200]))
|
|
174
|
+
return msg.get("result") or {}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def list_tools(server):
|
|
178
|
+
"""发现服务器工具。返回 {"ok", "tools":[{"name","description","input_schema"}], "error"}。"""
|
|
179
|
+
try:
|
|
180
|
+
with _Session(server) as sess:
|
|
181
|
+
res = sess._request("tools/list", {}, timeout=_INIT_TIMEOUT)
|
|
182
|
+
tools = []
|
|
183
|
+
for t in (res.get("tools") or [])[:16]:
|
|
184
|
+
if isinstance(t, dict) and isinstance(t.get("name"), str) and t["name"]:
|
|
185
|
+
tools.append({"name": t["name"],
|
|
186
|
+
"description": str(t.get("description") or "")[:400],
|
|
187
|
+
"input_schema": t.get("input_schema")
|
|
188
|
+
if isinstance(t.get("input_schema"), dict) else
|
|
189
|
+
{"type": "object", "properties": {}}})
|
|
190
|
+
return {"ok": True, "tools": tools, "error": ""}
|
|
191
|
+
except Exception as e:
|
|
192
|
+
return {"ok": False, "tools": [], "error": str(e)[:200]}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def call_tool(server, tool_name, arguments, timeout_s=_CALL_TIMEOUT):
|
|
196
|
+
"""调用一个工具。返回 {"ok", "text", "error", "is_error"}。"""
|
|
197
|
+
try:
|
|
198
|
+
timeout = min(_CALL_TIMEOUT_MAX, max(5.0, float(timeout_s or _CALL_TIMEOUT)))
|
|
199
|
+
with _Session(server) as sess:
|
|
200
|
+
res = sess._request("tools/call",
|
|
201
|
+
{"name": tool_name,
|
|
202
|
+
"arguments": arguments if isinstance(arguments, dict) else {}},
|
|
203
|
+
timeout=timeout)
|
|
204
|
+
parts = []
|
|
205
|
+
for c in res.get("content") or []:
|
|
206
|
+
if isinstance(c, dict) and c.get("type") == "text" and c.get("text"):
|
|
207
|
+
parts.append(str(c["text"]))
|
|
208
|
+
text = "\n".join(parts)[:20000]
|
|
209
|
+
is_err = bool(res.get("isError"))
|
|
210
|
+
return {"ok": bool(text) and not is_err, "text": text,
|
|
211
|
+
"error": "" if not is_err else (text or "工具返回 isError"),
|
|
212
|
+
"is_error": is_err}
|
|
213
|
+
except Exception as e:
|
|
214
|
+
return {"ok": False, "text": "", "error": str(e)[:200], "is_error": False}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ---- 面向 builtin_agent 的合并视图(带缓存) ----
|
|
218
|
+
|
|
219
|
+
def tool_specs_cached(force=False):
|
|
220
|
+
"""全部服务器的工具合并清单:[{server, name(原名), full_name, description,
|
|
221
|
+
input_schema}]。TTL 内走缓存;单个服务器失败跳过(error 挂在条目外不打断)。"""
|
|
222
|
+
with _LOCK:
|
|
223
|
+
if not force:
|
|
224
|
+
hit = True
|
|
225
|
+
for key, (ts, _t) in _TOOLS_CACHE.items():
|
|
226
|
+
if time.time() - ts > _TOOLS_TTL:
|
|
227
|
+
hit = False
|
|
228
|
+
break
|
|
229
|
+
if hit and _TOOLS_CACHE:
|
|
230
|
+
out = []
|
|
231
|
+
for _key, (_ts, tools) in _TOOLS_CACHE.items():
|
|
232
|
+
out.extend(tools)
|
|
233
|
+
return out[:MAX_MCP_TOOLS]
|
|
234
|
+
servers, err = parse_servers(_settings_text())
|
|
235
|
+
if err or not servers:
|
|
236
|
+
return []
|
|
237
|
+
out = []
|
|
238
|
+
with _LOCK:
|
|
239
|
+
_TOOLS_CACHE.clear()
|
|
240
|
+
for srv in servers:
|
|
241
|
+
res = list_tools(srv)
|
|
242
|
+
if not res.get("ok"):
|
|
243
|
+
continue
|
|
244
|
+
specs = []
|
|
245
|
+
for t in res["tools"]:
|
|
246
|
+
specs.append({"server": srv["name"], "name": t["name"],
|
|
247
|
+
"full_name": "mcp__%s__%s" % (srv["name"], t["name"]),
|
|
248
|
+
"description": t["description"],
|
|
249
|
+
"input_schema": t["input_schema"]})
|
|
250
|
+
with _LOCK:
|
|
251
|
+
_TOOLS_CACHE[srv["name"]] = (time.time(), specs)
|
|
252
|
+
out.extend(specs)
|
|
253
|
+
if len(out) >= MAX_MCP_TOOLS:
|
|
254
|
+
break
|
|
255
|
+
return out[:MAX_MCP_TOOLS]
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def dispatch_full_name(full_name, arguments, timeout_s=_CALL_TIMEOUT):
|
|
259
|
+
"""mcp__<server>__<tool> → 找服务器配置并调用。返回 {"ok","text","error"}。"""
|
|
260
|
+
m = re.match(r"^mcp__([a-z0-9_-]{1,24})__(.+)$", str(full_name or ""))
|
|
261
|
+
if not m:
|
|
262
|
+
return {"ok": False, "text": "", "error": "非法 MCP 工具名"}
|
|
263
|
+
sname, tool = m.group(1), m.group(2)
|
|
264
|
+
servers, err = parse_servers(_settings_text())
|
|
265
|
+
if err:
|
|
266
|
+
return {"ok": False, "text": "", "error": err}
|
|
267
|
+
srv = next((s for s in servers if s["name"] == sname), None)
|
|
268
|
+
if not srv:
|
|
269
|
+
return {"ok": False, "text": "", "error": "MCP 服务器 %s 未配置" % sname}
|
|
270
|
+
return call_tool(srv, tool, arguments, timeout_s=timeout_s)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
_SETTINGS_TEXT = None # main.py 启动注入(读 settings.mcp_servers)——本模块
|
|
274
|
+
# 不直接依赖 settings(L0),静态图保持干净(同 set_run_estimator 模式)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def set_settings_text(fn):
|
|
278
|
+
"""注入 MCP 服务器清单读取器;未注入时工具清单为空(MCP 功能关闭)。"""
|
|
279
|
+
global _SETTINGS_TEXT
|
|
280
|
+
_SETTINGS_TEXT = fn if callable(fn) else None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _settings_text():
|
|
284
|
+
try:
|
|
285
|
+
return str(_SETTINGS_TEXT() or "") if _SETTINGS_TEXT else ""
|
|
286
|
+
except Exception:
|
|
287
|
+
return ""
|
package/app/core/mocks.py
CHANGED
|
@@ -1,64 +1,64 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
"""内置演示/测试智能体(mock):不消耗任何配额即可跑通全流程。"""
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import hashlib
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def _seed(agent_id):
|
|
9
|
-
return int(hashlib.sha256(agent_id.encode("utf-8")).hexdigest()[:6], 16)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def draft_manuscript(task, round_no):
|
|
13
|
-
"""生成一份示例稿件(mock 起草/修订直接落盘的内容)。"""
|
|
14
|
-
title = task.get("title") or "未命名章节"
|
|
15
|
-
goal = task.get("goal") or ""
|
|
16
|
-
return (
|
|
17
|
-
"# {title}\n\n"
|
|
18
|
-
"这是第 {r} 轮 mock 草稿(演示用,不消耗模型配额)。\n\n"
|
|
19
|
-
"## 任务目标\n{goal}\n\n"
|
|
20
|
-
"## 正文\n"
|
|
21
|
-
"夜色沿着山脊线缓慢退去,主角终于看清了石门上的刻痕——那不是文字,"
|
|
22
|
-
"而是一段被反复涂抹又反复显影的名字。他伸手触碰的瞬间,耳边响起的"
|
|
23
|
-
"却是自己昨夜说过的话。{extra}\n\n"
|
|
24
|
-
"(本轮修订关注:{focus})\n"
|
|
25
|
-
).format(
|
|
26
|
-
title=title, r=round_no, goal=goal,
|
|
27
|
-
extra="这一段在第 2 轮修订中补充了人物动机的伏笔。" if round_no >= 2 else "",
|
|
28
|
-
focus="节奏与悬念铺排" if round_no == 1 else "人物弧光与首尾呼应",
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def implement_note(task):
|
|
33
|
-
return "(mock 实现)已在目标目录写入 mock-impl.txt 作为变更占位。"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def critique(agent_id, round_no, dims, threshold):
|
|
37
|
-
"""确定性评分:第 1 轮略低于阈值,第 2 轮达标,用于验证发布门禁逻辑。"""
|
|
38
|
-
s = _seed(agent_id)
|
|
39
|
-
scores = {}
|
|
40
|
-
for i, d in enumerate(dims):
|
|
41
|
-
jitter = ((s >> i) % 5) * 0.1
|
|
42
|
-
scores[d] = round(min(9.5, 4.8 + 1.2 * round_no + jitter), 1)
|
|
43
|
-
passed = all(v >= threshold for v in scores.values())
|
|
44
|
-
issues = []
|
|
45
|
-
if round_no == 1:
|
|
46
|
-
issues = [
|
|
47
|
-
{"dim": dims[0], "severity": "major", "note": "开篇信息密度偏低,钩子出现太晚(mock 意见)"},
|
|
48
|
-
{"dim": dims[-1], "severity": "minor", "note": "段落长短交替不够,读感偏平(mock 意见)"},
|
|
49
|
-
]
|
|
50
|
-
return {
|
|
51
|
-
"scores": scores,
|
|
52
|
-
"issues": issues,
|
|
53
|
-
"summary": "mock 总评:第 %d 轮稿件%s发布阈值" % (round_no, "已达到" if passed else "尚未达到"),
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def review(task, verify_passed):
|
|
58
|
-
return {
|
|
59
|
-
"pass": bool(verify_passed),
|
|
60
|
-
"scores": {"正确性": 8.0 if verify_passed else 5.0, "可维护性": 8.0, "安全": 8.5},
|
|
61
|
-
"issues": [] if verify_passed else [
|
|
62
|
-
{"severity": "major", "title": "验证命令未通过", "detail": "请检查测试输出(mock 意见)"}],
|
|
63
|
-
"summary": "mock 代码评审:%s" % ("通过" if verify_passed else "未通过"),
|
|
64
|
-
}
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""内置演示/测试智能体(mock):不消耗任何配额即可跑通全流程。"""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _seed(agent_id):
|
|
9
|
+
return int(hashlib.sha256(agent_id.encode("utf-8")).hexdigest()[:6], 16)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def draft_manuscript(task, round_no):
|
|
13
|
+
"""生成一份示例稿件(mock 起草/修订直接落盘的内容)。"""
|
|
14
|
+
title = task.get("title") or "未命名章节"
|
|
15
|
+
goal = task.get("goal") or ""
|
|
16
|
+
return (
|
|
17
|
+
"# {title}\n\n"
|
|
18
|
+
"这是第 {r} 轮 mock 草稿(演示用,不消耗模型配额)。\n\n"
|
|
19
|
+
"## 任务目标\n{goal}\n\n"
|
|
20
|
+
"## 正文\n"
|
|
21
|
+
"夜色沿着山脊线缓慢退去,主角终于看清了石门上的刻痕——那不是文字,"
|
|
22
|
+
"而是一段被反复涂抹又反复显影的名字。他伸手触碰的瞬间,耳边响起的"
|
|
23
|
+
"却是自己昨夜说过的话。{extra}\n\n"
|
|
24
|
+
"(本轮修订关注:{focus})\n"
|
|
25
|
+
).format(
|
|
26
|
+
title=title, r=round_no, goal=goal,
|
|
27
|
+
extra="这一段在第 2 轮修订中补充了人物动机的伏笔。" if round_no >= 2 else "",
|
|
28
|
+
focus="节奏与悬念铺排" if round_no == 1 else "人物弧光与首尾呼应",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def implement_note(task):
|
|
33
|
+
return "(mock 实现)已在目标目录写入 mock-impl.txt 作为变更占位。"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def critique(agent_id, round_no, dims, threshold):
|
|
37
|
+
"""确定性评分:第 1 轮略低于阈值,第 2 轮达标,用于验证发布门禁逻辑。"""
|
|
38
|
+
s = _seed(agent_id)
|
|
39
|
+
scores = {}
|
|
40
|
+
for i, d in enumerate(dims):
|
|
41
|
+
jitter = ((s >> i) % 5) * 0.1
|
|
42
|
+
scores[d] = round(min(9.5, 4.8 + 1.2 * round_no + jitter), 1)
|
|
43
|
+
passed = all(v >= threshold for v in scores.values())
|
|
44
|
+
issues = []
|
|
45
|
+
if round_no == 1:
|
|
46
|
+
issues = [
|
|
47
|
+
{"dim": dims[0], "severity": "major", "note": "开篇信息密度偏低,钩子出现太晚(mock 意见)"},
|
|
48
|
+
{"dim": dims[-1], "severity": "minor", "note": "段落长短交替不够,读感偏平(mock 意见)"},
|
|
49
|
+
]
|
|
50
|
+
return {
|
|
51
|
+
"scores": scores,
|
|
52
|
+
"issues": issues,
|
|
53
|
+
"summary": "mock 总评:第 %d 轮稿件%s发布阈值" % (round_no, "已达到" if passed else "尚未达到"),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def review(task, verify_passed):
|
|
58
|
+
return {
|
|
59
|
+
"pass": bool(verify_passed),
|
|
60
|
+
"scores": {"正确性": 8.0 if verify_passed else 5.0, "可维护性": 8.0, "安全": 8.5},
|
|
61
|
+
"issues": [] if verify_passed else [
|
|
62
|
+
{"severity": "major", "title": "验证命令未通过", "detail": "请检查测试输出(mock 意见)"}],
|
|
63
|
+
"summary": "mock 代码评审:%s" % ("通过" if verify_passed else "未通过"),
|
|
64
|
+
}
|
package/app/core/modelhub.py
CHANGED
|
@@ -2452,7 +2452,9 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
|
|
|
2452
2452
|
|
|
2453
2453
|
call_chain 是跨厂商降级链(唯一真源):每条 {model, env, provider, [codex_provider]},
|
|
2454
2454
|
按序尝试;供应商失效(停用/删除/无密钥/协议不可注入)的条目被跳过,全链失效
|
|
2455
|
-
→ 整体回落 CLI 默认(None)。纯链条目(无供应商)不注入 env,只传 -m
|
|
2455
|
+
→ 整体回落 CLI 默认(None)。纯链条目(无供应商)不注入 env,只传 -m,
|
|
2456
|
+
并带 no_cred=True 标记供 runner 审计层点名(零凭据条目走本机登录态,
|
|
2457
|
+
本机无登录态必死在凭据闸门——2026-09-25 Mac claude 实案)。
|
|
2456
2458
|
难度路由只在无显式链时生效。
|
|
2457
2459
|
"""
|
|
2458
2460
|
# Local-credential-only CLIs must never expose a binding, including legacy
|
|
@@ -2487,7 +2489,11 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
|
|
|
2487
2489
|
pid = (item.get("provider_id") or "").strip()
|
|
2488
2490
|
if not pid:
|
|
2489
2491
|
if model:
|
|
2490
|
-
|
|
2492
|
+
# 纯模型条目:零凭据注入,只传 -m 走 CLI 本机登录态。
|
|
2493
|
+
# no_cred 标记供 runner 审计行/登录指引点名(Mac claude 案:
|
|
2494
|
+
# 零注入死在登录闸门,起跑前无人知、死后只能靠 init JSON 反推)
|
|
2495
|
+
entries.append({"model": model, "env": {}, "provider": None,
|
|
2496
|
+
"no_cred": True})
|
|
2491
2497
|
continue
|
|
2492
2498
|
prov = provs.get(pid)
|
|
2493
2499
|
if not prov or not prov.get("enabled", True) or not prov.get("api_key"):
|
|
@@ -2803,12 +2809,84 @@ def models_view():
|
|
|
2803
2809
|
if m.get("hidden"):
|
|
2804
2810
|
continue
|
|
2805
2811
|
pr = pricing.get(m["name"]) or {}
|
|
2812
|
+
if m.get("price_in") is not None or m.get("price_out") is not None:
|
|
2813
|
+
pr = {"in": m.get("price_in"), "out": m.get("price_out")} # 手标优先
|
|
2806
2814
|
rows.append(dict(base, name=m["name"], enabled=bool(m.get("enabled", True)),
|
|
2807
2815
|
priority=m.get("priority", 0),
|
|
2808
2816
|
price_in=pr.get("in"), price_out=pr.get("out")))
|
|
2809
2817
|
return rows
|
|
2810
2818
|
|
|
2811
2819
|
|
|
2820
|
+
def model_price(model_name, provider=""):
|
|
2821
|
+
"""模型单价(¥ / 百万 tokens):指定供应商的手标价优先,回落 CCSwitch
|
|
2822
|
+
导入价表;都没有返回 None——不猜价,台账不记钱、预算不计入。
|
|
2823
|
+
|
|
2824
|
+
手标是「按供应商」标定的(同名模型在不同网关价格可不同),因此指定
|
|
2825
|
+
供应商未标价时不继承别家手标,直接看价表。
|
|
2826
|
+
列名沿用台账 cost_usd 字段,语义自本函数起 = 费用(¥)。
|
|
2827
|
+
"""
|
|
2828
|
+
def _manual(prov_id):
|
|
2829
|
+
with _LOCK:
|
|
2830
|
+
for p in _load().get("providers", []):
|
|
2831
|
+
if not prov_id or p.get("id") != prov_id:
|
|
2832
|
+
continue
|
|
2833
|
+
for m in p.get("models") or []:
|
|
2834
|
+
if m.get("name") != model_name:
|
|
2835
|
+
continue
|
|
2836
|
+
try:
|
|
2837
|
+
pin = float(m.get("price_in"))
|
|
2838
|
+
pout = float(m.get("price_out"))
|
|
2839
|
+
except (TypeError, ValueError):
|
|
2840
|
+
continue
|
|
2841
|
+
if pin >= 0 and pout >= 0 and (pin or pout):
|
|
2842
|
+
return {"in": pin, "out": pout}
|
|
2843
|
+
return None
|
|
2844
|
+
|
|
2845
|
+
try:
|
|
2846
|
+
pr = _manual(provider)
|
|
2847
|
+
if pr:
|
|
2848
|
+
return pr
|
|
2849
|
+
with _LOCK:
|
|
2850
|
+
raw = (_load().get("pricing") or {}).get(model_name) or {}
|
|
2851
|
+
pin, pout = float(raw.get("in") or 0), float(raw.get("out") or 0)
|
|
2852
|
+
if pin > 0 or pout > 0:
|
|
2853
|
+
return {"in": pin, "out": pout}
|
|
2854
|
+
except Exception:
|
|
2855
|
+
pass
|
|
2856
|
+
return None
|
|
2857
|
+
|
|
2858
|
+
|
|
2859
|
+
def set_model_price(provider_id, name, price_in, price_out):
|
|
2860
|
+
"""手标模型单价(¥/百万 tokens;空串=清除标定,回落价表/未标价)。"""
|
|
2861
|
+
def _num(v):
|
|
2862
|
+
v = str(v if v is not None else "").strip()
|
|
2863
|
+
return float(v) if v != "" else None
|
|
2864
|
+
try:
|
|
2865
|
+
pin, pout = _num(price_in), _num(price_out)
|
|
2866
|
+
except (TypeError, ValueError):
|
|
2867
|
+
return "单价必须是数字"
|
|
2868
|
+
if (pin is not None and pin < 0) or (pout is not None and pout < 0):
|
|
2869
|
+
return "单价不能为负"
|
|
2870
|
+
with _LOCK:
|
|
2871
|
+
data = _load()
|
|
2872
|
+
prov = next((p for p in data.get("providers", []) if p.get("id") == provider_id), None)
|
|
2873
|
+
if not prov:
|
|
2874
|
+
return "供应商不存在"
|
|
2875
|
+
m = next((x for x in prov.get("models") or [] if x.get("name") == name), None)
|
|
2876
|
+
if not m:
|
|
2877
|
+
return "模型不存在"
|
|
2878
|
+
if pin is None:
|
|
2879
|
+
m.pop("price_in", None)
|
|
2880
|
+
else:
|
|
2881
|
+
m["price_in"] = pin
|
|
2882
|
+
if pout is None:
|
|
2883
|
+
m.pop("price_out", None)
|
|
2884
|
+
else:
|
|
2885
|
+
m["price_out"] = pout
|
|
2886
|
+
_save(data)
|
|
2887
|
+
return None
|
|
2888
|
+
|
|
2889
|
+
|
|
2812
2890
|
def reorder_models(provider_id, ordered_names):
|
|
2813
2891
|
"""按给定名称顺序重设优先级(列表中未出现的模型排在最后,保持相对顺序)。"""
|
|
2814
2892
|
with _LOCK:
|
|
@@ -3103,6 +3181,111 @@ def test_model(provider_id, model_name, key_id=""):
|
|
|
3103
3181
|
return finish(last)
|
|
3104
3182
|
|
|
3105
3183
|
|
|
3184
|
+
def generate_once(provider_id, model_name, prompt, max_tokens=2048, timeout_s=120):
|
|
3185
|
+
"""单轮真实生成(评测基准台等需要「自定义 prompt 实答」的场景)。
|
|
3186
|
+
|
|
3187
|
+
与 test_model 同一套 wire 解析(auto 探测/多 KEY 回退/KEY 健康记账),
|
|
3188
|
+
但发的是真实生成请求并取回正文。返回
|
|
3189
|
+
{ok, text, usage{input,output}, protocol, key_id, latency_ms, error};
|
|
3190
|
+
任何失败折叠进 ok=False + error,绝不抛出。
|
|
3191
|
+
"""
|
|
3192
|
+
import time as _t
|
|
3193
|
+
with _LOCK:
|
|
3194
|
+
prov = next((p for p in providers() if p.get("id") == provider_id), None)
|
|
3195
|
+
if not prov:
|
|
3196
|
+
return {"ok": False, "text": "", "usage": None, "error": "供应商不存在"}
|
|
3197
|
+
keys = _chain_keys(prov)
|
|
3198
|
+
if not keys:
|
|
3199
|
+
return {"ok": False, "text": "", "usage": None, "error": "无可用 API KEY"}
|
|
3200
|
+
allow_private = bool(prov.get("allow_private"))
|
|
3201
|
+
t0 = _t.time()
|
|
3202
|
+
|
|
3203
|
+
def out(ok, text="", usage=None, protocol="", key_id="", error=""):
|
|
3204
|
+
return {"ok": ok, "text": text, "usage": usage, "protocol": protocol,
|
|
3205
|
+
"key_id": key_id, "error": error,
|
|
3206
|
+
"latency_ms": int((_t.time() - t0) * 1000)}
|
|
3207
|
+
|
|
3208
|
+
last_err = "无可用 wire"
|
|
3209
|
+
for key_index, kk in enumerate(keys):
|
|
3210
|
+
probe_key_id = (kk.get("id") or "") if key_index > 0 else ""
|
|
3211
|
+
protos, note = _ensure_wire_candidates(provider_id, prov, model_name,
|
|
3212
|
+
key_id=probe_key_id,
|
|
3213
|
+
force_probe=bool(probe_key_id))
|
|
3214
|
+
if not protos:
|
|
3215
|
+
last_err = "无已验证的兼容 wire" + ("(%s)" % note if note else "")
|
|
3216
|
+
note_key_error(provider_id, kk.get("id") or "", last_err)
|
|
3217
|
+
continue
|
|
3218
|
+
for proto, pbase in protos:
|
|
3219
|
+
base = (pbase or "").rstrip("/")
|
|
3220
|
+
if proto == "google":
|
|
3221
|
+
url = base + ("/models/%s:generateContent" % model_name
|
|
3222
|
+
if base.endswith("/v1beta")
|
|
3223
|
+
else "/v1beta/models/%s:generateContent" % model_name)
|
|
3224
|
+
headers = {"x-goog-api-key": kk["key"]}
|
|
3225
|
+
body = {"contents": [{"parts": [{"text": prompt}]}],
|
|
3226
|
+
"generationConfig": {"maxOutputTokens": max_tokens}}
|
|
3227
|
+
else:
|
|
3228
|
+
path = "/messages" if proto == "anthropic" else "/chat/completions"
|
|
3229
|
+
url = (base + path) if base.endswith("/v1") else (base + "/v1" + path)
|
|
3230
|
+
if proto == "anthropic":
|
|
3231
|
+
headers = {"x-api-key": kk["key"], "anthropic-version": "2023-06-01"}
|
|
3232
|
+
else:
|
|
3233
|
+
headers = {"Authorization": "Bearer " + kk["key"]}
|
|
3234
|
+
body = {"model": model_name, "max_tokens": max_tokens,
|
|
3235
|
+
"messages": [{"role": "user", "content": prompt}]}
|
|
3236
|
+
status, data, err = _post_json_http(url, headers, body, allow_private,
|
|
3237
|
+
timeout=timeout_s)
|
|
3238
|
+
if status == 0:
|
|
3239
|
+
last_err = err or "连接失败"
|
|
3240
|
+
note_key_error(provider_id, kk.get("id") or "", last_err)
|
|
3241
|
+
continue
|
|
3242
|
+
if not (200 <= status < 300):
|
|
3243
|
+
last_err = "HTTP %s %s" % (status, _response_error_text(data) or "")
|
|
3244
|
+
note_key_error(provider_id, kk.get("id") or "", last_err)
|
|
3245
|
+
continue
|
|
3246
|
+
text, usage_d = _extract_generation(proto, data)
|
|
3247
|
+
if not (text or "").strip():
|
|
3248
|
+
last_err = "HTTP 200 空响应"
|
|
3249
|
+
note_key_error(provider_id, kk.get("id") or "", last_err)
|
|
3250
|
+
continue
|
|
3251
|
+
note_key_ok(provider_id, kk.get("id") or "")
|
|
3252
|
+
return out(True, text, usage_d, proto, kk.get("id") or "")
|
|
3253
|
+
return out(False, error=last_err)
|
|
3254
|
+
|
|
3255
|
+
|
|
3256
|
+
def _extract_generation(proto, data):
|
|
3257
|
+
"""从三族 wire 的生成响应里取正文与 token 细分。取不到返回 ("", None)。"""
|
|
3258
|
+
if not isinstance(data, dict):
|
|
3259
|
+
return "", None
|
|
3260
|
+
text, usage_d = "", None
|
|
3261
|
+
try:
|
|
3262
|
+
if proto == "openai":
|
|
3263
|
+
msg = ((data.get("choices") or [{}])[0] or {}).get("message") or {}
|
|
3264
|
+
c = msg.get("content")
|
|
3265
|
+
text = c if isinstance(c, str) else "".join(
|
|
3266
|
+
p.get("text") or "" for p in c) if isinstance(c, list) else ""
|
|
3267
|
+
u = data.get("usage") or {}
|
|
3268
|
+
usage_d = {"input": u.get("prompt_tokens") or 0,
|
|
3269
|
+
"output": u.get("completion_tokens") or 0}
|
|
3270
|
+
elif proto == "anthropic":
|
|
3271
|
+
text = "".join(b.get("text") or "" for b in data.get("content") or []
|
|
3272
|
+
if isinstance(b, dict) and b.get("type") == "text")
|
|
3273
|
+
u = data.get("usage") or {}
|
|
3274
|
+
usage_d = {"input": u.get("input_tokens") or 0,
|
|
3275
|
+
"output": u.get("output_tokens") or 0}
|
|
3276
|
+
elif proto == "google":
|
|
3277
|
+
cand = ((data.get("candidates") or [{}])[0] or {})
|
|
3278
|
+
text = "".join(p.get("text") or "" for p in (cand.get("content") or {}).get("parts") or [])
|
|
3279
|
+
u = data.get("usageMetadata") or {}
|
|
3280
|
+
usage_d = {"input": u.get("promptTokenCount") or 0,
|
|
3281
|
+
"output": u.get("candidatesTokenCount") or 0}
|
|
3282
|
+
except Exception:
|
|
3283
|
+
pass
|
|
3284
|
+
if usage_d is not None and not any(isinstance(v, (int, float)) for v in usage_d.values()):
|
|
3285
|
+
usage_d = None
|
|
3286
|
+
return text, usage_d
|
|
3287
|
+
|
|
3288
|
+
|
|
3106
3289
|
# ---------------------------------------------------------------- wire 协议适配
|
|
3107
3290
|
# 聚合中转网关(new-api/one-api 系)通常同一密钥同时开 openai(/chat/completions)
|
|
3108
3291
|
# 与 anthropic(/v1/messages) 两面 wire。在「模型接入」页用 1 token 最小对话实测,
|