windcode 0.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.
- windcode/__init__.py +6 -0
- windcode/__main__.py +4 -0
- windcode/auth/__init__.py +11 -0
- windcode/auth/store.py +90 -0
- windcode/cli.py +181 -0
- windcode/config/__init__.py +41 -0
- windcode/config/loader.py +117 -0
- windcode/config/models.py +203 -0
- windcode/config/writer.py +74 -0
- windcode/context/__init__.py +18 -0
- windcode/context/compactor.py +72 -0
- windcode/context/estimator.py +89 -0
- windcode/context/truncation.py +87 -0
- windcode/domain/__init__.py +1 -0
- windcode/domain/errors.py +33 -0
- windcode/domain/events.py +597 -0
- windcode/domain/messages.py +226 -0
- windcode/domain/models.py +73 -0
- windcode/domain/subagents.py +263 -0
- windcode/domain/tools.py +55 -0
- windcode/extensions/__init__.py +27 -0
- windcode/extensions/commands.py +25 -0
- windcode/extensions/discovery.py +139 -0
- windcode/extensions/events.py +58 -0
- windcode/extensions/hooks/__init__.py +4 -0
- windcode/extensions/hooks/dispatcher.py +107 -0
- windcode/extensions/hooks/executor.py +49 -0
- windcode/extensions/hooks/loader.py +101 -0
- windcode/extensions/hooks/models.py +109 -0
- windcode/extensions/mcp/__init__.py +10 -0
- windcode/extensions/mcp/adapter.py +101 -0
- windcode/extensions/mcp/catalog.py +153 -0
- windcode/extensions/mcp/client.py +273 -0
- windcode/extensions/mcp/runtime.py +144 -0
- windcode/extensions/mcp/tools.py +570 -0
- windcode/extensions/models.py +168 -0
- windcode/extensions/paths.py +71 -0
- windcode/extensions/plugins/__init__.py +3 -0
- windcode/extensions/plugins/installer.py +93 -0
- windcode/extensions/plugins/manifest.py +166 -0
- windcode/extensions/runtime.py +366 -0
- windcode/extensions/service.py +437 -0
- windcode/extensions/skills/__init__.py +3 -0
- windcode/extensions/skills/loader.py +67 -0
- windcode/extensions/skills/parser.py +57 -0
- windcode/extensions/skills/tools.py +213 -0
- windcode/extensions/snapshot.py +57 -0
- windcode/extensions/state.py +158 -0
- windcode/instructions/__init__.py +8 -0
- windcode/instructions/loader.py +50 -0
- windcode/memory/__init__.py +57 -0
- windcode/memory/extraction.py +83 -0
- windcode/memory/models.py +218 -0
- windcode/memory/refiner.py +189 -0
- windcode/memory/security.py +23 -0
- windcode/memory/service.py +179 -0
- windcode/memory/store.py +362 -0
- windcode/observability/__init__.py +4 -0
- windcode/observability/redaction.py +95 -0
- windcode/observability/trace.py +115 -0
- windcode/policy/__init__.py +22 -0
- windcode/policy/engine.py +137 -0
- windcode/policy/models.py +69 -0
- windcode/providers/__init__.py +29 -0
- windcode/providers/_utils.py +19 -0
- windcode/providers/anthropic.py +215 -0
- windcode/providers/base.py +46 -0
- windcode/providers/catalog.py +119 -0
- windcode/providers/errors.py +96 -0
- windcode/providers/openai_compat.py +231 -0
- windcode/providers/openai_responses.py +189 -0
- windcode/providers/registry.py +105 -0
- windcode/runtime/__init__.py +15 -0
- windcode/runtime/control.py +89 -0
- windcode/runtime/event_bus.py +67 -0
- windcode/runtime/loop.py +510 -0
- windcode/runtime/prompts.py +132 -0
- windcode/runtime/report.py +64 -0
- windcode/runtime/retry.py +73 -0
- windcode/runtime/scheduler.py +194 -0
- windcode/runtime/subagents/__init__.py +28 -0
- windcode/runtime/subagents/approvals.py +88 -0
- windcode/runtime/subagents/budgets.py +79 -0
- windcode/runtime/subagents/coordinator.py +714 -0
- windcode/runtime/subagents/factory.py +311 -0
- windcode/runtime/subagents/roles.py +74 -0
- windcode/runtime/subagents/verification.py +53 -0
- windcode/sandbox/__init__.py +3 -0
- windcode/sandbox/bwrap.py +79 -0
- windcode/sdk.py +1259 -0
- windcode/sessions/__init__.py +23 -0
- windcode/sessions/artifacts.py +69 -0
- windcode/sessions/models.py +104 -0
- windcode/sessions/store.py +167 -0
- windcode/sessions/tree.py +35 -0
- windcode/tools/__init__.py +10 -0
- windcode/tools/apply_patch.py +191 -0
- windcode/tools/ask_user.py +58 -0
- windcode/tools/builtins.py +44 -0
- windcode/tools/edit_file.py +54 -0
- windcode/tools/filesystem.py +77 -0
- windcode/tools/glob.py +35 -0
- windcode/tools/grep.py +66 -0
- windcode/tools/memory.py +400 -0
- windcode/tools/read_file.py +54 -0
- windcode/tools/registry.py +120 -0
- windcode/tools/shell.py +159 -0
- windcode/tools/subagents/__init__.py +31 -0
- windcode/tools/subagents/cancel.py +35 -0
- windcode/tools/subagents/integrate.py +54 -0
- windcode/tools/subagents/list.py +46 -0
- windcode/tools/subagents/spawn.py +86 -0
- windcode/tools/subagents/wait.py +74 -0
- windcode/tools/write_file.py +61 -0
- windcode/tui/__init__.py +3 -0
- windcode/tui/app.py +1015 -0
- windcode/tui/commands.py +90 -0
- windcode/tui/permission_display.py +24 -0
- windcode/tui/styles.tcss +591 -0
- windcode/tui/widgets/__init__.py +31 -0
- windcode/tui/widgets/approval.py +98 -0
- windcode/tui/widgets/command_menu.py +90 -0
- windcode/tui/widgets/extensions.py +48 -0
- windcode/tui/widgets/input.py +110 -0
- windcode/tui/widgets/memory.py +143 -0
- windcode/tui/widgets/messages.py +299 -0
- windcode/tui/widgets/models.py +565 -0
- windcode/tui/widgets/question.py +46 -0
- windcode/tui/widgets/sessions.py +68 -0
- windcode/tui/widgets/status.py +46 -0
- windcode/tui/widgets/subagents.py +195 -0
- windcode/tui/widgets/tools.py +66 -0
- windcode/tui/widgets/welcome.py +149 -0
- windcode/types.py +80 -0
- windcode/worktrees/__init__.py +24 -0
- windcode/worktrees/git.py +92 -0
- windcode/worktrees/manager.py +265 -0
- windcode/worktrees/models.py +62 -0
- windcode-0.1.0.dist-info/METADATA +207 -0
- windcode-0.1.0.dist-info/RECORD +143 -0
- windcode-0.1.0.dist-info/WHEEL +4 -0
- windcode-0.1.0.dist-info/entry_points.txt +2 -0
- windcode-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from typing import ClassVar, cast
|
|
6
|
+
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
from textual import on
|
|
9
|
+
from textual.app import ComposeResult
|
|
10
|
+
from textual.binding import Binding
|
|
11
|
+
from textual.containers import Horizontal, Vertical
|
|
12
|
+
from textual.message import Message
|
|
13
|
+
from textual.screen import ModalScreen
|
|
14
|
+
from textual.widgets import Button, Input, OptionList, Select, Static
|
|
15
|
+
from textual.widgets.option_list import Option
|
|
16
|
+
|
|
17
|
+
from windcode.config import ProviderConfig, ProviderProtocol
|
|
18
|
+
from windcode.providers.catalog import (
|
|
19
|
+
PRESETS_BY_ID,
|
|
20
|
+
PROVIDER_PRESETS,
|
|
21
|
+
ProviderPreset,
|
|
22
|
+
provider_preset,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
PROTOCOL_LABELS = {
|
|
26
|
+
ProviderProtocol.ANTHROPIC_MESSAGES: "Anthropic",
|
|
27
|
+
ProviderProtocol.OPENAI_RESPONSES: "OpenAI Responses",
|
|
28
|
+
ProviderProtocol.OPENAI_COMPATIBLE: "OpenAI Compatible",
|
|
29
|
+
}
|
|
30
|
+
DEFAULT_KEY_ENV = {
|
|
31
|
+
ProviderProtocol.ANTHROPIC_MESSAGES: "ANTHROPIC_API_KEY",
|
|
32
|
+
ProviderProtocol.OPENAI_RESPONSES: "OPENAI_API_KEY",
|
|
33
|
+
ProviderProtocol.OPENAI_COMPATIBLE: "OPENAI_API_KEY",
|
|
34
|
+
}
|
|
35
|
+
_ALIAS_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _model_option(
|
|
39
|
+
alias: str,
|
|
40
|
+
provider: ProviderConfig,
|
|
41
|
+
*,
|
|
42
|
+
selected: str | None,
|
|
43
|
+
primary: str | None,
|
|
44
|
+
connected: bool,
|
|
45
|
+
) -> Option:
|
|
46
|
+
text = Text()
|
|
47
|
+
text.append("● " if alias == selected else " ", style="cyan")
|
|
48
|
+
text.append(provider.model, style="bold")
|
|
49
|
+
text.append(f" {alias}", style="dim")
|
|
50
|
+
if alias == selected:
|
|
51
|
+
text.append(" 当前", style="bold cyan")
|
|
52
|
+
elif alias == primary:
|
|
53
|
+
text.append(" 默认", style="cyan")
|
|
54
|
+
if not connected:
|
|
55
|
+
text.append(" 未连接", style="yellow")
|
|
56
|
+
return Option(text, id=alias)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _field_label(title: str, description: str) -> Static:
|
|
60
|
+
text = Text(title, style="bold")
|
|
61
|
+
text.append(f"\n{description}", style="dim")
|
|
62
|
+
return Static(text, classes="provider-field-label")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ModelManager(ModalScreen[None]):
|
|
66
|
+
BINDINGS: ClassVar[list[Binding]] = [
|
|
67
|
+
Binding("escape", "close", "关闭"),
|
|
68
|
+
Binding("left", "previous_provider", "上一个厂商", priority=True),
|
|
69
|
+
Binding("right", "next_provider", "下一个厂商", priority=True),
|
|
70
|
+
Binding("up", "cursor_up", "上一个模型", priority=True),
|
|
71
|
+
Binding("down", "cursor_down", "下一个模型", priority=True),
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
class Use(Message):
|
|
75
|
+
def __init__(self, alias: str) -> None:
|
|
76
|
+
super().__init__()
|
|
77
|
+
self.alias = alias
|
|
78
|
+
|
|
79
|
+
class Manage(Message):
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
class Connect(Message):
|
|
83
|
+
def __init__(self, provider_id: str, alias: str | None = None) -> None:
|
|
84
|
+
super().__init__()
|
|
85
|
+
self.provider_id = provider_id
|
|
86
|
+
self.alias = alias
|
|
87
|
+
|
|
88
|
+
class Closed(Message):
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
profiles: Mapping[str, ProviderConfig],
|
|
94
|
+
*,
|
|
95
|
+
selected: str | None,
|
|
96
|
+
primary: str | None,
|
|
97
|
+
connected: Mapping[str, bool],
|
|
98
|
+
) -> None:
|
|
99
|
+
super().__init__(id="model-manager")
|
|
100
|
+
self.profiles = dict(profiles)
|
|
101
|
+
self.selected = selected if selected in profiles else primary
|
|
102
|
+
self.primary = primary
|
|
103
|
+
self.connected = dict(connected)
|
|
104
|
+
self._visible_aliases: tuple[str, ...] = ()
|
|
105
|
+
groups: list[tuple[str | None, str]] = [
|
|
106
|
+
(None, "全部"),
|
|
107
|
+
*((preset.id, preset.name) for preset in PROVIDER_PRESETS),
|
|
108
|
+
]
|
|
109
|
+
seen = {preset.id for preset in PROVIDER_PRESETS}
|
|
110
|
+
for alias, provider in self.profiles.items():
|
|
111
|
+
preset = provider_preset(provider)
|
|
112
|
+
group_id = preset.id if preset is not None else alias
|
|
113
|
+
if group_id not in seen:
|
|
114
|
+
seen.add(group_id)
|
|
115
|
+
groups.append((group_id, preset.name if preset is not None else alias))
|
|
116
|
+
self._groups = tuple(groups)
|
|
117
|
+
self._group_index = 0
|
|
118
|
+
|
|
119
|
+
def compose(self) -> ComposeResult:
|
|
120
|
+
with Vertical(id="model-dialog"):
|
|
121
|
+
yield Static("选择模型", id="model-manager-title")
|
|
122
|
+
yield Input(placeholder="搜索模型或 Provider", id="model-search")
|
|
123
|
+
yield Static("", id="model-provider-tabs")
|
|
124
|
+
yield OptionList(id="model-list")
|
|
125
|
+
yield Static("", id="model-picker-status")
|
|
126
|
+
with Horizontal(classes="dialog-actions"):
|
|
127
|
+
yield Button("使用", id="model-use", variant="primary")
|
|
128
|
+
yield Button("管理 Provider", id="model-manage")
|
|
129
|
+
yield Button("关闭", id="model-close")
|
|
130
|
+
|
|
131
|
+
def on_mount(self) -> None:
|
|
132
|
+
self._filter("")
|
|
133
|
+
self.query_one("#model-search", Input).focus()
|
|
134
|
+
|
|
135
|
+
def _filter(self, query: str) -> None:
|
|
136
|
+
query = query.casefold().strip()
|
|
137
|
+
active_group = self._groups[self._group_index][0]
|
|
138
|
+
matched: list[str] = []
|
|
139
|
+
for alias, provider in self.profiles.items():
|
|
140
|
+
preset = provider_preset(provider)
|
|
141
|
+
group_id = preset.id if preset is not None else alias
|
|
142
|
+
if active_group is not None and group_id != active_group:
|
|
143
|
+
continue
|
|
144
|
+
if query and not any(
|
|
145
|
+
query in candidate.casefold()
|
|
146
|
+
for candidate in (
|
|
147
|
+
alias,
|
|
148
|
+
provider.model,
|
|
149
|
+
PROTOCOL_LABELS[provider.protocol],
|
|
150
|
+
preset.name if preset is not None else "",
|
|
151
|
+
)
|
|
152
|
+
):
|
|
153
|
+
continue
|
|
154
|
+
matched.append(alias)
|
|
155
|
+
aliases = tuple(matched)
|
|
156
|
+
self._visible_aliases = aliases
|
|
157
|
+
options: list[Option] = []
|
|
158
|
+
last_group: str | None = None
|
|
159
|
+
for alias in aliases:
|
|
160
|
+
provider = self.profiles[alias]
|
|
161
|
+
preset = provider_preset(provider)
|
|
162
|
+
group_name = preset.name if preset is not None else alias
|
|
163
|
+
if not query and group_name != last_group:
|
|
164
|
+
options.append(Option(Text(group_name, style="bold magenta"), disabled=True))
|
|
165
|
+
last_group = group_name
|
|
166
|
+
options.append(
|
|
167
|
+
_model_option(
|
|
168
|
+
alias,
|
|
169
|
+
provider,
|
|
170
|
+
selected=self.selected,
|
|
171
|
+
primary=self.primary,
|
|
172
|
+
connected=self.connected.get(alias, False),
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
configured_presets = {
|
|
176
|
+
preset.id
|
|
177
|
+
for provider in self.profiles.values()
|
|
178
|
+
if (preset := provider_preset(provider)) is not None
|
|
179
|
+
}
|
|
180
|
+
available_presets = tuple(
|
|
181
|
+
preset
|
|
182
|
+
for preset in PROVIDER_PRESETS
|
|
183
|
+
if preset.id not in configured_presets
|
|
184
|
+
and (active_group is None or preset.id == active_group)
|
|
185
|
+
and (not query or query in preset.name.casefold() or query in preset.id.casefold())
|
|
186
|
+
)
|
|
187
|
+
if available_presets:
|
|
188
|
+
if active_group is None:
|
|
189
|
+
options.append(Option(Text("连接 Provider", style="bold magenta"), disabled=True))
|
|
190
|
+
for preset in available_presets:
|
|
191
|
+
text = Text(" ")
|
|
192
|
+
text.append(preset.name, style="bold")
|
|
193
|
+
text.append(" 尚未连接", style="dim")
|
|
194
|
+
options.append(Option(text, id=f"connect:{preset.id}"))
|
|
195
|
+
model_list = self.query_one("#model-list", OptionList)
|
|
196
|
+
model_list.clear_options()
|
|
197
|
+
model_list.add_options(options)
|
|
198
|
+
status = "" if options else "没有匹配的模型或 Provider"
|
|
199
|
+
self.query_one("#model-picker-status", Static).update(status)
|
|
200
|
+
target = self.selected if self.selected in aliases else None
|
|
201
|
+
if target is not None:
|
|
202
|
+
model_list.highlighted = model_list.get_option_index(target)
|
|
203
|
+
elif options:
|
|
204
|
+
model_list.action_first()
|
|
205
|
+
self._update_group_tabs()
|
|
206
|
+
|
|
207
|
+
def _update_group_tabs(self) -> None:
|
|
208
|
+
tabs = Text()
|
|
209
|
+
previous = self._groups[(self._group_index - 1) % len(self._groups)][1]
|
|
210
|
+
current = self._groups[self._group_index][1]
|
|
211
|
+
following = self._groups[(self._group_index + 1) % len(self._groups)][1]
|
|
212
|
+
tabs.append(f"← {previous}", style="dim")
|
|
213
|
+
tabs.append(f" {current} ", style="bold cyan")
|
|
214
|
+
tabs.append(f"{following} →", style="dim")
|
|
215
|
+
self.query_one("#model-provider-tabs", Static).update(tabs)
|
|
216
|
+
|
|
217
|
+
def _highlighted_alias(self) -> str | None:
|
|
218
|
+
model_list = self.query_one("#model-list", OptionList)
|
|
219
|
+
option = model_list.highlighted_option
|
|
220
|
+
return option.id if option is not None else None
|
|
221
|
+
|
|
222
|
+
def action_previous_provider(self) -> None:
|
|
223
|
+
self._group_index = (self._group_index - 1) % len(self._groups)
|
|
224
|
+
self._filter(self.query_one("#model-search", Input).value)
|
|
225
|
+
|
|
226
|
+
def action_next_provider(self) -> None:
|
|
227
|
+
self._group_index = (self._group_index + 1) % len(self._groups)
|
|
228
|
+
self._filter(self.query_one("#model-search", Input).value)
|
|
229
|
+
|
|
230
|
+
def action_cursor_up(self) -> None:
|
|
231
|
+
self.query_one("#model-list", OptionList).action_cursor_up()
|
|
232
|
+
|
|
233
|
+
def action_cursor_down(self) -> None:
|
|
234
|
+
self.query_one("#model-list", OptionList).action_cursor_down()
|
|
235
|
+
|
|
236
|
+
def _use(self, alias: str | None = None) -> None:
|
|
237
|
+
target = alias or self._highlighted_alias()
|
|
238
|
+
if target is None:
|
|
239
|
+
return
|
|
240
|
+
if target.startswith("connect:"):
|
|
241
|
+
self.post_message(self.Connect(target.removeprefix("connect:")))
|
|
242
|
+
return
|
|
243
|
+
if not self.connected.get(target, False):
|
|
244
|
+
preset = provider_preset(self.profiles[target])
|
|
245
|
+
if preset is not None:
|
|
246
|
+
self.post_message(self.Connect(preset.id, target))
|
|
247
|
+
else:
|
|
248
|
+
self.query_one("#model-picker-status", Static).update("该 Provider 尚未连接")
|
|
249
|
+
return
|
|
250
|
+
self.post_message(self.Use(target))
|
|
251
|
+
|
|
252
|
+
def show_error(self, message: str) -> None:
|
|
253
|
+
self.query_one("#model-picker-status", Static).update(message)
|
|
254
|
+
|
|
255
|
+
@on(Input.Changed, "#model-search")
|
|
256
|
+
def search_changed(self, event: Input.Changed) -> None:
|
|
257
|
+
self._filter(event.value)
|
|
258
|
+
|
|
259
|
+
@on(Input.Submitted, "#model-search")
|
|
260
|
+
def search_submitted(self) -> None:
|
|
261
|
+
self._use()
|
|
262
|
+
|
|
263
|
+
@on(OptionList.OptionSelected, "#model-list")
|
|
264
|
+
def option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
265
|
+
self._use(event.option.id)
|
|
266
|
+
|
|
267
|
+
@on(Button.Pressed)
|
|
268
|
+
def button_pressed(self, event: Button.Pressed) -> None:
|
|
269
|
+
if event.button.id == "model-use":
|
|
270
|
+
self._use()
|
|
271
|
+
elif event.button.id == "model-manage":
|
|
272
|
+
self.post_message(self.Manage())
|
|
273
|
+
elif event.button.id == "model-close":
|
|
274
|
+
self.action_close()
|
|
275
|
+
|
|
276
|
+
def action_close(self) -> None:
|
|
277
|
+
self.post_message(self.Closed())
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class ProviderManager(ModalScreen[None]):
|
|
281
|
+
BINDINGS: ClassVar[list[Binding]] = [Binding("escape", "close_or_cancel", "返回")]
|
|
282
|
+
|
|
283
|
+
class Save(Message):
|
|
284
|
+
def __init__(self, alias: str, provider: ProviderConfig, secret: str | None) -> None:
|
|
285
|
+
super().__init__()
|
|
286
|
+
self.alias = alias
|
|
287
|
+
self.provider = provider
|
|
288
|
+
self.secret = secret
|
|
289
|
+
|
|
290
|
+
class Delete(Message):
|
|
291
|
+
def __init__(self, alias: str) -> None:
|
|
292
|
+
super().__init__()
|
|
293
|
+
self.alias = alias
|
|
294
|
+
|
|
295
|
+
class SetDefault(Message):
|
|
296
|
+
def __init__(self, alias: str) -> None:
|
|
297
|
+
super().__init__()
|
|
298
|
+
self.alias = alias
|
|
299
|
+
|
|
300
|
+
class Closed(Message):
|
|
301
|
+
pass
|
|
302
|
+
|
|
303
|
+
def __init__(
|
|
304
|
+
self,
|
|
305
|
+
profiles: Mapping[str, ProviderConfig],
|
|
306
|
+
*,
|
|
307
|
+
selected: str | None,
|
|
308
|
+
primary: str | None,
|
|
309
|
+
connected: Mapping[str, bool],
|
|
310
|
+
preset_id: str | None = None,
|
|
311
|
+
) -> None:
|
|
312
|
+
super().__init__(id="provider-manager")
|
|
313
|
+
self.profiles = dict(profiles)
|
|
314
|
+
self.selected = selected if selected in profiles else primary
|
|
315
|
+
self.primary = primary
|
|
316
|
+
self.connected = dict(connected)
|
|
317
|
+
self.initial_preset_id = preset_id
|
|
318
|
+
self._aliases = tuple(self.profiles)
|
|
319
|
+
self._editing_alias: str | None = None
|
|
320
|
+
self._pending_delete: str | None = None
|
|
321
|
+
|
|
322
|
+
def compose(self) -> ComposeResult:
|
|
323
|
+
with Vertical(id="provider-dialog"):
|
|
324
|
+
yield Static("Provider 管理", id="provider-manager-title")
|
|
325
|
+
yield OptionList(id="provider-list")
|
|
326
|
+
yield Static("", id="provider-details")
|
|
327
|
+
with Horizontal(id="provider-actions", classes="dialog-actions"):
|
|
328
|
+
yield Button("新增", id="provider-add", variant="primary")
|
|
329
|
+
yield Button("编辑", id="provider-edit")
|
|
330
|
+
yield Button("设为默认", id="provider-default")
|
|
331
|
+
yield Button("断开", id="provider-delete", variant="error")
|
|
332
|
+
yield Button("确认断开", id="provider-confirm-delete", variant="error")
|
|
333
|
+
yield Button("完成", id="provider-close")
|
|
334
|
+
with Vertical(id="provider-editor"):
|
|
335
|
+
with Horizontal(classes="provider-field"):
|
|
336
|
+
yield _field_label("厂商模板", "预填协议、地址和变量")
|
|
337
|
+
yield Select(
|
|
338
|
+
(
|
|
339
|
+
("自定义 Provider", "custom"),
|
|
340
|
+
*((preset.name, preset.id) for preset in PROVIDER_PRESETS),
|
|
341
|
+
),
|
|
342
|
+
allow_blank=False,
|
|
343
|
+
value="openai",
|
|
344
|
+
id="provider-preset",
|
|
345
|
+
)
|
|
346
|
+
with Horizontal(classes="provider-field"):
|
|
347
|
+
yield _field_label("配置别名", "用于 /model 和配置引用")
|
|
348
|
+
yield Input(placeholder="Provider 别名", id="provider-alias")
|
|
349
|
+
with Horizontal(classes="provider-field"):
|
|
350
|
+
yield _field_label("接口协议", "服务端请求格式")
|
|
351
|
+
yield Select(
|
|
352
|
+
tuple(
|
|
353
|
+
(label, protocol.value) for protocol, label in PROTOCOL_LABELS.items()
|
|
354
|
+
),
|
|
355
|
+
allow_blank=False,
|
|
356
|
+
value=ProviderProtocol.OPENAI_RESPONSES.value,
|
|
357
|
+
id="provider-protocol",
|
|
358
|
+
)
|
|
359
|
+
with Horizontal(classes="provider-field"):
|
|
360
|
+
yield _field_label("模型 ID", "API 接受的模型标识")
|
|
361
|
+
yield Input(placeholder="例如 deepseek-chat", id="provider-model")
|
|
362
|
+
with Horizontal(classes="provider-field"):
|
|
363
|
+
yield _field_label("Base URL", "API 根地址; 模板已预填")
|
|
364
|
+
yield Input(placeholder="https://api.example.com/v1", id="provider-base-url")
|
|
365
|
+
with Horizontal(classes="provider-field"):
|
|
366
|
+
yield _field_label("API Key", "存入密钥库; 留空保留")
|
|
367
|
+
yield Input(placeholder="API Key", password=True, id="provider-api-key")
|
|
368
|
+
with Horizontal(classes="provider-field"):
|
|
369
|
+
yield _field_label("环境变量", "变量名; 其值优先于密钥库")
|
|
370
|
+
yield Input(placeholder="例如 DEEPSEEK_API_KEY", id="provider-api-key-env")
|
|
371
|
+
yield Static("", id="provider-editor-error")
|
|
372
|
+
with Horizontal(classes="dialog-actions"):
|
|
373
|
+
yield Button("保存连接", id="provider-save", variant="primary")
|
|
374
|
+
yield Button("取消", id="provider-cancel")
|
|
375
|
+
|
|
376
|
+
def on_mount(self) -> None:
|
|
377
|
+
self.query_one("#provider-editor", Vertical).display = False
|
|
378
|
+
self.query_one("#provider-confirm-delete", Button).display = False
|
|
379
|
+
self._refresh_options()
|
|
380
|
+
if self._aliases:
|
|
381
|
+
if self.initial_preset_id is not None:
|
|
382
|
+
self._open_editor(None)
|
|
383
|
+
else:
|
|
384
|
+
self.query_one("#provider-list", OptionList).focus()
|
|
385
|
+
else:
|
|
386
|
+
self._open_editor(None)
|
|
387
|
+
|
|
388
|
+
def _refresh_options(self) -> None:
|
|
389
|
+
options: list[Option] = []
|
|
390
|
+
for alias in self._aliases:
|
|
391
|
+
provider = self.profiles[alias]
|
|
392
|
+
preset = provider_preset(provider)
|
|
393
|
+
text = Text()
|
|
394
|
+
text.append(alias, style="bold")
|
|
395
|
+
platform = preset.name if preset is not None else PROTOCOL_LABELS[provider.protocol]
|
|
396
|
+
text.append(f" {provider.model} · {platform}", style="dim")
|
|
397
|
+
text.append(
|
|
398
|
+
" 已连接" if self.connected.get(alias, False) else " 未连接",
|
|
399
|
+
style="green" if self.connected.get(alias, False) else "yellow",
|
|
400
|
+
)
|
|
401
|
+
if alias == self.primary:
|
|
402
|
+
text.append(" 默认", style="cyan")
|
|
403
|
+
options.append(Option(text, id=alias))
|
|
404
|
+
provider_list = self.query_one("#provider-list", OptionList)
|
|
405
|
+
provider_list.clear_options()
|
|
406
|
+
provider_list.add_options(options)
|
|
407
|
+
if self.selected in self._aliases:
|
|
408
|
+
provider_list.highlighted = self._aliases.index(self.selected)
|
|
409
|
+
self._update_details(self._highlighted_alias())
|
|
410
|
+
|
|
411
|
+
def _highlighted_alias(self) -> str | None:
|
|
412
|
+
index = self.query_one("#provider-list", OptionList).highlighted
|
|
413
|
+
return self._aliases[index] if index is not None and index < len(self._aliases) else None
|
|
414
|
+
|
|
415
|
+
def _update_details(self, alias: str | None) -> None:
|
|
416
|
+
if alias is None:
|
|
417
|
+
self.query_one("#provider-details", Static).update("连接 Provider 后即可选择模型")
|
|
418
|
+
return
|
|
419
|
+
provider = self.profiles[alias]
|
|
420
|
+
preset = provider_preset(provider)
|
|
421
|
+
env = f"环境变量: {provider.api_key_env}" if provider.api_key_env else "仅使用已保存密钥"
|
|
422
|
+
endpoint = provider.base_url or "官方端点"
|
|
423
|
+
platform = preset.name if preset is not None else "自定义 Provider"
|
|
424
|
+
self.query_one("#provider-details", Static).update(f"{platform} · {env} · {endpoint}")
|
|
425
|
+
|
|
426
|
+
def show_error(self, message: str) -> None:
|
|
427
|
+
target = (
|
|
428
|
+
"#provider-editor-error"
|
|
429
|
+
if self.query_one("#provider-editor", Vertical).display
|
|
430
|
+
else "#provider-details"
|
|
431
|
+
)
|
|
432
|
+
self.query_one(target, Static).update(message)
|
|
433
|
+
|
|
434
|
+
@on(OptionList.OptionHighlighted, "#provider-list")
|
|
435
|
+
def provider_highlighted(self, event: OptionList.OptionHighlighted) -> None:
|
|
436
|
+
self._update_details(event.option.id)
|
|
437
|
+
|
|
438
|
+
@on(Select.Changed, "#provider-preset")
|
|
439
|
+
def preset_changed(self, event: Select.Changed) -> None:
|
|
440
|
+
if self._editing_alias is not None or not isinstance(event.value, str):
|
|
441
|
+
return
|
|
442
|
+
preset = PRESETS_BY_ID.get(event.value)
|
|
443
|
+
if preset is not None:
|
|
444
|
+
self._apply_preset(preset)
|
|
445
|
+
|
|
446
|
+
@on(Button.Pressed)
|
|
447
|
+
def button_pressed(self, event: Button.Pressed) -> None:
|
|
448
|
+
button_id = event.button.id
|
|
449
|
+
alias = self._highlighted_alias()
|
|
450
|
+
if button_id == "provider-add":
|
|
451
|
+
self._open_editor(None)
|
|
452
|
+
elif button_id == "provider-edit" and alias is not None:
|
|
453
|
+
self._open_editor(alias)
|
|
454
|
+
elif button_id == "provider-default" and alias is not None:
|
|
455
|
+
self.post_message(self.SetDefault(alias))
|
|
456
|
+
elif button_id == "provider-delete" and alias is not None:
|
|
457
|
+
self._pending_delete = alias
|
|
458
|
+
event.button.display = False
|
|
459
|
+
self.query_one("#provider-confirm-delete", Button).display = True
|
|
460
|
+
self.query_one("#provider-details", Static).update(
|
|
461
|
+
f"再次确认将断开 {alias} 并删除已保存密钥"
|
|
462
|
+
)
|
|
463
|
+
elif button_id == "provider-confirm-delete" and self._pending_delete is not None:
|
|
464
|
+
self.post_message(self.Delete(self._pending_delete))
|
|
465
|
+
elif button_id == "provider-close":
|
|
466
|
+
self.post_message(self.Closed())
|
|
467
|
+
elif button_id == "provider-save":
|
|
468
|
+
self._save_editor()
|
|
469
|
+
elif button_id == "provider-cancel":
|
|
470
|
+
self._close_editor()
|
|
471
|
+
|
|
472
|
+
def _open_editor(self, alias: str | None) -> None:
|
|
473
|
+
self._editing_alias = alias
|
|
474
|
+
provider = self.profiles.get(alias) if alias else None
|
|
475
|
+
existing_preset = provider_preset(provider) if provider is not None else None
|
|
476
|
+
new_preset = PRESETS_BY_ID.get(self.initial_preset_id or "openai")
|
|
477
|
+
new_preset = new_preset or PRESETS_BY_ID["openai"]
|
|
478
|
+
preset_select = cast(Select[str], self.query_one("#provider-preset", Select))
|
|
479
|
+
preset_select.value = (
|
|
480
|
+
existing_preset.id
|
|
481
|
+
if existing_preset is not None
|
|
482
|
+
else ("custom" if provider is not None else new_preset.id)
|
|
483
|
+
)
|
|
484
|
+
preset_select.disabled = provider is not None
|
|
485
|
+
alias_input = self.query_one("#provider-alias", Input)
|
|
486
|
+
alias_input.value = alias or new_preset.id
|
|
487
|
+
alias_input.disabled = provider is not None
|
|
488
|
+
protocol = provider.protocol if provider else new_preset.protocol
|
|
489
|
+
self.query_one("#provider-protocol", Select).value = protocol.value
|
|
490
|
+
self.query_one("#provider-model", Input).value = provider.model if provider else ""
|
|
491
|
+
self.query_one("#provider-base-url", Input).value = (
|
|
492
|
+
provider.base_url or "" if provider else new_preset.base_url
|
|
493
|
+
)
|
|
494
|
+
self.query_one("#provider-api-key", Input).value = ""
|
|
495
|
+
self.query_one("#provider-api-key-env", Input).value = (
|
|
496
|
+
provider.api_key_env or DEFAULT_KEY_ENV[protocol]
|
|
497
|
+
if provider
|
|
498
|
+
else new_preset.api_key_env
|
|
499
|
+
)
|
|
500
|
+
self.query_one("#provider-api-key", Input).placeholder = (
|
|
501
|
+
"新 API Key (留空则保留)" if provider else "API Key (也可只使用环境变量)"
|
|
502
|
+
)
|
|
503
|
+
self.query_one("#provider-editor-error", Static).update("")
|
|
504
|
+
self.query_one("#provider-list", OptionList).display = False
|
|
505
|
+
self.query_one("#provider-details", Static).display = False
|
|
506
|
+
self.query_one("#provider-actions", Horizontal).display = False
|
|
507
|
+
self.query_one("#provider-editor", Vertical).display = True
|
|
508
|
+
alias_input.focus()
|
|
509
|
+
|
|
510
|
+
def _apply_preset(self, preset: ProviderPreset) -> None:
|
|
511
|
+
self.query_one("#provider-alias", Input).value = preset.id
|
|
512
|
+
self.query_one("#provider-protocol", Select).value = preset.protocol.value
|
|
513
|
+
self.query_one("#provider-base-url", Input).value = preset.base_url
|
|
514
|
+
self.query_one("#provider-api-key-env", Input).value = preset.api_key_env
|
|
515
|
+
|
|
516
|
+
def _close_editor(self) -> None:
|
|
517
|
+
self.query_one("#provider-editor", Vertical).display = False
|
|
518
|
+
self.query_one("#provider-list", OptionList).display = True
|
|
519
|
+
self.query_one("#provider-details", Static).display = True
|
|
520
|
+
self.query_one("#provider-actions", Horizontal).display = True
|
|
521
|
+
self.query_one("#provider-list", OptionList).focus()
|
|
522
|
+
|
|
523
|
+
def _save_editor(self) -> None:
|
|
524
|
+
alias = self.query_one("#provider-alias", Input).value.strip()
|
|
525
|
+
model = self.query_one("#provider-model", Input).value.strip()
|
|
526
|
+
api_key_env = self.query_one("#provider-api-key-env", Input).value.strip() or None
|
|
527
|
+
secret = self.query_one("#provider-api-key", Input).value.strip() or None
|
|
528
|
+
base_url = self.query_one("#provider-base-url", Input).value.strip() or None
|
|
529
|
+
preset_value = cast(Select[str], self.query_one("#provider-preset", Select)).value
|
|
530
|
+
protocol_value = cast(Select[str], self.query_one("#provider-protocol", Select)).value
|
|
531
|
+
if not _ALIAS_PATTERN.fullmatch(alias):
|
|
532
|
+
self.show_error("别名只能包含字母、数字、点、下划线和连字符")
|
|
533
|
+
return
|
|
534
|
+
if self._editing_alias is None and alias in self.profiles:
|
|
535
|
+
self.show_error(f"Provider 已存在: {alias}")
|
|
536
|
+
return
|
|
537
|
+
if not isinstance(protocol_value, str):
|
|
538
|
+
self.show_error("请选择协议")
|
|
539
|
+
return
|
|
540
|
+
try:
|
|
541
|
+
provider = ProviderConfig(
|
|
542
|
+
protocol=ProviderProtocol(protocol_value),
|
|
543
|
+
model=model,
|
|
544
|
+
provider_id=(
|
|
545
|
+
preset_value
|
|
546
|
+
if isinstance(preset_value, str) and preset_value in PRESETS_BY_ID
|
|
547
|
+
else None
|
|
548
|
+
),
|
|
549
|
+
api_key_env=api_key_env,
|
|
550
|
+
credential_id=alias,
|
|
551
|
+
base_url=base_url,
|
|
552
|
+
)
|
|
553
|
+
except (ValueError, TypeError) as exc:
|
|
554
|
+
self.show_error(str(exc))
|
|
555
|
+
return
|
|
556
|
+
if provider.protocol is ProviderProtocol.OPENAI_COMPATIBLE and not provider.base_url:
|
|
557
|
+
self.show_error("OpenAI Compatible 协议必须填写 Base URL")
|
|
558
|
+
return
|
|
559
|
+
self.post_message(self.Save(alias, provider, secret))
|
|
560
|
+
|
|
561
|
+
def action_close_or_cancel(self) -> None:
|
|
562
|
+
if self.query_one("#provider-editor", Vertical).display:
|
|
563
|
+
self._close_editor()
|
|
564
|
+
else:
|
|
565
|
+
self.post_message(self.Closed())
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import cast
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Vertical
|
|
7
|
+
from textual.message import Message
|
|
8
|
+
from textual.widgets import Button, Label, Select
|
|
9
|
+
|
|
10
|
+
from windcode.domain.events import UserInputRequested
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class QuestionWidget(Vertical):
|
|
14
|
+
class Submitted(Message):
|
|
15
|
+
def __init__(self, request_id: str, answers: dict[str, str]) -> None:
|
|
16
|
+
super().__init__()
|
|
17
|
+
self.request_id = request_id
|
|
18
|
+
self.answers = answers
|
|
19
|
+
|
|
20
|
+
def __init__(self, request: UserInputRequested) -> None:
|
|
21
|
+
super().__init__(classes="interaction")
|
|
22
|
+
self.request = request
|
|
23
|
+
|
|
24
|
+
def compose(self) -> ComposeResult:
|
|
25
|
+
for question in self.request.questions:
|
|
26
|
+
question_id = str(question.get("id", "question"))
|
|
27
|
+
prompt = str(question.get("prompt", question_id))
|
|
28
|
+
raw_options = question.get("options", ())
|
|
29
|
+
options = tuple(str(option) for option in raw_options)
|
|
30
|
+
yield Label(prompt)
|
|
31
|
+
yield Select(((option, option) for option in options), id=f"question-{question_id}")
|
|
32
|
+
yield Button("提交", id="question-submit", variant="primary")
|
|
33
|
+
|
|
34
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
35
|
+
if event.button.id != "question-submit":
|
|
36
|
+
return
|
|
37
|
+
answers: dict[str, str] = {}
|
|
38
|
+
for question in self.request.questions:
|
|
39
|
+
question_id = str(question.get("id", "question"))
|
|
40
|
+
select = cast(Select[str], self.query_one(f"#question-{question_id}", Select))
|
|
41
|
+
value = select.value
|
|
42
|
+
if not isinstance(value, str):
|
|
43
|
+
return
|
|
44
|
+
answers[question_id] = str(value)
|
|
45
|
+
self.post_message(self.Submitted(self.request.request_id, answers))
|
|
46
|
+
self.remove()
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
from textual import on
|
|
6
|
+
from textual.binding import Binding
|
|
7
|
+
from textual.message import Message
|
|
8
|
+
from textual.widgets import Select
|
|
9
|
+
from textual.widgets._select import SelectOverlay
|
|
10
|
+
|
|
11
|
+
from windcode.sessions import SessionMetadata
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SessionSelector(Select[str]):
|
|
15
|
+
BINDINGS: ClassVar[list[Binding]] = [Binding("escape", "cancel", "返回", priority=True)]
|
|
16
|
+
|
|
17
|
+
class Selected(Message):
|
|
18
|
+
def __init__(self, session_id: str) -> None:
|
|
19
|
+
super().__init__()
|
|
20
|
+
self.session_id = session_id
|
|
21
|
+
|
|
22
|
+
class Cancelled(Message):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
def __init__(self, sessions: tuple[SessionMetadata, ...]) -> None:
|
|
26
|
+
statuses = {
|
|
27
|
+
"active": "进行中",
|
|
28
|
+
"running": "运行中",
|
|
29
|
+
"completed": "已完成",
|
|
30
|
+
"unverified": "已完成 · 未验证",
|
|
31
|
+
"failed": "失败",
|
|
32
|
+
"cancelled": "已取消",
|
|
33
|
+
"interrupted": "已中断",
|
|
34
|
+
}
|
|
35
|
+
ordered_sessions = sorted(sessions, key=lambda session: session.updated_at, reverse=True)
|
|
36
|
+
options = [
|
|
37
|
+
(
|
|
38
|
+
f"{session.summary or '未命名会话'} · "
|
|
39
|
+
f"{session.updated_at.astimezone().strftime('%Y-%m-%d %H:%M')} · "
|
|
40
|
+
f"{statuses.get(session.status.value, session.status.value)} · "
|
|
41
|
+
f"{session.session_id[:12]}",
|
|
42
|
+
session.session_id,
|
|
43
|
+
)
|
|
44
|
+
for session in ordered_sessions
|
|
45
|
+
]
|
|
46
|
+
super().__init__(
|
|
47
|
+
options,
|
|
48
|
+
prompt="暂无可恢复的会话",
|
|
49
|
+
allow_blank=not options,
|
|
50
|
+
value=options[0][1] if options else Select.NULL,
|
|
51
|
+
id="sessions",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def action_cancel(self) -> None:
|
|
55
|
+
self.expanded = False
|
|
56
|
+
self.post_message(self.Cancelled())
|
|
57
|
+
|
|
58
|
+
@on(SelectOverlay.UpdateSelection)
|
|
59
|
+
def _update_selection(self, event: SelectOverlay.UpdateSelection) -> None:
|
|
60
|
+
"""Confirm a session even when the highlighted option is already selected."""
|
|
61
|
+
event.stop()
|
|
62
|
+
value = self._options[event.option_index][1]
|
|
63
|
+
if isinstance(value, str):
|
|
64
|
+
if value != self.value:
|
|
65
|
+
self.value = value
|
|
66
|
+
self.post_message(self.Selected(value))
|
|
67
|
+
self.focus()
|
|
68
|
+
self.expanded = False
|