codebee 0.1.2 → 0.1.4
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/app/core/bookmeta.py +161 -27
- package/app/core/bookmeta_catalog.py +187 -0
- package/app/core/builtin_agent.py +382 -0
- package/app/core/flows.py +1 -1
- package/app/core/jobs.py +441 -424
- package/app/core/manager.py +1523 -1511
- package/app/core/market_remote.py +959 -896
- package/app/core/modelhub.py +7 -0
- package/app/core/pipeline.py +138 -32
- package/app/core/store.py +3 -1
- package/app/main.py +1478 -1448
- package/app/ui/app.js +212 -20
- package/app/ui/i18n.js +1732 -1712
- package/app/ui/index.html +6 -2
- package/app/ui/style.css +87 -59
- package/bin/tutti.js +147 -147
- package/package.json +39 -39
package/app/core/manager.py
CHANGED
|
@@ -1,1511 +1,1523 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
"""智能体管理器:安装检测、版本、安装/升级、模型配置读写。
|
|
3
|
-
|
|
4
|
-
安全约束:catalog 中的配置文件路径展开后必须落在用户主目录内,
|
|
5
|
-
防止相对路径穿越到预期之外的系统位置。
|
|
6
|
-
"""
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
import json
|
|
10
|
-
import os
|
|
11
|
-
import re
|
|
12
|
-
import shutil
|
|
13
|
-
import socket
|
|
14
|
-
import subprocess
|
|
15
|
-
import sys
|
|
16
|
-
import threading
|
|
17
|
-
import time
|
|
18
|
-
import webbrowser
|
|
19
|
-
import zlib
|
|
20
|
-
from pathlib import Path
|
|
21
|
-
|
|
22
|
-
from . import catalog, paths, runner
|
|
23
|
-
|
|
24
|
-
CREATE_NO_WINDOW = 0x08000000
|
|
25
|
-
VERSION_TTL = 300 # 版本缓存 5 分钟
|
|
26
|
-
|
|
27
|
-
_LOCK = threading.RLock()
|
|
28
|
-
_STATE = {"detected": {}, "versions": {}, "detect_ts": 0.0, "detect_ev": None}
|
|
29
|
-
|
|
30
|
-
# 能自动写入默认模型的 config.format(其余格式只能手动编辑)
|
|
31
|
-
_WRITABLE_FORMATS = ("toml-line", "toml-section", "json", "json-path", "jsonc",
|
|
32
|
-
"yaml-line")
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def _expand(p):
|
|
36
|
-
return os.path.abspath(os.path.expanduser(os.path.expandvars(p)))
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def _safe_config_path(raw):
|
|
40
|
-
"""展开并校验配置路径:必须在用户主目录内(防穿越)。"""
|
|
41
|
-
if not raw:
|
|
42
|
-
return None
|
|
43
|
-
full = _expand(raw)
|
|
44
|
-
home = os.path.abspath(os.path.expanduser("~"))
|
|
45
|
-
try:
|
|
46
|
-
if os.path.commonpath([full, home]) != home:
|
|
47
|
-
return None
|
|
48
|
-
except ValueError:
|
|
49
|
-
return None
|
|
50
|
-
return full
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
# ---------------------------------------------------------------- 检测
|
|
54
|
-
|
|
55
|
-
def detect_entry(entry):
|
|
56
|
-
d = entry.get("detect") or {}
|
|
57
|
-
if d.get("cli"):
|
|
58
|
-
path = shutil.which(d["cli"])
|
|
59
|
-
return {"installed": bool(path), "detail": path or ""}
|
|
60
|
-
if d.get("exe"):
|
|
61
|
-
full = _expand(d["exe"])
|
|
62
|
-
return {"installed": os.path.isfile(full), "detail": full if os.path.isfile(full) else ""}
|
|
63
|
-
if d.get("uwp"):
|
|
64
|
-
base = os.path.join(os.environ.get("LOCALAPPDATA", ""), "Packages", d["uwp"])
|
|
65
|
-
return {"installed": os.path.isdir(base), "detail": base if os.path.isdir(base) else ""}
|
|
66
|
-
if d.get("dir"):
|
|
67
|
-
full = _expand(d["dir"])
|
|
68
|
-
return {"installed": os.path.isdir(full), "detail": full if os.path.isdir(full) else ""}
|
|
69
|
-
return {"installed": False, "detail": ""}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def detect_all(force=False):
|
|
73
|
-
"""检测全部条目。检测(慢磁盘 IO)在锁外跑:shutil.which/isfile 在
|
|
74
|
-
Windows 上遇到断链的 PATH 项可能卡数秒,持锁会把所有并发请求堵死
|
|
75
|
-
(曾导致 SSE 多连接时服务假死)。等待方有界等待 30s 后拿旧结果。
|
|
76
|
-
"""
|
|
77
|
-
with _LOCK:
|
|
78
|
-
if not force and _STATE["detected"] and time.time() - _STATE["detect_ts"] < 60:
|
|
79
|
-
return _STATE["detected"]
|
|
80
|
-
ev = _STATE["detect_ev"]
|
|
81
|
-
lead = ev is None # 我是本次检测的执行者
|
|
82
|
-
if lead:
|
|
83
|
-
ev = _STATE["detect_ev"] = threading.Event()
|
|
84
|
-
if not lead:
|
|
85
|
-
ev.wait(30) # 检测完成或超时;两种情况都拿当前最新快照
|
|
86
|
-
with _LOCK:
|
|
87
|
-
return dict(_STATE["detected"])
|
|
88
|
-
try:
|
|
89
|
-
detected = {}
|
|
90
|
-
for entry in catalog.load():
|
|
91
|
-
try:
|
|
92
|
-
detected[entry["id"]] = detect_entry(entry)
|
|
93
|
-
except Exception as e:
|
|
94
|
-
detected[entry["id"]] = {"installed": False, "detail": "检测出错: %r" % e}
|
|
95
|
-
if detected:
|
|
96
|
-
with _LOCK:
|
|
97
|
-
_STATE["detected"] = detected
|
|
98
|
-
finally:
|
|
99
|
-
with _LOCK:
|
|
100
|
-
_STATE["detect_ts"] = time.time()
|
|
101
|
-
_STATE["detect_ev"] = None
|
|
102
|
-
ev.set()
|
|
103
|
-
return detected
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def _uwp_version(package_dir):
|
|
107
|
-
import xml.etree.ElementTree as ET
|
|
108
|
-
mf = os.path.join(package_dir, "AppxManifest.xml")
|
|
109
|
-
if not os.path.isfile(mf):
|
|
110
|
-
return None
|
|
111
|
-
try:
|
|
112
|
-
tree = ET.parse(mf)
|
|
113
|
-
for el in tree.iter():
|
|
114
|
-
if el.tag.endswith("}Identity") or el.tag == "Identity":
|
|
115
|
-
return el.get("Version")
|
|
116
|
-
except Exception:
|
|
117
|
-
pass
|
|
118
|
-
return None
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def _exe_version(path):
|
|
122
|
-
try:
|
|
123
|
-
r = subprocess.run(
|
|
124
|
-
["powershell", "-NoProfile", "-Command",
|
|
125
|
-
"(Get-Item -LiteralPath '%s').VersionInfo.ProductVersion" % path.replace("'", "''")],
|
|
126
|
-
capture_output=True, creationflags=CREATE_NO_WINDOW, timeout=25)
|
|
127
|
-
out = r.stdout.decode("utf-8", "replace").strip()
|
|
128
|
-
return out or None
|
|
129
|
-
except Exception:
|
|
130
|
-
return None
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
def version_of(entry):
|
|
134
|
-
"""版本探测:CLI 走 --version;UWP 读 AppxManifest;exe 走 PowerShell(惰性缓存)。"""
|
|
135
|
-
eid = entry["id"]
|
|
136
|
-
with _LOCK:
|
|
137
|
-
cached = _STATE["versions"].get(eid)
|
|
138
|
-
if cached and time.time() - cached[0] < VERSION_TTL:
|
|
139
|
-
return cached[1]
|
|
140
|
-
det = (detect_all() or {}).get(eid) or {}
|
|
141
|
-
version = None
|
|
142
|
-
cli = (entry.get("detect") or {}).get("cli")
|
|
143
|
-
if cli and det.get("installed"):
|
|
144
|
-
try:
|
|
145
|
-
r = subprocess.run(["cmd", "/c", cli, "--version"], capture_output=True,
|
|
146
|
-
creationflags=CREATE_NO_WINDOW, timeout=20)
|
|
147
|
-
out = (r.stdout or b"").decode("utf-8", "replace").strip()
|
|
148
|
-
if not out:
|
|
149
|
-
out = (r.stderr or b"").decode("utf-8", "replace").strip()
|
|
150
|
-
version = out.splitlines()[0][:60] if out else None
|
|
151
|
-
except Exception:
|
|
152
|
-
version = None
|
|
153
|
-
elif det.get("detail") and os.path.isdir(det["detail"]):
|
|
154
|
-
version = _uwp_version(det["detail"])
|
|
155
|
-
elif det.get("detail") and os.path.isfile(det["detail"]):
|
|
156
|
-
version = _exe_version(det["detail"])
|
|
157
|
-
with _LOCK:
|
|
158
|
-
_STATE["versions"][eid] = (time.time(), version or "-")
|
|
159
|
-
return version or "-"
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
# ---------------------------------------------------------------- TOML 表内键
|
|
163
|
-
|
|
164
|
-
def _toml_span(lines, table):
|
|
165
|
-
"""定位顶层表 `[table]` 的行区间 [start, end);start 为表头行。
|
|
166
|
-
只认顶层表头(行首无空白),不误吞嵌套 `[[array]]` 之外的子表——
|
|
167
|
-
子表在 TOML 里也是 `[a.b]` 顶层写法,同样按表头截断。"""
|
|
168
|
-
header = re.compile(r"^\[([^\[\]]+)\]\s*$")
|
|
169
|
-
start = None
|
|
170
|
-
for i, ln in enumerate(lines):
|
|
171
|
-
if ln.lstrip().startswith("["):
|
|
172
|
-
m = header.match(ln.strip())
|
|
173
|
-
if not m:
|
|
174
|
-
continue
|
|
175
|
-
if start is not None:
|
|
176
|
-
return start, i
|
|
177
|
-
if m.group(1).strip() == table:
|
|
178
|
-
start = i
|
|
179
|
-
if start is None:
|
|
180
|
-
return None, None
|
|
181
|
-
return start, len(lines)
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
def _toml_read_value(text, table, leaf):
|
|
185
|
-
m = re.search(r'(?m)^\s*%s\s*=\s*"([^"]*)"\s*(#.*)?$' % re.escape(leaf), text) \
|
|
186
|
-
if table is None else None
|
|
187
|
-
if table is None:
|
|
188
|
-
return m.group(1) if m else None
|
|
189
|
-
start, end = _toml_span(text.splitlines(), table)
|
|
190
|
-
if start is None:
|
|
191
|
-
return None
|
|
192
|
-
for ln in text.splitlines()[start + 1:end]:
|
|
193
|
-
m = re.match(r'^\s*%s\s*=\s*"([^"]*)"\s*(#.*)?$' % re.escape(leaf), ln)
|
|
194
|
-
if m:
|
|
195
|
-
return m.group(1)
|
|
196
|
-
return None
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
def _toml_write_value(text, table, leaf, value):
|
|
200
|
-
"""就地写入 TOML 的 [table] leaf(双引号标量),保留其余内容与换行风格。"""
|
|
201
|
-
eol = "\r\n" if "\r\n" in text else "\n"
|
|
202
|
-
lines = text.splitlines()
|
|
203
|
-
new_line = '%s = "%s"' % (leaf, value.replace("\\", "\\\\").replace('"', '\\"'))
|
|
204
|
-
# 换值不换行:保留行尾注释等其余内容
|
|
205
|
-
pat = re.compile(r'^(\s*%s\s*=\s*)"(?:[^"\\]|\\.)*"(.*)$' % re.escape(leaf))
|
|
206
|
-
if table is None:
|
|
207
|
-
for i, ln in enumerate(lines):
|
|
208
|
-
m = pat.match(ln)
|
|
209
|
-
if m:
|
|
210
|
-
lines[i] = "%s\"%s\"%s" % (m.group(1), value.replace("\\", "\\\\").replace('"', '\\"'), m.group(2))
|
|
211
|
-
break
|
|
212
|
-
else:
|
|
213
|
-
lines.append(new_line)
|
|
214
|
-
return eol.join(lines).rstrip("\r\n") + eol
|
|
215
|
-
start, end = _toml_span(lines, table)
|
|
216
|
-
if start is None: # 表不存在:整段追加
|
|
217
|
-
if lines and lines[-1].strip():
|
|
218
|
-
lines.append("")
|
|
219
|
-
lines.append("[%s]" % table)
|
|
220
|
-
lines.append(new_line)
|
|
221
|
-
return eol.join(lines).rstrip("\r\n") + eol
|
|
222
|
-
for i in range(start + 1, end):
|
|
223
|
-
m = pat.match(lines[i])
|
|
224
|
-
if m:
|
|
225
|
-
lines[i] = "%s\"%s\"%s" % (m.group(1), value.replace("\\", "\\\\").replace('"', '\\"'), m.group(2))
|
|
226
|
-
return eol.join(lines).rstrip("\r\n") + eol
|
|
227
|
-
lines.insert(end, new_line) # 表内末尾追加(表头区间终点即下一表头前)
|
|
228
|
-
return eol.join(lines).rstrip("\r\n") + eol
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
# ---------------------------------------------------------------- 模型配置
|
|
232
|
-
|
|
233
|
-
def _config_path(entry):
|
|
234
|
-
cfg = entry.get("config") or {}
|
|
235
|
-
return _safe_config_path(cfg.get("path"))
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
def _yaml_model_path(cfg):
|
|
239
|
-
"""把 config.model_key 的点号路径拆成 (段, 键);无点号时段为 None(顶层键)。"""
|
|
240
|
-
return _dotted_key(cfg)
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
def _dotted_key(cfg):
|
|
244
|
-
"""model_key 点号路径拆 (表/段, 键);无点号时第一元为 None(顶层键)。"""
|
|
245
|
-
key = (cfg.get("model_key") or "model").strip()
|
|
246
|
-
if "." in key:
|
|
247
|
-
section, leaf = key.split(".", 1)
|
|
248
|
-
return section.strip(), leaf.strip()
|
|
249
|
-
return None, key
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
def _yaml_quote(value):
|
|
253
|
-
"""YAML 双引号标量。必须加引号:模型名可能以 [ 开头(YAML 流序列)或含 #。"""
|
|
254
|
-
return '"%s"' % value.replace("\\", "\\\\").replace('"', '\\"')
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
def _yaml_unquote(raw):
|
|
258
|
-
"""取 YAML 标量的值:剥引号、丢行尾注释。引号内的 # 不算注释。"""
|
|
259
|
-
s = (raw or "").strip()
|
|
260
|
-
if not s:
|
|
261
|
-
return ""
|
|
262
|
-
if s[0] in ("'", '"'):
|
|
263
|
-
q = s[0]
|
|
264
|
-
i = 1
|
|
265
|
-
buf = []
|
|
266
|
-
while i < len(s):
|
|
267
|
-
ch = s[i]
|
|
268
|
-
if q == '"' and ch == "\\" and i + 1 < len(s):
|
|
269
|
-
nxt = s[i + 1]
|
|
270
|
-
buf.append('"' if nxt == '"' else ("\\" if nxt == "\\" else nxt))
|
|
271
|
-
i += 2
|
|
272
|
-
continue
|
|
273
|
-
if ch == q:
|
|
274
|
-
if q == "'" and i + 1 < len(s) and s[i + 1] == "'": # '' 转义
|
|
275
|
-
buf.append("'")
|
|
276
|
-
i += 2
|
|
277
|
-
continue
|
|
278
|
-
break
|
|
279
|
-
buf.append(ch)
|
|
280
|
-
i += 1
|
|
281
|
-
return "".join(buf).strip()
|
|
282
|
-
# 无引号:截断行尾注释(# 前的空白才算注释起始)
|
|
283
|
-
return re.split(r"\s+#", s, 1)[0].strip()
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
def _yaml_span(lines, section):
|
|
287
|
-
"""定位顶层段的行区间 [start, end);start 为段名行,其子键在 start+1..end。"""
|
|
288
|
-
start = None
|
|
289
|
-
for i, ln in enumerate(lines):
|
|
290
|
-
m = re.match(r"^([^\s#][^:]*):\s*(.*)$", ln)
|
|
291
|
-
if not m:
|
|
292
|
-
continue
|
|
293
|
-
if m.group(1).strip() == section:
|
|
294
|
-
start = i
|
|
295
|
-
continue
|
|
296
|
-
if start is not None:
|
|
297
|
-
return start, i
|
|
298
|
-
if start is None:
|
|
299
|
-
return None, None
|
|
300
|
-
return start, len(lines)
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
def _yaml_read_value(text, section, leaf):
|
|
304
|
-
lines = text.splitlines()
|
|
305
|
-
if section is None:
|
|
306
|
-
m = re.search(r"(?m)^%s\s*:\s*(.+?)\s*$" % re.escape(leaf), text)
|
|
307
|
-
return _yaml_unquote(m.group(1)) or None if m else None
|
|
308
|
-
start, end = _yaml_span(lines, section)
|
|
309
|
-
if start is None:
|
|
310
|
-
return None
|
|
311
|
-
for ln in lines[start + 1:end]:
|
|
312
|
-
m = re.match(r"^\s+%s\s*:\s*(.+?)\s*$" % re.escape(leaf), ln)
|
|
313
|
-
if m:
|
|
314
|
-
return _yaml_unquote(m.group(1)) or None
|
|
315
|
-
return None
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
def _yaml_write_value(text, section, leaf, value):
|
|
319
|
-
"""就地写入 YAML 的 section.leaf,保留其余内容、缩进与换行风格。"""
|
|
320
|
-
eol = "\r\n" if "\r\n" in text else "\n"
|
|
321
|
-
lines = text.splitlines()
|
|
322
|
-
quoted = _yaml_quote(value)
|
|
323
|
-
if section is None:
|
|
324
|
-
for i, ln in enumerate(lines):
|
|
325
|
-
if re.match(r"^%s\s*:" % re.escape(leaf), ln):
|
|
326
|
-
lines[i] = "%s: %s" % (leaf, quoted)
|
|
327
|
-
break
|
|
328
|
-
else:
|
|
329
|
-
lines.append("%s: %s" % (leaf, quoted))
|
|
330
|
-
return eol.join(lines).rstrip("\r\n") + eol
|
|
331
|
-
start, end = _yaml_span(lines, section)
|
|
332
|
-
if start is None: # 段不存在:整段追加
|
|
333
|
-
if lines and lines[-1].strip():
|
|
334
|
-
lines.append("")
|
|
335
|
-
lines.append("%s:" % section)
|
|
336
|
-
lines.append(" %s: %s" % (leaf, quoted))
|
|
337
|
-
return eol.join(lines).rstrip("\r\n") + eol
|
|
338
|
-
for i in range(start + 1, end):
|
|
339
|
-
m = re.match(r"^(\s+)%s\s*:" % re.escape(leaf), lines[i])
|
|
340
|
-
if m: # 键已存在:只换值,保留原缩进
|
|
341
|
-
lines[i] = "%s%s: %s" % (m.group(1), leaf, quoted)
|
|
342
|
-
return eol.join(lines).rstrip("\r\n") + eol
|
|
343
|
-
indent, last = " ", start # 段存在但无该键:跟随段内缩进、追加到段尾
|
|
344
|
-
for i in range(start + 1, end):
|
|
345
|
-
if not lines[i].strip():
|
|
346
|
-
continue
|
|
347
|
-
if indent == " ":
|
|
348
|
-
m = re.match(r"^(\s+)\S", lines[i])
|
|
349
|
-
if m:
|
|
350
|
-
indent = m.group(1)
|
|
351
|
-
last = i
|
|
352
|
-
lines.insert(last + 1, "%s%s: %s" % (indent, leaf, quoted))
|
|
353
|
-
return eol.join(lines).rstrip("\r\n") + eol
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
def _json_path_get(data, keys):
|
|
357
|
-
"""沿点号路径下钻 JSON 嵌套;终点必须是字符串。"""
|
|
358
|
-
cur = data
|
|
359
|
-
for k in keys:
|
|
360
|
-
if isinstance(cur, dict) and k in cur:
|
|
361
|
-
cur = cur[k]
|
|
362
|
-
else:
|
|
363
|
-
return None
|
|
364
|
-
return cur if isinstance(cur, str) else None
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
def _json_path_set(data, keys, value):
|
|
368
|
-
"""沿点号路径写入 JSON 嵌套,缺中间对象就地创建;
|
|
369
|
-
中途遇到非 dict(如 string 简写形式)升级为对象。"""
|
|
370
|
-
cur = data
|
|
371
|
-
for k in keys[:-1]:
|
|
372
|
-
if not isinstance(cur.get(k), dict):
|
|
373
|
-
cur[k] = {}
|
|
374
|
-
cur = cur[k]
|
|
375
|
-
cur[keys[-1]] = value
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
def read_model(entry):
|
|
379
|
-
path = _config_path(entry)
|
|
380
|
-
cfg = entry.get("config") or {}
|
|
381
|
-
if not path or not os.path.isfile(path) or not cfg.get("format"):
|
|
382
|
-
return None
|
|
383
|
-
try:
|
|
384
|
-
text = open(path, encoding="utf-8", errors="replace").read()
|
|
385
|
-
except Exception:
|
|
386
|
-
return None
|
|
387
|
-
if cfg["format"] == "toml-line":
|
|
388
|
-
m = re.search(r'(?m)^\s*model\s*=\s*"([^"]+)"', text)
|
|
389
|
-
return m.group(1) if m else None
|
|
390
|
-
if cfg["format"] == "toml-section":
|
|
391
|
-
table, leaf = _dotted_key(cfg)
|
|
392
|
-
return _toml_read_value(text, table, leaf)
|
|
393
|
-
if cfg["format"] == "json":
|
|
394
|
-
try:
|
|
395
|
-
v = json.loads(text).get("model")
|
|
396
|
-
if v:
|
|
397
|
-
return v
|
|
398
|
-
except Exception:
|
|
399
|
-
pass
|
|
400
|
-
m = re.search(r'"model"\s*:\s*"([^"]+)"', text)
|
|
401
|
-
return m.group(1) if m else None
|
|
402
|
-
if cfg["format"] == "jsonc":
|
|
403
|
-
keys = (cfg.get("model_key") or "model").split(".")
|
|
404
|
-
try:
|
|
405
|
-
data = json.loads(_jsonc_strip_comments(text) or "{}")
|
|
406
|
-
except Exception:
|
|
407
|
-
return None
|
|
408
|
-
return _json_path_get(data, keys)
|
|
409
|
-
if cfg["format"] == "json-path":
|
|
410
|
-
try:
|
|
411
|
-
data = json.loads(text or "{}")
|
|
412
|
-
except Exception:
|
|
413
|
-
return None
|
|
414
|
-
return _json_path_get(data, (cfg.get("model_key") or "model").split("."))
|
|
415
|
-
if cfg["format"] == "yaml-line":
|
|
416
|
-
return _yaml_read_value(text, *_yaml_model_path(cfg))
|
|
417
|
-
return None
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
def write_model(entry, model):
|
|
421
|
-
"""写入默认模型(改动前自动备份 .bak)。支持 toml-line / toml-section /
|
|
422
|
-
json / json-path / jsonc / yaml-line。"""
|
|
423
|
-
path = _config_path(entry)
|
|
424
|
-
cfg = entry.get("config") or {}
|
|
425
|
-
fmt = cfg.get("format")
|
|
426
|
-
if not path:
|
|
427
|
-
return {"ok": False,
|
|
428
|
-
"error": "配置路径无效或不在用户主目录内,已拒绝写入"}
|
|
429
|
-
if fmt not in _WRITABLE_FORMATS:
|
|
430
|
-
return {"ok": False, "error": "该工具的模型配置格式暂不支持自动写入,请手动编辑 %s" % path}
|
|
431
|
-
model = (model or "").strip()
|
|
432
|
-
if not model:
|
|
433
|
-
return {"ok": False, "error": "模型名不能为空"}
|
|
434
|
-
# 写入前二次校验:解析真实路径后必须仍在用户主目录内(防穿越/符号链接),
|
|
435
|
-
# 校验通过后统一改用解析路径写入
|
|
436
|
-
home = os.path.abspath(os.path.expanduser("~"))
|
|
437
|
-
path = os.path.realpath(path)
|
|
438
|
-
try:
|
|
439
|
-
if os.path.commonpath([path, home]) != home:
|
|
440
|
-
return {"ok": False, "error": "配置路径越出用户主目录,已拒绝写入"}
|
|
441
|
-
except ValueError:
|
|
442
|
-
return {"ok": False, "error": "配置路径越出用户主目录,已拒绝写入"}
|
|
443
|
-
try:
|
|
444
|
-
if os.path.isfile(path):
|
|
445
|
-
shutil.copyfile(path, path + ".bak")
|
|
446
|
-
else:
|
|
447
|
-
# 各 CLI 首次运行都未必建主配置(grok 不建 config.toml、pi 不建
|
|
448
|
-
# settings.json、openclaw 缺失即安全默认——官方文档明确「缺失即
|
|
449
|
-
# 内置默认」);它正是用户层覆盖的落点,缺了按需创建
|
|
450
|
-
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
451
|
-
Path(path).write_bytes(b"{}" if fmt in ("json", "json-path", "jsonc") else b"")
|
|
452
|
-
if fmt == "jsonc":
|
|
453
|
-
# mimo(mimocode.jsonc)有注释,整体重解析会丢注释——复用
|
|
454
|
-
# _jsonc_set 做就地片段改写(只动目标键,其余原样保留)
|
|
455
|
-
keys = (cfg.get("model_key") or "model").split(".")
|
|
456
|
-
text = open(path, encoding="utf-8", errors="replace", newline="").read()
|
|
457
|
-
new_text, ok = _jsonc_set(text, tuple(keys),
|
|
458
|
-
json.dumps(model, ensure_ascii=False))
|
|
459
|
-
if not ok:
|
|
460
|
-
return {"ok": False,
|
|
461
|
-
"error": "jsonc 结构异常,未能就地写入 %s(已避免覆盖)" % path}
|
|
462
|
-
Path(path).write_bytes(new_text.encode("utf-8"))
|
|
463
|
-
elif fmt == "toml-line":
|
|
464
|
-
text = open(path, encoding="utf-8", errors="replace").read()
|
|
465
|
-
new_line = 'model = "%s"' % model
|
|
466
|
-
if re.search(r'(?m)^\s*model\s*=\s*"[^"]*"', text):
|
|
467
|
-
text = re.sub(r'(?m)^\s*model\s*=\s*"[^"]*"', new_line, text)
|
|
468
|
-
else:
|
|
469
|
-
text = text.rstrip("\n") + "\n" + new_line + "\n"
|
|
470
|
-
Path(path).write_bytes(text.encode("utf-8"))
|
|
471
|
-
elif fmt == "toml-section":
|
|
472
|
-
text = open(path, encoding="utf-8", errors="replace", newline="").read()
|
|
473
|
-
text = _toml_write_value(text, *_dotted_key(cfg), value=model)
|
|
474
|
-
Path(path).write_bytes(text.encode("utf-8"))
|
|
475
|
-
elif fmt == "yaml-line":
|
|
476
|
-
# newline="" 关掉通用换行转换:文本层面看不出 \r\n 就会被静默改写成 LF,
|
|
477
|
-
# 用户的 Windows 配置不该因为写个模型名而整篇换行符被替换
|
|
478
|
-
text = open(path, encoding="utf-8", errors="replace", newline="").read()
|
|
479
|
-
text = _yaml_write_value(text, *_yaml_model_path(cfg), value=model)
|
|
480
|
-
Path(path).write_bytes(text.encode("utf-8"))
|
|
481
|
-
elif fmt == "json-path":
|
|
482
|
-
# openclaw(agents.defaults.model.primary):默认模型藏在嵌套对象里,
|
|
483
|
-
# 且 openclaw 对未知顶层键直接拒绝启动——绝不能写顶层 "model"
|
|
484
|
-
text = open(path, encoding="utf-8", errors="replace", newline="").read()
|
|
485
|
-
try:
|
|
486
|
-
data = json.loads(text) if text.strip() else {}
|
|
487
|
-
except Exception:
|
|
488
|
-
return {"ok": False, "error": "配置文件不是合法 JSON,已中止(避免覆盖)"}
|
|
489
|
-
_json_path_set(data, (cfg.get("model_key") or "model").split("."), model)
|
|
490
|
-
# pi 的 settings.json:defaultModel 必须配 defaultProvider 才能解析出
|
|
491
|
-
# (provider, model) 二元组;catalog
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
json.
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
def
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
""
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
if
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
if
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
break
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
if
|
|
610
|
-
return text, False
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
""
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
if not
|
|
685
|
-
return
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
if not
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
""
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
if
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
lines
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
i
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
if ch == "
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
return text,
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
if
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
if
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
"""
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
try:
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
if
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
"""
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
if
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
if
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
notes.append(
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
return {"ok":
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
"""
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
return
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
result["
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
if
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
"
|
|
1506
|
-
|
|
1507
|
-
"
|
|
1508
|
-
|
|
1509
|
-
"
|
|
1510
|
-
|
|
1511
|
-
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""智能体管理器:安装检测、版本、安装/升级、模型配置读写。
|
|
3
|
+
|
|
4
|
+
安全约束:catalog 中的配置文件路径展开后必须落在用户主目录内,
|
|
5
|
+
防止相对路径穿越到预期之外的系统位置。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import socket
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
import webbrowser
|
|
19
|
+
import zlib
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from . import catalog, paths, runner
|
|
23
|
+
|
|
24
|
+
CREATE_NO_WINDOW = 0x08000000
|
|
25
|
+
VERSION_TTL = 300 # 版本缓存 5 分钟
|
|
26
|
+
|
|
27
|
+
_LOCK = threading.RLock()
|
|
28
|
+
_STATE = {"detected": {}, "versions": {}, "detect_ts": 0.0, "detect_ev": None}
|
|
29
|
+
|
|
30
|
+
# 能自动写入默认模型的 config.format(其余格式只能手动编辑)
|
|
31
|
+
_WRITABLE_FORMATS = ("toml-line", "toml-section", "json", "json-path", "jsonc",
|
|
32
|
+
"yaml-line")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _expand(p):
|
|
36
|
+
return os.path.abspath(os.path.expanduser(os.path.expandvars(p)))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _safe_config_path(raw):
|
|
40
|
+
"""展开并校验配置路径:必须在用户主目录内(防穿越)。"""
|
|
41
|
+
if not raw:
|
|
42
|
+
return None
|
|
43
|
+
full = _expand(raw)
|
|
44
|
+
home = os.path.abspath(os.path.expanduser("~"))
|
|
45
|
+
try:
|
|
46
|
+
if os.path.commonpath([full, home]) != home:
|
|
47
|
+
return None
|
|
48
|
+
except ValueError:
|
|
49
|
+
return None
|
|
50
|
+
return full
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------- 检测
|
|
54
|
+
|
|
55
|
+
def detect_entry(entry):
|
|
56
|
+
d = entry.get("detect") or {}
|
|
57
|
+
if d.get("cli"):
|
|
58
|
+
path = shutil.which(d["cli"])
|
|
59
|
+
return {"installed": bool(path), "detail": path or ""}
|
|
60
|
+
if d.get("exe"):
|
|
61
|
+
full = _expand(d["exe"])
|
|
62
|
+
return {"installed": os.path.isfile(full), "detail": full if os.path.isfile(full) else ""}
|
|
63
|
+
if d.get("uwp"):
|
|
64
|
+
base = os.path.join(os.environ.get("LOCALAPPDATA", ""), "Packages", d["uwp"])
|
|
65
|
+
return {"installed": os.path.isdir(base), "detail": base if os.path.isdir(base) else ""}
|
|
66
|
+
if d.get("dir"):
|
|
67
|
+
full = _expand(d["dir"])
|
|
68
|
+
return {"installed": os.path.isdir(full), "detail": full if os.path.isdir(full) else ""}
|
|
69
|
+
return {"installed": False, "detail": ""}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def detect_all(force=False):
|
|
73
|
+
"""检测全部条目。检测(慢磁盘 IO)在锁外跑:shutil.which/isfile 在
|
|
74
|
+
Windows 上遇到断链的 PATH 项可能卡数秒,持锁会把所有并发请求堵死
|
|
75
|
+
(曾导致 SSE 多连接时服务假死)。等待方有界等待 30s 后拿旧结果。
|
|
76
|
+
"""
|
|
77
|
+
with _LOCK:
|
|
78
|
+
if not force and _STATE["detected"] and time.time() - _STATE["detect_ts"] < 60:
|
|
79
|
+
return _STATE["detected"]
|
|
80
|
+
ev = _STATE["detect_ev"]
|
|
81
|
+
lead = ev is None # 我是本次检测的执行者
|
|
82
|
+
if lead:
|
|
83
|
+
ev = _STATE["detect_ev"] = threading.Event()
|
|
84
|
+
if not lead:
|
|
85
|
+
ev.wait(30) # 检测完成或超时;两种情况都拿当前最新快照
|
|
86
|
+
with _LOCK:
|
|
87
|
+
return dict(_STATE["detected"])
|
|
88
|
+
try:
|
|
89
|
+
detected = {}
|
|
90
|
+
for entry in catalog.load():
|
|
91
|
+
try:
|
|
92
|
+
detected[entry["id"]] = detect_entry(entry)
|
|
93
|
+
except Exception as e:
|
|
94
|
+
detected[entry["id"]] = {"installed": False, "detail": "检测出错: %r" % e}
|
|
95
|
+
if detected:
|
|
96
|
+
with _LOCK:
|
|
97
|
+
_STATE["detected"] = detected
|
|
98
|
+
finally:
|
|
99
|
+
with _LOCK:
|
|
100
|
+
_STATE["detect_ts"] = time.time()
|
|
101
|
+
_STATE["detect_ev"] = None
|
|
102
|
+
ev.set()
|
|
103
|
+
return detected
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _uwp_version(package_dir):
|
|
107
|
+
import xml.etree.ElementTree as ET
|
|
108
|
+
mf = os.path.join(package_dir, "AppxManifest.xml")
|
|
109
|
+
if not os.path.isfile(mf):
|
|
110
|
+
return None
|
|
111
|
+
try:
|
|
112
|
+
tree = ET.parse(mf)
|
|
113
|
+
for el in tree.iter():
|
|
114
|
+
if el.tag.endswith("}Identity") or el.tag == "Identity":
|
|
115
|
+
return el.get("Version")
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _exe_version(path):
|
|
122
|
+
try:
|
|
123
|
+
r = subprocess.run(
|
|
124
|
+
["powershell", "-NoProfile", "-Command",
|
|
125
|
+
"(Get-Item -LiteralPath '%s').VersionInfo.ProductVersion" % path.replace("'", "''")],
|
|
126
|
+
capture_output=True, creationflags=CREATE_NO_WINDOW, timeout=25)
|
|
127
|
+
out = r.stdout.decode("utf-8", "replace").strip()
|
|
128
|
+
return out or None
|
|
129
|
+
except Exception:
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def version_of(entry):
|
|
134
|
+
"""版本探测:CLI 走 --version;UWP 读 AppxManifest;exe 走 PowerShell(惰性缓存)。"""
|
|
135
|
+
eid = entry["id"]
|
|
136
|
+
with _LOCK:
|
|
137
|
+
cached = _STATE["versions"].get(eid)
|
|
138
|
+
if cached and time.time() - cached[0] < VERSION_TTL:
|
|
139
|
+
return cached[1]
|
|
140
|
+
det = (detect_all() or {}).get(eid) or {}
|
|
141
|
+
version = None
|
|
142
|
+
cli = (entry.get("detect") or {}).get("cli")
|
|
143
|
+
if cli and det.get("installed"):
|
|
144
|
+
try:
|
|
145
|
+
r = subprocess.run(["cmd", "/c", cli, "--version"], capture_output=True,
|
|
146
|
+
creationflags=CREATE_NO_WINDOW, timeout=20)
|
|
147
|
+
out = (r.stdout or b"").decode("utf-8", "replace").strip()
|
|
148
|
+
if not out:
|
|
149
|
+
out = (r.stderr or b"").decode("utf-8", "replace").strip()
|
|
150
|
+
version = out.splitlines()[0][:60] if out else None
|
|
151
|
+
except Exception:
|
|
152
|
+
version = None
|
|
153
|
+
elif det.get("detail") and os.path.isdir(det["detail"]):
|
|
154
|
+
version = _uwp_version(det["detail"])
|
|
155
|
+
elif det.get("detail") and os.path.isfile(det["detail"]):
|
|
156
|
+
version = _exe_version(det["detail"])
|
|
157
|
+
with _LOCK:
|
|
158
|
+
_STATE["versions"][eid] = (time.time(), version or "-")
|
|
159
|
+
return version or "-"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ---------------------------------------------------------------- TOML 表内键
|
|
163
|
+
|
|
164
|
+
def _toml_span(lines, table):
|
|
165
|
+
"""定位顶层表 `[table]` 的行区间 [start, end);start 为表头行。
|
|
166
|
+
只认顶层表头(行首无空白),不误吞嵌套 `[[array]]` 之外的子表——
|
|
167
|
+
子表在 TOML 里也是 `[a.b]` 顶层写法,同样按表头截断。"""
|
|
168
|
+
header = re.compile(r"^\[([^\[\]]+)\]\s*$")
|
|
169
|
+
start = None
|
|
170
|
+
for i, ln in enumerate(lines):
|
|
171
|
+
if ln.lstrip().startswith("["):
|
|
172
|
+
m = header.match(ln.strip())
|
|
173
|
+
if not m:
|
|
174
|
+
continue
|
|
175
|
+
if start is not None:
|
|
176
|
+
return start, i
|
|
177
|
+
if m.group(1).strip() == table:
|
|
178
|
+
start = i
|
|
179
|
+
if start is None:
|
|
180
|
+
return None, None
|
|
181
|
+
return start, len(lines)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _toml_read_value(text, table, leaf):
|
|
185
|
+
m = re.search(r'(?m)^\s*%s\s*=\s*"([^"]*)"\s*(#.*)?$' % re.escape(leaf), text) \
|
|
186
|
+
if table is None else None
|
|
187
|
+
if table is None:
|
|
188
|
+
return m.group(1) if m else None
|
|
189
|
+
start, end = _toml_span(text.splitlines(), table)
|
|
190
|
+
if start is None:
|
|
191
|
+
return None
|
|
192
|
+
for ln in text.splitlines()[start + 1:end]:
|
|
193
|
+
m = re.match(r'^\s*%s\s*=\s*"([^"]*)"\s*(#.*)?$' % re.escape(leaf), ln)
|
|
194
|
+
if m:
|
|
195
|
+
return m.group(1)
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _toml_write_value(text, table, leaf, value):
|
|
200
|
+
"""就地写入 TOML 的 [table] leaf(双引号标量),保留其余内容与换行风格。"""
|
|
201
|
+
eol = "\r\n" if "\r\n" in text else "\n"
|
|
202
|
+
lines = text.splitlines()
|
|
203
|
+
new_line = '%s = "%s"' % (leaf, value.replace("\\", "\\\\").replace('"', '\\"'))
|
|
204
|
+
# 换值不换行:保留行尾注释等其余内容
|
|
205
|
+
pat = re.compile(r'^(\s*%s\s*=\s*)"(?:[^"\\]|\\.)*"(.*)$' % re.escape(leaf))
|
|
206
|
+
if table is None:
|
|
207
|
+
for i, ln in enumerate(lines):
|
|
208
|
+
m = pat.match(ln)
|
|
209
|
+
if m:
|
|
210
|
+
lines[i] = "%s\"%s\"%s" % (m.group(1), value.replace("\\", "\\\\").replace('"', '\\"'), m.group(2))
|
|
211
|
+
break
|
|
212
|
+
else:
|
|
213
|
+
lines.append(new_line)
|
|
214
|
+
return eol.join(lines).rstrip("\r\n") + eol
|
|
215
|
+
start, end = _toml_span(lines, table)
|
|
216
|
+
if start is None: # 表不存在:整段追加
|
|
217
|
+
if lines and lines[-1].strip():
|
|
218
|
+
lines.append("")
|
|
219
|
+
lines.append("[%s]" % table)
|
|
220
|
+
lines.append(new_line)
|
|
221
|
+
return eol.join(lines).rstrip("\r\n") + eol
|
|
222
|
+
for i in range(start + 1, end):
|
|
223
|
+
m = pat.match(lines[i])
|
|
224
|
+
if m:
|
|
225
|
+
lines[i] = "%s\"%s\"%s" % (m.group(1), value.replace("\\", "\\\\").replace('"', '\\"'), m.group(2))
|
|
226
|
+
return eol.join(lines).rstrip("\r\n") + eol
|
|
227
|
+
lines.insert(end, new_line) # 表内末尾追加(表头区间终点即下一表头前)
|
|
228
|
+
return eol.join(lines).rstrip("\r\n") + eol
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ---------------------------------------------------------------- 模型配置
|
|
232
|
+
|
|
233
|
+
def _config_path(entry):
|
|
234
|
+
cfg = entry.get("config") or {}
|
|
235
|
+
return _safe_config_path(cfg.get("path"))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _yaml_model_path(cfg):
|
|
239
|
+
"""把 config.model_key 的点号路径拆成 (段, 键);无点号时段为 None(顶层键)。"""
|
|
240
|
+
return _dotted_key(cfg)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _dotted_key(cfg):
|
|
244
|
+
"""model_key 点号路径拆 (表/段, 键);无点号时第一元为 None(顶层键)。"""
|
|
245
|
+
key = (cfg.get("model_key") or "model").strip()
|
|
246
|
+
if "." in key:
|
|
247
|
+
section, leaf = key.split(".", 1)
|
|
248
|
+
return section.strip(), leaf.strip()
|
|
249
|
+
return None, key
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _yaml_quote(value):
|
|
253
|
+
"""YAML 双引号标量。必须加引号:模型名可能以 [ 开头(YAML 流序列)或含 #。"""
|
|
254
|
+
return '"%s"' % value.replace("\\", "\\\\").replace('"', '\\"')
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _yaml_unquote(raw):
|
|
258
|
+
"""取 YAML 标量的值:剥引号、丢行尾注释。引号内的 # 不算注释。"""
|
|
259
|
+
s = (raw or "").strip()
|
|
260
|
+
if not s:
|
|
261
|
+
return ""
|
|
262
|
+
if s[0] in ("'", '"'):
|
|
263
|
+
q = s[0]
|
|
264
|
+
i = 1
|
|
265
|
+
buf = []
|
|
266
|
+
while i < len(s):
|
|
267
|
+
ch = s[i]
|
|
268
|
+
if q == '"' and ch == "\\" and i + 1 < len(s):
|
|
269
|
+
nxt = s[i + 1]
|
|
270
|
+
buf.append('"' if nxt == '"' else ("\\" if nxt == "\\" else nxt))
|
|
271
|
+
i += 2
|
|
272
|
+
continue
|
|
273
|
+
if ch == q:
|
|
274
|
+
if q == "'" and i + 1 < len(s) and s[i + 1] == "'": # '' 转义
|
|
275
|
+
buf.append("'")
|
|
276
|
+
i += 2
|
|
277
|
+
continue
|
|
278
|
+
break
|
|
279
|
+
buf.append(ch)
|
|
280
|
+
i += 1
|
|
281
|
+
return "".join(buf).strip()
|
|
282
|
+
# 无引号:截断行尾注释(# 前的空白才算注释起始)
|
|
283
|
+
return re.split(r"\s+#", s, 1)[0].strip()
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _yaml_span(lines, section):
|
|
287
|
+
"""定位顶层段的行区间 [start, end);start 为段名行,其子键在 start+1..end。"""
|
|
288
|
+
start = None
|
|
289
|
+
for i, ln in enumerate(lines):
|
|
290
|
+
m = re.match(r"^([^\s#][^:]*):\s*(.*)$", ln)
|
|
291
|
+
if not m:
|
|
292
|
+
continue
|
|
293
|
+
if m.group(1).strip() == section:
|
|
294
|
+
start = i
|
|
295
|
+
continue
|
|
296
|
+
if start is not None:
|
|
297
|
+
return start, i
|
|
298
|
+
if start is None:
|
|
299
|
+
return None, None
|
|
300
|
+
return start, len(lines)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _yaml_read_value(text, section, leaf):
|
|
304
|
+
lines = text.splitlines()
|
|
305
|
+
if section is None:
|
|
306
|
+
m = re.search(r"(?m)^%s\s*:\s*(.+?)\s*$" % re.escape(leaf), text)
|
|
307
|
+
return _yaml_unquote(m.group(1)) or None if m else None
|
|
308
|
+
start, end = _yaml_span(lines, section)
|
|
309
|
+
if start is None:
|
|
310
|
+
return None
|
|
311
|
+
for ln in lines[start + 1:end]:
|
|
312
|
+
m = re.match(r"^\s+%s\s*:\s*(.+?)\s*$" % re.escape(leaf), ln)
|
|
313
|
+
if m:
|
|
314
|
+
return _yaml_unquote(m.group(1)) or None
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _yaml_write_value(text, section, leaf, value):
|
|
319
|
+
"""就地写入 YAML 的 section.leaf,保留其余内容、缩进与换行风格。"""
|
|
320
|
+
eol = "\r\n" if "\r\n" in text else "\n"
|
|
321
|
+
lines = text.splitlines()
|
|
322
|
+
quoted = _yaml_quote(value)
|
|
323
|
+
if section is None:
|
|
324
|
+
for i, ln in enumerate(lines):
|
|
325
|
+
if re.match(r"^%s\s*:" % re.escape(leaf), ln):
|
|
326
|
+
lines[i] = "%s: %s" % (leaf, quoted)
|
|
327
|
+
break
|
|
328
|
+
else:
|
|
329
|
+
lines.append("%s: %s" % (leaf, quoted))
|
|
330
|
+
return eol.join(lines).rstrip("\r\n") + eol
|
|
331
|
+
start, end = _yaml_span(lines, section)
|
|
332
|
+
if start is None: # 段不存在:整段追加
|
|
333
|
+
if lines and lines[-1].strip():
|
|
334
|
+
lines.append("")
|
|
335
|
+
lines.append("%s:" % section)
|
|
336
|
+
lines.append(" %s: %s" % (leaf, quoted))
|
|
337
|
+
return eol.join(lines).rstrip("\r\n") + eol
|
|
338
|
+
for i in range(start + 1, end):
|
|
339
|
+
m = re.match(r"^(\s+)%s\s*:" % re.escape(leaf), lines[i])
|
|
340
|
+
if m: # 键已存在:只换值,保留原缩进
|
|
341
|
+
lines[i] = "%s%s: %s" % (m.group(1), leaf, quoted)
|
|
342
|
+
return eol.join(lines).rstrip("\r\n") + eol
|
|
343
|
+
indent, last = " ", start # 段存在但无该键:跟随段内缩进、追加到段尾
|
|
344
|
+
for i in range(start + 1, end):
|
|
345
|
+
if not lines[i].strip():
|
|
346
|
+
continue
|
|
347
|
+
if indent == " ":
|
|
348
|
+
m = re.match(r"^(\s+)\S", lines[i])
|
|
349
|
+
if m:
|
|
350
|
+
indent = m.group(1)
|
|
351
|
+
last = i
|
|
352
|
+
lines.insert(last + 1, "%s%s: %s" % (indent, leaf, quoted))
|
|
353
|
+
return eol.join(lines).rstrip("\r\n") + eol
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _json_path_get(data, keys):
|
|
357
|
+
"""沿点号路径下钻 JSON 嵌套;终点必须是字符串。"""
|
|
358
|
+
cur = data
|
|
359
|
+
for k in keys:
|
|
360
|
+
if isinstance(cur, dict) and k in cur:
|
|
361
|
+
cur = cur[k]
|
|
362
|
+
else:
|
|
363
|
+
return None
|
|
364
|
+
return cur if isinstance(cur, str) else None
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _json_path_set(data, keys, value):
|
|
368
|
+
"""沿点号路径写入 JSON 嵌套,缺中间对象就地创建;
|
|
369
|
+
中途遇到非 dict(如 string 简写形式)升级为对象。"""
|
|
370
|
+
cur = data
|
|
371
|
+
for k in keys[:-1]:
|
|
372
|
+
if not isinstance(cur.get(k), dict):
|
|
373
|
+
cur[k] = {}
|
|
374
|
+
cur = cur[k]
|
|
375
|
+
cur[keys[-1]] = value
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def read_model(entry):
|
|
379
|
+
path = _config_path(entry)
|
|
380
|
+
cfg = entry.get("config") or {}
|
|
381
|
+
if not path or not os.path.isfile(path) or not cfg.get("format"):
|
|
382
|
+
return None
|
|
383
|
+
try:
|
|
384
|
+
text = open(path, encoding="utf-8", errors="replace").read()
|
|
385
|
+
except Exception:
|
|
386
|
+
return None
|
|
387
|
+
if cfg["format"] == "toml-line":
|
|
388
|
+
m = re.search(r'(?m)^\s*model\s*=\s*"([^"]+)"', text)
|
|
389
|
+
return m.group(1) if m else None
|
|
390
|
+
if cfg["format"] == "toml-section":
|
|
391
|
+
table, leaf = _dotted_key(cfg)
|
|
392
|
+
return _toml_read_value(text, table, leaf)
|
|
393
|
+
if cfg["format"] == "json":
|
|
394
|
+
try:
|
|
395
|
+
v = json.loads(text).get("model")
|
|
396
|
+
if v:
|
|
397
|
+
return v
|
|
398
|
+
except Exception:
|
|
399
|
+
pass
|
|
400
|
+
m = re.search(r'"model"\s*:\s*"([^"]+)"', text)
|
|
401
|
+
return m.group(1) if m else None
|
|
402
|
+
if cfg["format"] == "jsonc":
|
|
403
|
+
keys = (cfg.get("model_key") or "model").split(".")
|
|
404
|
+
try:
|
|
405
|
+
data = json.loads(_jsonc_strip_comments(text) or "{}")
|
|
406
|
+
except Exception:
|
|
407
|
+
return None
|
|
408
|
+
return _json_path_get(data, keys)
|
|
409
|
+
if cfg["format"] == "json-path":
|
|
410
|
+
try:
|
|
411
|
+
data = json.loads(text or "{}")
|
|
412
|
+
except Exception:
|
|
413
|
+
return None
|
|
414
|
+
return _json_path_get(data, (cfg.get("model_key") or "model").split("."))
|
|
415
|
+
if cfg["format"] == "yaml-line":
|
|
416
|
+
return _yaml_read_value(text, *_yaml_model_path(cfg))
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def write_model(entry, model):
|
|
421
|
+
"""写入默认模型(改动前自动备份 .bak)。支持 toml-line / toml-section /
|
|
422
|
+
json / json-path / jsonc / yaml-line。"""
|
|
423
|
+
path = _config_path(entry)
|
|
424
|
+
cfg = entry.get("config") or {}
|
|
425
|
+
fmt = cfg.get("format")
|
|
426
|
+
if not path:
|
|
427
|
+
return {"ok": False,
|
|
428
|
+
"error": "配置路径无效或不在用户主目录内,已拒绝写入"}
|
|
429
|
+
if fmt not in _WRITABLE_FORMATS:
|
|
430
|
+
return {"ok": False, "error": "该工具的模型配置格式暂不支持自动写入,请手动编辑 %s" % path}
|
|
431
|
+
model = (model or "").strip()
|
|
432
|
+
if not model:
|
|
433
|
+
return {"ok": False, "error": "模型名不能为空"}
|
|
434
|
+
# 写入前二次校验:解析真实路径后必须仍在用户主目录内(防穿越/符号链接),
|
|
435
|
+
# 校验通过后统一改用解析路径写入
|
|
436
|
+
home = os.path.abspath(os.path.expanduser("~"))
|
|
437
|
+
path = os.path.realpath(path)
|
|
438
|
+
try:
|
|
439
|
+
if os.path.commonpath([path, home]) != home:
|
|
440
|
+
return {"ok": False, "error": "配置路径越出用户主目录,已拒绝写入"}
|
|
441
|
+
except ValueError:
|
|
442
|
+
return {"ok": False, "error": "配置路径越出用户主目录,已拒绝写入"}
|
|
443
|
+
try:
|
|
444
|
+
if os.path.isfile(path):
|
|
445
|
+
shutil.copyfile(path, path + ".bak")
|
|
446
|
+
else:
|
|
447
|
+
# 各 CLI 首次运行都未必建主配置(grok 不建 config.toml、pi 不建
|
|
448
|
+
# settings.json、openclaw 缺失即安全默认——官方文档明确「缺失即
|
|
449
|
+
# 内置默认」);它正是用户层覆盖的落点,缺了按需创建
|
|
450
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
451
|
+
Path(path).write_bytes(b"{}" if fmt in ("json", "json-path", "jsonc") else b"")
|
|
452
|
+
if fmt == "jsonc":
|
|
453
|
+
# mimo(mimocode.jsonc)有注释,整体重解析会丢注释——复用
|
|
454
|
+
# _jsonc_set 做就地片段改写(只动目标键,其余原样保留)
|
|
455
|
+
keys = (cfg.get("model_key") or "model").split(".")
|
|
456
|
+
text = open(path, encoding="utf-8", errors="replace", newline="").read()
|
|
457
|
+
new_text, ok = _jsonc_set(text, tuple(keys),
|
|
458
|
+
json.dumps(model, ensure_ascii=False))
|
|
459
|
+
if not ok:
|
|
460
|
+
return {"ok": False,
|
|
461
|
+
"error": "jsonc 结构异常,未能就地写入 %s(已避免覆盖)" % path}
|
|
462
|
+
Path(path).write_bytes(new_text.encode("utf-8"))
|
|
463
|
+
elif fmt == "toml-line":
|
|
464
|
+
text = open(path, encoding="utf-8", errors="replace").read()
|
|
465
|
+
new_line = 'model = "%s"' % model
|
|
466
|
+
if re.search(r'(?m)^\s*model\s*=\s*"[^"]*"', text):
|
|
467
|
+
text = re.sub(r'(?m)^\s*model\s*=\s*"[^"]*"', new_line, text)
|
|
468
|
+
else:
|
|
469
|
+
text = text.rstrip("\n") + "\n" + new_line + "\n"
|
|
470
|
+
Path(path).write_bytes(text.encode("utf-8"))
|
|
471
|
+
elif fmt == "toml-section":
|
|
472
|
+
text = open(path, encoding="utf-8", errors="replace", newline="").read()
|
|
473
|
+
text = _toml_write_value(text, *_dotted_key(cfg), value=model)
|
|
474
|
+
Path(path).write_bytes(text.encode("utf-8"))
|
|
475
|
+
elif fmt == "yaml-line":
|
|
476
|
+
# newline="" 关掉通用换行转换:文本层面看不出 \r\n 就会被静默改写成 LF,
|
|
477
|
+
# 用户的 Windows 配置不该因为写个模型名而整篇换行符被替换
|
|
478
|
+
text = open(path, encoding="utf-8", errors="replace", newline="").read()
|
|
479
|
+
text = _yaml_write_value(text, *_yaml_model_path(cfg), value=model)
|
|
480
|
+
Path(path).write_bytes(text.encode("utf-8"))
|
|
481
|
+
elif fmt == "json-path":
|
|
482
|
+
# openclaw(agents.defaults.model.primary):默认模型藏在嵌套对象里,
|
|
483
|
+
# 且 openclaw 对未知顶层键直接拒绝启动——绝不能写顶层 "model"
|
|
484
|
+
text = open(path, encoding="utf-8", errors="replace", newline="").read()
|
|
485
|
+
try:
|
|
486
|
+
data = json.loads(text) if text.strip() else {}
|
|
487
|
+
except Exception:
|
|
488
|
+
return {"ok": False, "error": "配置文件不是合法 JSON,已中止(避免覆盖)"}
|
|
489
|
+
_json_path_set(data, (cfg.get("model_key") or "model").split("."), model)
|
|
490
|
+
# pi 的 settings.json:defaultModel 必须配 defaultProvider 才能解析出
|
|
491
|
+
# (provider, model) 二元组;catalog 里声明了的伴随键一并落盘。
|
|
492
|
+
# setdefault 语义:用户已设的值(如 defaultProvider: anthropic)不被空串覆盖
|
|
493
|
+
for k, v in (cfg.get("model_extra_keys") or {}).items():
|
|
494
|
+
cur = data
|
|
495
|
+
ks = k.split(".")
|
|
496
|
+
for kk in ks[:-1]:
|
|
497
|
+
if not isinstance(cur.get(kk), dict):
|
|
498
|
+
cur[kk] = {}
|
|
499
|
+
cur = cur[kk]
|
|
500
|
+
if cur.get(ks[-1]) in (None, ""):
|
|
501
|
+
cur[ks[-1]] = v
|
|
502
|
+
Path(path).write_bytes(
|
|
503
|
+
json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
|
|
504
|
+
else:
|
|
505
|
+
try:
|
|
506
|
+
data = json.loads(open(path, encoding="utf-8", errors="replace").read())
|
|
507
|
+
except FileNotFoundError:
|
|
508
|
+
return {"ok": False,
|
|
509
|
+
"error": "配置文件尚未生成(%s 首次运行后才有),暂无法写入" % path}
|
|
510
|
+
except Exception:
|
|
511
|
+
return {"ok": False, "error": "配置文件不是合法 JSON,已中止(避免覆盖)"}
|
|
512
|
+
data["model"] = model
|
|
513
|
+
Path(path).write_bytes(
|
|
514
|
+
json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
|
|
515
|
+
return {"ok": True, "model": read_model(entry)}
|
|
516
|
+
except Exception as e:
|
|
517
|
+
return {"ok": False, "error": repr(e)}
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
# ---------------------------------------------------------------- 一键打开
|
|
521
|
+
|
|
522
|
+
def _port_open(port, timeout=0.5):
|
|
523
|
+
try:
|
|
524
|
+
with socket.create_connection(("127.0.0.1", port), timeout=timeout):
|
|
525
|
+
return True
|
|
526
|
+
except OSError:
|
|
527
|
+
return False
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def launch_env(entry):
|
|
531
|
+
"""打开交互/网页版时注入的子进程环境变量:与编排同源的绑定凭据
|
|
532
|
+
(dsh=DEEPSEEK_*,claude=ANTHROPIC_*,codex=ORCH_API_KEY)。交互进程
|
|
533
|
+
脱离了编排链路,没有这层注入就拿不到 API key。无绑定时返回 {}。"""
|
|
534
|
+
try:
|
|
535
|
+
from . import modelhub # 惰性导入:modelhub 体量大且避免潜在环
|
|
536
|
+
b = modelhub.resolve_binding(entry["id"]) or {}
|
|
537
|
+
except Exception:
|
|
538
|
+
return {}
|
|
539
|
+
return dict(b.get("env") or {})
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _launch_log_path(entry):
|
|
543
|
+
"""web 类启动日志的落点:id 白名单化后仅作文件名成分,最终路径必须仍围栏
|
|
544
|
+
在数据目录内(catalog.json 用户可编辑,id 不可信;非白名单 id 用 crc32
|
|
545
|
+
稳定代称——跨进程重启不变,「已在运行」回读上次日志才找得到)。"""
|
|
546
|
+
raw_id = str(entry.get("id") or "")
|
|
547
|
+
if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", raw_id):
|
|
548
|
+
stem = raw_id
|
|
549
|
+
else:
|
|
550
|
+
stem = "agent-%d" % (zlib.crc32(raw_id.encode("utf-8")) & 0xFFFFFFFF)
|
|
551
|
+
data_root = os.path.abspath(str(paths.DATA_DIR))
|
|
552
|
+
p = Path(data_root, "launch", stem + ".log")
|
|
553
|
+
try:
|
|
554
|
+
if os.path.commonpath([os.path.abspath(str(p)), data_root]) != data_root:
|
|
555
|
+
return None
|
|
556
|
+
except ValueError:
|
|
557
|
+
return None
|
|
558
|
+
return p
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _best_url(log_path, port):
|
|
562
|
+
"""从启动日志提取该端口的信任 URL(含 token 优先)。无日志/未匹配返回 None。"""
|
|
563
|
+
try:
|
|
564
|
+
text = open(str(log_path), encoding="utf-8", errors="replace").read()
|
|
565
|
+
except Exception:
|
|
566
|
+
return None
|
|
567
|
+
urls = re.findall(r"https?://[^\s\"'<>]+", text)
|
|
568
|
+
same = [u for u in urls if ":%d" % port in u]
|
|
569
|
+
if not same:
|
|
570
|
+
return None
|
|
571
|
+
tokened = [u for u in same if "token=" in u]
|
|
572
|
+
return (tokened or same)[0]
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _yaml_model_ids(text, section):
|
|
576
|
+
"""收集 section.models 序列里的全部模型 id(保持顺序)。段/键缺失返回 []。"""
|
|
577
|
+
lines = text.splitlines()
|
|
578
|
+
start, end = _yaml_span(lines, section)
|
|
579
|
+
if start is None:
|
|
580
|
+
return []
|
|
581
|
+
m_indent = None
|
|
582
|
+
for i in range(start + 1, end):
|
|
583
|
+
m = re.match(r"^(\s+)models\s*:\s*(?:#.*)?$", lines[i])
|
|
584
|
+
if m:
|
|
585
|
+
m_indent = len(m.group(1))
|
|
586
|
+
start = i
|
|
587
|
+
break
|
|
588
|
+
if m_indent is None:
|
|
589
|
+
return []
|
|
590
|
+
ids = []
|
|
591
|
+
for ln in lines[start + 1:end]:
|
|
592
|
+
if not ln.strip():
|
|
593
|
+
continue
|
|
594
|
+
if len(ln) - len(ln.lstrip(" ")) <= m_indent:
|
|
595
|
+
break # models 列表结束(遇到同级或更浅缩进的键)
|
|
596
|
+
m = re.match(r"^\s*-\s+id\s*:\s*(.+?)\s*$", ln)
|
|
597
|
+
if m:
|
|
598
|
+
ids.append(_yaml_unquote(m.group(1)))
|
|
599
|
+
return ids
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _yaml_ensure_model_entry(text, section, model, context_window=1000000):
|
|
603
|
+
"""确保 section.models 序列里有 id==model 的条目,缺则按现有条目形状追加
|
|
604
|
+
(id/name/contextWindow——dsh 的 catalog 校验要求 id 与 name 非空)。
|
|
605
|
+
已存在或段/列表结构不完整时原文返回。返回 (new_text, added)。"""
|
|
606
|
+
eol = "\r\n" if "\r\n" in text else "\n"
|
|
607
|
+
lines = text.splitlines()
|
|
608
|
+
start, end = _yaml_span(lines, section)
|
|
609
|
+
if start is None:
|
|
610
|
+
return text, False
|
|
611
|
+
m_idx = m_indent = None
|
|
612
|
+
for i in range(start + 1, end):
|
|
613
|
+
m = re.match(r"^(\s+)models\s*:\s*(?:#.*)?$", lines[i])
|
|
614
|
+
if m:
|
|
615
|
+
m_idx, m_indent = i, len(m.group(1))
|
|
616
|
+
break
|
|
617
|
+
if m_idx is None:
|
|
618
|
+
return text, False # 段内没有 models 键:不凭空造结构(保守)
|
|
619
|
+
item_indent = None
|
|
620
|
+
last_item = m_idx
|
|
621
|
+
i = m_idx + 1
|
|
622
|
+
while i < end:
|
|
623
|
+
ln = lines[i]
|
|
624
|
+
if not ln.strip():
|
|
625
|
+
i += 1
|
|
626
|
+
continue
|
|
627
|
+
if len(ln) - len(ln.lstrip(" ")) <= m_indent:
|
|
628
|
+
break # models 列表结束
|
|
629
|
+
m = re.match(r"^(\s*)-\s+id\s*:\s*(.+?)\s*$", ln)
|
|
630
|
+
if m:
|
|
631
|
+
item_indent = m.group(1)
|
|
632
|
+
last_item = i
|
|
633
|
+
if _yaml_unquote(m.group(2)) == model:
|
|
634
|
+
return text, False
|
|
635
|
+
elif re.match(r"^\s+\S", ln):
|
|
636
|
+
last_item = i # 条目的续属性行(name/contextWindow…)
|
|
637
|
+
i += 1
|
|
638
|
+
item_indent = item_indent or (" " * (m_indent + 2))
|
|
639
|
+
block = ["%s- id: %s" % (item_indent, _yaml_quote(model)),
|
|
640
|
+
"%s name: %s" % (item_indent, _yaml_quote(model)),
|
|
641
|
+
"%s contextWindow: %d" % (item_indent, context_window)]
|
|
642
|
+
lines[last_item + 1:last_item + 1] = block
|
|
643
|
+
return eol.join(lines).rstrip("\r\n") + eol, True
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _sync_dsh_settings(entry, model, base_url):
|
|
647
|
+
"""dsh 专属:把绑定模型的端点与模型目录写进 ~/.dsh/settings.yaml 的
|
|
648
|
+
llm-deepseek 段(agent-default-model.model 由 write_model 负责)。
|
|
649
|
+
端点不同步不行——settings 优先级高于 env,密钥会发给旧端点;
|
|
650
|
+
models 列表不同步不行——dsh web 的模型下拉只列它,缺条目就选不中。
|
|
651
|
+
返回错误串或 None。"""
|
|
652
|
+
path = _config_path(entry)
|
|
653
|
+
if not path:
|
|
654
|
+
return "dsh 配置路径无效"
|
|
655
|
+
path = os.path.realpath(path)
|
|
656
|
+
home = os.path.abspath(os.path.expanduser("~"))
|
|
657
|
+
try:
|
|
658
|
+
if os.path.commonpath([path, home]) != home:
|
|
659
|
+
return "dsh 配置路径越出用户主目录,已拒绝"
|
|
660
|
+
except ValueError:
|
|
661
|
+
return "dsh 配置路径越出用户主目录,已拒绝"
|
|
662
|
+
try:
|
|
663
|
+
if os.path.isfile(path):
|
|
664
|
+
text = open(path, encoding="utf-8", errors="replace", newline="").read()
|
|
665
|
+
else:
|
|
666
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
667
|
+
text = ""
|
|
668
|
+
text2 = _yaml_write_value(text, "llm-deepseek", "baseURL", base_url)
|
|
669
|
+
text2, _added = _yaml_ensure_model_entry(text2, "llm-deepseek", model)
|
|
670
|
+
if text2 != text:
|
|
671
|
+
if os.path.isfile(path):
|
|
672
|
+
shutil.copyfile(path, path + ".bak")
|
|
673
|
+
Path(path).write_bytes(text2.encode("utf-8"))
|
|
674
|
+
return None
|
|
675
|
+
except Exception as e:
|
|
676
|
+
return repr(e)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _dsh_selfcheck_model(entry):
|
|
680
|
+
"""dsh 无绑定时自检:agent-default-model.model 必须在端点 models 列表内,
|
|
681
|
+
否则 web UI 打开就是一个选不中的模型(glm-5.3-flash vs V4 端点的实况)。
|
|
682
|
+
不在列表则改选列表第一个并写回。返回 (生效模型, note)。"""
|
|
683
|
+
path = _config_path(entry)
|
|
684
|
+
if not path or not os.path.isfile(path):
|
|
685
|
+
return None, ""
|
|
686
|
+
try:
|
|
687
|
+
text = open(path, encoding="utf-8", errors="replace", newline="").read()
|
|
688
|
+
except Exception:
|
|
689
|
+
return None, ""
|
|
690
|
+
cur = _yaml_read_value(text, "agent-default-model", "model")
|
|
691
|
+
ids = _yaml_model_ids(text, "llm-deepseek")
|
|
692
|
+
if not ids:
|
|
693
|
+
return cur, "dsh 端点未登记任何模型,请先在 dsh 侧配置模型目录"
|
|
694
|
+
if cur in ids:
|
|
695
|
+
return cur, ""
|
|
696
|
+
first = ids[0]
|
|
697
|
+
w = write_model(entry, first)
|
|
698
|
+
fixed = w.get("model") if w.get("ok") else None
|
|
699
|
+
note = "dsh 默认模型 %s 不在端点模型列表,已改选 %s" % (cur or "(空)", first)
|
|
700
|
+
if not fixed:
|
|
701
|
+
note += "(写入失败:%s)" % w.get("error")
|
|
702
|
+
return fixed, note
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _dsh_key_present():
|
|
706
|
+
"""dsh 的密钥是否有着落:进程 env 或它自己的 ~/.dsh/.env(credentials-local)。"""
|
|
707
|
+
if os.environ.get("DEEPSEEK_API_KEY"):
|
|
708
|
+
return True
|
|
709
|
+
try:
|
|
710
|
+
envfile = os.path.join(os.path.expanduser("~"), ".dsh", ".env")
|
|
711
|
+
return "DEEPSEEK_API_KEY" in open(envfile, encoding="utf-8", errors="replace").read()
|
|
712
|
+
except Exception:
|
|
713
|
+
return False
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _toml_section_set(text, section, pairs):
|
|
717
|
+
"""就地写 TOML 段([section] 下多键)。段存在则逐键替换,缺则整段追加在文末。
|
|
718
|
+
只处理 codex config.toml 这种顶层简单段;返回 (new_text, changed)。"""
|
|
719
|
+
eol = "\r\n" if "\r\n" in text else "\n"
|
|
720
|
+
lines = text.splitlines()
|
|
721
|
+
start = end = None
|
|
722
|
+
header = "[%s]" % section
|
|
723
|
+
for i, ln in enumerate(lines):
|
|
724
|
+
if ln.strip() == header:
|
|
725
|
+
start = i
|
|
726
|
+
elif start is not None and ln.startswith("[") and ln.rstrip().endswith("]"):
|
|
727
|
+
end = i
|
|
728
|
+
break
|
|
729
|
+
if start is None:
|
|
730
|
+
block = [header] + ["%s = %s" % (k, v) for k, v in pairs]
|
|
731
|
+
if lines and lines[-1].strip():
|
|
732
|
+
lines.append("")
|
|
733
|
+
lines.extend(block)
|
|
734
|
+
return eol.join(lines).rstrip("\r\n") + eol, True
|
|
735
|
+
end = end if end is not None else len(lines)
|
|
736
|
+
changed = False
|
|
737
|
+
todo = dict(pairs)
|
|
738
|
+
for i in range(start + 1, end):
|
|
739
|
+
m = re.match(r"^(\s*)([A-Za-z0-9_.-]+)\s*=", lines[i])
|
|
740
|
+
if m and m.group(2) in todo:
|
|
741
|
+
lines[i] = "%s%s = %s" % (m.group(1), m.group(2), todo.pop(m.group(2)))
|
|
742
|
+
changed = True
|
|
743
|
+
if todo:
|
|
744
|
+
ins = start + 1
|
|
745
|
+
while ins < end and not lines[ins].strip():
|
|
746
|
+
ins += 1
|
|
747
|
+
for k, v in list(todo.items())[::-1]:
|
|
748
|
+
lines.insert(ins, "%s = %s" % (k, v))
|
|
749
|
+
changed = True
|
|
750
|
+
return eol.join(lines).rstrip("\r\n") + eol, changed
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _toml_top_set(text, key, value):
|
|
754
|
+
"""写 TOML 顶层键(第一个 [段] 之前的区域)。返回 (new_text, changed)。"""
|
|
755
|
+
eol = "\r\n" if "\r\n" in text else "\n"
|
|
756
|
+
lines = text.splitlines()
|
|
757
|
+
pat = re.compile(r"^(\s*)%s\s*=\s*.+$" % re.escape(key))
|
|
758
|
+
for i, ln in enumerate(lines):
|
|
759
|
+
if pat.match(ln):
|
|
760
|
+
lines[i] = "%s%s = %s" % (pat.match(ln).group(1), key, value)
|
|
761
|
+
return eol.join(lines).rstrip("\r\n") + eol, True
|
|
762
|
+
first_section = next((i for i, ln in enumerate(lines)
|
|
763
|
+
if ln.startswith("[") and ln.rstrip().endswith("]")), len(lines))
|
|
764
|
+
lines.insert(first_section, "%s = %s" % (key, value))
|
|
765
|
+
return eol.join(lines).rstrip("\r\n") + eol, True
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _sync_codex_settings(entry, model, cp):
|
|
769
|
+
"""codex 专属:把绑定供应商与模型写进 ~/.codex/config.toml
|
|
770
|
+
([model_providers.orch] 段 + 顶层 model_provider/model)。
|
|
771
|
+
|
|
772
|
+
codex 交互 TUI 不认编排的 -c 一次性覆盖,也不认 ORCH_API_KEY env——
|
|
773
|
+
没有 config.toml 里的 provider 段,绑定模型根本无处可用;而 model 单写
|
|
774
|
+
不写 provider 会指到 codex 自带 openai 官方端点上(401)。与编排的
|
|
775
|
+
_codex_provider_args 同构,但落 config 文件。返回错误串或 None。"""
|
|
776
|
+
path = _config_path(entry)
|
|
777
|
+
if not path:
|
|
778
|
+
return "codex 配置路径无效"
|
|
779
|
+
path = os.path.realpath(path)
|
|
780
|
+
home = os.path.abspath(os.path.expanduser("~"))
|
|
781
|
+
try:
|
|
782
|
+
if os.path.commonpath([path, home]) != home:
|
|
783
|
+
return "codex 配置路径越出用户主目录,已拒绝"
|
|
784
|
+
except ValueError:
|
|
785
|
+
return "codex 配置路径越出用户主目录,已拒绝"
|
|
786
|
+
name = cp.get("name", "orch")
|
|
787
|
+
def q(v):
|
|
788
|
+
return '"%s"' % str(v).replace("\\", "\\\\").replace('"', '\\"')
|
|
789
|
+
pairs = [("name", q(cp.get("name", name))),
|
|
790
|
+
("base_url", q(cp.get("base_url", ""))),
|
|
791
|
+
("env_key", q(cp.get("env_key", "ORCH_API_KEY"))),
|
|
792
|
+
("wire_api", q(cp.get("wire_api", "responses")))]
|
|
793
|
+
try:
|
|
794
|
+
if os.path.isfile(path):
|
|
795
|
+
text = open(path, encoding="utf-8", errors="replace", newline="").read()
|
|
796
|
+
else:
|
|
797
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
798
|
+
text = ""
|
|
799
|
+
text2, _ = _toml_section_set(text, "model_providers.%s" % name, pairs)
|
|
800
|
+
text2, _ = _toml_top_set(text2, "model_provider", q(name))
|
|
801
|
+
if model:
|
|
802
|
+
text2, _ = _toml_top_set(text2, "model", q(model))
|
|
803
|
+
if text2 != text:
|
|
804
|
+
if os.path.isfile(path):
|
|
805
|
+
shutil.copyfile(path, path + ".bak")
|
|
806
|
+
Path(path).write_bytes(text2.encode("utf-8"))
|
|
807
|
+
return None
|
|
808
|
+
except Exception as e:
|
|
809
|
+
return repr(e)
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
# ---------------------------------------------------------------- 打开前凭据注入
|
|
813
|
+
|
|
814
|
+
def _jsonc_scan_object(text, start):
|
|
815
|
+
"""扫描 text[start](须为 '{')起的 JSON(C) 对象:返回 (闭合偏移, 键表)。
|
|
816
|
+
键表为 [key, key_start, key_end, value_start, value_end](偏移相对整个 text,
|
|
817
|
+
value_end 指向值结束后一格)。跳过字符串转义、// 与 /* */ 注释、嵌套括号,
|
|
818
|
+
未闭合时容错返回文末。"""
|
|
819
|
+
n = len(text)
|
|
820
|
+
i = start + 1
|
|
821
|
+
keys = []
|
|
822
|
+
state = "key"
|
|
823
|
+
depth = 0
|
|
824
|
+
|
|
825
|
+
def skip_string(j):
|
|
826
|
+
while j < n:
|
|
827
|
+
if text[j] == "\\":
|
|
828
|
+
j += 2
|
|
829
|
+
continue
|
|
830
|
+
if text[j] == '"':
|
|
831
|
+
return j + 1
|
|
832
|
+
j += 1
|
|
833
|
+
return n
|
|
834
|
+
|
|
835
|
+
def skip_comment(j):
|
|
836
|
+
if j + 1 < n and text[j + 1] == "/":
|
|
837
|
+
e = text.find("\n", j)
|
|
838
|
+
return n if e < 0 else e
|
|
839
|
+
if j + 1 < n and text[j + 1] == "*":
|
|
840
|
+
e = text.find("*/", j + 2)
|
|
841
|
+
return n if e < 0 else e + 2
|
|
842
|
+
return j + 1 # 非注释的孤立斜杠:当普通字符
|
|
843
|
+
|
|
844
|
+
while i < n:
|
|
845
|
+
ch = text[i]
|
|
846
|
+
if ch in " \t\r\n":
|
|
847
|
+
i += 1
|
|
848
|
+
continue
|
|
849
|
+
if ch == "/":
|
|
850
|
+
i = skip_comment(i)
|
|
851
|
+
continue
|
|
852
|
+
if state == "key":
|
|
853
|
+
if ch == "}":
|
|
854
|
+
return i, keys
|
|
855
|
+
if ch == ",":
|
|
856
|
+
i += 1
|
|
857
|
+
continue
|
|
858
|
+
if ch == '"':
|
|
859
|
+
j = skip_string(i + 1)
|
|
860
|
+
keys.append([text[i + 1:j - 1], i, j, 0, 0])
|
|
861
|
+
state = "colon"
|
|
862
|
+
i = j
|
|
863
|
+
continue
|
|
864
|
+
i += 1
|
|
865
|
+
continue
|
|
866
|
+
if state == "colon":
|
|
867
|
+
if ch == ":":
|
|
868
|
+
state = "value"
|
|
869
|
+
i += 1
|
|
870
|
+
continue
|
|
871
|
+
# state == value
|
|
872
|
+
if not keys:
|
|
873
|
+
return n, keys # 结构异常:放弃扫描
|
|
874
|
+
keys[-1][3] = i
|
|
875
|
+
if ch in "{[":
|
|
876
|
+
depth = 0
|
|
877
|
+
j = i
|
|
878
|
+
while j < n:
|
|
879
|
+
c = text[j]
|
|
880
|
+
if c == '"':
|
|
881
|
+
j = skip_string(j + 1)
|
|
882
|
+
continue
|
|
883
|
+
if c == "/":
|
|
884
|
+
j = skip_comment(j)
|
|
885
|
+
continue
|
|
886
|
+
if c in "{[":
|
|
887
|
+
depth += 1
|
|
888
|
+
elif c in "}]":
|
|
889
|
+
depth -= 1
|
|
890
|
+
if depth == 0:
|
|
891
|
+
break
|
|
892
|
+
j += 1
|
|
893
|
+
keys[-1][4] = j + 1
|
|
894
|
+
i = j + 1
|
|
895
|
+
state = "key"
|
|
896
|
+
elif ch == '"':
|
|
897
|
+
j = skip_string(i + 1)
|
|
898
|
+
keys[-1][4] = j
|
|
899
|
+
i = j
|
|
900
|
+
state = "key"
|
|
901
|
+
else: # 数字 / true / false / null(或值后直接撞上 , } 换行的容错)
|
|
902
|
+
j = i
|
|
903
|
+
while j < n and text[j] not in ",}\r\n":
|
|
904
|
+
j += 1
|
|
905
|
+
keys[-1][4] = j
|
|
906
|
+
# j==i 说明值位置直接是分隔符/换行(空值或状态残留)——必须前进一格,
|
|
907
|
+
# 否则 while i < n 永远停在原地(死循环)
|
|
908
|
+
i = j if j > i else j + 1
|
|
909
|
+
state = "key"
|
|
910
|
+
return n, keys
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def _jsonc_strip_comments(text):
|
|
914
|
+
"""剥掉 jsonc 的 // 行注释与 /* */ 块注释,供 json.loads 解析。
|
|
915
|
+
字符串字面量内部的 //($schema 的 https:// 等)不剥——逐字符扫描;
|
|
916
|
+
简单的 re.sub(r"//[^\\n]*") 会把 URL 截断导致解析失败。"""
|
|
917
|
+
out = []
|
|
918
|
+
i, n = 0, len(text)
|
|
919
|
+
while i < n:
|
|
920
|
+
ch = text[i]
|
|
921
|
+
if ch == '"':
|
|
922
|
+
j = i + 1
|
|
923
|
+
while j < n:
|
|
924
|
+
if text[j] == "\\":
|
|
925
|
+
j += 2
|
|
926
|
+
continue
|
|
927
|
+
if text[j] == '"':
|
|
928
|
+
break
|
|
929
|
+
j += 1
|
|
930
|
+
out.append(text[i:min(j + 1, n)])
|
|
931
|
+
i = j + 1
|
|
932
|
+
continue
|
|
933
|
+
if ch == "/" and i + 1 < n and text[i + 1] == "/":
|
|
934
|
+
e = text.find("\n", i)
|
|
935
|
+
i = n if e < 0 else e # 行注释:换行符本身保留
|
|
936
|
+
continue
|
|
937
|
+
if ch == "/" and i + 1 < n and text[i + 1] == "*":
|
|
938
|
+
e = text.find("*/", i + 2)
|
|
939
|
+
i = n if e < 0 else e + 2
|
|
940
|
+
out.append(" ")
|
|
941
|
+
continue
|
|
942
|
+
out.append(ch)
|
|
943
|
+
i += 1
|
|
944
|
+
return "".join(out)
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def _jsonc_set(text, path, value_json):
|
|
948
|
+
"""JSON(C) 顶层就地写键:path=("provider","orch") 或 ("model",)。
|
|
949
|
+
只动目标片段,其余文本(含注释与原格式)原样保留。返回 (new_text, ok)。
|
|
950
|
+
空文件按空对象起笔(write_model 缺文件按需创建的产物)。"""
|
|
951
|
+
brace = text.find("{")
|
|
952
|
+
if brace < 0 and not text.strip():
|
|
953
|
+
return ('{\n "%s": %s\n}' % (path[0], value_json), True)
|
|
954
|
+
if brace < 0:
|
|
955
|
+
return text, False
|
|
956
|
+
end, keys = _jsonc_scan_object(text, brace)
|
|
957
|
+
head = path[0]
|
|
958
|
+
if len(path) == 1:
|
|
959
|
+
for _k, _ks, _ke, vs, ve in keys:
|
|
960
|
+
if _k == head:
|
|
961
|
+
return text[:vs] + value_json + text[ve:], True
|
|
962
|
+
return _jsonc_insert_entry(text, brace, end, head, value_json), True
|
|
963
|
+
# 二级路径:先定位一级键的值对象
|
|
964
|
+
tgt = None
|
|
965
|
+
for k, _ks, _ke, vs, ve in keys:
|
|
966
|
+
if k == head:
|
|
967
|
+
tgt = (vs, ve)
|
|
968
|
+
break
|
|
969
|
+
if tgt is None: # 一级键不存在:整体插入
|
|
970
|
+
block = json.dumps({path[1]: json.loads(value_json)}, ensure_ascii=False)
|
|
971
|
+
return _jsonc_insert_entry(text, brace, end, head, block), True
|
|
972
|
+
vs, ve = tgt
|
|
973
|
+
if text[vs:ve].lstrip()[0:1] != "{":
|
|
974
|
+
return text, False # 一级值不是对象:保守放弃(不覆盖用户的非标结构)
|
|
975
|
+
end2, keys2 = _jsonc_scan_object(text, vs)
|
|
976
|
+
for k, _ks, _ke, v2s, v2e in keys2:
|
|
977
|
+
if k == path[1]:
|
|
978
|
+
return text[:v2s] + value_json + text[v2e:], True
|
|
979
|
+
seg = text[vs:end2 + 1]
|
|
980
|
+
new_seg = _jsonc_insert_entry(seg, 0, end2 - vs, path[1], value_json)
|
|
981
|
+
if new_seg is None:
|
|
982
|
+
return text, False
|
|
983
|
+
return text[:vs] + new_seg + text[end2 + 1:], True
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def _jsonc_insert_entry(text, brace, end, key, value_json):
|
|
987
|
+
"""在 {brace..end} 对象的开头插入 "key": value(带尾逗号,不依赖原文件的
|
|
988
|
+
逗号风格);对象为空时去掉多余逗号。"""
|
|
989
|
+
m = re.match(r"\{([ \t\r\n]*)", text[brace:end + 1])
|
|
990
|
+
first = brace + (m.end(1) if m else 1) # m 从 brace 起 match:end(1) 已含 '{'
|
|
991
|
+
if first >= end: # 空对象 {}
|
|
992
|
+
return text[:brace + 1] + ' "%s": %s ' % (key, value_json) + text[end:]
|
|
993
|
+
return text[:first] + '"%s": %s,\n ' % (key, value_json) + text[first:]
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
def _sync_settings_env(path, updates, remove_keys=()):
|
|
997
|
+
"""把键值对写进目标 settings.json 的 env 段(claude/qwen 等同构:交互 TUI
|
|
998
|
+
启动时把该段合并进进程环境)。只动 env 相关键,其余内容保留;坏 JSON 中止
|
|
999
|
+
不覆盖;改动前 .bak。返回错误串或 None。path 必须已过主目录围栏校验。"""
|
|
1000
|
+
try:
|
|
1001
|
+
if os.path.isfile(path):
|
|
1002
|
+
text = open(path, encoding="utf-8", errors="replace").read()
|
|
1003
|
+
try:
|
|
1004
|
+
data = json.loads(text)
|
|
1005
|
+
except Exception:
|
|
1006
|
+
return "settings.json 不是合法 JSON,已中止(避免覆盖)"
|
|
1007
|
+
else:
|
|
1008
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
1009
|
+
data = {}
|
|
1010
|
+
if not isinstance(data, dict):
|
|
1011
|
+
return "settings.json 结构异常(顶层不是对象),已中止"
|
|
1012
|
+
env = data.get("env")
|
|
1013
|
+
if not isinstance(env, dict):
|
|
1014
|
+
env = {}
|
|
1015
|
+
for k in remove_keys:
|
|
1016
|
+
env.pop(k, None)
|
|
1017
|
+
env.update(updates)
|
|
1018
|
+
data["env"] = env
|
|
1019
|
+
if os.path.isfile(path):
|
|
1020
|
+
shutil.copyfile(path, path + ".bak")
|
|
1021
|
+
Path(path).write_bytes(json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
|
|
1022
|
+
return None
|
|
1023
|
+
except Exception as e:
|
|
1024
|
+
return repr(e)
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def _guard_home(path):
|
|
1028
|
+
"""展开并校验配置路径必须在用户主目录内;通过则返回 realpath,否则 None。"""
|
|
1029
|
+
if not path:
|
|
1030
|
+
return None
|
|
1031
|
+
path = os.path.realpath(path)
|
|
1032
|
+
home = os.path.abspath(os.path.expanduser("~"))
|
|
1033
|
+
try:
|
|
1034
|
+
if os.path.commonpath([path, home]) != home:
|
|
1035
|
+
return None
|
|
1036
|
+
except ValueError:
|
|
1037
|
+
return None
|
|
1038
|
+
return path
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def _sync_claude_settings(entry, model, prov):
|
|
1042
|
+
"""claude-code 专属:把 anthropic 供应商的端点+密钥+模型写进
|
|
1043
|
+
~/.claude/settings.json 的 env 段(交互 TUI 与无头共用该文件,只认
|
|
1044
|
+
ANTHROPIC_*;编排降级链给的 ORCH_API_KEY 对它等于没 key)。
|
|
1045
|
+
返回错误串或 None。"""
|
|
1046
|
+
path = _guard_home(_config_path(entry))
|
|
1047
|
+
if not path:
|
|
1048
|
+
return "claude 配置路径无效或越出用户主目录,已拒绝"
|
|
1049
|
+
updates = {"ANTHROPIC_BASE_URL": prov.get("base_url") or "",
|
|
1050
|
+
# AUTH_TOKEN 走 Bearer 头(Z.ai 等原生 anthropic 网关的用法);
|
|
1051
|
+
# 与 x-api-key 互斥,清掉可能残留的 ANTHROPIC_API_KEY 防止带错头
|
|
1052
|
+
"ANTHROPIC_AUTH_TOKEN": prov.get("api_key") or ""}
|
|
1053
|
+
if model:
|
|
1054
|
+
updates["ANTHROPIC_MODEL"] = model
|
|
1055
|
+
return _sync_settings_env(path, updates, remove_keys=("ANTHROPIC_API_KEY",))
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def _sync_qwen_settings(entry, model, prov):
|
|
1059
|
+
"""qwencode 专属:openai 兼容供应商写进 ~/.qwen/settings.json 的 env 段
|
|
1060
|
+
(qwen-code 实测认 OPENAI_API_KEY/OPENAI_BASE_URL/OPENAI_MODEL,存在即
|
|
1061
|
+
优先走 openai 兼容通道——2026-09-15 真机对维云端点实测请求到达并鉴权)。
|
|
1062
|
+
anthropic 协议未实证,不开(协议不匹配时宁可提示)。返回错误串或 None。"""
|
|
1063
|
+
path = _guard_home(_config_path(entry))
|
|
1064
|
+
if not path:
|
|
1065
|
+
return "qwen 配置路径无效或越出用户主目录,已拒绝"
|
|
1066
|
+
updates = {"OPENAI_API_KEY": prov.get("api_key") or "",
|
|
1067
|
+
"OPENAI_BASE_URL": prov.get("base_url") or ""}
|
|
1068
|
+
if model:
|
|
1069
|
+
updates["OPENAI_MODEL"] = model
|
|
1070
|
+
return _sync_settings_env(path, updates)
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def _opencode_config_candidates(entry):
|
|
1074
|
+
"""opencode 配置的候选路径:catalog 登记的 jsonc 优先,其次同目录的
|
|
1075
|
+
opencode.json(opencode 两种文件名都认,用户现有安装多用 json)。"""
|
|
1076
|
+
out = []
|
|
1077
|
+
primary = _config_path(entry)
|
|
1078
|
+
if primary:
|
|
1079
|
+
out.append(primary)
|
|
1080
|
+
alt = os.path.join(os.path.abspath(os.path.expanduser("~/.config/opencode")),
|
|
1081
|
+
"opencode.json")
|
|
1082
|
+
if alt not in out:
|
|
1083
|
+
out.append(alt)
|
|
1084
|
+
return out
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def _sync_opencode_settings(entry, model, prov):
|
|
1088
|
+
"""opencode 专属:把绑定供应商写进其配置的 provider.orch 段 + 顶层 model
|
|
1089
|
+
(opencode 交互 TUI 只认自家配置文件里的凭据,ORCH_API_KEY env 对它等于
|
|
1090
|
+
没 key)。写入所有已存在的候选文件(避免新文件遮蔽旧文件的读取优先级),
|
|
1091
|
+
全不存在时建 catalog 登记的那个。纯 JSON 走整体读改写;带注释的 JSONC 走
|
|
1092
|
+
文本级就地 patch(保留注释)。返回错误串或 None。"""
|
|
1093
|
+
npm = "@ai-sdk/anthropic" if prov.get("protocol") == "anthropic" else "@ai-sdk/openai-compatible"
|
|
1094
|
+
block = {"npm": npm, "name": prov.get("name") or "CodeBee 绑定",
|
|
1095
|
+
"options": {"baseURL": prov.get("base_url") or "",
|
|
1096
|
+
"apiKey": prov.get("api_key") or ""},
|
|
1097
|
+
"models": {model: {"name": model}} if model else {}}
|
|
1098
|
+
top_model = ("orch/" + model) if model else ""
|
|
1099
|
+
targets = [p for p in _opencode_config_candidates(entry) if os.path.isfile(p)] \
|
|
1100
|
+
or [_opencode_config_candidates(entry)[0]]
|
|
1101
|
+
errs = []
|
|
1102
|
+
for path in targets:
|
|
1103
|
+
try:
|
|
1104
|
+
path = os.path.realpath(path)
|
|
1105
|
+
home = os.path.abspath(os.path.expanduser("~"))
|
|
1106
|
+
try:
|
|
1107
|
+
if os.path.commonpath([path, home]) != home:
|
|
1108
|
+
errs.append("路径越出主目录:%s" % path)
|
|
1109
|
+
continue
|
|
1110
|
+
except ValueError:
|
|
1111
|
+
errs.append("路径越出主目录:%s" % path)
|
|
1112
|
+
continue
|
|
1113
|
+
if os.path.isfile(path):
|
|
1114
|
+
text = open(path, encoding="utf-8", errors="replace", newline="").read()
|
|
1115
|
+
else:
|
|
1116
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
1117
|
+
text = ""
|
|
1118
|
+
try:
|
|
1119
|
+
data = json.loads(text) if text.strip() else {}
|
|
1120
|
+
except Exception:
|
|
1121
|
+
data = None
|
|
1122
|
+
if data is not None: # 纯 JSON:整体读改写(保序)
|
|
1123
|
+
if not isinstance(data, dict):
|
|
1124
|
+
errs.append("结构异常(顶层不是对象):%s" % path)
|
|
1125
|
+
continue
|
|
1126
|
+
provs = data.get("provider")
|
|
1127
|
+
if not isinstance(provs, dict):
|
|
1128
|
+
provs = {}
|
|
1129
|
+
provs["orch"] = block
|
|
1130
|
+
data["provider"] = provs
|
|
1131
|
+
if top_model:
|
|
1132
|
+
data["model"] = top_model
|
|
1133
|
+
new_text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
|
|
1134
|
+
else: # JSONC(带注释):文本级 patch
|
|
1135
|
+
block_json = json.dumps(block, ensure_ascii=False)
|
|
1136
|
+
new_text, ok = _jsonc_set(text, ("provider", "orch"), block_json)
|
|
1137
|
+
if not ok:
|
|
1138
|
+
errs.append("JSONC 就地改写失败:%s" % path)
|
|
1139
|
+
continue
|
|
1140
|
+
if top_model:
|
|
1141
|
+
new_text, _ = _jsonc_set(new_text, ("model",),
|
|
1142
|
+
json.dumps(top_model))
|
|
1143
|
+
if os.path.isfile(path):
|
|
1144
|
+
shutil.copyfile(path, path + ".bak")
|
|
1145
|
+
Path(path).write_bytes(new_text.encode("utf-8"))
|
|
1146
|
+
except Exception as e:
|
|
1147
|
+
errs.append("%s: %r" % (path, e))
|
|
1148
|
+
return ";".join(errs) or None
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
# 打开前专属注入通道:{agent_id: (可注入协议, 注入器)}。交互 TUI 脱离编排链路,
|
|
1152
|
+
# 只认自家配置文件里的凭据,编排降级给的 env(ORCH_API_KEY 等)对它们无效。
|
|
1153
|
+
_AGENT_INJECTORS = {
|
|
1154
|
+
"claude-code": (("anthropic",), _sync_claude_settings),
|
|
1155
|
+
"opencode": (("anthropic", "openai"), _sync_opencode_settings),
|
|
1156
|
+
"qwencode": (("openai",), _sync_qwen_settings),
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
# 无专属注入通道的专有协议 CLI:env 注入大概率无效,打开时明确告知而非静默废
|
|
1160
|
+
# (mimo 系 opencode 衍生但配置路径未实证,先按提示类;grok 吃 XAI_API_KEY 但
|
|
1161
|
+
# 无端点 env 可指中转,openai 协议供应商也用不上)
|
|
1162
|
+
_NO_CHANNEL_HINT = ("grok-build", "pi", "mimo-code")
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
def _sync_agent_injection(entry, binding):
|
|
1166
|
+
"""打开前把绑定链里第一个可注入供应商落进 CLI 自家配置。与编排降级链解耦:
|
|
1167
|
+
协议不匹配不降级(claude 拿 openai 的 key 等于没 key)。返回给用户看的提示
|
|
1168
|
+
(成功注入 / 不可用原因),None=该 CLI 无需处理。"""
|
|
1169
|
+
from . import modelhub
|
|
1170
|
+
spec = _AGENT_INJECTORS.get(entry["id"])
|
|
1171
|
+
if spec:
|
|
1172
|
+
protocols, injector = spec
|
|
1173
|
+
pick, note = modelhub.launch_pick(entry["id"], protocols)
|
|
1174
|
+
if pick:
|
|
1175
|
+
prov = pick["provider"]
|
|
1176
|
+
err = injector(entry, pick["model"], prov)
|
|
1177
|
+
if err:
|
|
1178
|
+
return "%s 凭据同步失败:%s(打开后可能需在其自带界面登录)" % (
|
|
1179
|
+
entry.get("name", entry["id"]), err)
|
|
1180
|
+
return "已注入 %s(%s · %s)" % (prov.get("name") or "供应商",
|
|
1181
|
+
prov.get("protocol"), prov.get("base_url", ""))
|
|
1182
|
+
return note
|
|
1183
|
+
if entry["id"] in _NO_CHANNEL_HINT:
|
|
1184
|
+
if binding.get("env"):
|
|
1185
|
+
return "已按绑定注入 env,但该 CLI 未必认 CodeBee 的凭据通道,打开后若要求登录请在其界面内登录"
|
|
1186
|
+
return "未绑定可用供应商:打开后需在其自带界面登录;要打开即用请到「CLI 绑定」页绑定"
|
|
1187
|
+
return None
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
def _sync_launch_model(entry, binding):
|
|
1191
|
+
"""打开前把「CLI 绑定」页选中的模型落到该 CLI 自己的配置文件——保证
|
|
1192
|
+
交互/网页版启动即选中绑定模型(绑定页是运行时模型唯一真源,目录页的
|
|
1193
|
+
「默认模型」只是手动快照,会滞后)。dsh 额外同步端点与 models 目录;
|
|
1194
|
+
codex 额外落 provider 段(交互 TUI 只认 config.toml,不认编排的 -c 覆盖
|
|
1195
|
+
与 ORCH_API_KEY env)。返回给用户看的同步笔记列表。"""
|
|
1196
|
+
notes = []
|
|
1197
|
+
model = (binding.get("model") or "").strip()
|
|
1198
|
+
prov = binding.get("provider") or {}
|
|
1199
|
+
fmt = (entry.get("config") or {}).get("format")
|
|
1200
|
+
is_dsh = entry["id"] in ("deepseek-harness", "dsh")
|
|
1201
|
+
is_codex = entry["id"] in ("codex-cli", "codex")
|
|
1202
|
+
cp = binding.get("codex_provider")
|
|
1203
|
+
if model and fmt in _WRITABLE_FORMATS:
|
|
1204
|
+
w = write_model(entry, model)
|
|
1205
|
+
notes.append("模型已同步为 %s" % w["model"] if w.get("ok")
|
|
1206
|
+
else "模型同步失败:%s" % w.get("error"))
|
|
1207
|
+
elif model and not is_dsh and entry["id"] not in _AGENT_INJECTORS:
|
|
1208
|
+
# 有专属注入通道的 CLI 由 _sync_agent_injection 负责落模型(含 jsonc),
|
|
1209
|
+
# 不再报「只读配置」以免与注入成功的提示互相矛盾
|
|
1210
|
+
notes.append("该工具模型为只读配置,按其现有配置打开(绑定模型 %s 未自动写入)" % model)
|
|
1211
|
+
if is_dsh and model and prov.get("base_url"):
|
|
1212
|
+
err = _sync_dsh_settings(entry, model, prov["base_url"])
|
|
1213
|
+
notes.append("dsh 端点已同步为 %s" % prov["base_url"] if not err
|
|
1214
|
+
else "dsh 端点同步失败:%s" % err)
|
|
1215
|
+
elif is_dsh and not model:
|
|
1216
|
+
_fixed, note = _dsh_selfcheck_model(entry)
|
|
1217
|
+
if note:
|
|
1218
|
+
notes.append(note)
|
|
1219
|
+
if is_codex and cp:
|
|
1220
|
+
err = _sync_codex_settings(entry, model, cp)
|
|
1221
|
+
notes.append("codex 供应商已同步为 %s" % cp.get("base_url", "") if not err
|
|
1222
|
+
else "codex 供应商同步失败:%s" % err)
|
|
1223
|
+
inj = _sync_agent_injection(entry, binding)
|
|
1224
|
+
if inj:
|
|
1225
|
+
notes.append(inj)
|
|
1226
|
+
return notes
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
def launch(entry, open_browser=True):
|
|
1230
|
+
"""一键打开:web 类后台起服务并自动开浏览器;console 类新开终端窗口跑交互 TUI。
|
|
1231
|
+
|
|
1232
|
+
打开前把「CLI 绑定」页的模型落盘到该 CLI 配置文件(dsh 连端点与 models
|
|
1233
|
+
目录一起同步),并注入绑定密钥 env——保证打开即选中可用模型。"""
|
|
1234
|
+
if not detect_entry(entry).get("installed"):
|
|
1235
|
+
return {"ok": False, "error": "未安装,无法打开"}
|
|
1236
|
+
launch = entry.get("launch") or {}
|
|
1237
|
+
cmd = (launch.get("command") or "").strip()
|
|
1238
|
+
if not cmd:
|
|
1239
|
+
return {"ok": False, "error": "未配置打开命令(可在 data/catalog.json 补 launch 字段)"}
|
|
1240
|
+
is_dsh = entry["id"] in ("deepseek-harness", "dsh")
|
|
1241
|
+
try:
|
|
1242
|
+
from . import modelhub
|
|
1243
|
+
binding = modelhub.resolve_binding(entry["id"]) or {}
|
|
1244
|
+
except Exception:
|
|
1245
|
+
binding = {}
|
|
1246
|
+
env = os.environ.copy()
|
|
1247
|
+
env.update(binding.get("env") or {})
|
|
1248
|
+
notes = _sync_launch_model(entry, binding)
|
|
1249
|
+
if is_dsh and not (binding.get("env") or {}) and not _dsh_key_present():
|
|
1250
|
+
notes.append("未发现 dsh 密钥:打开后可能需在 dsh 内登录配置;"
|
|
1251
|
+
"要打开即用,请到「CLI 绑定」页给 DeepSeek Harness 绑定 openai 协议供应商")
|
|
1252
|
+
name = entry.get("name", entry["id"])
|
|
1253
|
+
kind = (launch.get("kind") or "console").lower()
|
|
1254
|
+
extra = (";".join(notes)) if notes else ""
|
|
1255
|
+
|
|
1256
|
+
if kind == "web":
|
|
1257
|
+
try:
|
|
1258
|
+
port = int(launch.get("port") or 0)
|
|
1259
|
+
except (TypeError, ValueError):
|
|
1260
|
+
port = 0
|
|
1261
|
+
if port <= 0:
|
|
1262
|
+
return {"ok": False, "error": "web 类打开必须配置固定端口(launch.port)"}
|
|
1263
|
+
bare = "http://127.0.0.1:%d" % port
|
|
1264
|
+
log_path = _launch_log_path(entry)
|
|
1265
|
+
if not log_path:
|
|
1266
|
+
return {"ok": False, "error": "启动日志路径不可信,已拒绝打开"}
|
|
1267
|
+
if _port_open(port):
|
|
1268
|
+
# 已在运行:新实例抢不到端口、新 token 也拿不到——从上次启动日志
|
|
1269
|
+
# 恢复带 token 的信任 URL(dsh web 有 /?token=... 围栏,裸开是 401)。
|
|
1270
|
+
# 模型同步照做:正在跑的实例不重启,改的是它下次生效的配置
|
|
1271
|
+
url = _best_url(log_path, port) or bare
|
|
1272
|
+
if open_browser:
|
|
1273
|
+
webbrowser.open(url)
|
|
1274
|
+
msg = "服务已在运行,已打开 " + url
|
|
1275
|
+
if extra:
|
|
1276
|
+
msg += "(" + extra + ")"
|
|
1277
|
+
return {"ok": True, "kind": "web", "url": url, "message": msg}
|
|
1278
|
+
# 后台起服务:无窗口、不阻塞请求;子进程输出重定向到启动日志(cmd 层
|
|
1279
|
+
# 重定向,路径含空格才加引号)——web 类普遍会在启动行打印带 token 的
|
|
1280
|
+
# 信任 URL(如 dsh web),就绪线程从日志提取后再开浏览器
|
|
1281
|
+
ls = str(log_path)
|
|
1282
|
+
spawn_cmd = "%s > %s 2>&1" % (cmd, ('"%s"' % ls) if " " in ls else ls)
|
|
1283
|
+
try:
|
|
1284
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1285
|
+
subprocess.Popen(["cmd", "/c", spawn_cmd], cwd=str(paths.ROOT), env=env,
|
|
1286
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
|
1287
|
+
stderr=subprocess.DEVNULL, creationflags=CREATE_NO_WINDOW)
|
|
1288
|
+
except Exception as e:
|
|
1289
|
+
return {"ok": False, "error": "无法启动服务: %r" % e}
|
|
1290
|
+
if open_browser:
|
|
1291
|
+
threading.Thread(target=_open_when_ready, args=(port, bare, log_path),
|
|
1292
|
+
name="launch-wait-%d" % port, daemon=True).start()
|
|
1293
|
+
return {"ok": True, "kind": "web", "url": bare,
|
|
1294
|
+
"message": "%s 正在启动,就绪后浏览器会自动打开(%s)%s"
|
|
1295
|
+
% (name, bare, (";" + extra) if extra else "")}
|
|
1296
|
+
|
|
1297
|
+
if sys.platform != "win32":
|
|
1298
|
+
return {"ok": False, "error": "终端窗口拉起暂仅支持 Windows"}
|
|
1299
|
+
# start 为目标命令新开一个可见终端窗口;cmd /k 让 CLI 退出后窗口保留,
|
|
1300
|
+
# 报错不至于一闪而过。外层 cmd 用 CREATE_NO_WINDOW 隐藏。
|
|
1301
|
+
argv = ["cmd", "/c", "start", "CodeBee %s" % name, "/D", str(paths.ROOT),
|
|
1302
|
+
"cmd", "/k", cmd]
|
|
1303
|
+
try:
|
|
1304
|
+
subprocess.Popen(argv, cwd=str(paths.ROOT), env=env,
|
|
1305
|
+
creationflags=CREATE_NO_WINDOW)
|
|
1306
|
+
except Exception as e:
|
|
1307
|
+
return {"ok": False, "error": "无法打开终端窗口: %r" % e}
|
|
1308
|
+
return {"ok": True, "kind": "console", "message": "已在新的终端窗口打开 %s%s"
|
|
1309
|
+
% (name, ("(" + extra + ")") if extra else "")}
|
|
1310
|
+
|
|
1311
|
+
|
|
1312
|
+
def _open_when_ready(port, url, log_path, timeout=30):
|
|
1313
|
+
"""等 web 服务端口就绪后开浏览器。优先从启动日志提取带 token 的信任 URL
|
|
1314
|
+
(端口通了 token 行可能还差几十毫秒才落盘,故就绪后最多再等 6 秒);
|
|
1315
|
+
超时兜底也开——服务可能只是慢,用户手动刷新即可。"""
|
|
1316
|
+
deadline = time.time() + timeout
|
|
1317
|
+
while time.time() < deadline:
|
|
1318
|
+
if _port_open(port):
|
|
1319
|
+
break
|
|
1320
|
+
time.sleep(0.5)
|
|
1321
|
+
token_deadline = time.time() + 6
|
|
1322
|
+
best = None
|
|
1323
|
+
while time.time() < token_deadline:
|
|
1324
|
+
best = _best_url(log_path, port)
|
|
1325
|
+
if best and "token=" in best:
|
|
1326
|
+
break
|
|
1327
|
+
time.sleep(0.5)
|
|
1328
|
+
try:
|
|
1329
|
+
webbrowser.open(best or url)
|
|
1330
|
+
except Exception:
|
|
1331
|
+
pass
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
# ---------------------------------------------------------------- 安装/升级
|
|
1335
|
+
|
|
1336
|
+
def run_mgmt_command(entry, op, cancel_event=None, log_path=None):
|
|
1337
|
+
"""执行 install/upgrade/uninstall 命令(在任务队列里跑,日志实时落盘)。"""
|
|
1338
|
+
if op == "uninstall":
|
|
1339
|
+
cmd = catalog.uninstall_command(entry)
|
|
1340
|
+
if not cmd:
|
|
1341
|
+
return {"ok": False,
|
|
1342
|
+
"error": "无法推导卸载命令:请在 data/catalog.json 的 \"%s\" 里配置 uninstall 字段"
|
|
1343
|
+
% entry["id"]}
|
|
1344
|
+
else:
|
|
1345
|
+
cmd = entry.get(op)
|
|
1346
|
+
if not cmd:
|
|
1347
|
+
return {"ok": False,
|
|
1348
|
+
"error": "未配置 %s 命令:请在 data/catalog.json 的 \"%s\" 里补充,或用官方渠道安装"
|
|
1349
|
+
% (op, entry["id"])}
|
|
1350
|
+
res = runner.run_process(shell_cmd=cmd, cwd=str(paths.ROOT),
|
|
1351
|
+
timeout=1800, cancel_event=cancel_event, log_path=log_path)
|
|
1352
|
+
detect_all(force=True)
|
|
1353
|
+
with _LOCK:
|
|
1354
|
+
_STATE["versions"].pop(entry["id"], None)
|
|
1355
|
+
return {"ok": res["ok"], "exit_code": res["exit_code"], "command": cmd,
|
|
1356
|
+
"error": "" if res["ok"] else (res["stderr"][-800:] or "退出码 %s" % res["exit_code"])}
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
# ---------------------------------------------------------------- 版本检查
|
|
1360
|
+
|
|
1361
|
+
_UPDATE_CACHE = {} # agent_id → (ts, {current, latest, updatable, note})
|
|
1362
|
+
UPDATE_TTL = 600
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
def _npm_pkg_name(cmd):
|
|
1366
|
+
"""从 npm 安装命令里取包名(实现已统一到 catalog.npm_pkg_name)。"""
|
|
1367
|
+
return catalog.npm_pkg_name(cmd)
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
def _ver_tuple(s):
|
|
1371
|
+
return [int(x) for x in re.findall(r"\d+", str(s or ""))[:4]]
|
|
1372
|
+
|
|
1373
|
+
|
|
1374
|
+
def check_update(entry, force=False):
|
|
1375
|
+
"""检查是否有新版本可用。npm 走 npm view;winget 走 winget upgrade 列表;
|
|
1376
|
+
其他渠道标记为不支持。结果缓存 10 分钟。"""
|
|
1377
|
+
eid = entry["id"]
|
|
1378
|
+
if not force:
|
|
1379
|
+
cached = _UPDATE_CACHE.get(eid)
|
|
1380
|
+
if cached and time.time() - cached[0] < UPDATE_TTL:
|
|
1381
|
+
return cached[1]
|
|
1382
|
+
current = version_of(entry)
|
|
1383
|
+
cur_num = ".".join(str(x) for x in _ver_tuple(current)) or "-"
|
|
1384
|
+
result = {"current": current, "latest": None, "updatable": None, "note": ""}
|
|
1385
|
+
|
|
1386
|
+
cmd = entry.get("install") or entry.get("upgrade") or ""
|
|
1387
|
+
pkg = _npm_pkg_name(cmd)
|
|
1388
|
+
if pkg:
|
|
1389
|
+
r = runner.run_process(argv=["cmd", "/c", "npm", "view", pkg, "version"], timeout=90)
|
|
1390
|
+
latest = ""
|
|
1391
|
+
if r["ok"]:
|
|
1392
|
+
for line in (r["stdout"] or "").splitlines():
|
|
1393
|
+
line = line.strip()
|
|
1394
|
+
if line and re.match(r"^\d", line):
|
|
1395
|
+
latest = line
|
|
1396
|
+
if not latest:
|
|
1397
|
+
result["note"] = "查询 npm 失败(网络或 registry 问题):" + (r["stderr"] or "")[-200:]
|
|
1398
|
+
else:
|
|
1399
|
+
result["latest"] = latest
|
|
1400
|
+
result["updatable"] = bool(_ver_tuple(latest) > _ver_tuple(cur_num))
|
|
1401
|
+
if not result["updatable"]:
|
|
1402
|
+
result["note"] = "已是最新版本"
|
|
1403
|
+
elif "winget" in cmd:
|
|
1404
|
+
m = re.search(r"--id\s+([A-Za-z0-9._-]+)", cmd)
|
|
1405
|
+
wid = m.group(1) if m else None
|
|
1406
|
+
if not wid:
|
|
1407
|
+
result["note"] = "无法从命令中解析 winget 包 ID"
|
|
1408
|
+
else:
|
|
1409
|
+
r = runner.run_process(argv=["cmd", "/c", "winget", "upgrade"], timeout=180)
|
|
1410
|
+
if not r["ok"]:
|
|
1411
|
+
result["note"] = "winget upgrade 查询失败:" + (r["stderr"] or "")[-200:]
|
|
1412
|
+
else:
|
|
1413
|
+
hit = [l for l in (r["stdout"] or "").splitlines() if wid.lower() in l.lower()]
|
|
1414
|
+
result["updatable"] = bool(hit)
|
|
1415
|
+
if hit:
|
|
1416
|
+
result["latest"] = "(winget 有可用更新)"
|
|
1417
|
+
else:
|
|
1418
|
+
result["latest"] = cur_num
|
|
1419
|
+
result["note"] = "已是最新版本"
|
|
1420
|
+
else:
|
|
1421
|
+
result["note"] = "该渠道暂不支持自动检查更新,可直接点升级尝试"
|
|
1422
|
+
|
|
1423
|
+
with _LOCK:
|
|
1424
|
+
_UPDATE_CACHE[eid] = (time.time(), result)
|
|
1425
|
+
return result
|
|
1426
|
+
|
|
1427
|
+
|
|
1428
|
+
# 「进入智能体目录页自动检查更新」的后台任务状态
|
|
1429
|
+
_UPDATE_CHECK = {"running": False, "total": 0, "done": 0}
|
|
1430
|
+
|
|
1431
|
+
|
|
1432
|
+
def updates_checking():
|
|
1433
|
+
with _LOCK:
|
|
1434
|
+
return bool(_UPDATE_CHECK["running"])
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
def update_info(entry):
|
|
1438
|
+
"""单个 CLI 的更新检查结果(供管理页卡片展示)。没查过时为 unknown。"""
|
|
1439
|
+
with _LOCK:
|
|
1440
|
+
cached = _UPDATE_CACHE.get(entry["id"])
|
|
1441
|
+
if not cached:
|
|
1442
|
+
return {"status": "unknown", "latest": None, "updatable": None,
|
|
1443
|
+
"note": "", "checked_at": ""}
|
|
1444
|
+
res = cached[1] or {}
|
|
1445
|
+
up = res.get("updatable")
|
|
1446
|
+
status = "updatable" if up is True else ("current" if up is False else "unsupported")
|
|
1447
|
+
return {"status": status, "latest": res.get("latest"), "updatable": up,
|
|
1448
|
+
"note": res.get("note") or "",
|
|
1449
|
+
"checked_at": time.strftime("%H:%M", time.localtime(cached[0]))}
|
|
1450
|
+
|
|
1451
|
+
|
|
1452
|
+
def _checkable_entries():
|
|
1453
|
+
"""已安装且配了 install/upgrade 的条目——只有这些才谈得上「是否有新版本」。"""
|
|
1454
|
+
detected = detect_all()
|
|
1455
|
+
out = []
|
|
1456
|
+
for e in catalog.load():
|
|
1457
|
+
det = (detected or {}).get(e["id"]) or {}
|
|
1458
|
+
if det.get("installed") and (e.get("upgrade") or e.get("install")):
|
|
1459
|
+
out.append(e)
|
|
1460
|
+
return out
|
|
1461
|
+
|
|
1462
|
+
|
|
1463
|
+
def check_updates_async(force=False):
|
|
1464
|
+
"""后台逐个检查已安装 CLI 的远端最新版本,返回本次要检查的条目数。
|
|
1465
|
+
|
|
1466
|
+
进入「智能体目录」页时自动触发。单条结果复用 check_update 的 10 分钟缓存,
|
|
1467
|
+
所以反复进出页面几乎不产生额外子进程/网络开销;已在跑时不重复起线程。
|
|
1468
|
+
"""
|
|
1469
|
+
with _LOCK:
|
|
1470
|
+
if _UPDATE_CHECK["running"]:
|
|
1471
|
+
return 0
|
|
1472
|
+
entries = _checkable_entries()
|
|
1473
|
+
_UPDATE_CHECK.update(running=True, total=len(entries), done=0)
|
|
1474
|
+
|
|
1475
|
+
def _worker():
|
|
1476
|
+
try:
|
|
1477
|
+
for e in entries:
|
|
1478
|
+
try:
|
|
1479
|
+
check_update(e, force=force)
|
|
1480
|
+
except Exception:
|
|
1481
|
+
pass
|
|
1482
|
+
finally:
|
|
1483
|
+
with _LOCK:
|
|
1484
|
+
_UPDATE_CHECK["done"] += 1
|
|
1485
|
+
finally:
|
|
1486
|
+
with _LOCK:
|
|
1487
|
+
_UPDATE_CHECK["running"] = False
|
|
1488
|
+
|
|
1489
|
+
threading.Thread(target=_worker, name="catalog-update-check", daemon=True).start()
|
|
1490
|
+
return len(entries)
|
|
1491
|
+
|
|
1492
|
+
|
|
1493
|
+
def catalog_view():
|
|
1494
|
+
"""管理页数据:catalog + 检测 + 版本 + 模型 + 编排启用状态。"""
|
|
1495
|
+
from . import registry
|
|
1496
|
+
entries = catalog.load()
|
|
1497
|
+
detected = detect_all(force=False)
|
|
1498
|
+
enabled = registry.load_enabled()
|
|
1499
|
+
view = []
|
|
1500
|
+
for e in entries:
|
|
1501
|
+
det = detected.get(e["id"]) or {}
|
|
1502
|
+
pref = enabled.get(e["id"]) or {}
|
|
1503
|
+
orch_enabled = bool(pref.get("enabled", e.get("default_enabled", False))) if e.get("orch") else False
|
|
1504
|
+
view.append({
|
|
1505
|
+
"id": e["id"], "name": e.get("name", e["id"]), "note": e.get("note", ""),
|
|
1506
|
+
"group": "installed" if det.get("installed") else "installable",
|
|
1507
|
+
"installed": det.get("installed", False),
|
|
1508
|
+
"detail": det.get("detail", ""),
|
|
1509
|
+
"version": version_of(e),
|
|
1510
|
+
"config_path": _config_path(e),
|
|
1511
|
+
"config_writable": (e.get("config") or {}).get("format") in _WRITABLE_FORMATS,
|
|
1512
|
+
"model": read_model(e),
|
|
1513
|
+
"orch_kind": (e.get("orch") or {}).get("kind"),
|
|
1514
|
+
"orch_enabled": orch_enabled,
|
|
1515
|
+
"update": update_info(e),
|
|
1516
|
+
"has_install": bool(e.get("install")),
|
|
1517
|
+
"has_upgrade": bool(e.get("upgrade")),
|
|
1518
|
+
# 卸载命令由 install/upgrade 推导(或 catalog 显式配置),供 UI 确认框展示
|
|
1519
|
+
"uninstall_cmd": catalog.uninstall_command(e) if det.get("installed") else None,
|
|
1520
|
+
# 一键打开配置(kind=web/console + command);没配的条目 UI 不出「打开」按钮
|
|
1521
|
+
"launch": e.get("launch"),
|
|
1522
|
+
})
|
|
1523
|
+
return view
|