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/cli/commands.py
ADDED
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
"""Commands shared by the inline CLI and the fullscreen UI.
|
|
2
|
+
|
|
3
|
+
The command service deliberately returns data instead of printing it. The
|
|
4
|
+
legacy helpers in :mod:`xg.cli.app` remain the compatibility implementation
|
|
5
|
+
for now; keeping this adapter small lets the TUI use the exact same command
|
|
6
|
+
semantics while the inline renderer is migrated incrementally.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from xg.tui.i18n import normalize_language
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class CommandContext:
|
|
19
|
+
agent: Any
|
|
20
|
+
settings: Any
|
|
21
|
+
manager: Any
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class CommandResult:
|
|
26
|
+
ok: bool
|
|
27
|
+
message: str = ""
|
|
28
|
+
should_exit: bool = False
|
|
29
|
+
open_modal: str = ""
|
|
30
|
+
data: object | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class SlashSubcommandSpec:
|
|
35
|
+
"""Help metadata for one command mode or subcommand."""
|
|
36
|
+
|
|
37
|
+
name: str
|
|
38
|
+
usage: str
|
|
39
|
+
description: str
|
|
40
|
+
options: tuple[str, ...] = ()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class SlashCommandSpec:
|
|
45
|
+
"""Read-only metadata shared by command help and TUI suggestions."""
|
|
46
|
+
|
|
47
|
+
name: str
|
|
48
|
+
aliases: tuple[str, ...] = ()
|
|
49
|
+
usage: str = ""
|
|
50
|
+
description: str = ""
|
|
51
|
+
category: str = "general"
|
|
52
|
+
details: tuple[str, ...] = ()
|
|
53
|
+
subcommands: tuple[SlashSubcommandSpec, ...] = ()
|
|
54
|
+
examples: tuple[str, ...] = ()
|
|
55
|
+
options: tuple[str, ...] = ()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Keep this tuple in presentation order. It is intentionally metadata only;
|
|
59
|
+
# command execution remains in CommandService and the legacy compatibility
|
|
60
|
+
# helpers in xg.cli.app.
|
|
61
|
+
SLASH_COMMANDS: tuple[SlashCommandSpec, ...] = (
|
|
62
|
+
SlashCommandSpec(
|
|
63
|
+
"/plan",
|
|
64
|
+
usage="/plan <任务>",
|
|
65
|
+
description="生成、审阅并执行计划",
|
|
66
|
+
category="workflow",
|
|
67
|
+
details=("先生成任务依赖图,审阅通过后再按依赖轮次执行。",),
|
|
68
|
+
examples=("/plan 检查项目配置并修复测试",),
|
|
69
|
+
),
|
|
70
|
+
SlashCommandSpec(
|
|
71
|
+
"/team",
|
|
72
|
+
usage="/team <任务>",
|
|
73
|
+
description="使用多 Agent 协作完成复杂任务",
|
|
74
|
+
category="workflow",
|
|
75
|
+
details=(
|
|
76
|
+
"由 Supervisor 调度隔离上下文的 Worker,并对任务结果进行证据化审查。",
|
|
77
|
+
"Reviewer 审查失败且缺少修复范围时,任务会进入 needs_input。",
|
|
78
|
+
"用户确认允许修改的文件范围后,可以恢复 Repairer,不会重新执行原任务。",
|
|
79
|
+
"Repairer 只允许修改明确声明的 write scope,不能把只读范围升级为写入权限。",
|
|
80
|
+
),
|
|
81
|
+
subcommands=(
|
|
82
|
+
SlashSubcommandSpec(
|
|
83
|
+
"run",
|
|
84
|
+
"/team <任务>",
|
|
85
|
+
"创建并执行一个 Team 任务",
|
|
86
|
+
),
|
|
87
|
+
SlashSubcommandSpec(
|
|
88
|
+
"resume",
|
|
89
|
+
"/team resume <任务ID> --write-scope <范围>",
|
|
90
|
+
"确认写入范围后恢复暂停的 Repairer",
|
|
91
|
+
options=("--write-scope",),
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
examples=(
|
|
95
|
+
"/team 实现一个带测试的登录模块",
|
|
96
|
+
"/team resume t4 --write-scope xg/auth/*.py",
|
|
97
|
+
),
|
|
98
|
+
),
|
|
99
|
+
SlashCommandSpec(
|
|
100
|
+
"/model",
|
|
101
|
+
usage="/model [list|<model-name>]",
|
|
102
|
+
description="查看当前模型,或在当前 provider 内切换模型",
|
|
103
|
+
category="config",
|
|
104
|
+
details=(
|
|
105
|
+
"不带参数或带 list 时,查看当前模型和可用 provider(含各 provider 模型列表)。",
|
|
106
|
+
"`<model-name>` 在当前 base provider 内切换模型,仅接受该 provider 的模型列表内模型"
|
|
107
|
+
"(含 default_model)。",
|
|
108
|
+
"切换 provider 请用 /provider switch <name>;给 provider 添加模型请用 "
|
|
109
|
+
"/provider <name> model <model>。",
|
|
110
|
+
),
|
|
111
|
+
subcommands=(
|
|
112
|
+
SlashSubcommandSpec("list", "/model", "查看当前模型和可用 provider"),
|
|
113
|
+
SlashSubcommandSpec("model", "/model <model-name>", "在当前 provider 内切换模型"),
|
|
114
|
+
),
|
|
115
|
+
examples=(
|
|
116
|
+
"/model",
|
|
117
|
+
"/model list",
|
|
118
|
+
"/model deepseek-chat",
|
|
119
|
+
),
|
|
120
|
+
),
|
|
121
|
+
SlashCommandSpec(
|
|
122
|
+
"/config",
|
|
123
|
+
usage="/config get|set ...",
|
|
124
|
+
description="查看或修改配置",
|
|
125
|
+
category="config",
|
|
126
|
+
details=(
|
|
127
|
+
"不带参数时查看当前生效配置,API Key 会脱敏。",
|
|
128
|
+
"`set` 的 value 当前按单个空格分隔参数解析。",
|
|
129
|
+
),
|
|
130
|
+
subcommands=(
|
|
131
|
+
SlashSubcommandSpec("overview", "/config", "查看当前生效配置"),
|
|
132
|
+
SlashSubcommandSpec("list", "/config list", "查看 provider 能力列表"),
|
|
133
|
+
SlashSubcommandSpec("get", "/config get <key>", "查看指定配置项"),
|
|
134
|
+
SlashSubcommandSpec("set", "/config set <key> <value>", "修改并持久化配置"),
|
|
135
|
+
),
|
|
136
|
+
examples=(
|
|
137
|
+
"/config",
|
|
138
|
+
"/config list",
|
|
139
|
+
"/config get active_provider",
|
|
140
|
+
"/config set active_model gpt-4o",
|
|
141
|
+
),
|
|
142
|
+
),
|
|
143
|
+
SlashCommandSpec(
|
|
144
|
+
"/provider",
|
|
145
|
+
usage="/provider [list|add|show|set|switch|key|remove|<name> model ...]",
|
|
146
|
+
description="在界面内管理 provider(增删改查、切 base、写 Key、维护模型列表)",
|
|
147
|
+
category="config",
|
|
148
|
+
details=(
|
|
149
|
+
"全程界面配置即可,无需手改文件:provider 定义、API Key 与模型列表一律写入 config.json。",
|
|
150
|
+
"add 参数齐全即直接执行;缺省参数会提示所需字段。",
|
|
151
|
+
"给某 provider 加/删模型:/provider <name> model <model>;删除 /provider <name> model rm <model>。",
|
|
152
|
+
"remove 与覆盖已有 Key 需加 --yes 确认;不能删除当前 base provider。",
|
|
153
|
+
),
|
|
154
|
+
subcommands=(
|
|
155
|
+
SlashSubcommandSpec("list", "/provider", "列出所有 provider(默认/模型列表、是否 base 与来源层)"),
|
|
156
|
+
SlashSubcommandSpec("add", "/provider add <name> <api_base> [--model M] [--label L] [--key K] [--set-base]", "新增一个 provider"),
|
|
157
|
+
SlashSubcommandSpec("show", "/provider show <name>", "查看单个 provider(Key 脱敏、含模型列表)"),
|
|
158
|
+
SlashSubcommandSpec("set", "/provider set <name> <field> <value>", "改单一字段:api_base|default_model|display_name"),
|
|
159
|
+
SlashSubcommandSpec("switch", "/provider switch <name> [model]", "切换 base provider"),
|
|
160
|
+
SlashSubcommandSpec("key", "/provider key <name> <KEY> [--yes]", "写入/覆盖 API Key 到 config.json"),
|
|
161
|
+
SlashSubcommandSpec("remove", "/provider remove <name> [--yes]", "删除 provider(需 --yes;base 不可删)"),
|
|
162
|
+
SlashSubcommandSpec("model", "/provider <name> model <model>", "给某 provider 添加模型到列表"),
|
|
163
|
+
SlashSubcommandSpec("model rm", "/provider <name> model rm <model>", "从某 provider 的模型列表移除模型"),
|
|
164
|
+
),
|
|
165
|
+
examples=(
|
|
166
|
+
"/provider",
|
|
167
|
+
"/provider add myproxy https://gateway.my.com/v1 --model deepseek-v4 -k sk_x --set-base",
|
|
168
|
+
"/provider myproxy model deepseek-r1",
|
|
169
|
+
"/provider myproxy model rm deepseek-r1",
|
|
170
|
+
"/provider switch deepseek",
|
|
171
|
+
"/provider show myproxy",
|
|
172
|
+
),
|
|
173
|
+
),
|
|
174
|
+
SlashCommandSpec(
|
|
175
|
+
"/tier",
|
|
176
|
+
usage="/tier [list|show|set|clear]",
|
|
177
|
+
description="配置 SmartRouter 档位(provider/model,写入 config.json)",
|
|
178
|
+
category="config",
|
|
179
|
+
details=(
|
|
180
|
+
"档位固定为 Basic/Enhanced/Superior/Ultimate 四档。",
|
|
181
|
+
"set 缺省 model 时取该 provider 的 default_model;宽松校验:"
|
|
182
|
+
"provider 需存在,model 非空且无非法字符即可。",
|
|
183
|
+
),
|
|
184
|
+
subcommands=(
|
|
185
|
+
SlashSubcommandSpec("list", "/tier", "列出四档 provider/model(未配回落主动 active)"),
|
|
186
|
+
SlashSubcommandSpec("show", "/tier show <tier>", "查看单个档位"),
|
|
187
|
+
SlashSubcommandSpec("set", "/tier set <tier> <provider> [model]", "设置档位 provider/model"),
|
|
188
|
+
SlashSubcommandSpec("clear", "/tier clear <tier>", "清空档位,回落到手动 active"),
|
|
189
|
+
),
|
|
190
|
+
examples=(
|
|
191
|
+
"/tier",
|
|
192
|
+
"/tier list",
|
|
193
|
+
"/tier set Basic deepseek deepseek-chat",
|
|
194
|
+
"/tier set Ultimate deepseek",
|
|
195
|
+
"/tier clear Superior",
|
|
196
|
+
"/tier show Enhanced",
|
|
197
|
+
),
|
|
198
|
+
),
|
|
199
|
+
SlashCommandSpec(
|
|
200
|
+
"/mcp",
|
|
201
|
+
usage="/mcp status|restart|logs|enable|disable|resources",
|
|
202
|
+
description="管理 MCP Server",
|
|
203
|
+
category="config",
|
|
204
|
+
details=("省略子命令时默认查看 Server 状态。",),
|
|
205
|
+
subcommands=(
|
|
206
|
+
SlashSubcommandSpec("status", "/mcp status", "查看 Server、工具和资源状态"),
|
|
207
|
+
SlashSubcommandSpec("restart", "/mcp restart <server>", "重启并重新发现指定 Server"),
|
|
208
|
+
SlashSubcommandSpec("logs", "/mcp logs <server>", "查看指定 Server 的脱敏日志"),
|
|
209
|
+
SlashSubcommandSpec("enable", "/mcp enable <server>", "启用指定 Server"),
|
|
210
|
+
SlashSubcommandSpec("disable", "/mcp disable <server>", "禁用指定 Server"),
|
|
211
|
+
SlashSubcommandSpec("resources", "/mcp resources [server]", "查看全部或指定 Server 的 resources"),
|
|
212
|
+
),
|
|
213
|
+
examples=(
|
|
214
|
+
"/mcp status",
|
|
215
|
+
"/mcp restart local",
|
|
216
|
+
"/mcp logs local",
|
|
217
|
+
"/mcp resources",
|
|
218
|
+
),
|
|
219
|
+
),
|
|
220
|
+
SlashCommandSpec(
|
|
221
|
+
"/web",
|
|
222
|
+
usage="/web status|providers|search|fetch",
|
|
223
|
+
description="查看或使用只读联网能力",
|
|
224
|
+
category="config",
|
|
225
|
+
details=(
|
|
226
|
+
"搜索和抓取均为只读能力;搜索需要配置 provider,抓取接受公开 HTTP(S) URL。",
|
|
227
|
+
"`search` 和 `fetch` 的参数分别作为 query 和 URL 传递。",
|
|
228
|
+
),
|
|
229
|
+
subcommands=(
|
|
230
|
+
SlashSubcommandSpec("status", "/web status", "查看联网开关、provider 和抓取限制"),
|
|
231
|
+
SlashSubcommandSpec("providers", "/web providers", "查看搜索 provider 配置状态"),
|
|
232
|
+
SlashSubcommandSpec("search", "/web search <query>", "搜索公开互联网"),
|
|
233
|
+
SlashSubcommandSpec("fetch", "/web fetch <url>", "抓取公开网页正文"),
|
|
234
|
+
),
|
|
235
|
+
examples=(
|
|
236
|
+
"/web status",
|
|
237
|
+
"/web providers",
|
|
238
|
+
"/web search Python 3.13 新特性",
|
|
239
|
+
"/web fetch https://www.example.com/",
|
|
240
|
+
),
|
|
241
|
+
),
|
|
242
|
+
SlashCommandSpec(
|
|
243
|
+
"/skill",
|
|
244
|
+
usage="/skill list|load|enable|disable",
|
|
245
|
+
description="管理任务 Skill",
|
|
246
|
+
category="config",
|
|
247
|
+
details=(
|
|
248
|
+
"Skill 是本地任务规范;加载 Skill 不会新增工具权限,也不能覆盖系统提示和安全策略。",
|
|
249
|
+
"`reference` 为可选的指定参考资料路径或名称。",
|
|
250
|
+
),
|
|
251
|
+
subcommands=(
|
|
252
|
+
SlashSubcommandSpec("list", "/skill list", "查看可用 Skill 元信息"),
|
|
253
|
+
SlashSubcommandSpec("load", "/skill load <name> [reference ...]", "按需加载 Skill 和参考资料"),
|
|
254
|
+
SlashSubcommandSpec("enable", "/skill enable <name>", "启用指定 Skill"),
|
|
255
|
+
SlashSubcommandSpec("disable", "/skill disable <name>", "禁用指定 Skill"),
|
|
256
|
+
),
|
|
257
|
+
examples=(
|
|
258
|
+
"/skill list",
|
|
259
|
+
"/skill load code-review",
|
|
260
|
+
"/skill load code-review references/style.md",
|
|
261
|
+
"/skill disable code-review",
|
|
262
|
+
),
|
|
263
|
+
),
|
|
264
|
+
SlashCommandSpec(
|
|
265
|
+
"/history",
|
|
266
|
+
usage="/history status|clear",
|
|
267
|
+
description="查看或清理输入历史",
|
|
268
|
+
category="session",
|
|
269
|
+
details=("命令只管理本地输入历史,不显示历史全文,也不影响 Agent 对话和长期记忆。",),
|
|
270
|
+
subcommands=(
|
|
271
|
+
SlashSubcommandSpec("status", "/history status", "查看输入历史状态和数量"),
|
|
272
|
+
SlashSubcommandSpec("clear", "/history clear", "清理当前项目的输入历史"),
|
|
273
|
+
),
|
|
274
|
+
examples=("/history status", "/history clear"),
|
|
275
|
+
),
|
|
276
|
+
SlashCommandSpec(
|
|
277
|
+
"/memory",
|
|
278
|
+
usage="/memory list|search|delete|clear",
|
|
279
|
+
description="管理长期记忆",
|
|
280
|
+
category="memory",
|
|
281
|
+
details=(
|
|
282
|
+
"长期记忆按当前项目隔离保存;普通对话不会自动写入长期记忆。",
|
|
283
|
+
"`clear` 会要求确认,`delete` 需要使用记忆条目的数字 ID。",
|
|
284
|
+
),
|
|
285
|
+
subcommands=(
|
|
286
|
+
SlashSubcommandSpec("list", "/memory list [limit]", "列出长期记忆,可选限制条数"),
|
|
287
|
+
SlashSubcommandSpec("search", "/memory search <关键词>", "按关键词搜索长期记忆"),
|
|
288
|
+
SlashSubcommandSpec("delete", "/memory delete <id>", "删除指定 ID 的长期记忆"),
|
|
289
|
+
SlashSubcommandSpec("clear", "/memory clear", "清空全部长期记忆并请求确认"),
|
|
290
|
+
),
|
|
291
|
+
examples=(
|
|
292
|
+
"/memory list",
|
|
293
|
+
"/memory list 10",
|
|
294
|
+
"/memory search 发布流程",
|
|
295
|
+
"/memory delete 3",
|
|
296
|
+
"/memory clear",
|
|
297
|
+
),
|
|
298
|
+
),
|
|
299
|
+
SlashCommandSpec(
|
|
300
|
+
"/lang",
|
|
301
|
+
aliases=("/language",),
|
|
302
|
+
usage="/lang [en|zh|reset]",
|
|
303
|
+
description="查看或切换 Inspector language",
|
|
304
|
+
category="session",
|
|
305
|
+
details=(
|
|
306
|
+
"默认语言为 English;切换只影响 TUI 右侧 Inspector,不影响 Agent 对话。",
|
|
307
|
+
"reset 会清除用户级语言偏好并恢复 English。",
|
|
308
|
+
),
|
|
309
|
+
examples=("/lang", "/lang zh", "/lang en", "/lang reset"),
|
|
310
|
+
),
|
|
311
|
+
SlashCommandSpec("/help", aliases=("/?",), usage="/help", description="查看命令帮助", category="general"),
|
|
312
|
+
SlashCommandSpec(
|
|
313
|
+
"/init",
|
|
314
|
+
usage="/init",
|
|
315
|
+
description="初始化项目记忆",
|
|
316
|
+
category="memory",
|
|
317
|
+
details=("分析当前项目并生成 XG.md 草稿;写入前会请求确认,已有文件不会直接覆盖。",),
|
|
318
|
+
examples=("/init",),
|
|
319
|
+
),
|
|
320
|
+
SlashCommandSpec(
|
|
321
|
+
"/save",
|
|
322
|
+
usage="/save <内容>",
|
|
323
|
+
description="保存长期记忆",
|
|
324
|
+
category="memory",
|
|
325
|
+
details=("只保存用户明确提供的内容;敏感片段会按现有记忆策略脱敏。",),
|
|
326
|
+
examples=("/save 项目使用 Python 3.13,测试命令是 uv run pytest",),
|
|
327
|
+
),
|
|
328
|
+
SlashCommandSpec(
|
|
329
|
+
"/hitl",
|
|
330
|
+
usage="/hitl on|off|reset",
|
|
331
|
+
description="管理人工审批开关",
|
|
332
|
+
category="safety",
|
|
333
|
+
details=(
|
|
334
|
+
"HITL 用于危险工具调用的人工审批。关闭后危险操作不再弹出审批,属于高风险模式。",
|
|
335
|
+
),
|
|
336
|
+
subcommands=(
|
|
337
|
+
SlashSubcommandSpec("status", "/hitl", "查看当前审批开关和本会话放行状态"),
|
|
338
|
+
SlashSubcommandSpec("on", "/hitl on", "开启危险操作审批"),
|
|
339
|
+
SlashSubcommandSpec("off", "/hitl off", "关闭危险操作审批并重置放行状态"),
|
|
340
|
+
SlashSubcommandSpec("reset", "/hitl reset", "清除本会话全部放行状态"),
|
|
341
|
+
),
|
|
342
|
+
examples=("/hitl", "/hitl on", "/hitl reset"),
|
|
343
|
+
),
|
|
344
|
+
SlashCommandSpec("/clear", usage="/clear", description="清空当前上下文", category="session", examples=("/clear",)),
|
|
345
|
+
SlashCommandSpec(
|
|
346
|
+
"/smartRouter",
|
|
347
|
+
usage="/smartRouter on|off|status|reset",
|
|
348
|
+
description="智能路由开关:每轮输入自动按档位选模型",
|
|
349
|
+
category="general",
|
|
350
|
+
subcommands=(
|
|
351
|
+
SlashSubcommandSpec("status", "/smartRouter status", "查看开关状态、四档模型配置、校准与自学习规则"),
|
|
352
|
+
SlashSubcommandSpec("on", "/smartRouter on", "开启智能路由"),
|
|
353
|
+
SlashSubcommandSpec("off", "/smartRouter off", "关闭并恢复开启前的手动模型"),
|
|
354
|
+
SlashSubcommandSpec("reset", "/smartRouter reset", "清空校准与自学习规则(feedback.log 保留)"),
|
|
355
|
+
),
|
|
356
|
+
examples=("/smartRouter", "/smartRouter on", "/smartRouter status", "/smartRouter reset"),
|
|
357
|
+
),
|
|
358
|
+
SlashCommandSpec(
|
|
359
|
+
"/train",
|
|
360
|
+
usage="/train [<dataset>] [--output <path>] [--yes] [--no-semantic]",
|
|
361
|
+
description="训练 SmartRouter ML 模型(手动触发)",
|
|
362
|
+
category="general",
|
|
363
|
+
details=(
|
|
364
|
+
"缺省只用 feedback.log 累积样本;可给手动标注 JSONL 路径混合训练。",
|
|
365
|
+
"命令输入后需加 --yes 确认才会执行;训练过程实时输出进度日志。",
|
|
366
|
+
"训练跑在独立子进程 tools/train_router.py,不阻塞主界面;产物写 router.lgb。",
|
|
367
|
+
"默认自动探测语义编码器并导入语义列(sem_dim>0);--no-semantic 关闭则纯 TF-IDF。",
|
|
368
|
+
),
|
|
369
|
+
options=("--yes", "--output <path>", "--feedback-only", "--no-semantic"),
|
|
370
|
+
examples=("/train", "/train labeled.jsonl --yes", "/train --output router.lgb --yes"),
|
|
371
|
+
),
|
|
372
|
+
SlashCommandSpec("/cancel", aliases=("/c",), usage="/cancel", description="取消当前任务", category="control", examples=("/cancel",)),
|
|
373
|
+
SlashCommandSpec("/exit", aliases=("/quit",), usage="/exit", description="退出程序", category="control", examples=("/exit",)),
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def filter_slash_commands(query: str) -> tuple[SlashCommandSpec, ...]:
|
|
378
|
+
"""Return stable, prefix-matched command specs for a top-level token."""
|
|
379
|
+
|
|
380
|
+
normalized = query.strip().lower()
|
|
381
|
+
if not normalized.startswith("/") or any(char.isspace() for char in normalized):
|
|
382
|
+
return ()
|
|
383
|
+
|
|
384
|
+
# An exact alias is an unambiguous command selection. For example, `/c`
|
|
385
|
+
# is the cancel alias even though it is also a prefix of `/config` and
|
|
386
|
+
# `/clear`.
|
|
387
|
+
exact_alias = next(
|
|
388
|
+
(
|
|
389
|
+
spec
|
|
390
|
+
for spec in SLASH_COMMANDS
|
|
391
|
+
if any(alias.lower() == normalized for alias in spec.aliases)
|
|
392
|
+
),
|
|
393
|
+
None,
|
|
394
|
+
)
|
|
395
|
+
if exact_alias is not None:
|
|
396
|
+
return (exact_alias,)
|
|
397
|
+
|
|
398
|
+
matches = [
|
|
399
|
+
(index, spec)
|
|
400
|
+
for index, spec in enumerate(SLASH_COMMANDS)
|
|
401
|
+
if spec.name.lower().startswith(normalized)
|
|
402
|
+
or any(alias.lower().startswith(normalized) for alias in spec.aliases)
|
|
403
|
+
]
|
|
404
|
+
matches.sort(
|
|
405
|
+
key=lambda pair: (
|
|
406
|
+
0
|
|
407
|
+
if pair[1].name.lower() == normalized
|
|
408
|
+
or any(alias.lower() == normalized for alias in pair[1].aliases)
|
|
409
|
+
else 1,
|
|
410
|
+
pair[0],
|
|
411
|
+
)
|
|
412
|
+
)
|
|
413
|
+
return tuple(spec for _, spec in matches)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
class CommandService:
|
|
417
|
+
"""Execute slash commands without knowing anything about Textual."""
|
|
418
|
+
|
|
419
|
+
def __init__(self, context: CommandContext, log_sink: callable | None = None) -> None:
|
|
420
|
+
self.context = context
|
|
421
|
+
# 可选的实时日志通道:由 TUI 传入,把训练/长流程的每一行即时上屏。
|
|
422
|
+
self.log_sink = log_sink
|
|
423
|
+
|
|
424
|
+
async def execute(self, raw: str) -> CommandResult:
|
|
425
|
+
raw = raw.strip()
|
|
426
|
+
if not raw:
|
|
427
|
+
return CommandResult(ok=False, message="命令不能为空")
|
|
428
|
+
if raw.lower() in ("/cancel", "/c"):
|
|
429
|
+
return CommandResult(ok=True, message="已请求取消当前任务")
|
|
430
|
+
|
|
431
|
+
parts = raw.split(maxsplit=1)
|
|
432
|
+
if parts[0].lower() in ("/help", "/?"):
|
|
433
|
+
from xg.cli.help import format_command_help, format_help
|
|
434
|
+
|
|
435
|
+
query = parts[1].strip() if len(parts) > 1 else ""
|
|
436
|
+
return CommandResult(
|
|
437
|
+
ok=True,
|
|
438
|
+
message=format_command_help(query) if query else format_help(),
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
if parts[0].lower() in ("/lang", "/language"):
|
|
442
|
+
return _execute_language_command(self.context.settings, self.context.manager, raw)
|
|
443
|
+
|
|
444
|
+
if parts[0].lower() == "/provider":
|
|
445
|
+
message, ok = execute_provider_command(self.context.manager, self.context.settings, raw)
|
|
446
|
+
if ok and self.context.agent is not None:
|
|
447
|
+
# TUI/CommandService 路径:/provider 变更后热同步运行中 client
|
|
448
|
+
#(与 inline _handle_command 保持一致,配好即用、无需重启)。
|
|
449
|
+
from xg.cli.app import _reapply_active
|
|
450
|
+
|
|
451
|
+
_reapply_active(self.context.agent, self.context.settings, self.context.manager)
|
|
452
|
+
return CommandResult(ok=ok, message=message)
|
|
453
|
+
if parts[0].lower() == "/tier":
|
|
454
|
+
message, ok = execute_tier_command(self.context.manager, self.context.settings, raw)
|
|
455
|
+
return CommandResult(ok=ok, message=message)
|
|
456
|
+
if parts[0].lower() == "/train":
|
|
457
|
+
return await execute_train_command(raw, log_sink=self.log_sink)
|
|
458
|
+
if parts[0].lower() == "/mcp":
|
|
459
|
+
message, ok = await execute_mcp_command(self.context.agent, raw)
|
|
460
|
+
return CommandResult(ok=ok, message=message)
|
|
461
|
+
if parts[0].lower() == "/web":
|
|
462
|
+
message, ok = await execute_web_command(self.context.agent, raw)
|
|
463
|
+
return CommandResult(ok=ok, message=message)
|
|
464
|
+
if parts[0].lower() == "/skill":
|
|
465
|
+
message, ok = await execute_skill_command(self.context.agent, raw)
|
|
466
|
+
return CommandResult(ok=ok, message=message)
|
|
467
|
+
if parts[0].lower() == "/history":
|
|
468
|
+
message, ok = await execute_history_command(self.context.agent, raw)
|
|
469
|
+
return CommandResult(ok=ok, message=message)
|
|
470
|
+
|
|
471
|
+
# Lazy import avoids a cycle: app.py still owns the legacy renderer
|
|
472
|
+
# and its command helpers are kept as the public compatibility API.
|
|
473
|
+
from xg.cli.app import _handle_command, _handle_memory_command
|
|
474
|
+
|
|
475
|
+
cmd = raw.split(maxsplit=1)[0].lower()
|
|
476
|
+
if cmd == "/init":
|
|
477
|
+
# Generation and confirmation are UI concerns. The controller
|
|
478
|
+
# handles this command separately so a TUI can show a modal.
|
|
479
|
+
return CommandResult(ok=True, open_modal="init", message="正在准备项目记忆草稿")
|
|
480
|
+
|
|
481
|
+
if cmd in ("/save", "/memory"):
|
|
482
|
+
message, should_exit = _handle_command(
|
|
483
|
+
self.context.agent, self.context.settings, self.context.manager, raw
|
|
484
|
+
)
|
|
485
|
+
return CommandResult(ok=not should_exit, message=message or "", should_exit=should_exit)
|
|
486
|
+
|
|
487
|
+
message, should_exit = _handle_command(
|
|
488
|
+
self.context.agent, self.context.settings, self.context.manager, raw
|
|
489
|
+
)
|
|
490
|
+
return CommandResult(ok=not (message and message.startswith("未知命令")), message=message or "", should_exit=should_exit)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _execute_language_command(settings: Any, manager: Any, raw: str) -> CommandResult:
|
|
494
|
+
"""Handle the UI-only language preference without touching the Agent."""
|
|
495
|
+
parts = raw.split()
|
|
496
|
+
current = normalize_language(getattr(settings, "ui_language", "en"))
|
|
497
|
+
if len(parts) == 1:
|
|
498
|
+
source = "default"
|
|
499
|
+
if hasattr(manager, "ui_language_source"):
|
|
500
|
+
source = manager.ui_language_source()
|
|
501
|
+
return CommandResult(
|
|
502
|
+
ok=True,
|
|
503
|
+
message=(
|
|
504
|
+
f"Inspector language: {current} (source: {source})\n"
|
|
505
|
+
"Available: en, zh\n"
|
|
506
|
+
"Usage: /lang en|zh|reset"
|
|
507
|
+
),
|
|
508
|
+
data={"ui_language": current, "persisted": True},
|
|
509
|
+
)
|
|
510
|
+
if len(parts) != 2 or parts[1].lower() not in {"en", "zh", "reset"}:
|
|
511
|
+
return CommandResult(
|
|
512
|
+
ok=False,
|
|
513
|
+
message="Usage: /lang [en|zh|reset]",
|
|
514
|
+
data={"ui_language": current, "persisted": False},
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
requested = parts[1].lower()
|
|
518
|
+
language = "en" if requested == "reset" else requested
|
|
519
|
+
persisted = True
|
|
520
|
+
try:
|
|
521
|
+
if requested == "reset":
|
|
522
|
+
manager.reset_ui_language()
|
|
523
|
+
else:
|
|
524
|
+
manager.set_ui_language(language)
|
|
525
|
+
except (AttributeError, OSError, ValueError) as exc:
|
|
526
|
+
# A read-only or broken config directory must not prevent a session
|
|
527
|
+
# from changing its presentation language.
|
|
528
|
+
persisted = False
|
|
529
|
+
warning = f" (session only; could not persist: {exc})"
|
|
530
|
+
else:
|
|
531
|
+
warning = ""
|
|
532
|
+
|
|
533
|
+
settings.ui_language = language
|
|
534
|
+
action = "reset to English" if requested == "reset" else f"changed to {language}"
|
|
535
|
+
return CommandResult(
|
|
536
|
+
ok=True,
|
|
537
|
+
message=f"Inspector language {action}.{warning}",
|
|
538
|
+
data={"ui_language": language, "persisted": persisted},
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
async def execute_mcp_command(agent: Any, raw: str) -> tuple[str, bool]:
|
|
543
|
+
"""Execute the shared asynchronous /mcp command group."""
|
|
544
|
+
manager = getattr(agent, "mcp_manager", None)
|
|
545
|
+
if manager is None:
|
|
546
|
+
return "MCP 未初始化。", False
|
|
547
|
+
await manager.ensure_started()
|
|
548
|
+
parts = raw.split()
|
|
549
|
+
sub = parts[1].lower() if len(parts) > 1 else "status"
|
|
550
|
+
name = parts[2] if len(parts) > 2 else ""
|
|
551
|
+
if sub == "status":
|
|
552
|
+
return manager.format_status(), True
|
|
553
|
+
if sub == "resources":
|
|
554
|
+
return manager.format_resources(name or None), True
|
|
555
|
+
if sub == "logs":
|
|
556
|
+
if not name:
|
|
557
|
+
return "用法: /mcp logs <server>", False
|
|
558
|
+
return manager.logs(name), name in {item.name for item in manager.snapshots()}
|
|
559
|
+
if sub == "restart":
|
|
560
|
+
if not name:
|
|
561
|
+
return "用法: /mcp restart <server>", False
|
|
562
|
+
ok = await manager.restart(name)
|
|
563
|
+
return (f"MCP Server {name} 已重启。" if ok else f"MCP Server {name} 重启失败或不存在。"), ok
|
|
564
|
+
if sub in {"enable", "disable"}:
|
|
565
|
+
if not name:
|
|
566
|
+
return f"用法: /mcp {sub} <server>", False
|
|
567
|
+
enabled = sub == "enable"
|
|
568
|
+
ok = await manager.set_enabled(name, enabled)
|
|
569
|
+
action = "启用" if enabled else "禁用"
|
|
570
|
+
return (f"MCP Server {name} 已{action}。" if ok else f"MCP Server {name} {action}失败或不存在。"), ok
|
|
571
|
+
return "用法: /mcp status|restart <server>|logs <server>|enable <server>|disable <server>|resources [server]", False
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
async def execute_web_command(agent: Any, raw: str) -> tuple[str, bool]:
|
|
575
|
+
"""Shared read-only Web command semantics for inline and TUI."""
|
|
576
|
+
config = getattr(agent, "web_config", None)
|
|
577
|
+
parts = raw.split(maxsplit=2)
|
|
578
|
+
sub = parts[1].lower() if len(parts) > 1 else "status"
|
|
579
|
+
if config is None:
|
|
580
|
+
return "Web 能力未初始化。", False
|
|
581
|
+
if sub in {"status", ""}:
|
|
582
|
+
search = config.search
|
|
583
|
+
configured = bool(search.api_base and (search.api_key or search.provider == "searxng"))
|
|
584
|
+
return (f"Web: {'启用' if config.enabled else '关闭'}\n"
|
|
585
|
+
f"search provider: {search.provider}({'已配置' if configured else '未配置'})\n"
|
|
586
|
+
f"fetch: timeout={config.fetch.timeout}s, max={config.fetch.max_response_bytes} bytes, "
|
|
587
|
+
f"chars={config.fetch.max_chars}, redirects={config.fetch.max_redirects}"), True
|
|
588
|
+
if sub == "providers":
|
|
589
|
+
lines = []
|
|
590
|
+
for name in ("zhipu", "serpapi", "searxng"):
|
|
591
|
+
data = config.providers.get(name, {})
|
|
592
|
+
active = name == config.search.provider
|
|
593
|
+
has_key = bool((config.search.api_key if active else None) or data.get("api_key"))
|
|
594
|
+
has_url = bool((config.search.api_base if active else None) or data.get("api_base") or data.get("url"))
|
|
595
|
+
lines.append(f"{name}: {'已配置' if has_url and (has_key or name == 'searxng') else '未配置'}")
|
|
596
|
+
return "\n".join(lines), True
|
|
597
|
+
if sub == "search":
|
|
598
|
+
if len(parts) < 3 or not parts[2].strip():
|
|
599
|
+
return "用法: /web search <query>", False
|
|
600
|
+
service = getattr(agent, "web_search", None)
|
|
601
|
+
if service is None:
|
|
602
|
+
return "Web 搜索未启用或未配置 provider。", False
|
|
603
|
+
ok, output = await service.search_tool({"query": parts[2].strip()})
|
|
604
|
+
return output, ok
|
|
605
|
+
if sub == "fetch":
|
|
606
|
+
if len(parts) < 3 or not parts[2].strip():
|
|
607
|
+
return "用法: /web fetch <url>", False
|
|
608
|
+
service = getattr(agent, "web_fetch", None)
|
|
609
|
+
if service is None:
|
|
610
|
+
return "Web 抓取未启用。", False
|
|
611
|
+
ok, output = await service.fetch_tool({"url": parts[2].strip()})
|
|
612
|
+
return output, ok
|
|
613
|
+
return "用法: /web status|providers|search <query>|fetch <url>", False
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
async def execute_skill_command(agent: Any, raw: str) -> tuple[str, bool]:
|
|
617
|
+
"""Shared read-only Skill management command semantics."""
|
|
618
|
+
registry = getattr(agent, "skill_registry", None)
|
|
619
|
+
if registry is None or not registry.config.enabled:
|
|
620
|
+
return "Skill 能力未启用。", False
|
|
621
|
+
parts = raw.split()
|
|
622
|
+
sub = parts[1].lower() if len(parts) > 1 else "list"
|
|
623
|
+
if sub == "list":
|
|
624
|
+
registry.reload()
|
|
625
|
+
return registry.format_list(), True
|
|
626
|
+
if sub == "load":
|
|
627
|
+
if len(parts) < 3:
|
|
628
|
+
return "用法: /skill load <name> [reference ...]", False
|
|
629
|
+
ok, output = registry.manual_load(parts[2], tuple(parts[3:]))
|
|
630
|
+
return output, ok
|
|
631
|
+
if sub in {"enable", "disable"}:
|
|
632
|
+
if len(parts) < 3:
|
|
633
|
+
return f"用法: /skill {sub} <name>", False
|
|
634
|
+
enabled = sub == "enable"
|
|
635
|
+
ok = registry.set_enabled(parts[2], enabled)
|
|
636
|
+
action = "启用" if enabled else "禁用"
|
|
637
|
+
return (f"Skill {parts[2]} 已{action}。" if ok else f"Skill {parts[2]} 不存在或无法修改。"), ok
|
|
638
|
+
return "用法: /skill list|load <name> [reference ...]|enable <name>|disable <name>", False
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
async def execute_history_command(agent: Any, raw: str) -> tuple[str, bool]:
|
|
642
|
+
"""Show or explicitly clear local input history without exposing entries."""
|
|
643
|
+
history = getattr(agent, "input_history", None)
|
|
644
|
+
if history is None:
|
|
645
|
+
return "输入历史未初始化。", False
|
|
646
|
+
parts = raw.split()
|
|
647
|
+
sub = parts[1].lower() if len(parts) > 1 else "status"
|
|
648
|
+
if sub in {"status", ""}:
|
|
649
|
+
return history.status(), True
|
|
650
|
+
if sub == "clear":
|
|
651
|
+
count = history.clear(persistent=True)
|
|
652
|
+
return f"已清理输入历史({count} 条)。", True
|
|
653
|
+
return "用法: /history status|clear", False
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
# ---------------------------------------------------------------------------
|
|
657
|
+
# /provider 命令(共享给 inline 与 TUI;确定性实现,确认动作走 --yes)
|
|
658
|
+
# ---------------------------------------------------------------------------
|
|
659
|
+
|
|
660
|
+
def _provider_service(manager: Any, settings: Any) -> Any:
|
|
661
|
+
from xg.config.provider_service import ProviderConfigService
|
|
662
|
+
|
|
663
|
+
return ProviderConfigService(manager, settings)
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _provider_usage(sub: str) -> str:
|
|
667
|
+
usage = {
|
|
668
|
+
"list": "/provider",
|
|
669
|
+
"add": "/provider add <name> <api_base> [--model M] [--label L] [--key K] [--set-base]",
|
|
670
|
+
"show": "/provider show <name>",
|
|
671
|
+
"set": "/provider set <name> <field> <value> (field: api_base|default_model|display_name)",
|
|
672
|
+
"switch": "/provider switch <name> [model]",
|
|
673
|
+
"key": "/provider key <name> <KEY> [--yes]",
|
|
674
|
+
"remove": "/provider remove <name> [--yes]",
|
|
675
|
+
"model": "/provider <name> model <model> 或 /provider <name> model rm <model>",
|
|
676
|
+
}
|
|
677
|
+
return usage.get(sub, "/provider [list|add|show|set|switch|key|remove|<name> model ...]")
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _consume_flags(tokens: list[str]) -> tuple[dict[str, str], list[str]]:
|
|
681
|
+
"""把 ``--key k --model m`` 这类选项从 tokens 中拆出;返回 (flags, 剩余位置参数)。"""
|
|
682
|
+
flags: dict[str, str] = {}
|
|
683
|
+
positional: list[str] = []
|
|
684
|
+
i = 0
|
|
685
|
+
alias = {"--label": "--label", "-l": "--label", "--model": "--model", "-m": "--model", "--key": "--key", "-k": "--key"}
|
|
686
|
+
while i < len(tokens):
|
|
687
|
+
tok = tokens[i]
|
|
688
|
+
if tok in alias:
|
|
689
|
+
# --label 可接单个 token(空格分隔暂不拼接多词 display_name)
|
|
690
|
+
if i + 1 < len(tokens):
|
|
691
|
+
flags[alias[tok]] = tokens[i + 1]
|
|
692
|
+
i += 2
|
|
693
|
+
continue
|
|
694
|
+
i += 1
|
|
695
|
+
continue
|
|
696
|
+
if tok in ("--set-base",):
|
|
697
|
+
flags["--set-base"] = "1"
|
|
698
|
+
i += 1
|
|
699
|
+
continue
|
|
700
|
+
if tok in ("--yes", "-y"):
|
|
701
|
+
flags["--yes"] = "1"
|
|
702
|
+
i += 1
|
|
703
|
+
continue
|
|
704
|
+
positional.append(tok)
|
|
705
|
+
i += 1
|
|
706
|
+
return flags, positional
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def execute_provider_command(manager: Any, settings: Any, raw: str) -> tuple[str, bool]:
|
|
710
|
+
"""执行 /provider 子命令,返回 (message, ok)。
|
|
711
|
+
|
|
712
|
+
首个参数优先按子命令解析(list/add/show/set/switch/key);否则若它
|
|
713
|
+
是既有 provider 名,则进入 provider 作用域(model 加/删)。其余报用法。
|
|
714
|
+
"""
|
|
715
|
+
parts = raw.split()
|
|
716
|
+
sub = parts[1].lower() if len(parts) > 1 else "list"
|
|
717
|
+
service = _provider_service(manager, settings)
|
|
718
|
+
|
|
719
|
+
if sub in {"list", ""}:
|
|
720
|
+
return _render_provider_list(service), True
|
|
721
|
+
|
|
722
|
+
if sub == "add":
|
|
723
|
+
flags, positional = _consume_flags(parts[2:])
|
|
724
|
+
if len(positional) < 2:
|
|
725
|
+
return _provider_usage("add"), False
|
|
726
|
+
name, api_base = positional[0], positional[1]
|
|
727
|
+
result = service.add(
|
|
728
|
+
name,
|
|
729
|
+
api_base,
|
|
730
|
+
flags.get("--model", ""),
|
|
731
|
+
display_name=flags.get("--label"),
|
|
732
|
+
api_key=flags.get("--key"),
|
|
733
|
+
)
|
|
734
|
+
if not result.ok:
|
|
735
|
+
return result.message, False
|
|
736
|
+
if "--set-base" in flags:
|
|
737
|
+
switch_result = service.switch(name)
|
|
738
|
+
return f"{result.message}\n{switch_result.message}", switch_result.ok
|
|
739
|
+
return result.message, True
|
|
740
|
+
|
|
741
|
+
if sub == "show":
|
|
742
|
+
flags, positional = _consume_flags(parts[2:])
|
|
743
|
+
if not positional:
|
|
744
|
+
return _provider_usage("show"), False
|
|
745
|
+
row = service.get(positional[0])
|
|
746
|
+
if row is None:
|
|
747
|
+
return f"未知 provider: {positional[0]}", False
|
|
748
|
+
models = "、".join(row["models"]) if row["models"] else "(无,可用 /provider <name> model <model> 添加)"
|
|
749
|
+
return "\n".join(
|
|
750
|
+
[
|
|
751
|
+
f"name: {row['name']}",
|
|
752
|
+
f"display: {row['display_name']}",
|
|
753
|
+
f"api_base: {row['api_base']}",
|
|
754
|
+
f"default: {row['default_model']}",
|
|
755
|
+
f"models: {models}",
|
|
756
|
+
f"api_key: {row['api_key_masked']}(config.json)",
|
|
757
|
+
f"key 已配置: {'✓' if row['has_key'] else '✗'}",
|
|
758
|
+
f"is_base: {'✓' if row['is_base'] else '✗'}",
|
|
759
|
+
f"来源层: {row['layer']}",
|
|
760
|
+
]
|
|
761
|
+
), True
|
|
762
|
+
|
|
763
|
+
if sub == "switch":
|
|
764
|
+
flags, positional = _consume_flags(parts[2:])
|
|
765
|
+
if not positional:
|
|
766
|
+
return _provider_usage("switch"), False
|
|
767
|
+
model = positional[1] if len(positional) > 1 else None
|
|
768
|
+
result = service.switch(positional[0], model)
|
|
769
|
+
return result.message, result.ok
|
|
770
|
+
|
|
771
|
+
if sub == "set":
|
|
772
|
+
flags, positional = _consume_flags(parts[2:])
|
|
773
|
+
if len(positional) < 3:
|
|
774
|
+
return _provider_usage("set"), False
|
|
775
|
+
name, field, value = positional[0], positional[1], positional[2]
|
|
776
|
+
result = service.update(name, {field: value})
|
|
777
|
+
return result.message, result.ok
|
|
778
|
+
|
|
779
|
+
if sub == "key":
|
|
780
|
+
flags, positional = _consume_flags(parts[2:])
|
|
781
|
+
if len(positional) < 2:
|
|
782
|
+
return _provider_usage("key"), False
|
|
783
|
+
result = service.set_api_key(positional[0], positional[1], yes="--yes" in flags)
|
|
784
|
+
return result.message, result.ok
|
|
785
|
+
|
|
786
|
+
if sub == "remove":
|
|
787
|
+
flags, positional = _consume_flags(parts[2:])
|
|
788
|
+
if not positional:
|
|
789
|
+
return _provider_usage("remove"), False
|
|
790
|
+
result = service.remove(positional[0], yes="--yes" in flags)
|
|
791
|
+
return result.message, result.ok
|
|
792
|
+
|
|
793
|
+
# provider 作用域:/provider <name> model [rm] <model>
|
|
794
|
+
if len(parts) >= 3 and parts[2].lower() == "model":
|
|
795
|
+
tokens = [t.lower() if t.lower() in ("model", "rm") else t for t in parts[3:]]
|
|
796
|
+
if not tokens:
|
|
797
|
+
return _provider_usage("model"), False
|
|
798
|
+
if tokens[0] == "rm":
|
|
799
|
+
if len(tokens) < 2:
|
|
800
|
+
return _provider_usage("model"), False
|
|
801
|
+
result = service.remove_model(parts[1], tokens[1])
|
|
802
|
+
return result.message, result.ok
|
|
803
|
+
result = service.add_model(parts[1], tokens[0])
|
|
804
|
+
return result.message, result.ok
|
|
805
|
+
|
|
806
|
+
return _provider_usage(""), False
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _render_provider_list(service: Any) -> str:
|
|
810
|
+
rows = service.list()
|
|
811
|
+
if not rows:
|
|
812
|
+
return "尚未配置任何 provider。\n用法: /provider add <name> <api_base> --model <M> [--key K] [--set-base]"
|
|
813
|
+
lines = ["NAME DISPLAY DEFAULT_MODEL MODELS KEY BASE LAYER"]
|
|
814
|
+
for row in rows:
|
|
815
|
+
models = "、".join(row["models"]) if row["models"] else "-"
|
|
816
|
+
lines.append(
|
|
817
|
+
f"{row['name']:<16}"
|
|
818
|
+
f"{row['display_name'][:12]:<12}"
|
|
819
|
+
f"{row['default_model'][:15]:<15}"
|
|
820
|
+
f"{models[:50]:<50}"
|
|
821
|
+
f"{'✓' if row['has_key'] else '✗':^4}"
|
|
822
|
+
f"{'●' if row['is_base'] else ' ':^6}"
|
|
823
|
+
f"{row['layer']}"
|
|
824
|
+
)
|
|
825
|
+
return "\n".join(lines)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def execute_tier_command(manager: Any, settings: Any, raw: str) -> tuple[str, bool]:
|
|
829
|
+
"""执行 /tier 子命令(list/show/set/clear)。"""
|
|
830
|
+
from xg.config.smart_router_service import ( # 延迟导入避免环
|
|
831
|
+
SmartRouterConfigService,
|
|
832
|
+
_SMART_ROUTER_TIERS,
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
parts = raw.split()
|
|
836
|
+
sub = parts[1].lower() if len(parts) > 1 else "list"
|
|
837
|
+
service = SmartRouterConfigService(manager, settings)
|
|
838
|
+
|
|
839
|
+
if sub in {"list", ""}:
|
|
840
|
+
rows = service.list_tiers()
|
|
841
|
+
lines = ["TIER PROVIDER MODEL"]
|
|
842
|
+
for row in rows:
|
|
843
|
+
mark = "*" if row["configured"] else "-"
|
|
844
|
+
provider = row["provider"] or "(回落 active)"
|
|
845
|
+
model = row["model"] or ""
|
|
846
|
+
lines.append(f"{mark} {row['name']:<9}{provider:<14}{model}")
|
|
847
|
+
lines.append("*=显式配置 -=未配回落手动 active")
|
|
848
|
+
return "\n".join(lines), True
|
|
849
|
+
|
|
850
|
+
if sub == "show":
|
|
851
|
+
if len(parts) < 3:
|
|
852
|
+
return "/tier show <tier> (tier: " + _tiers_join() + ")", False
|
|
853
|
+
result, row = service.get_tier(parts[2])
|
|
854
|
+
if not result.ok:
|
|
855
|
+
return result.message, False
|
|
856
|
+
state = "已配置" if row["provider"] else "未配置(回落 active)"
|
|
857
|
+
return (f"tier: {row['name']}\nprovider: {row['provider'] or '-'}\nmodel: {row['model'] or '-'}\n状态: {state}", True)
|
|
858
|
+
|
|
859
|
+
if sub == "set":
|
|
860
|
+
if len(parts) < 4:
|
|
861
|
+
return "/tier set <tier> <provider> [model]", False
|
|
862
|
+
tier = parts[2]
|
|
863
|
+
provider = parts[3]
|
|
864
|
+
model = parts[4] if len(parts) > 4 else None
|
|
865
|
+
result = service.set_tier(tier, provider, model)
|
|
866
|
+
return result.message, result.ok
|
|
867
|
+
|
|
868
|
+
if sub == "clear":
|
|
869
|
+
if len(parts) < 3:
|
|
870
|
+
return "/tier clear <tier>", False
|
|
871
|
+
result = service.clear_tier(parts[2])
|
|
872
|
+
return result.message, result.ok
|
|
873
|
+
|
|
874
|
+
return _tier_usage(""), False
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def _tiers_join() -> str:
|
|
878
|
+
return "/".join(_SMART_ROUTER_TIERS)
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _tier_usage(sub: str) -> str:
|
|
882
|
+
usage = {
|
|
883
|
+
"list": "/tier",
|
|
884
|
+
"show": "/tier show <tier>",
|
|
885
|
+
"set": "/tier set <tier> <provider> [model]",
|
|
886
|
+
"clear": "/tier clear <tier>",
|
|
887
|
+
}
|
|
888
|
+
return usage.get(sub, "/tier [list|show|set|clear]")
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
async def execute_train_command(raw: str, log_sink: callable | None = None) -> CommandResult:
|
|
892
|
+
"""执行 /train:不带 --yes 只返回确认提示;带 --yes 才 spawn 训练并流式上报日志。
|
|
893
|
+
|
|
894
|
+
log_sink 由 TUI 提供,逐行即时上屏;缺失时日志合并进返回的 message。
|
|
895
|
+
"""
|
|
896
|
+
# 延迟导入避免与 app.py 的循环依赖;train 层不 import 训练逻辑(只 spawn 子进程)。
|
|
897
|
+
from xg.cli.train import ( # noqa: PLC0415
|
|
898
|
+
check_train_deps,
|
|
899
|
+
confirmation_message,
|
|
900
|
+
parse_train_command,
|
|
901
|
+
run_training_async,
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
plan, err = parse_train_command(raw)
|
|
905
|
+
if err:
|
|
906
|
+
return CommandResult(ok=False, message=err)
|
|
907
|
+
dep_err = check_train_deps()
|
|
908
|
+
if dep_err:
|
|
909
|
+
return CommandResult(ok=False, message=dep_err)
|
|
910
|
+
if not plan.overwrite:
|
|
911
|
+
return CommandResult(ok=False, message=confirmation_message(plan))
|
|
912
|
+
|
|
913
|
+
ok, lines = await run_training_async(plan, log_sink)
|
|
914
|
+
# log_sink 存在时行日志已逐行实时上屏,这里不再重复,只给简短收尾。
|
|
915
|
+
if ok:
|
|
916
|
+
return CommandResult(
|
|
917
|
+
ok=True,
|
|
918
|
+
message="训练完成,产物已写入。" if log_sink else ("\n".join(lines[-3:]) or "训练完成。"),
|
|
919
|
+
)
|
|
920
|
+
tail = "\n".join(lines[-6:]) or "训练失败(无输出)"
|
|
921
|
+
return CommandResult(ok=False, message="训练失败" if log_sink else f"训练失败:\n{tail}")
|