xg-cli 1.0__py3-none-any.whl
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.
- xg/__init__.py +3 -0
- xg/__main__.py +3 -0
- xg/adaptive/__init__.py +27 -0
- xg/adaptive/calibrate.py +191 -0
- xg/adaptive/feedback.py +170 -0
- xg/adaptive/learned_rules.py +284 -0
- xg/adaptive/signals.py +125 -0
- xg/adaptive/store.py +164 -0
- xg/agent/__init__.py +0 -0
- xg/agent/plan.py +631 -0
- xg/agent/react.py +268 -0
- xg/agent/team.py +1793 -0
- xg/assets/router.lgb +0 -0
- xg/assets/router_semantics.json +21278 -0
- xg/assets/router_semantics.onnx +0 -0
- xg/cli/__init__.py +0 -0
- xg/cli/app.py +1372 -0
- xg/cli/commands.py +921 -0
- xg/cli/completion.py +740 -0
- xg/cli/help.py +185 -0
- xg/cli/train.py +200 -0
- xg/config/__init__.py +0 -0
- xg/config/env_writer.py +121 -0
- xg/config/manager.py +446 -0
- xg/config/mcp.py +229 -0
- xg/config/provider_service.py +267 -0
- xg/config/providers.py +46 -0
- xg/config/settings.py +258 -0
- xg/config/skills.py +100 -0
- xg/config/smart_router_service.py +121 -0
- xg/config/web.py +140 -0
- xg/input_history/__init__.py +7 -0
- xg/input_history/models.py +28 -0
- xg/input_history/persistence.py +126 -0
- xg/input_history/policy.py +39 -0
- xg/input_history/prompt_toolkit.py +36 -0
- xg/input_history/store.py +118 -0
- xg/llm/__init__.py +0 -0
- xg/llm/client.py +49 -0
- xg/llm/factory.py +40 -0
- xg/llm/openai_compat.py +275 -0
- xg/llm/types.py +98 -0
- xg/mcp/__init__.py +4 -0
- xg/mcp/http.py +192 -0
- xg/mcp/manager.py +726 -0
- xg/mcp/models.py +86 -0
- xg/mcp/protocol.py +62 -0
- xg/mcp/resources.py +72 -0
- xg/mcp/schema.py +137 -0
- xg/mcp/stdio.py +210 -0
- xg/mcp/transport.py +66 -0
- xg/memory/__init__.py +15 -0
- xg/memory/context.py +327 -0
- xg/memory/manager.py +111 -0
- xg/memory/models.py +41 -0
- xg/memory/project.py +187 -0
- xg/memory/store.py +144 -0
- xg/router/__init__.py +124 -0
- xg/router/features.py +66 -0
- xg/router/keywords.py +50 -0
- xg/router/ml_router.py +178 -0
- xg/router/model_tiers.py +73 -0
- xg/router/postprocess.py +167 -0
- xg/router/rule_router.py +77 -0
- xg/router/semantic.py +138 -0
- xg/safety/__init__.py +0 -0
- xg/safety/audit.py +96 -0
- xg/safety/guards.py +106 -0
- xg/safety/hitl.py +73 -0
- xg/skill/__init__.py +9 -0
- xg/skill/errors.py +45 -0
- xg/skill/loader.py +42 -0
- xg/skill/models.py +57 -0
- xg/skill/parser.py +93 -0
- xg/skill/policy.py +40 -0
- xg/skill/prompt.py +45 -0
- xg/skill/registry.py +169 -0
- xg/tool/__init__.py +0 -0
- xg/tool/builtin.py +356 -0
- xg/tool/registry.py +228 -0
- xg/tui/__init__.py +34 -0
- xg/tui/app.py +612 -0
- xg/tui/controller.py +1296 -0
- xg/tui/diagrams/__init__.py +22 -0
- xg/tui/diagrams/layout.py +110 -0
- xg/tui/diagrams/markdown.py +39 -0
- xg/tui/diagrams/model.py +36 -0
- xg/tui/diagrams/parser.py +119 -0
- xg/tui/diagrams/renderer.py +551 -0
- xg/tui/i18n.py +169 -0
- xg/tui/messages.py +45 -0
- xg/tui/plan_renderables.py +147 -0
- xg/tui/reducer.py +1029 -0
- xg/tui/renderables.py +252 -0
- xg/tui/state.py +240 -0
- xg/tui/theme.tcss +208 -0
- xg/tui/widgets/__init__.py +1 -0
- xg/tui/widgets/action_card.py +94 -0
- xg/tui/widgets/agent_group_card.py +39 -0
- xg/tui/widgets/approval_modal.py +59 -0
- xg/tui/widgets/collapsible_card.py +40 -0
- xg/tui/widgets/command_suggestions.py +128 -0
- xg/tui/widgets/composer.py +151 -0
- xg/tui/widgets/config_panel.py +198 -0
- xg/tui/widgets/confirm_modal.py +31 -0
- xg/tui/widgets/footer.py +9 -0
- xg/tui/widgets/header.py +118 -0
- xg/tui/widgets/inspector.py +378 -0
- xg/tui/widgets/plan_modal.py +53 -0
- xg/tui/widgets/provider_form.py +141 -0
- xg/tui/widgets/queue_status.py +30 -0
- xg/tui/widgets/smart_router_form.py +95 -0
- xg/tui/widgets/transcript.py +354 -0
- xg/tui/workers.py +14 -0
- xg/web/__init__.py +21 -0
- xg/web/errors.py +57 -0
- xg/web/extract.py +127 -0
- xg/web/fetch.py +106 -0
- xg/web/markdown.py +77 -0
- xg/web/models.py +91 -0
- xg/web/providers.py +79 -0
- xg/web/search.py +118 -0
- xg/web/searxng.py +27 -0
- xg/web/serpapi.py +29 -0
- xg/web/url_policy.py +110 -0
- xg/web/zhipu.py +27 -0
- xg_cli-1.0.dist-info/METADATA +284 -0
- xg_cli-1.0.dist-info/RECORD +130 -0
- xg_cli-1.0.dist-info/WHEEL +4 -0
- xg_cli-1.0.dist-info/entry_points.txt +2 -0
xg/config/manager.py
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"""配置合并与持久化。
|
|
2
|
+
|
|
3
|
+
优先级(从低到高):默认值 < 用户级 ~/.xg/config.json < 项目级 .xg/config.json < 环境变量/.env。
|
|
4
|
+
API Key 只从环境变量 / .env 读取,绝不写入配置文件。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from dotenv import load_dotenv
|
|
16
|
+
|
|
17
|
+
from xg.config.providers import Provider, ProviderRegistry
|
|
18
|
+
|
|
19
|
+
USER_CONFIG = "config.json"
|
|
20
|
+
DEFAULT_CONTEXT_WINDOW = 128_000
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ProviderNotConfigured(Exception):
|
|
24
|
+
"""未配置可用的 base provider 时抛出,提示用户先配置。"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _api_key_env_for(name: str) -> str:
|
|
28
|
+
"""由 provider 名称自动推导对应的 API Key 环境变量名。"""
|
|
29
|
+
return f"XG_{name.upper()}_API_KEY"
|
|
30
|
+
|
|
31
|
+
# SmartRouter 档位固定名称(顺序即惯例展示顺序)
|
|
32
|
+
_SMART_ROUTER_TIERS = ("Basic", "Enhanced", "Superior", "Ultimate")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ActiveConfig:
|
|
37
|
+
"""解析后的当前生效配置快照。"""
|
|
38
|
+
|
|
39
|
+
provider_name: str
|
|
40
|
+
model: str
|
|
41
|
+
api_base: str
|
|
42
|
+
api_key: str
|
|
43
|
+
context_window: int
|
|
44
|
+
supports_cache: bool = False
|
|
45
|
+
supports_vision: bool = False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def mask_key(key: str) -> str:
|
|
49
|
+
"""API Key 脱敏:前 4 位 + ****。"""
|
|
50
|
+
if not key:
|
|
51
|
+
return "(未配置)"
|
|
52
|
+
return f"{key[:4]}****" if len(key) > 4 else "****"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_PLACEHOLDER_EXACT = {"sk-xxx", "xxx", "your-api-key", "api-key", "changeme", "placeholder", "sk-xxx-xxx"}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _is_placeholder(value: str) -> bool:
|
|
59
|
+
"""判断是否为占位值(如 sk-xxx / xxx)。占位值视为未配置。"""
|
|
60
|
+
v = value.strip().lower()
|
|
61
|
+
if not v:
|
|
62
|
+
return True
|
|
63
|
+
if v in _PLACEHOLDER_EXACT:
|
|
64
|
+
return True
|
|
65
|
+
return v.startswith("sk-xxx") or v.startswith("xxx")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _deep_merge(base: dict, override: dict) -> dict:
|
|
69
|
+
"""递归合并两个 dict,override 按名覆盖 base。"""
|
|
70
|
+
result = dict(base)
|
|
71
|
+
for key, value in override.items():
|
|
72
|
+
if isinstance(value, dict) and isinstance(result.get(key), dict):
|
|
73
|
+
result[key] = _deep_merge(result[key], value)
|
|
74
|
+
else:
|
|
75
|
+
result[key] = value
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _find_env_file() -> Path | None:
|
|
80
|
+
"""从当前工作目录向上查找 .env(最多 3 层)。"""
|
|
81
|
+
cur = Path.cwd()
|
|
82
|
+
for _ in range(3):
|
|
83
|
+
candidate = cur / ".env"
|
|
84
|
+
if candidate.is_file():
|
|
85
|
+
return candidate
|
|
86
|
+
if cur == cur.parent:
|
|
87
|
+
break
|
|
88
|
+
cur = cur.parent
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ConfigManager:
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
user_dir: str | Path | None = None,
|
|
96
|
+
project_dir: str | Path | None = None,
|
|
97
|
+
env: dict[str, str] | None = None,
|
|
98
|
+
env_file: str | Path | None = None,
|
|
99
|
+
load_env: bool = True,
|
|
100
|
+
) -> None:
|
|
101
|
+
self.user_dir = Path(user_dir) if user_dir else Path.home() / ".xg"
|
|
102
|
+
self.project_dir = Path(project_dir) if project_dir else Path.cwd() / ".xg"
|
|
103
|
+
self.user_config_path = self.user_dir / USER_CONFIG
|
|
104
|
+
self.project_config_path = self.project_dir / USER_CONFIG
|
|
105
|
+
self.env: dict[str, str] = env if env is not None else os.environ
|
|
106
|
+
self.registry = ProviderRegistry()
|
|
107
|
+
if load_env:
|
|
108
|
+
self._load_env_file(env_file)
|
|
109
|
+
|
|
110
|
+
# ---------- 文件读写 ----------
|
|
111
|
+
|
|
112
|
+
def _load_env_file(self, env_file: str | Path | None) -> None:
|
|
113
|
+
if env_file is None:
|
|
114
|
+
env_file = _find_env_file()
|
|
115
|
+
if env_file and Path(env_file).is_file():
|
|
116
|
+
load_dotenv(env_file, override=False)
|
|
117
|
+
|
|
118
|
+
def _read_config(self, path: Path) -> dict:
|
|
119
|
+
if not path.is_file():
|
|
120
|
+
return {}
|
|
121
|
+
try:
|
|
122
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
123
|
+
return data if isinstance(data, dict) else {}
|
|
124
|
+
except (OSError, json.JSONDecodeError):
|
|
125
|
+
return {}
|
|
126
|
+
|
|
127
|
+
def _write_config(self, path: Path, data: dict) -> None:
|
|
128
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
130
|
+
|
|
131
|
+
def _merged_config(self) -> dict:
|
|
132
|
+
"""项目级按名覆盖用户级。"""
|
|
133
|
+
return _deep_merge(
|
|
134
|
+
self._read_config(self.user_config_path),
|
|
135
|
+
self._read_config(self.project_config_path),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# ---------- provider 解析 ----------
|
|
139
|
+
|
|
140
|
+
def provider_names(self) -> list[str]:
|
|
141
|
+
"""内置 + 配置文件中定义的自定义 provider。"""
|
|
142
|
+
names = set(self.registry.names())
|
|
143
|
+
names.update((self._merged_config().get("providers") or {}).keys())
|
|
144
|
+
return sorted(names)
|
|
145
|
+
|
|
146
|
+
def resolve_provider(self, name: str) -> Provider | None:
|
|
147
|
+
"""返回合并配置后的 provider;未定义返回 None。
|
|
148
|
+
|
|
149
|
+
API Key: 优先 providers.<name>.api_key;若缺失且占位值则视为未配置。
|
|
150
|
+
"""
|
|
151
|
+
cfg = (self._merged_config().get("providers") or {}).get(name)
|
|
152
|
+
if not isinstance(cfg, dict):
|
|
153
|
+
cfg = None
|
|
154
|
+
base = self.registry.get(name)
|
|
155
|
+
|
|
156
|
+
if base is None and cfg is None:
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
def _extract_api_key(cfg_dict: dict | None) -> str:
|
|
160
|
+
if not cfg_dict:
|
|
161
|
+
return ""
|
|
162
|
+
key = str(cfg_dict.get("api_key", "")).strip()
|
|
163
|
+
if _is_placeholder(key):
|
|
164
|
+
return ""
|
|
165
|
+
return key
|
|
166
|
+
|
|
167
|
+
def _extract_models(cfg_dict: dict | None) -> tuple[str, ...]:
|
|
168
|
+
if not cfg_dict:
|
|
169
|
+
return ()
|
|
170
|
+
raw = cfg_dict.get("models")
|
|
171
|
+
if not isinstance(raw, list):
|
|
172
|
+
return ()
|
|
173
|
+
models = [str(m).strip() for m in raw if str(m).strip()]
|
|
174
|
+
return tuple(dict.fromkeys(models)) # 去重保序
|
|
175
|
+
|
|
176
|
+
if base is None:
|
|
177
|
+
required = ("api_base", "default_model")
|
|
178
|
+
if not cfg or not all(cfg.get(k) for k in required):
|
|
179
|
+
return None
|
|
180
|
+
api_key = _extract_api_key(cfg)
|
|
181
|
+
return Provider(
|
|
182
|
+
name=name,
|
|
183
|
+
display_name=str(cfg.get("display_name", name)),
|
|
184
|
+
api_base=str(cfg["api_base"]),
|
|
185
|
+
default_model=str(cfg["default_model"]),
|
|
186
|
+
models=_extract_models(cfg),
|
|
187
|
+
api_key=api_key,
|
|
188
|
+
context_window=int(cfg.get("context_window", DEFAULT_CONTEXT_WINDOW)),
|
|
189
|
+
supports_cache=bool(cfg.get("supports_cache", False)),
|
|
190
|
+
supports_vision=bool(cfg.get("supports_vision", False)),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
api_key = _extract_api_key(cfg) or base.api_key
|
|
194
|
+
return Provider(
|
|
195
|
+
name=base.name,
|
|
196
|
+
display_name=str((cfg or {}).get("display_name", base.display_name)),
|
|
197
|
+
api_base=str((cfg or {}).get("api_base", base.api_base)),
|
|
198
|
+
default_model=str((cfg or {}).get("default_model", base.default_model)),
|
|
199
|
+
models=_extract_models(cfg) or base.models,
|
|
200
|
+
api_key=api_key,
|
|
201
|
+
context_window=int((cfg or {}).get("context_window", base.context_window)),
|
|
202
|
+
supports_cache=base.supports_cache,
|
|
203
|
+
supports_vision=base.supports_vision,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def list_providers(self) -> list[Provider]:
|
|
207
|
+
return [p for name in self.provider_names() if (p := self.resolve_provider(name))]
|
|
208
|
+
|
|
209
|
+
def resolve_api_base(self, provider: Provider) -> str:
|
|
210
|
+
"""URL 读取:provider 来自 config.json 的 providers.<name>.api_base(不走 .env)。"""
|
|
211
|
+
return provider.api_base
|
|
212
|
+
|
|
213
|
+
def resolve_api_key(self, provider: Provider) -> str:
|
|
214
|
+
"""API Key:读取 config.json 的 providers.<name>.api_key(不走 .env)。
|
|
215
|
+
|
|
216
|
+
占位值(如 sk-xxx / xxx)视为未配置,避免占位符被当真实 key 使用。
|
|
217
|
+
"""
|
|
218
|
+
return provider.api_key if not _is_placeholder(provider.api_key) else ""
|
|
219
|
+
|
|
220
|
+
def resolve_window(self, provider: Provider) -> int:
|
|
221
|
+
"""上下文窗口:XG_CONTEXT_WINDOW 环境变量 > provider 能力。"""
|
|
222
|
+
raw = self.env.get("XG_CONTEXT_WINDOW", "")
|
|
223
|
+
try:
|
|
224
|
+
return int(raw) if raw else provider.context_window
|
|
225
|
+
except ValueError:
|
|
226
|
+
return provider.context_window
|
|
227
|
+
|
|
228
|
+
# ---------- 生效配置 ----------
|
|
229
|
+
|
|
230
|
+
def active(self) -> ActiveConfig:
|
|
231
|
+
merged = self._merged_config()
|
|
232
|
+
# 激活的 base provider 只认 config.json 的 active_provider(不再读 XG_PROVIDER)。
|
|
233
|
+
# 未配置可用的 provider 时 fail-fast 提示。
|
|
234
|
+
provider_name = str(merged.get("active_provider", "") or "")
|
|
235
|
+
provider = self.resolve_provider(provider_name) if provider_name else None
|
|
236
|
+
if provider is None:
|
|
237
|
+
raise ProviderNotConfigured(
|
|
238
|
+
"未配置可用的 base provider。请在 config.json 的 providers 中定义 "
|
|
239
|
+
f"{provider_name or '<name>'}(name / api_base / default_model / api_key),"
|
|
240
|
+
"并设置 active_provider 选中 base;配置可全程用 /provider 命令或 TUI 面板完成。"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# 模型:配置 active_model > provider 默认(不再读 XG_MODEL)
|
|
244
|
+
model = (
|
|
245
|
+
str(merged.get("active_model", "") or "")
|
|
246
|
+
or provider.default_model
|
|
247
|
+
)
|
|
248
|
+
# base url:来自 config.json 的 api_base
|
|
249
|
+
api_base = provider.api_base
|
|
250
|
+
|
|
251
|
+
return ActiveConfig(
|
|
252
|
+
provider_name=provider.name,
|
|
253
|
+
model=model,
|
|
254
|
+
api_base=api_base,
|
|
255
|
+
api_key=self.resolve_api_key(provider),
|
|
256
|
+
context_window=self.resolve_window(provider),
|
|
257
|
+
supports_cache=provider.supports_cache,
|
|
258
|
+
supports_vision=provider.supports_vision,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# ---------- 持久化 ----------
|
|
262
|
+
|
|
263
|
+
def set_active(self, provider: str, model: str) -> None:
|
|
264
|
+
user = self._read_config(self.user_config_path)
|
|
265
|
+
user["active_provider"] = provider
|
|
266
|
+
user["active_model"] = model
|
|
267
|
+
self._write_config(self.user_config_path, user)
|
|
268
|
+
|
|
269
|
+
def set_config_value(self, dotted_key: str, value: str) -> None:
|
|
270
|
+
"""按点路径写入用户配置,如 providers.deepseek.default_model。"""
|
|
271
|
+
user = self._read_config(self.user_config_path)
|
|
272
|
+
keys = dotted_key.split(".")
|
|
273
|
+
node: Any = user
|
|
274
|
+
for key in keys[:-1]:
|
|
275
|
+
nxt = node.get(key)
|
|
276
|
+
if not isinstance(nxt, dict):
|
|
277
|
+
nxt = {}
|
|
278
|
+
node[key] = nxt
|
|
279
|
+
node = nxt
|
|
280
|
+
node[keys[-1]] = value
|
|
281
|
+
self._write_config(self.user_config_path, user)
|
|
282
|
+
|
|
283
|
+
# ---------- provider 分层读写 ----------
|
|
284
|
+
|
|
285
|
+
def provider_layer(self, name: str) -> str:
|
|
286
|
+
"""返回定义该 provider 的配置层:project > user > ''(项目级优先)。"""
|
|
287
|
+
project = (self._read_config(self.project_config_path).get("providers") or {})
|
|
288
|
+
user = (self._read_config(self.user_config_path).get("providers") or {})
|
|
289
|
+
if name in project:
|
|
290
|
+
return "project"
|
|
291
|
+
if name in user:
|
|
292
|
+
return "user"
|
|
293
|
+
return ""
|
|
294
|
+
|
|
295
|
+
def upsert_provider(self, name: str, fields: dict) -> str:
|
|
296
|
+
"""写入/更新 provider 字段到其**生效层**(项目级优先,缺省 user)。
|
|
297
|
+
|
|
298
|
+
返回实际写入的层名(``user`` / ``project``)。只有 ``None`` 值的字段会被忽略,
|
|
299
|
+
以便做部分更新(如只改 api_base 时保留 display_name)。
|
|
300
|
+
"""
|
|
301
|
+
layer = self.provider_layer(name) or "user"
|
|
302
|
+
path = self.project_config_path if layer == "project" else self.user_config_path
|
|
303
|
+
data = self._read_config(path)
|
|
304
|
+
providers = data.setdefault("providers", {})
|
|
305
|
+
existing = providers.get(name)
|
|
306
|
+
updates = {k: v for k, v in fields.items() if v is not None}
|
|
307
|
+
if isinstance(existing, dict):
|
|
308
|
+
existing.update(updates)
|
|
309
|
+
else:
|
|
310
|
+
providers[name] = updates
|
|
311
|
+
self._write_config(path, data)
|
|
312
|
+
return layer
|
|
313
|
+
|
|
314
|
+
def delete_provider(self, name: str) -> bool:
|
|
315
|
+
"""从用户级与项目级配置中删除该 provider;返回是否实际删除(清理悬空节)。"""
|
|
316
|
+
changed = False
|
|
317
|
+
for path in (self.user_config_path, self.project_config_path):
|
|
318
|
+
data = self._read_config(path)
|
|
319
|
+
providers = data.get("providers")
|
|
320
|
+
if isinstance(providers, dict) and name in providers:
|
|
321
|
+
del providers[name]
|
|
322
|
+
if not providers:
|
|
323
|
+
data.pop("providers", None)
|
|
324
|
+
self._write_config(path, data)
|
|
325
|
+
changed = True
|
|
326
|
+
return changed
|
|
327
|
+
|
|
328
|
+
def get_config_value(self, dotted_key: str) -> str | None:
|
|
329
|
+
"""从合并配置读取值;非标量时返回 JSON 字符串。"""
|
|
330
|
+
node: Any = self._merged_config()
|
|
331
|
+
for key in dotted_key.split("."):
|
|
332
|
+
if isinstance(node, dict) and key in node:
|
|
333
|
+
node = node[key]
|
|
334
|
+
else:
|
|
335
|
+
return None
|
|
336
|
+
if isinstance(node, (str, int, float, bool)):
|
|
337
|
+
return str(node)
|
|
338
|
+
return json.dumps(node, ensure_ascii=False)
|
|
339
|
+
|
|
340
|
+
# ---------- SmartRouter ----------
|
|
341
|
+
|
|
342
|
+
def smart_router_config(self) -> dict[str, Any]:
|
|
343
|
+
"""读取合并配置中的 smart_router 节(用户级 < 项目级深合并)。
|
|
344
|
+
|
|
345
|
+
返回结构(缺省时):
|
|
346
|
+
{"enabled": False, "tiers": {}}
|
|
347
|
+
tiers 中每个档位(Basic/Enhanced/Superior/Ultimate)形如:
|
|
348
|
+
{"provider": "deepseek", "model": "deepseek-chat"}
|
|
349
|
+
校验规则:非法结构/非法档位名/非法 provider-model 类型一律丢弃该条目,
|
|
350
|
+
绝不抛错(配置损坏时静默回退默认行为)。
|
|
351
|
+
"""
|
|
352
|
+
merged = self._merged_config()
|
|
353
|
+
raw = merged.get("smart_router") or {}
|
|
354
|
+
if not isinstance(raw, dict):
|
|
355
|
+
return {"enabled": False, "tiers": {}}
|
|
356
|
+
|
|
357
|
+
enabled_raw = raw.get("enabled", False)
|
|
358
|
+
enabled = enabled_raw if isinstance(enabled_raw, bool) else str(enabled_raw).lower() in ("on", "1", "true")
|
|
359
|
+
|
|
360
|
+
tiers_raw = raw.get("tiers") or {}
|
|
361
|
+
tiers: dict[str, dict[str, str]] = {}
|
|
362
|
+
if isinstance(tiers_raw, dict):
|
|
363
|
+
for tier_name, tier_cfg in tiers_raw.items():
|
|
364
|
+
if not isinstance(tier_cfg, dict):
|
|
365
|
+
continue
|
|
366
|
+
entry: dict[str, str] = {}
|
|
367
|
+
for key in ("provider", "model"):
|
|
368
|
+
value = tier_cfg.get(key)
|
|
369
|
+
if isinstance(value, str) and value.strip():
|
|
370
|
+
entry[key] = value.strip()
|
|
371
|
+
if entry:
|
|
372
|
+
tiers[str(tier_name)] = entry
|
|
373
|
+
|
|
374
|
+
return {"enabled": enabled, "tiers": tiers}
|
|
375
|
+
|
|
376
|
+
def set_smart_router_enabled(self, enabled: bool) -> None:
|
|
377
|
+
"""持久化开关位到用户级配置(/smartRouter on|off 时调用)。"""
|
|
378
|
+
user = self._read_config(self.user_config_path)
|
|
379
|
+
node = user.get("smart_router")
|
|
380
|
+
if not isinstance(node, dict):
|
|
381
|
+
node = {}
|
|
382
|
+
node["enabled"] = bool(enabled)
|
|
383
|
+
user["smart_router"] = node
|
|
384
|
+
self._write_config(self.user_config_path, user)
|
|
385
|
+
|
|
386
|
+
def set_smart_router_tier(self, tier_name: str, provider: str, model: str) -> None:
|
|
387
|
+
"""持久化一个档位的 provider/model 到用户级配置(/tier set 时调用)。"""
|
|
388
|
+
user = self._read_config(self.user_config_path)
|
|
389
|
+
node = user.get("smart_router")
|
|
390
|
+
if not isinstance(node, dict):
|
|
391
|
+
node = {}
|
|
392
|
+
tiers = node.get("tiers")
|
|
393
|
+
if not isinstance(tiers, dict):
|
|
394
|
+
tiers = {}
|
|
395
|
+
tiers[tier_name] = {"provider": provider, "model": model}
|
|
396
|
+
node["tiers"] = tiers
|
|
397
|
+
user["smart_router"] = node
|
|
398
|
+
self._write_config(self.user_config_path, user)
|
|
399
|
+
|
|
400
|
+
def remove_smart_router_tier(self, tier_name: str) -> bool:
|
|
401
|
+
"""清空一个档位(删除其配置,回落到手动 active);返回是否有实际配置被删。"""
|
|
402
|
+
user = self._read_config(self.user_config_path)
|
|
403
|
+
node = user.get("smart_router")
|
|
404
|
+
if not isinstance(node, dict):
|
|
405
|
+
return False
|
|
406
|
+
tiers = node.get("tiers")
|
|
407
|
+
if not isinstance(tiers, dict) or tier_name not in tiers:
|
|
408
|
+
return False
|
|
409
|
+
del tiers[tier_name]
|
|
410
|
+
if not tiers:
|
|
411
|
+
node.pop("tiers", None)
|
|
412
|
+
user["smart_router"] = node
|
|
413
|
+
self._write_config(self.user_config_path, user)
|
|
414
|
+
return True
|
|
415
|
+
|
|
416
|
+
# ---------- UI 偏好 ----------
|
|
417
|
+
|
|
418
|
+
def get_ui_language(self) -> str:
|
|
419
|
+
"""读取 Inspector language;非法值回退到 English。"""
|
|
420
|
+
value = self.get_config_value("ui_language")
|
|
421
|
+
return value.strip().lower() if value and value.strip().lower() in {"en", "zh"} else "en"
|
|
422
|
+
|
|
423
|
+
def ui_language_source(self) -> str:
|
|
424
|
+
"""返回 Inspector language 的配置来源,便于 /lang 展示。"""
|
|
425
|
+
project = self._read_config(self.project_config_path)
|
|
426
|
+
if str(project.get("ui_language", "")).strip().lower() in {"en", "zh"}:
|
|
427
|
+
return "project config"
|
|
428
|
+
user = self._read_config(self.user_config_path)
|
|
429
|
+
if str(user.get("ui_language", "")).strip().lower() in {"en", "zh"}:
|
|
430
|
+
return "user config"
|
|
431
|
+
return "default"
|
|
432
|
+
|
|
433
|
+
def set_ui_language(self, language: str) -> None:
|
|
434
|
+
"""Persist a validated Inspector language in the user config."""
|
|
435
|
+
normalized = language.strip().lower()
|
|
436
|
+
if normalized not in {"en", "zh"}:
|
|
437
|
+
raise ValueError("ui_language must be en or zh")
|
|
438
|
+
self.set_config_value("ui_language", normalized)
|
|
439
|
+
|
|
440
|
+
def reset_ui_language(self) -> None:
|
|
441
|
+
"""Remove the user override for Inspector language."""
|
|
442
|
+
user = self._read_config(self.user_config_path)
|
|
443
|
+
if "ui_language" not in user:
|
|
444
|
+
return
|
|
445
|
+
user.pop("ui_language")
|
|
446
|
+
self._write_config(self.user_config_path, user)
|
xg/config/mcp.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""MCP server configuration loading, merging and environment expansion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
|
|
13
|
+
from xg.mcp.models import McpServerConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
MCP_CONFIG_FILE = "mcp.json"
|
|
17
|
+
_SERVER_RE = re.compile(r"^[A-Za-z0-9_-]{1,32}$")
|
|
18
|
+
_ENV_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
19
|
+
_LEVELS = {"default", "never", "confirm", "always"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _deep_merge(base: dict, override: dict) -> dict:
|
|
23
|
+
result = dict(base)
|
|
24
|
+
for key, value in override.items():
|
|
25
|
+
if isinstance(value, dict) and isinstance(result.get(key), dict):
|
|
26
|
+
result[key] = _deep_merge(result[key], value)
|
|
27
|
+
else:
|
|
28
|
+
result[key] = value
|
|
29
|
+
return result
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class McpConfigLoadResult:
|
|
34
|
+
servers: dict[str, McpServerConfig]
|
|
35
|
+
errors: tuple[str, ...] = ()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class McpConfigManager:
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
*,
|
|
42
|
+
user_dir: str | Path | None = None,
|
|
43
|
+
project_root: str | Path | None = None,
|
|
44
|
+
env: dict[str, str] | None = None,
|
|
45
|
+
defaults: dict[str, Any] | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
self.user_dir = Path(user_dir) if user_dir else Path.home() / ".xg"
|
|
48
|
+
self.project_root = (Path(project_root) if project_root else Path.cwd()).resolve()
|
|
49
|
+
self.user_config_path = self.user_dir / MCP_CONFIG_FILE
|
|
50
|
+
self.project_config_path = self.project_root / ".xg" / MCP_CONFIG_FILE
|
|
51
|
+
self.env = env if env is not None else os.environ
|
|
52
|
+
self.defaults = {
|
|
53
|
+
"startup_timeout": 15.0,
|
|
54
|
+
"request_timeout": 120.0,
|
|
55
|
+
"shutdown_timeout": 5.0,
|
|
56
|
+
"max_output_chars": 20_000,
|
|
57
|
+
"max_tools": 256,
|
|
58
|
+
"max_resources": 512,
|
|
59
|
+
"max_message_bytes": 2_097_152,
|
|
60
|
+
"resource_max_chars": 32_000,
|
|
61
|
+
"log_lines": 200,
|
|
62
|
+
**(defaults or {}),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
def _read(self, path: Path) -> tuple[dict, str | None]:
|
|
66
|
+
if not path.is_file():
|
|
67
|
+
return {}, None
|
|
68
|
+
try:
|
|
69
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
70
|
+
except json.JSONDecodeError as exc:
|
|
71
|
+
return {}, f"{path}: JSON 格式错误({exc.lineno}:{exc.colno})"
|
|
72
|
+
except OSError as exc:
|
|
73
|
+
return {}, f"{path}: 读取失败({exc})"
|
|
74
|
+
if not isinstance(data, dict):
|
|
75
|
+
return {}, f"{path}: 顶层必须是 JSON 对象"
|
|
76
|
+
return data, None
|
|
77
|
+
|
|
78
|
+
def _expand(self, value: Any, missing: set[str]) -> Any:
|
|
79
|
+
if isinstance(value, str):
|
|
80
|
+
def replace(match: re.Match[str]) -> str:
|
|
81
|
+
name = match.group(1)
|
|
82
|
+
if name not in self.env:
|
|
83
|
+
missing.add(name)
|
|
84
|
+
return ""
|
|
85
|
+
return str(self.env[name])
|
|
86
|
+
return _ENV_RE.sub(replace, value)
|
|
87
|
+
if isinstance(value, list):
|
|
88
|
+
return [self._expand(item, missing) for item in value]
|
|
89
|
+
if isinstance(value, dict):
|
|
90
|
+
return {str(key): self._expand(item, missing) for key, item in value.items()}
|
|
91
|
+
return value
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _number(raw: dict, name: str, default: float, minimum: float = 0.1) -> float:
|
|
95
|
+
try:
|
|
96
|
+
return max(minimum, float(raw.get(name, default)))
|
|
97
|
+
except (TypeError, ValueError):
|
|
98
|
+
return float(default)
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def _integer(raw: dict, name: str, default: int, minimum: int = 1) -> int:
|
|
102
|
+
try:
|
|
103
|
+
return max(minimum, int(raw.get(name, default)))
|
|
104
|
+
except (TypeError, ValueError):
|
|
105
|
+
return int(default)
|
|
106
|
+
|
|
107
|
+
def load(self) -> McpConfigLoadResult:
|
|
108
|
+
user, user_error = self._read(self.user_config_path)
|
|
109
|
+
project, project_error = self._read(self.project_config_path)
|
|
110
|
+
errors = [error for error in (user_error, project_error) if error]
|
|
111
|
+
merged = _deep_merge(user, project)
|
|
112
|
+
raw_servers = merged.get("servers", {})
|
|
113
|
+
if not isinstance(raw_servers, dict):
|
|
114
|
+
return McpConfigLoadResult({}, tuple([*errors, "mcp.json 的 servers 必须是对象"]))
|
|
115
|
+
|
|
116
|
+
servers: dict[str, McpServerConfig] = {}
|
|
117
|
+
enabled_overrides = user.get("enabled_overrides", {})
|
|
118
|
+
if not isinstance(enabled_overrides, dict):
|
|
119
|
+
enabled_overrides = {}
|
|
120
|
+
for name, original in raw_servers.items():
|
|
121
|
+
if not _SERVER_RE.fullmatch(str(name)):
|
|
122
|
+
errors.append(f"MCP Server 名称无效: {name}")
|
|
123
|
+
continue
|
|
124
|
+
if not isinstance(original, dict):
|
|
125
|
+
errors.append(f"MCP Server {name} 配置必须是对象")
|
|
126
|
+
continue
|
|
127
|
+
missing: set[str] = set()
|
|
128
|
+
source = dict(original)
|
|
129
|
+
if name in enabled_overrides:
|
|
130
|
+
source["enabled"] = bool(enabled_overrides[name])
|
|
131
|
+
raw = self._expand(source, missing)
|
|
132
|
+
if missing:
|
|
133
|
+
errors.append(f"MCP Server {name} 缺少环境变量: {', '.join(sorted(missing))}")
|
|
134
|
+
continue
|
|
135
|
+
try:
|
|
136
|
+
config = self._parse_server(str(name), raw)
|
|
137
|
+
except ValueError as exc:
|
|
138
|
+
errors.append(f"MCP Server {name}: {exc}")
|
|
139
|
+
continue
|
|
140
|
+
servers[name] = config
|
|
141
|
+
return McpConfigLoadResult(servers, tuple(errors))
|
|
142
|
+
|
|
143
|
+
def _parse_server(self, name: str, raw: dict) -> McpServerConfig:
|
|
144
|
+
transport = str(raw.get("transport", "stdio")).lower().replace("-", "_")
|
|
145
|
+
if transport == "http":
|
|
146
|
+
transport = "streamable_http"
|
|
147
|
+
if transport not in {"stdio", "streamable_http"}:
|
|
148
|
+
raise ValueError("transport 必须是 stdio 或 streamable_http")
|
|
149
|
+
command = str(raw.get("command", "")).strip() or None
|
|
150
|
+
url = str(raw.get("url", "")).strip() or None
|
|
151
|
+
if transport == "stdio" and not command:
|
|
152
|
+
raise ValueError("stdio transport 缺少 command")
|
|
153
|
+
if transport == "streamable_http":
|
|
154
|
+
if not url:
|
|
155
|
+
raise ValueError("streamable_http transport 缺少 url")
|
|
156
|
+
parsed_url = urlparse(url)
|
|
157
|
+
if parsed_url.scheme.lower() not in {"http", "https"}:
|
|
158
|
+
raise ValueError("url 只允许 http/https")
|
|
159
|
+
if parsed_url.username or parsed_url.password:
|
|
160
|
+
raise ValueError("url 禁止内嵌用户名或密码,请使用 headers + ${VAR}")
|
|
161
|
+
|
|
162
|
+
args = raw.get("args", [])
|
|
163
|
+
env = raw.get("env", {})
|
|
164
|
+
headers = raw.get("headers", {})
|
|
165
|
+
overrides = raw.get("tool_overrides", {})
|
|
166
|
+
if not isinstance(args, list) or not all(isinstance(item, (str, int, float)) for item in args):
|
|
167
|
+
raise ValueError("args 必须是字符串数组")
|
|
168
|
+
if not isinstance(env, dict) or not isinstance(headers, dict):
|
|
169
|
+
raise ValueError("env/headers 必须是对象")
|
|
170
|
+
if not isinstance(overrides, dict):
|
|
171
|
+
raise ValueError("tool_overrides 必须是对象")
|
|
172
|
+
hitl = str(raw.get("hitl", "default")).lower()
|
|
173
|
+
if hitl not in _LEVELS:
|
|
174
|
+
raise ValueError("hitl 必须是 default/never/confirm/always")
|
|
175
|
+
clean_overrides: dict[str, str] = {}
|
|
176
|
+
for tool_name, level in overrides.items():
|
|
177
|
+
level = str(level).lower()
|
|
178
|
+
if level not in _LEVELS - {"default"}:
|
|
179
|
+
raise ValueError(f"工具 {tool_name} 的审批级别无效")
|
|
180
|
+
clean_overrides[str(tool_name)] = level
|
|
181
|
+
|
|
182
|
+
cwd = str(raw.get("cwd", "")).strip() or None
|
|
183
|
+
if cwd:
|
|
184
|
+
cwd_path = Path(cwd)
|
|
185
|
+
if not cwd_path.is_absolute():
|
|
186
|
+
cwd_path = self.project_root / cwd_path
|
|
187
|
+
cwd_path = cwd_path.resolve()
|
|
188
|
+
if not cwd_path.is_relative_to(self.project_root):
|
|
189
|
+
raise ValueError("cwd 必须位于项目根目录内")
|
|
190
|
+
cwd = str(cwd_path)
|
|
191
|
+
elif transport == "stdio":
|
|
192
|
+
cwd = str(self.project_root)
|
|
193
|
+
|
|
194
|
+
return McpServerConfig(
|
|
195
|
+
name=name,
|
|
196
|
+
transport=transport, # type: ignore[arg-type]
|
|
197
|
+
enabled=bool(raw.get("enabled", True)),
|
|
198
|
+
command=command,
|
|
199
|
+
args=tuple(str(item) for item in args),
|
|
200
|
+
cwd=cwd,
|
|
201
|
+
env={str(key): str(value) for key, value in env.items()},
|
|
202
|
+
url=url,
|
|
203
|
+
headers={str(key): str(value) for key, value in headers.items()},
|
|
204
|
+
startup_timeout=self._number(raw, "startup_timeout", self.defaults["startup_timeout"]),
|
|
205
|
+
request_timeout=self._number(raw, "request_timeout", self.defaults["request_timeout"]),
|
|
206
|
+
shutdown_timeout=self._number(raw, "shutdown_timeout", self.defaults["shutdown_timeout"]),
|
|
207
|
+
max_output_chars=self._integer(raw, "max_output_chars", self.defaults["max_output_chars"]),
|
|
208
|
+
hitl=hitl, # type: ignore[arg-type]
|
|
209
|
+
tool_overrides=clean_overrides,
|
|
210
|
+
max_tools=self._integer(raw, "max_tools", self.defaults["max_tools"]),
|
|
211
|
+
max_resources=self._integer(raw, "max_resources", self.defaults["max_resources"]),
|
|
212
|
+
max_message_bytes=self._integer(raw, "max_message_bytes", self.defaults["max_message_bytes"], 1024),
|
|
213
|
+
resource_max_chars=self._integer(raw, "resource_max_chars", self.defaults["resource_max_chars"]),
|
|
214
|
+
log_lines=self._integer(raw, "log_lines", self.defaults["log_lines"]),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def set_enabled(self, name: str, enabled: bool) -> None:
|
|
218
|
+
data, error = self._read(self.user_config_path)
|
|
219
|
+
if error:
|
|
220
|
+
raise ValueError(error)
|
|
221
|
+
overrides = data.setdefault("enabled_overrides", {})
|
|
222
|
+
if not isinstance(overrides, dict):
|
|
223
|
+
overrides = {}
|
|
224
|
+
data["enabled_overrides"] = overrides
|
|
225
|
+
overrides[name] = enabled
|
|
226
|
+
self.user_config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
227
|
+
self.user_config_path.write_text(
|
|
228
|
+
json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
|
229
|
+
)
|