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/help.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Formatting helpers for the shared slash-command help."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
from xg.cli.commands import SLASH_COMMANDS, SlashCommandSpec, SlashSubcommandSpec
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
CATEGORY_LABELS: dict[str, str] = {
|
|
11
|
+
"workflow": "工作流",
|
|
12
|
+
"config": "配置与能力",
|
|
13
|
+
"session": "会话",
|
|
14
|
+
"memory": "记忆",
|
|
15
|
+
"safety": "安全",
|
|
16
|
+
"control": "控制",
|
|
17
|
+
"general": "通用",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
CATEGORY_ORDER = (
|
|
21
|
+
"workflow",
|
|
22
|
+
"config",
|
|
23
|
+
"session",
|
|
24
|
+
"memory",
|
|
25
|
+
"safety",
|
|
26
|
+
"control",
|
|
27
|
+
"general",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
SHORTCUTS = (
|
|
31
|
+
("Enter", "发送输入"),
|
|
32
|
+
("↑ / ↓", "浏览输入历史;输入 / 时浏览命令建议"),
|
|
33
|
+
("Ctrl+C", "取消当前任务"),
|
|
34
|
+
("Ctrl+L", "清屏"),
|
|
35
|
+
("Ctrl+R", "显示或隐藏侧栏"),
|
|
36
|
+
("Ctrl+T", "打开配置面板(Provider 与 SmartRouter)"),
|
|
37
|
+
("Ctrl+1..4", "切换 Inspector 视图(Session/Plan/Memory/Safety)"),
|
|
38
|
+
("Ctrl+Tab", "Inspector 视图循环切换(Ctrl+Shift+Tab 反向)"),
|
|
39
|
+
("Esc", "关闭弹窗、取消当前交互或清除输入"),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _command_key(value: str) -> str:
|
|
44
|
+
"""Normalize a command name or alias for exact help lookup."""
|
|
45
|
+
|
|
46
|
+
return value.strip().lower().lstrip("/")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_help_command(raw: str) -> str | None:
|
|
50
|
+
"""Return the optional help query, or ``None`` for a non-help input."""
|
|
51
|
+
|
|
52
|
+
parts = raw.strip().split(maxsplit=1)
|
|
53
|
+
if not parts or parts[0].lower() not in ("/help", "/?"):
|
|
54
|
+
return None
|
|
55
|
+
return parts[1].strip() if len(parts) > 1 else ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _aliases_text(spec: SlashCommandSpec) -> str:
|
|
59
|
+
if not spec.aliases:
|
|
60
|
+
return ""
|
|
61
|
+
return f"(别名:{'、'.join(spec.aliases)})"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _command_lines(commands: Sequence[SlashCommandSpec]) -> list[str]:
|
|
65
|
+
visible = [spec for spec in commands if spec.usage and spec.description]
|
|
66
|
+
if not visible:
|
|
67
|
+
return []
|
|
68
|
+
usage_width = max(len(spec.usage) for spec in visible)
|
|
69
|
+
lines: list[str] = []
|
|
70
|
+
for category in CATEGORY_ORDER:
|
|
71
|
+
grouped = [spec for spec in visible if spec.category == category]
|
|
72
|
+
if not grouped:
|
|
73
|
+
continue
|
|
74
|
+
lines.append(CATEGORY_LABELS.get(category, category))
|
|
75
|
+
for spec in grouped:
|
|
76
|
+
usage = spec.usage.ljust(usage_width)
|
|
77
|
+
lines.append(f" {usage} {spec.description}{_aliases_text(spec)}")
|
|
78
|
+
lines.append("")
|
|
79
|
+
|
|
80
|
+
# Keep custom categories visible even if a future command adds one that
|
|
81
|
+
# is not yet part of the standard presentation order.
|
|
82
|
+
known = set(CATEGORY_ORDER)
|
|
83
|
+
for category in dict.fromkeys(spec.category for spec in visible if spec.category not in known):
|
|
84
|
+
lines.append(CATEGORY_LABELS.get(category, category))
|
|
85
|
+
for spec in visible:
|
|
86
|
+
if spec.category == category:
|
|
87
|
+
usage = spec.usage.ljust(usage_width)
|
|
88
|
+
lines.append(f" {usage} {spec.description}{_aliases_text(spec)}")
|
|
89
|
+
lines.append("")
|
|
90
|
+
return lines
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def format_help(
|
|
94
|
+
commands: Sequence[SlashCommandSpec] = SLASH_COMMANDS,
|
|
95
|
+
*,
|
|
96
|
+
include_shortcuts: bool = True,
|
|
97
|
+
) -> str:
|
|
98
|
+
"""Return the complete, renderer-neutral slash-command help text."""
|
|
99
|
+
|
|
100
|
+
lines = ["XG 命令帮助", ""]
|
|
101
|
+
lines.extend(_command_lines(commands))
|
|
102
|
+
|
|
103
|
+
aliases = [
|
|
104
|
+
f"{alias} = {spec.name}"
|
|
105
|
+
for spec in commands
|
|
106
|
+
for alias in spec.aliases
|
|
107
|
+
]
|
|
108
|
+
if aliases:
|
|
109
|
+
lines.extend(["命令别名", f" {' '.join(aliases)}", ""])
|
|
110
|
+
|
|
111
|
+
if include_shortcuts:
|
|
112
|
+
lines.append("TUI 快捷键")
|
|
113
|
+
shortcut_width = max(len(key) for key, _ in SHORTCUTS)
|
|
114
|
+
lines.extend(
|
|
115
|
+
f" {key.ljust(shortcut_width)} {description}"
|
|
116
|
+
for key, description in SHORTCUTS
|
|
117
|
+
)
|
|
118
|
+
lines.append("")
|
|
119
|
+
|
|
120
|
+
lines.extend(
|
|
121
|
+
[
|
|
122
|
+
"提示:输入 / 后可以使用 ↑ / ↓ 选择命令建议。",
|
|
123
|
+
"输入 /help <命令> 可查看某条命令的详细用法。",
|
|
124
|
+
]
|
|
125
|
+
)
|
|
126
|
+
return "\n".join(lines).rstrip()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def format_command_help(
|
|
130
|
+
query: str,
|
|
131
|
+
commands: Sequence[SlashCommandSpec] = SLASH_COMMANDS,
|
|
132
|
+
) -> str:
|
|
133
|
+
"""Return help for one command or an actionable unknown-command hint."""
|
|
134
|
+
|
|
135
|
+
token = query.strip().split(maxsplit=1)[0] if query.strip() else ""
|
|
136
|
+
normalized = _command_key(token)
|
|
137
|
+
if not normalized:
|
|
138
|
+
return format_help(commands)
|
|
139
|
+
|
|
140
|
+
spec = find_command(token, commands)
|
|
141
|
+
if spec is None:
|
|
142
|
+
return f"未找到命令帮助:{token}。输入 /help 查看全部命令。"
|
|
143
|
+
|
|
144
|
+
lines = [f"{spec.name} — {spec.description}", f"用法:{spec.usage}"]
|
|
145
|
+
if spec.aliases:
|
|
146
|
+
lines.append(f"别名:{'、'.join(spec.aliases)}")
|
|
147
|
+
if spec.details:
|
|
148
|
+
lines.extend(["", *spec.details])
|
|
149
|
+
if spec.subcommands:
|
|
150
|
+
lines.extend(["", "子命令"])
|
|
151
|
+
lines.extend(_subcommand_lines(spec.subcommands))
|
|
152
|
+
if spec.examples:
|
|
153
|
+
lines.extend(["", "示例"])
|
|
154
|
+
lines.extend(f" {example}" for example in spec.examples)
|
|
155
|
+
return "\n".join(lines)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _subcommand_lines(subcommands: Sequence[SlashSubcommandSpec]) -> list[str]:
|
|
159
|
+
"""Format detailed command modes with stable aligned columns."""
|
|
160
|
+
|
|
161
|
+
usage_width = max(len(item.usage) for item in subcommands)
|
|
162
|
+
return [
|
|
163
|
+
f" {item.usage.ljust(usage_width)} {item.description}"
|
|
164
|
+
for item in subcommands
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def find_command(
|
|
169
|
+
query: str,
|
|
170
|
+
commands: Sequence[SlashCommandSpec] = SLASH_COMMANDS,
|
|
171
|
+
) -> SlashCommandSpec | None:
|
|
172
|
+
"""Find a command by its name or alias."""
|
|
173
|
+
|
|
174
|
+
normalized = _command_key(query)
|
|
175
|
+
if not normalized:
|
|
176
|
+
return None
|
|
177
|
+
return next(
|
|
178
|
+
(
|
|
179
|
+
item
|
|
180
|
+
for item in commands
|
|
181
|
+
if _command_key(item.name) == normalized
|
|
182
|
+
or any(_command_key(alias) == normalized for alias in item.aliases)
|
|
183
|
+
),
|
|
184
|
+
None,
|
|
185
|
+
)
|
xg/cli/train.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""训练 ML 模型命令的执行层:/train 的参数解析、确认提示与子进程流式执行。
|
|
2
|
+
|
|
3
|
+
设计口径(对齐 05 文档与现有 /provider 红线):
|
|
4
|
+
- 训练仍是显式手动触发:不带 --yes 只返回确认提示,绝不静默跑。
|
|
5
|
+
- 训练跑在独立子进程 :file:`tools/train_router.py`,本层只「spawn + 收日志」,
|
|
6
|
+
不 import 训练逻辑,规避 torch 等重依赖进主进程。
|
|
7
|
+
- inline 走同步版 :func:`run_training_sync`;TUI 走异步版 :func:`run_training_async`,
|
|
8
|
+
两者共用参数解析与 argv 构造,日志都逐行流式上报。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import os
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
_TRAIN_SCRIPT = Path(__file__).resolve().parents[2] / "tools" / "train_router.py"
|
|
21
|
+
_TRAIN_DEPS = ("lightgbm", "sklearn", "joblib", "numpy")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class TrainPlan:
|
|
26
|
+
dataset: str | None = None
|
|
27
|
+
output: str | None = None
|
|
28
|
+
overwrite: bool = False
|
|
29
|
+
feedback_only: bool = False
|
|
30
|
+
semantic: bool = True # 默认带语义列:自动探测语义编码器;--no-semantic 关闭
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _default_output() -> Path:
|
|
34
|
+
"""默认产物路径:与 feedback.log 同目录的 router.lgb(对齐 train_router)。"""
|
|
35
|
+
from xg.adaptive.store import data_dir # noqa: PLC0415
|
|
36
|
+
|
|
37
|
+
return data_dir() / "router.lgb"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _default_semantic_onnx() -> Path | None:
|
|
41
|
+
"""自动探测默认语义编码器产物:数据目录 → 随包 assets 兜底,找到即返回路径。"""
|
|
42
|
+
from xg.adaptive.store import data_dir # noqa: PLC0415
|
|
43
|
+
|
|
44
|
+
primary = data_dir() / "router_semantics.onnx"
|
|
45
|
+
if primary.exists():
|
|
46
|
+
return primary
|
|
47
|
+
bundled = Path(__file__).resolve().parents[1] / "assets" / "router_semantics.onnx"
|
|
48
|
+
return bundled if bundled.exists() else None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def check_train_deps() -> str | None:
|
|
52
|
+
"""缺训练依赖时返回安装提示;全部可用返回 None。
|
|
53
|
+
|
|
54
|
+
注意:语义编码器(bge)非必需——缺失时 train_router 自动回退 TF-IDF 特征。
|
|
55
|
+
"""
|
|
56
|
+
missing = [m for m in _TRAIN_DEPS if _import_safe(m) is False]
|
|
57
|
+
if missing:
|
|
58
|
+
return (
|
|
59
|
+
"缺少训练依赖:"
|
|
60
|
+
+ ", ".join(missing)
|
|
61
|
+
+ "。请先 `pip install -e .` 安装核心依赖后再试。"
|
|
62
|
+
)
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _import_safe(mod: str) -> bool:
|
|
67
|
+
try:
|
|
68
|
+
__import__(mod)
|
|
69
|
+
return True
|
|
70
|
+
except Exception:
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parse_train_command(raw: str) -> tuple[TrainPlan, str | None]:
|
|
75
|
+
"""解析 `/train [...]` 参数,返回 (plan, error)。错误时 plan 不保证完整。"""
|
|
76
|
+
plan = TrainPlan()
|
|
77
|
+
tokens = raw.split(maxsplit=1)[1].split() if len(raw.split(maxsplit=1)) > 1 else []
|
|
78
|
+
i = 0
|
|
79
|
+
while i < len(tokens):
|
|
80
|
+
t = tokens[i]
|
|
81
|
+
if t in ("--yes", "-y"):
|
|
82
|
+
plan.overwrite = True
|
|
83
|
+
elif t == "--feedback-only":
|
|
84
|
+
plan.feedback_only = True
|
|
85
|
+
elif t == "--no-semantic":
|
|
86
|
+
plan.semantic = False
|
|
87
|
+
elif t in ("--output", "--out", "-o"):
|
|
88
|
+
i += 1
|
|
89
|
+
if i >= len(tokens):
|
|
90
|
+
return plan, "--output 需要一个路径参数"
|
|
91
|
+
plan.output = tokens[i]
|
|
92
|
+
elif t.startswith("-"):
|
|
93
|
+
return plan, f"未知参数: {t}"
|
|
94
|
+
else:
|
|
95
|
+
if plan.dataset is not None:
|
|
96
|
+
return plan, f"多余参数: {t}"
|
|
97
|
+
plan.dataset = t
|
|
98
|
+
i += 1
|
|
99
|
+
if plan.dataset and plan.feedback_only:
|
|
100
|
+
return plan, "不能同时指定数据集与 --feedback-only"
|
|
101
|
+
if not plan.dataset and not plan.feedback_only:
|
|
102
|
+
plan.feedback_only = True # 缺省:仅用 feedback.log
|
|
103
|
+
if plan.output is not None:
|
|
104
|
+
plan.output = plan.output.strip('"\'')
|
|
105
|
+
return plan, None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def confirmation_message(plan: TrainPlan) -> str:
|
|
109
|
+
"""未带 --yes 时返回的确认提示:告知将如何训练、需加 --yes 才执行。"""
|
|
110
|
+
src = "反馈日志 feedback.log" if plan.feedback_only else f"数据集 {plan.dataset}"
|
|
111
|
+
out = plan.output or str(_default_output())
|
|
112
|
+
sem_note = "带语义列(默认,自动探测语义编码器)" if plan.semantic else "纯 TF-IDF(--no-semantic)"
|
|
113
|
+
return (
|
|
114
|
+
"确认将运行 SmartRouter 训练:\n"
|
|
115
|
+
f" 样本来源: {src}\n"
|
|
116
|
+
f" 产物路径: {out}\n"
|
|
117
|
+
f" 语义特征: {sem_note}\n"
|
|
118
|
+
"完整数据集 / feedback 样本会被去噪后用于训练;训练为手动触发,"
|
|
119
|
+
"不会自动运行。确认执行请在命令末尾加 --yes。"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def build_argv(plan: TrainPlan) -> list[str]:
|
|
124
|
+
"""构造 tools/train_router.py 的 argv(output 缺省时不传,交给脚本默认)。
|
|
125
|
+
|
|
126
|
+
语义列默认启用:plan.semantic 为真时自动探测默认语义编码器产物并传入
|
|
127
|
+
--semantic-onnx;未探测到或 --no-semantic 则不传,训练回退纯 TF-IDF 特征。
|
|
128
|
+
"""
|
|
129
|
+
argv = [sys.executable, str(_TRAIN_SCRIPT)]
|
|
130
|
+
if plan.output:
|
|
131
|
+
argv += ["--out", plan.output]
|
|
132
|
+
if plan.feedback_only:
|
|
133
|
+
argv += ["--feedback-only"]
|
|
134
|
+
elif plan.dataset:
|
|
135
|
+
argv += [plan.dataset]
|
|
136
|
+
if plan.semantic:
|
|
137
|
+
onnx = _default_semantic_onnx()
|
|
138
|
+
if onnx is not None:
|
|
139
|
+
argv += ["--semantic-onnx", str(onnx)]
|
|
140
|
+
return argv
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _build_env() -> dict[str, str]:
|
|
144
|
+
"""让子进程以 UTF-8 输出(Windows 默认 GBK 会与读取端 utf-8 解码冲突导致乱码)。"""
|
|
145
|
+
env = dict(os.environ)
|
|
146
|
+
env["PYTHONUTF8"] = "1"
|
|
147
|
+
env.setdefault("PYTHONIOENCODING", "utf-8")
|
|
148
|
+
return env
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def run_training_sync(
|
|
152
|
+
plan: TrainPlan, on_line: callable | None = None
|
|
153
|
+
) -> tuple[bool, list[str]]:
|
|
154
|
+
"""同步执行训练(inline 用)。逐行流式读子进程输出交给 on_line,返回 (ok, lines)。"""
|
|
155
|
+
argv = build_argv(plan)
|
|
156
|
+
proc = subprocess.Popen(
|
|
157
|
+
argv,
|
|
158
|
+
stdout=subprocess.PIPE,
|
|
159
|
+
stderr=subprocess.STDOUT,
|
|
160
|
+
text=True,
|
|
161
|
+
encoding="utf-8",
|
|
162
|
+
errors="replace",
|
|
163
|
+
env=_build_env(),
|
|
164
|
+
)
|
|
165
|
+
lines: list[str] = []
|
|
166
|
+
try:
|
|
167
|
+
assert proc.stdout is not None
|
|
168
|
+
for line in proc.stdout:
|
|
169
|
+
line = line.rstrip("\n")
|
|
170
|
+
lines.append(line)
|
|
171
|
+
if on_line is not None:
|
|
172
|
+
on_line(line)
|
|
173
|
+
finally:
|
|
174
|
+
rc = proc.wait()
|
|
175
|
+
return rc == 0, lines
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def run_training_async(
|
|
179
|
+
plan: TrainPlan, on_line: callable | None = None
|
|
180
|
+
) -> tuple[bool, list[str]]:
|
|
181
|
+
"""异步执行训练(TUI / CommandService 用)。逐行流式上报,返回 (ok, lines)。"""
|
|
182
|
+
argv = build_argv(plan)
|
|
183
|
+
proc = await asyncio.create_subprocess_exec(
|
|
184
|
+
*argv,
|
|
185
|
+
stdout=asyncio.subprocess.PIPE,
|
|
186
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
187
|
+
env=_build_env(),
|
|
188
|
+
)
|
|
189
|
+
lines: list[str] = []
|
|
190
|
+
assert proc.stdout is not None
|
|
191
|
+
while True:
|
|
192
|
+
raw = await proc.stdout.readline()
|
|
193
|
+
if not raw:
|
|
194
|
+
break
|
|
195
|
+
line = raw.decode("utf-8", errors="replace").rstrip("\n")
|
|
196
|
+
lines.append(line)
|
|
197
|
+
if on_line is not None:
|
|
198
|
+
on_line(line)
|
|
199
|
+
await proc.wait()
|
|
200
|
+
return (proc.returncode or 0) == 0, lines
|
xg/config/__init__.py
ADDED
|
File without changes
|
xg/config/env_writer.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""幂等读写本地 :file:`.env` 文件(用于交互式写入 API Key)。
|
|
2
|
+
|
|
3
|
+
设计口径(对齐 02 方案 F2 / J2):
|
|
4
|
+
- config.json 永不落 key;key 只经 :file:`.env` 的 ``XG_<NAME>_API_KEY`` 提供。
|
|
5
|
+
- 本模块只负责「定位、增改、去重、原子写」某一行,不做其他配置管理。
|
|
6
|
+
- 写入对原文幂等:已存在 -> 原位保留注释风格并替换值;不存在 -> 追加。
|
|
7
|
+
- 用临时文件 + ``os.replace`` 原子落盘,失败不破坏原文件。
|
|
8
|
+
|
|
9
|
+
定位规则(J2):从工作目录向上查找命中现有的 :file:`.env`(最多 3 层);
|
|
10
|
+
找不到时回退到 ``fallback_dir / ".env"``。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import tempfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def find_env_file(start_dir: str | Path | None = None, max_up: int = 3) -> Path | None:
|
|
21
|
+
"""从 ``start_dir`` 向上查找已存在的 :file:`.env`(最多 ``max_up`` 层)。"""
|
|
22
|
+
cur = Path(start_dir) if start_dir else Path.cwd()
|
|
23
|
+
for _ in range(max_up):
|
|
24
|
+
candidate = cur / ".env"
|
|
25
|
+
if candidate.is_file():
|
|
26
|
+
return candidate
|
|
27
|
+
if cur == cur.parent:
|
|
28
|
+
break
|
|
29
|
+
cur = cur.parent
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def decide_env_path(start_dir: str | Path | None = None, fallback_dir: str | Path | None = None) -> Path:
|
|
34
|
+
"""确定要写入的 .env 路径:优先命中现有文件,否则落到 fallback 目录。"""
|
|
35
|
+
existing = find_env_file(start_dir)
|
|
36
|
+
if existing is not None:
|
|
37
|
+
return existing
|
|
38
|
+
base = Path(fallback_dir) if fallback_dir else Path.cwd()
|
|
39
|
+
return base / ".env"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def env_value(lines: list[str], key: str) -> str | None:
|
|
43
|
+
"""取 ``key`` 当前生效值(容忍 ``KEY=v`` / ``KEY = v``,返回去引号值)。"""
|
|
44
|
+
prefix = f"{key}="
|
|
45
|
+
for raw in lines:
|
|
46
|
+
line = raw.strip()
|
|
47
|
+
if line.startswith(prefix):
|
|
48
|
+
return line[len(prefix):].strip().strip('"').strip("'")
|
|
49
|
+
if line.startswith(f"{key} ="):
|
|
50
|
+
return line[len(f"{key} ="):].strip().strip('"').strip("'")
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def upsert_env_key(lines: list[str], key: str, value: str) -> list[str]:
|
|
55
|
+
"""返回把 ``key=value`` 写入后的新行列表(已存在则替换,否则追加)。
|
|
56
|
+
|
|
57
|
+
- 只匹配精确 ``KEY=...`` 行;注释行、导出行不变。
|
|
58
|
+
- 存在重复行时保留第一处位置并去重后续重复。
|
|
59
|
+
"""
|
|
60
|
+
prefix = f"{key}="
|
|
61
|
+
key_line = f"{key}={value}"
|
|
62
|
+
out: list[str] = []
|
|
63
|
+
replaced = False
|
|
64
|
+
for raw in lines:
|
|
65
|
+
line = raw.strip()
|
|
66
|
+
if not replaced and line.startswith(prefix):
|
|
67
|
+
# 保留该行缩进/前缀风格:追加新赋值行
|
|
68
|
+
spaces = raw[: len(raw) - len(raw.lstrip())]
|
|
69
|
+
out.append(f"{spaces}{key_line}")
|
|
70
|
+
replaced = True
|
|
71
|
+
continue
|
|
72
|
+
if line.startswith(prefix):
|
|
73
|
+
# 已存在 -> 去重后续重复行
|
|
74
|
+
continue
|
|
75
|
+
out.append(raw)
|
|
76
|
+
if not replaced:
|
|
77
|
+
out.append(key_line)
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def write_env_atomic(path: str | Path, lines: list[str]) -> None:
|
|
82
|
+
"""把 ``lines`` 原子写入 ``path``(临时文件 + reinstated替换)。"""
|
|
83
|
+
target = Path(path)
|
|
84
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
fd, tmp_name = tempfile.mkstemp(dir=str(target.parent), prefix=".env.", suffix=".tmp")
|
|
86
|
+
try:
|
|
87
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
|
88
|
+
for line in lines:
|
|
89
|
+
handle.write(line if line.endswith("\n") else line + "\n")
|
|
90
|
+
os.replace(tmp_name, str(target))
|
|
91
|
+
except BaseException:
|
|
92
|
+
try:
|
|
93
|
+
os.remove(tmp_name)
|
|
94
|
+
except OSError:
|
|
95
|
+
pass
|
|
96
|
+
raise
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def set_env_key(
|
|
100
|
+
path: str | Path,
|
|
101
|
+
key: str,
|
|
102
|
+
value: str,
|
|
103
|
+
*,
|
|
104
|
+
overwrite: bool = False,
|
|
105
|
+
) -> tuple[bool, str | None]:
|
|
106
|
+
"""写入 ``key=value`` 到 ``path``。
|
|
107
|
+
|
|
108
|
+
返回 ``(changed, previous)``:
|
|
109
|
+
- ``key`` 不存在 -> 追加,返回 ``(True, None)``;
|
|
110
|
+
- ``key`` 已存在且 ``overwrite=True`` -> 替换,返回 ``(True, 旧值)``;
|
|
111
|
+
- ``key`` 已存在且 ``overwrite=False`` -> 不做修改,返回 ``(False, 旧值)``。
|
|
112
|
+
由调用方负责占位值拦截与确认,本函数只做机械写入(幂等原则)。
|
|
113
|
+
"""
|
|
114
|
+
target = Path(path)
|
|
115
|
+
original = target.read_text(encoding="utf-8").splitlines() if target.is_file() else []
|
|
116
|
+
previous = env_value(original, key)
|
|
117
|
+
if previous is not None and not overwrite:
|
|
118
|
+
return False, previous
|
|
119
|
+
next_lines = upsert_env_key(original, key, value)
|
|
120
|
+
write_env_atomic(target, next_lines)
|
|
121
|
+
return True, previous
|