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
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""SmartRouter 档位表单 Modal(TUI)。
|
|
2
|
+
|
|
3
|
+
仅于 TUI 运行时 import。选择档位 + 填 provider;model 可选。
|
|
4
|
+
provider 留空 = 清空该档位(回落到手动 active)。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from textual.app import ComposeResult
|
|
10
|
+
from textual.containers import Horizontal, Vertical
|
|
11
|
+
from textual.screen import ModalScreen
|
|
12
|
+
from textual.widgets import Button, Input, Select, Static
|
|
13
|
+
|
|
14
|
+
from xg.config.provider_service import validate_name
|
|
15
|
+
from xg.config.smart_router_service import SmartRouterConfigService
|
|
16
|
+
|
|
17
|
+
_TIER_ORDER = ("Basic", "Enhanced", "Superior", "Ultimate")
|
|
18
|
+
_TIER_LABELS = {
|
|
19
|
+
"Basic": "Basic(闲聊/简单问答)",
|
|
20
|
+
"Enhanced": "Enhanced(写函数/改文件)",
|
|
21
|
+
"Superior": "Superior(重构/排查/多文件)",
|
|
22
|
+
"Ultimate": "Ultimate(架构/设计/高风险)",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SmartRouterForm(ModalScreen[None]):
|
|
27
|
+
BINDINGS = [("escape", "cancel", "取消")]
|
|
28
|
+
|
|
29
|
+
def __init__(self, service: SmartRouterConfigService, *, tier: str | None = None) -> None:
|
|
30
|
+
super().__init__()
|
|
31
|
+
self.service = service
|
|
32
|
+
self.configs = {
|
|
33
|
+
r["name"]: {"provider": r.get("provider", ""), "model": r.get("model", "")}
|
|
34
|
+
for r in service.list_tiers()
|
|
35
|
+
}
|
|
36
|
+
# 默认定位:显式指定档位 > 首个已配置档位 > Basic
|
|
37
|
+
self.tier = tier
|
|
38
|
+
if tier is None or tier not in _TIER_ORDER:
|
|
39
|
+
configured = next(
|
|
40
|
+
(t for t in _TIER_ORDER if self.configs[t]["provider"]), None
|
|
41
|
+
)
|
|
42
|
+
self.tier = configured or "Basic"
|
|
43
|
+
|
|
44
|
+
def compose(self) -> ComposeResult:
|
|
45
|
+
cur = self.configs.get(self.tier, {"provider": "", "model": ""})
|
|
46
|
+
with Vertical(id="tier-form"):
|
|
47
|
+
yield Static("SmartRouter 档位", id="tier-form-title")
|
|
48
|
+
yield Static("档位", id="t-tier-label")
|
|
49
|
+
yield Select(
|
|
50
|
+
((_TIER_LABELS.get(t), t) for t in _TIER_ORDER),
|
|
51
|
+
value=self.tier,
|
|
52
|
+
allow_blank=False,
|
|
53
|
+
id="tier",
|
|
54
|
+
)
|
|
55
|
+
yield Static("provider(留空 = 清空该档位)", id="t-provider-label")
|
|
56
|
+
yield Input(
|
|
57
|
+
value=cur["provider"], id="provider", placeholder="如 deepseek"
|
|
58
|
+
)
|
|
59
|
+
yield Static("model(可选,缺省取 default_model)", id="t-model-label")
|
|
60
|
+
yield Input(value=cur["model"], id="model", placeholder="如 deepseek-chat")
|
|
61
|
+
with Horizontal(id="tier-form-actions"):
|
|
62
|
+
yield Button("保存", id="save", variant="primary")
|
|
63
|
+
yield Button("取消", id="cancel")
|
|
64
|
+
|
|
65
|
+
def on_select_changed(self, event: Select.Changed) -> None:
|
|
66
|
+
"""切换档位时联动加载对应 provider/model 配置。"""
|
|
67
|
+
tier = str(event.value or self.tier)
|
|
68
|
+
cur = self.configs.get(tier, {"provider": "", "model": ""})
|
|
69
|
+
self.query_one("#provider", Input).value = cur["provider"]
|
|
70
|
+
self.query_one("#model", Input).value = cur["model"]
|
|
71
|
+
|
|
72
|
+
def action_cancel(self) -> None:
|
|
73
|
+
self.dismiss(None)
|
|
74
|
+
|
|
75
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
76
|
+
if event.button.id == "cancel":
|
|
77
|
+
self.dismiss(None)
|
|
78
|
+
return
|
|
79
|
+
if event.button.id in ("save", ""):
|
|
80
|
+
self._save()
|
|
81
|
+
|
|
82
|
+
def _save(self) -> None:
|
|
83
|
+
tier = str(self.query_one("#tier", Select).value or "Basic")
|
|
84
|
+
provider = self.query_one("#provider", Input).value.strip()
|
|
85
|
+
model = self.query_one("#model", Input).value.strip() or None
|
|
86
|
+
if not provider:
|
|
87
|
+
result = self.service.clear_tier(tier)
|
|
88
|
+
else:
|
|
89
|
+
err = validate_name(provider)
|
|
90
|
+
if err:
|
|
91
|
+
return self.notify(f"provider 名非法: {err}", severity="error")
|
|
92
|
+
result = self.service.set_tier(tier, provider, model)
|
|
93
|
+
self.notify(result.message, severity="error" if not result.ok else "information")
|
|
94
|
+
if result.ok:
|
|
95
|
+
self.dismiss(None)
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""Stable, incrementally reconciled Transcript widgets for the fullscreen TUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from textual.containers import VerticalScroll
|
|
11
|
+
from textual.widget import Widget
|
|
12
|
+
from textual.widgets import Static
|
|
13
|
+
|
|
14
|
+
from xg.tui.renderables import render_item
|
|
15
|
+
from xg.tui.state import TuiState
|
|
16
|
+
from xg.tui.widgets.action_card import InlineApprovalCard, InlineConfirmationCard
|
|
17
|
+
from xg.tui.widgets.agent_group_card import AgentGroupCard
|
|
18
|
+
from xg.tui.widgets.collapsible_card import CollapsibleCard
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class _DesiredWidget:
|
|
25
|
+
key: str
|
|
26
|
+
kind: str
|
|
27
|
+
value: Any
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TranscriptView(VerticalScroll):
|
|
31
|
+
"""Render Transcript state without rebuilding the whole DOM on every event.
|
|
32
|
+
|
|
33
|
+
Textual schedules ``remove`` and ``mount`` operations asynchronously. The
|
|
34
|
+
view therefore keeps current widgets by stable business keys and runs one
|
|
35
|
+
reconciliation loop at a time. This keeps mouse hit-testing attached to
|
|
36
|
+
the same widgets while streaming content changes.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
CONTENT_DEBOUNCE_SECONDS = 0.04
|
|
40
|
+
|
|
41
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
42
|
+
super().__init__(*args, **kwargs)
|
|
43
|
+
self._latest_state: TuiState | None = None
|
|
44
|
+
self._widgets_by_key: dict[str, Widget] = {}
|
|
45
|
+
self._widget_kinds: dict[str, str] = {}
|
|
46
|
+
self._rendered_keys: tuple[str, ...] = ()
|
|
47
|
+
self._reconcile_task: asyncio.Task | None = None
|
|
48
|
+
self._request_generation = 0
|
|
49
|
+
self._rendered_generation = -1
|
|
50
|
+
self._content_refresh_pending = False
|
|
51
|
+
self._content_refresh_immediate = False
|
|
52
|
+
self._layout_refresh_pending = False
|
|
53
|
+
self._attach_refresh_pending = False
|
|
54
|
+
self._unmounted = False
|
|
55
|
+
self._approval_mode = ""
|
|
56
|
+
self._approval_modified_args: dict | None = None
|
|
57
|
+
|
|
58
|
+
def update_progress(self, item_id: str, item) -> bool:
|
|
59
|
+
"""Update a local progress card without scheduling a full refresh."""
|
|
60
|
+
widget = self._widgets_by_key.get(f"item:{item_id}")
|
|
61
|
+
if widget is None or widget.parent is not self:
|
|
62
|
+
return False
|
|
63
|
+
if isinstance(widget, CollapsibleCard):
|
|
64
|
+
widget.update_item(item)
|
|
65
|
+
elif isinstance(widget, Static):
|
|
66
|
+
widget.update(render_item(item))
|
|
67
|
+
else:
|
|
68
|
+
return False
|
|
69
|
+
return True
|
|
70
|
+
|
|
71
|
+
def update_state(self, state: TuiState) -> None:
|
|
72
|
+
"""Backward-compatible alias for callers using the old method name."""
|
|
73
|
+
self.request_state(state)
|
|
74
|
+
|
|
75
|
+
def request_state(self, state: TuiState) -> None:
|
|
76
|
+
"""Queue the newest state; DOM work is performed by one async worker."""
|
|
77
|
+
if self._unmounted:
|
|
78
|
+
return
|
|
79
|
+
self._latest_state = state
|
|
80
|
+
self._request_generation += 1
|
|
81
|
+
self._content_refresh_pending = True
|
|
82
|
+
desired_keys = tuple(item.key for item in self._desired_widgets(state))
|
|
83
|
+
self._content_refresh_immediate |= desired_keys != self._rendered_keys
|
|
84
|
+
self._schedule_reconcile()
|
|
85
|
+
|
|
86
|
+
def request_layout_refresh(self) -> None:
|
|
87
|
+
"""Request layout-only work without rebuilding or changing content."""
|
|
88
|
+
if self._unmounted:
|
|
89
|
+
return
|
|
90
|
+
self._layout_refresh_pending = True
|
|
91
|
+
self._schedule_reconcile()
|
|
92
|
+
|
|
93
|
+
def set_approval_mode(self, mode: str = "", modified_args: dict | None = None) -> None:
|
|
94
|
+
"""Apply approval editing state to the stable card, including a pending mount."""
|
|
95
|
+
self._approval_mode = mode
|
|
96
|
+
self._approval_modified_args = modified_args
|
|
97
|
+
for widget in self._widgets_by_key.values():
|
|
98
|
+
if isinstance(widget, InlineApprovalCard) and widget.parent is self:
|
|
99
|
+
widget.set_mode(mode, modified_args)
|
|
100
|
+
|
|
101
|
+
def _schedule_reconcile(self) -> None:
|
|
102
|
+
if self._unmounted:
|
|
103
|
+
return
|
|
104
|
+
if not self.is_attached:
|
|
105
|
+
if not self._attach_refresh_pending:
|
|
106
|
+
self._attach_refresh_pending = True
|
|
107
|
+
self.call_after_refresh(self._schedule_after_attach)
|
|
108
|
+
return
|
|
109
|
+
if self._reconcile_task is None or self._reconcile_task.done():
|
|
110
|
+
self._reconcile_task = asyncio.create_task(self._reconcile_loop())
|
|
111
|
+
|
|
112
|
+
def _schedule_after_attach(self) -> None:
|
|
113
|
+
self._attach_refresh_pending = False
|
|
114
|
+
self._schedule_reconcile()
|
|
115
|
+
|
|
116
|
+
async def _reconcile_loop(self) -> None:
|
|
117
|
+
"""Serialize DOM operations and coalesce high-frequency state events."""
|
|
118
|
+
try:
|
|
119
|
+
while not self._unmounted:
|
|
120
|
+
if not self._content_refresh_pending and not self._layout_refresh_pending:
|
|
121
|
+
break
|
|
122
|
+
|
|
123
|
+
if self._content_refresh_pending:
|
|
124
|
+
immediate = self._content_refresh_immediate
|
|
125
|
+
self._content_refresh_pending = False
|
|
126
|
+
self._content_refresh_immediate = False
|
|
127
|
+
if not immediate:
|
|
128
|
+
await asyncio.sleep(self.CONTENT_DEBOUNCE_SECONDS)
|
|
129
|
+
|
|
130
|
+
if self._unmounted or not self.is_attached:
|
|
131
|
+
break
|
|
132
|
+
|
|
133
|
+
state = self._latest_state
|
|
134
|
+
generation = self._request_generation
|
|
135
|
+
if state is not None and generation != self._rendered_generation:
|
|
136
|
+
await self._reconcile_state(state)
|
|
137
|
+
self._rendered_generation = generation
|
|
138
|
+
|
|
139
|
+
if self._layout_refresh_pending:
|
|
140
|
+
self._layout_refresh_pending = False
|
|
141
|
+
self._refresh_layout()
|
|
142
|
+
|
|
143
|
+
if generation != self._request_generation:
|
|
144
|
+
self._content_refresh_pending = True
|
|
145
|
+
except asyncio.CancelledError:
|
|
146
|
+
raise
|
|
147
|
+
except Exception:
|
|
148
|
+
logger.exception("Transcript reconciliation failed")
|
|
149
|
+
finally:
|
|
150
|
+
self._reconcile_task = None
|
|
151
|
+
if (
|
|
152
|
+
not self._unmounted
|
|
153
|
+
and self.is_attached
|
|
154
|
+
and (self._content_refresh_pending or self._layout_refresh_pending)
|
|
155
|
+
):
|
|
156
|
+
self._schedule_reconcile()
|
|
157
|
+
|
|
158
|
+
def _desired_widgets(self, state: TuiState) -> list[_DesiredWidget]:
|
|
159
|
+
desired: list[_DesiredWidget] = []
|
|
160
|
+
if not state.transcript:
|
|
161
|
+
desired.append(_DesiredWidget("empty", "empty", None))
|
|
162
|
+
else:
|
|
163
|
+
for item in state.transcript:
|
|
164
|
+
if item.kind == "agent_group" and item.agent_group_id:
|
|
165
|
+
group = state.agent_groups.get(item.agent_group_id)
|
|
166
|
+
if group is not None:
|
|
167
|
+
desired.append(_DesiredWidget(
|
|
168
|
+
f"agent_group:{item.agent_group_id}", "agent_group", group,
|
|
169
|
+
))
|
|
170
|
+
continue
|
|
171
|
+
kind = "trace" if item.collapsible and item.kind in {
|
|
172
|
+
"thinking", "tool_call", "tool_result", "approval",
|
|
173
|
+
} else "text"
|
|
174
|
+
desired.append(_DesiredWidget(f"item:{item.id}", kind, item))
|
|
175
|
+
|
|
176
|
+
if state.pending_approval is not None:
|
|
177
|
+
request = state.pending_approval
|
|
178
|
+
key = f"approval:{request.turn_id}:{request.tool_name}"
|
|
179
|
+
desired.append(_DesiredWidget(key, "approval", request))
|
|
180
|
+
elif state.pending_confirmation is not None:
|
|
181
|
+
request = state.pending_confirmation
|
|
182
|
+
key = f"confirmation:{request.kind}:{request.title}"
|
|
183
|
+
desired.append(_DesiredWidget(key, "confirmation", request))
|
|
184
|
+
return desired
|
|
185
|
+
|
|
186
|
+
async def _reconcile_state(self, state: TuiState) -> None:
|
|
187
|
+
if self._unmounted or not self.is_attached:
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
was_at_bottom = self._is_at_bottom()
|
|
191
|
+
previous_scroll_y = self.scroll_y
|
|
192
|
+
focused_key, focused_widget = self._focused_target()
|
|
193
|
+
desired = self._desired_widgets(state)
|
|
194
|
+
desired_keys = tuple(record.key for record in desired)
|
|
195
|
+
desired_by_key = {record.key: record for record in desired}
|
|
196
|
+
|
|
197
|
+
# Update stable widgets first. A type change is the only case that
|
|
198
|
+
# replaces a widget, and it is limited to that one stable key.
|
|
199
|
+
for record in desired:
|
|
200
|
+
widget = self._widgets_by_key.get(record.key)
|
|
201
|
+
if widget is not None and (
|
|
202
|
+
self._widget_kinds.get(record.key) != record.kind
|
|
203
|
+
or widget.parent is not self
|
|
204
|
+
):
|
|
205
|
+
await self._remove_widget(record.key, widget)
|
|
206
|
+
widget = None
|
|
207
|
+
if widget is None:
|
|
208
|
+
widget = self._create_widget(record)
|
|
209
|
+
self._widgets_by_key[record.key] = widget
|
|
210
|
+
self._widget_kinds[record.key] = record.kind
|
|
211
|
+
else:
|
|
212
|
+
self._update_widget(widget, record)
|
|
213
|
+
|
|
214
|
+
# Remove only keys no longer present. In particular, don't use
|
|
215
|
+
# remove_children(), which creates a stale-widget hit-testing window.
|
|
216
|
+
for key in tuple(self._widgets_by_key):
|
|
217
|
+
if key not in desired_by_key:
|
|
218
|
+
await self._remove_widget(key, self._widgets_by_key[key])
|
|
219
|
+
|
|
220
|
+
if self._unmounted or not self.is_attached:
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
# Mount new widgets in desired order. Existing widgets are used as
|
|
224
|
+
# anchors, so each mount is deterministic and avoids a full rebuild.
|
|
225
|
+
for index, record in enumerate(desired):
|
|
226
|
+
widget = self._widgets_by_key.get(record.key)
|
|
227
|
+
if widget is None or widget.parent is self:
|
|
228
|
+
continue
|
|
229
|
+
before = next(
|
|
230
|
+
(
|
|
231
|
+
self._widgets_by_key[key]
|
|
232
|
+
for key in desired_keys[index + 1:]
|
|
233
|
+
if key in self._widgets_by_key
|
|
234
|
+
and self._widgets_by_key[key].parent is self
|
|
235
|
+
),
|
|
236
|
+
None,
|
|
237
|
+
)
|
|
238
|
+
if before is None:
|
|
239
|
+
await self.mount(widget)
|
|
240
|
+
else:
|
|
241
|
+
await self.mount(widget, before=before)
|
|
242
|
+
|
|
243
|
+
# Reorder existing children without removing them. This normally is a
|
|
244
|
+
# no-op because mount anchors preserve order, but handles insertions.
|
|
245
|
+
self._reorder_children(desired_keys)
|
|
246
|
+
self._rendered_keys = desired_keys
|
|
247
|
+
self._restore_view(was_at_bottom, previous_scroll_y, focused_key, focused_widget)
|
|
248
|
+
|
|
249
|
+
def _create_widget(self, record: _DesiredWidget) -> Widget:
|
|
250
|
+
if record.kind == "empty":
|
|
251
|
+
return Static(
|
|
252
|
+
"输入任务开始与 Agent 对话\n\n试试:/help /plan <任务> /model",
|
|
253
|
+
classes="transcript-empty-state",
|
|
254
|
+
)
|
|
255
|
+
if record.kind == "agent_group":
|
|
256
|
+
return AgentGroupCard(record.value)
|
|
257
|
+
if record.kind == "trace":
|
|
258
|
+
return CollapsibleCard(record.value)
|
|
259
|
+
if record.kind == "approval":
|
|
260
|
+
widget = InlineApprovalCard(record.value)
|
|
261
|
+
widget.set_mode(self._approval_mode, self._approval_modified_args)
|
|
262
|
+
return widget
|
|
263
|
+
if record.kind == "confirmation":
|
|
264
|
+
return InlineConfirmationCard(record.value)
|
|
265
|
+
widget = Static(render_item(record.value))
|
|
266
|
+
widget.transcript_item_id = record.value.id
|
|
267
|
+
return widget
|
|
268
|
+
|
|
269
|
+
def _update_widget(self, widget: Widget, record: _DesiredWidget) -> None:
|
|
270
|
+
if record.kind == "agent_group" and isinstance(widget, AgentGroupCard):
|
|
271
|
+
widget.update_group(record.value)
|
|
272
|
+
elif record.kind == "trace" and isinstance(widget, CollapsibleCard):
|
|
273
|
+
widget.update_item(record.value)
|
|
274
|
+
elif record.kind == "approval" and isinstance(widget, InlineApprovalCard):
|
|
275
|
+
widget.update_request(record.value)
|
|
276
|
+
widget.set_mode(self._approval_mode, self._approval_modified_args)
|
|
277
|
+
elif record.kind == "confirmation" and isinstance(widget, InlineConfirmationCard):
|
|
278
|
+
widget.update_request(record.value)
|
|
279
|
+
elif record.kind == "text" and isinstance(widget, Static):
|
|
280
|
+
widget.update(render_item(record.value))
|
|
281
|
+
|
|
282
|
+
async def _remove_widget(self, key: str, widget: Widget) -> None:
|
|
283
|
+
if widget.parent is self:
|
|
284
|
+
await widget.remove()
|
|
285
|
+
self._widgets_by_key.pop(key, None)
|
|
286
|
+
self._widget_kinds.pop(key, None)
|
|
287
|
+
|
|
288
|
+
def _reorder_children(self, desired_keys: tuple[str, ...]) -> None:
|
|
289
|
+
for index, key in enumerate(desired_keys):
|
|
290
|
+
widget = self._widgets_by_key.get(key)
|
|
291
|
+
children = list(self.children)
|
|
292
|
+
if widget is None or widget not in children or index >= len(children):
|
|
293
|
+
continue
|
|
294
|
+
if children[index] is not widget:
|
|
295
|
+
self.move_child(widget, before=children[index])
|
|
296
|
+
|
|
297
|
+
def _focused_target(self) -> tuple[str | None, Widget | None]:
|
|
298
|
+
focused = getattr(getattr(self, "app", None), "focused", None)
|
|
299
|
+
if focused is None:
|
|
300
|
+
return None, None
|
|
301
|
+
for key, widget in self._widgets_by_key.items():
|
|
302
|
+
if focused is widget:
|
|
303
|
+
return key, widget
|
|
304
|
+
return None, None
|
|
305
|
+
|
|
306
|
+
def _is_at_bottom(self) -> bool:
|
|
307
|
+
return self.scroll_y >= max(0, self.max_scroll_y - 1)
|
|
308
|
+
|
|
309
|
+
def _restore_view(
|
|
310
|
+
self,
|
|
311
|
+
was_at_bottom: bool,
|
|
312
|
+
scroll_y: int,
|
|
313
|
+
focused_key: str | None,
|
|
314
|
+
focused_widget: Widget | None,
|
|
315
|
+
) -> None:
|
|
316
|
+
def restore() -> None:
|
|
317
|
+
if self._unmounted or not self.is_attached:
|
|
318
|
+
return
|
|
319
|
+
if was_at_bottom:
|
|
320
|
+
self.scroll_end(animate=False)
|
|
321
|
+
else:
|
|
322
|
+
self.scroll_to(y=max(0, scroll_y), animate=False, immediate=True)
|
|
323
|
+
current_focus = getattr(getattr(self, "app", None), "focused", None)
|
|
324
|
+
if focused_key and (current_focus is None or current_focus is focused_widget):
|
|
325
|
+
widget = self._widgets_by_key.get(focused_key)
|
|
326
|
+
if widget is not None and widget.parent is self:
|
|
327
|
+
widget.focus()
|
|
328
|
+
|
|
329
|
+
self.call_after_refresh(restore)
|
|
330
|
+
|
|
331
|
+
def _refresh_layout(self) -> None:
|
|
332
|
+
if self._unmounted or not self.is_attached:
|
|
333
|
+
return
|
|
334
|
+
self.refresh(layout=True)
|
|
335
|
+
for widget in tuple(self._widgets_by_key.values()):
|
|
336
|
+
if widget.parent is self:
|
|
337
|
+
widget.refresh(layout=True)
|
|
338
|
+
|
|
339
|
+
async def on_unmount(self) -> None:
|
|
340
|
+
self._unmounted = True
|
|
341
|
+
self._latest_state = None
|
|
342
|
+
self._content_refresh_pending = False
|
|
343
|
+
self._content_refresh_immediate = False
|
|
344
|
+
self._layout_refresh_pending = False
|
|
345
|
+
task = self._reconcile_task
|
|
346
|
+
if task is not None and not task.done() and task is not asyncio.current_task():
|
|
347
|
+
task.cancel()
|
|
348
|
+
try:
|
|
349
|
+
await task
|
|
350
|
+
except asyncio.CancelledError:
|
|
351
|
+
pass
|
|
352
|
+
self._reconcile_task = None
|
|
353
|
+
self._widgets_by_key.clear()
|
|
354
|
+
self._widget_kinds.clear()
|
xg/tui/workers.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Async task helpers kept separate from widget code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from typing import TypeVar
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def start(coro_factory: Callable[[], Awaitable[T]]) -> asyncio.Task[T]:
|
|
13
|
+
"""Start a controller coroutine on the current event loop."""
|
|
14
|
+
return asyncio.create_task(coro_factory())
|
xg/web/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Safe, read-only web capabilities for XG."""
|
|
2
|
+
|
|
3
|
+
from xg.web.fetch import WebFetchService
|
|
4
|
+
from xg.web.models import (
|
|
5
|
+
FetchRequest,
|
|
6
|
+
FetchResponse,
|
|
7
|
+
ProviderHealth,
|
|
8
|
+
SearchRequest,
|
|
9
|
+
SearchResponse,
|
|
10
|
+
SearchResult,
|
|
11
|
+
WebFetchConfig,
|
|
12
|
+
WebSearchConfig,
|
|
13
|
+
WebConfig,
|
|
14
|
+
)
|
|
15
|
+
from xg.web.search import WebSearchService
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"FetchRequest", "FetchResponse", "ProviderHealth", "SearchRequest",
|
|
19
|
+
"SearchResponse", "SearchResult", "WebFetchConfig", "WebSearchConfig",
|
|
20
|
+
"WebConfig", "WebFetchService", "WebSearchService",
|
|
21
|
+
]
|
xg/web/errors.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""User-facing error categories for web operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class WebError(Exception):
|
|
7
|
+
category = "web_error"
|
|
8
|
+
retryable = False
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class WebConfigError(WebError):
|
|
12
|
+
category = "configuration"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class WebInputError(WebError):
|
|
16
|
+
category = "invalid_input"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class WebSecurityError(WebError):
|
|
20
|
+
category = "security_rejected"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class WebRateLimitError(WebError):
|
|
24
|
+
category = "rate_limited"
|
|
25
|
+
retryable = True
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class WebTimeoutError(WebError):
|
|
29
|
+
category = "timeout"
|
|
30
|
+
retryable = True
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class WebProviderError(WebError):
|
|
34
|
+
category = "provider_error"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class WebContentError(WebError):
|
|
38
|
+
category = "unsupported_content"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def user_error(error: Exception) -> str:
|
|
42
|
+
"""Convert an internal exception to a concise, safe tool message."""
|
|
43
|
+
if isinstance(error, WebSecurityError):
|
|
44
|
+
return f"为安全原因拒绝访问该地址:{error}"
|
|
45
|
+
if isinstance(error, WebConfigError):
|
|
46
|
+
return f"未配置搜索服务,请配置后重试:{error}"
|
|
47
|
+
if isinstance(error, WebInputError):
|
|
48
|
+
return f"输入参数不合法:{error}"
|
|
49
|
+
if isinstance(error, WebRateLimitError):
|
|
50
|
+
return f"服务限流,请稍后重试:{error}"
|
|
51
|
+
if isinstance(error, WebTimeoutError):
|
|
52
|
+
return f"请求超时,可稍后重试:{error}"
|
|
53
|
+
if isinstance(error, WebContentError):
|
|
54
|
+
return str(error)
|
|
55
|
+
if isinstance(error, WebProviderError):
|
|
56
|
+
return str(error)
|
|
57
|
+
return f"Web 请求失败:{type(error).__name__}"
|
xg/web/extract.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Small dependency-free HTML article extractor.
|
|
2
|
+
|
|
3
|
+
It deliberately handles static documents only. Scripts, forms, navigation and
|
|
4
|
+
other page chrome are ignored; JavaScript is never executed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from html import unescape
|
|
12
|
+
from html.parser import HTMLParser
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class HtmlNode:
|
|
17
|
+
tag: str
|
|
18
|
+
attrs: dict[str, str]
|
|
19
|
+
children: list[object]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _TreeParser(HTMLParser):
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
super().__init__(convert_charrefs=True)
|
|
25
|
+
self.root = HtmlNode("root", {}, [])
|
|
26
|
+
self.stack = [self.root]
|
|
27
|
+
self.title = ""
|
|
28
|
+
self._in_title = False
|
|
29
|
+
|
|
30
|
+
def handle_starttag(self, tag, attrs):
|
|
31
|
+
node = HtmlNode(tag.lower(), {str(k).lower(): str(v or "") for k, v in attrs}, [])
|
|
32
|
+
self.stack[-1].children.append(node)
|
|
33
|
+
if tag.lower() not in {"meta", "link", "img", "br", "hr", "input", "source", "area", "base", "embed", "param", "track", "wbr"}:
|
|
34
|
+
self.stack.append(node)
|
|
35
|
+
if tag.lower() == "title":
|
|
36
|
+
self._in_title = True
|
|
37
|
+
|
|
38
|
+
def handle_startendtag(self, tag, attrs):
|
|
39
|
+
self.handle_starttag(tag, attrs)
|
|
40
|
+
if tag.lower() not in {"meta", "link", "img", "br", "hr", "input", "source", "area", "base", "embed", "param", "track", "wbr"} and len(self.stack) > 1:
|
|
41
|
+
self.stack.pop()
|
|
42
|
+
|
|
43
|
+
def handle_endtag(self, tag):
|
|
44
|
+
tag = tag.lower()
|
|
45
|
+
if tag == "title":
|
|
46
|
+
self._in_title = False
|
|
47
|
+
for i in range(len(self.stack) - 1, 0, -1):
|
|
48
|
+
if self.stack[i].tag == tag:
|
|
49
|
+
del self.stack[i:]
|
|
50
|
+
break
|
|
51
|
+
|
|
52
|
+
def handle_data(self, data):
|
|
53
|
+
if self._in_title:
|
|
54
|
+
self.title += data
|
|
55
|
+
self.stack[-1].children.append(data)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
DROP_TAGS = {"script", "style", "noscript", "template", "svg", "canvas", "nav", "header", "footer", "aside", "form", "button", "iframe", "広告"}
|
|
59
|
+
BLOCK_TAGS = {"article", "main", "section", "div", "p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote", "pre", "tr"}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _text(node: HtmlNode) -> str:
|
|
63
|
+
parts: list[str] = []
|
|
64
|
+
for child in node.children:
|
|
65
|
+
if isinstance(child, str):
|
|
66
|
+
parts.append(child)
|
|
67
|
+
elif child.tag not in DROP_TAGS:
|
|
68
|
+
parts.append(_text(child))
|
|
69
|
+
return " ".join("".join(parts).split())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _score(node: HtmlNode) -> int:
|
|
73
|
+
if node.tag in DROP_TAGS:
|
|
74
|
+
return -10_000
|
|
75
|
+
own = len(_text(node))
|
|
76
|
+
bonus = 1000 if node.tag in {"article", "main"} else 0
|
|
77
|
+
return own + bonus
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def parse_html(html: str) -> tuple[HtmlNode, str]:
|
|
81
|
+
parser = _TreeParser()
|
|
82
|
+
try:
|
|
83
|
+
parser.feed(html)
|
|
84
|
+
parser.close()
|
|
85
|
+
except Exception:
|
|
86
|
+
# HTMLParser is forgiving, but malformed input should still produce a
|
|
87
|
+
# safe partial result rather than escape into the caller.
|
|
88
|
+
pass
|
|
89
|
+
return parser.root, " ".join(parser.title.split())[:500]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def select_content(root: HtmlNode) -> HtmlNode:
|
|
93
|
+
candidates: list[HtmlNode] = []
|
|
94
|
+
def walk(node: HtmlNode):
|
|
95
|
+
if node.tag not in DROP_TAGS:
|
|
96
|
+
candidates.append(node)
|
|
97
|
+
for child in node.children:
|
|
98
|
+
if isinstance(child, HtmlNode):
|
|
99
|
+
walk(child)
|
|
100
|
+
walk(root)
|
|
101
|
+
preferred = [node for node in candidates if node.tag in {"article", "main"} and _text(node)]
|
|
102
|
+
if preferred:
|
|
103
|
+
return max(preferred, key=lambda node: len(_text(node)))
|
|
104
|
+
return max(candidates, key=_score) if candidates else root
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class ExtractedContent:
|
|
109
|
+
title: str
|
|
110
|
+
node: HtmlNode
|
|
111
|
+
text: str
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def extract_html(html: str, *, max_chars: int = 32_000) -> ExtractedContent:
|
|
115
|
+
root, title = parse_html(html)
|
|
116
|
+
node = select_content(root)
|
|
117
|
+
text = _text(node)
|
|
118
|
+
return ExtractedContent(title, node, text[:max_chars])
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def extract_main_content(html: str, max_chars: int = 32_000) -> ExtractedContent:
|
|
122
|
+
return extract_html(html, max_chars=max_chars)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def extract_text(html: str, max_chars: int = 32_000) -> str:
|
|
126
|
+
"""Compatibility helper for callers that only need plain extracted text."""
|
|
127
|
+
return extract_html(html, max_chars=max_chars).text
|