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.
Files changed (130) hide show
  1. xg/__init__.py +3 -0
  2. xg/__main__.py +3 -0
  3. xg/adaptive/__init__.py +27 -0
  4. xg/adaptive/calibrate.py +191 -0
  5. xg/adaptive/feedback.py +170 -0
  6. xg/adaptive/learned_rules.py +284 -0
  7. xg/adaptive/signals.py +125 -0
  8. xg/adaptive/store.py +164 -0
  9. xg/agent/__init__.py +0 -0
  10. xg/agent/plan.py +631 -0
  11. xg/agent/react.py +268 -0
  12. xg/agent/team.py +1793 -0
  13. xg/assets/router.lgb +0 -0
  14. xg/assets/router_semantics.json +21278 -0
  15. xg/assets/router_semantics.onnx +0 -0
  16. xg/cli/__init__.py +0 -0
  17. xg/cli/app.py +1372 -0
  18. xg/cli/commands.py +921 -0
  19. xg/cli/completion.py +740 -0
  20. xg/cli/help.py +185 -0
  21. xg/cli/train.py +200 -0
  22. xg/config/__init__.py +0 -0
  23. xg/config/env_writer.py +121 -0
  24. xg/config/manager.py +446 -0
  25. xg/config/mcp.py +229 -0
  26. xg/config/provider_service.py +267 -0
  27. xg/config/providers.py +46 -0
  28. xg/config/settings.py +258 -0
  29. xg/config/skills.py +100 -0
  30. xg/config/smart_router_service.py +121 -0
  31. xg/config/web.py +140 -0
  32. xg/input_history/__init__.py +7 -0
  33. xg/input_history/models.py +28 -0
  34. xg/input_history/persistence.py +126 -0
  35. xg/input_history/policy.py +39 -0
  36. xg/input_history/prompt_toolkit.py +36 -0
  37. xg/input_history/store.py +118 -0
  38. xg/llm/__init__.py +0 -0
  39. xg/llm/client.py +49 -0
  40. xg/llm/factory.py +40 -0
  41. xg/llm/openai_compat.py +275 -0
  42. xg/llm/types.py +98 -0
  43. xg/mcp/__init__.py +4 -0
  44. xg/mcp/http.py +192 -0
  45. xg/mcp/manager.py +726 -0
  46. xg/mcp/models.py +86 -0
  47. xg/mcp/protocol.py +62 -0
  48. xg/mcp/resources.py +72 -0
  49. xg/mcp/schema.py +137 -0
  50. xg/mcp/stdio.py +210 -0
  51. xg/mcp/transport.py +66 -0
  52. xg/memory/__init__.py +15 -0
  53. xg/memory/context.py +327 -0
  54. xg/memory/manager.py +111 -0
  55. xg/memory/models.py +41 -0
  56. xg/memory/project.py +187 -0
  57. xg/memory/store.py +144 -0
  58. xg/router/__init__.py +124 -0
  59. xg/router/features.py +66 -0
  60. xg/router/keywords.py +50 -0
  61. xg/router/ml_router.py +178 -0
  62. xg/router/model_tiers.py +73 -0
  63. xg/router/postprocess.py +167 -0
  64. xg/router/rule_router.py +77 -0
  65. xg/router/semantic.py +138 -0
  66. xg/safety/__init__.py +0 -0
  67. xg/safety/audit.py +96 -0
  68. xg/safety/guards.py +106 -0
  69. xg/safety/hitl.py +73 -0
  70. xg/skill/__init__.py +9 -0
  71. xg/skill/errors.py +45 -0
  72. xg/skill/loader.py +42 -0
  73. xg/skill/models.py +57 -0
  74. xg/skill/parser.py +93 -0
  75. xg/skill/policy.py +40 -0
  76. xg/skill/prompt.py +45 -0
  77. xg/skill/registry.py +169 -0
  78. xg/tool/__init__.py +0 -0
  79. xg/tool/builtin.py +356 -0
  80. xg/tool/registry.py +228 -0
  81. xg/tui/__init__.py +34 -0
  82. xg/tui/app.py +612 -0
  83. xg/tui/controller.py +1296 -0
  84. xg/tui/diagrams/__init__.py +22 -0
  85. xg/tui/diagrams/layout.py +110 -0
  86. xg/tui/diagrams/markdown.py +39 -0
  87. xg/tui/diagrams/model.py +36 -0
  88. xg/tui/diagrams/parser.py +119 -0
  89. xg/tui/diagrams/renderer.py +551 -0
  90. xg/tui/i18n.py +169 -0
  91. xg/tui/messages.py +45 -0
  92. xg/tui/plan_renderables.py +147 -0
  93. xg/tui/reducer.py +1029 -0
  94. xg/tui/renderables.py +252 -0
  95. xg/tui/state.py +240 -0
  96. xg/tui/theme.tcss +208 -0
  97. xg/tui/widgets/__init__.py +1 -0
  98. xg/tui/widgets/action_card.py +94 -0
  99. xg/tui/widgets/agent_group_card.py +39 -0
  100. xg/tui/widgets/approval_modal.py +59 -0
  101. xg/tui/widgets/collapsible_card.py +40 -0
  102. xg/tui/widgets/command_suggestions.py +128 -0
  103. xg/tui/widgets/composer.py +151 -0
  104. xg/tui/widgets/config_panel.py +198 -0
  105. xg/tui/widgets/confirm_modal.py +31 -0
  106. xg/tui/widgets/footer.py +9 -0
  107. xg/tui/widgets/header.py +118 -0
  108. xg/tui/widgets/inspector.py +378 -0
  109. xg/tui/widgets/plan_modal.py +53 -0
  110. xg/tui/widgets/provider_form.py +141 -0
  111. xg/tui/widgets/queue_status.py +30 -0
  112. xg/tui/widgets/smart_router_form.py +95 -0
  113. xg/tui/widgets/transcript.py +354 -0
  114. xg/tui/workers.py +14 -0
  115. xg/web/__init__.py +21 -0
  116. xg/web/errors.py +57 -0
  117. xg/web/extract.py +127 -0
  118. xg/web/fetch.py +106 -0
  119. xg/web/markdown.py +77 -0
  120. xg/web/models.py +91 -0
  121. xg/web/providers.py +79 -0
  122. xg/web/search.py +118 -0
  123. xg/web/searxng.py +27 -0
  124. xg/web/serpapi.py +29 -0
  125. xg/web/url_policy.py +110 -0
  126. xg/web/zhipu.py +27 -0
  127. xg_cli-1.0.dist-info/METADATA +284 -0
  128. xg_cli-1.0.dist-info/RECORD +130 -0
  129. xg_cli-1.0.dist-info/WHEEL +4 -0
  130. 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