zettcode-app 0.4.2__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.
- zettcode/app/__init__.py +63 -0
- zettcode/app/agent/__init__.py +47 -0
- zettcode/app/agent/agent/__init__.py +84 -0
- zettcode/app/agent/agent/commands.py +269 -0
- zettcode/app/agent/agent/settings.py +132 -0
- zettcode/app/agent/conversation/__init__.py +7 -0
- zettcode/app/agent/conversation/changes.py +64 -0
- zettcode/app/agent/conversation/effort_gauge.py +126 -0
- zettcode/app/agent/conversation/projection.py +265 -0
- zettcode/app/agent/conversation/rendering.py +837 -0
- zettcode/app/agent/conversation/replay.py +198 -0
- zettcode/app/agent/conversation/shimmer.py +256 -0
- zettcode/app/agent/conversation/stops.py +57 -0
- zettcode/app/agent/conversation/tips.py +86 -0
- zettcode/app/agent/conversation/transcript/__init__.py +80 -0
- zettcode/app/agent/conversation/transcript/fold.py +57 -0
- zettcode/app/agent/conversation/transcript/log.py +182 -0
- zettcode/app/agent/conversation/transcript/oneoff.py +55 -0
- zettcode/app/agent/conversation/transcript/stream.py +187 -0
- zettcode/app/agent/conversation/transcript/turn.py +323 -0
- zettcode/app/commandline/__init__.py +47 -0
- zettcode/app/commandline/arguments.py +169 -0
- zettcode/app/commandline/completion.py +262 -0
- zettcode/app/commandline/history.py +305 -0
- zettcode/app/commandline/runner.py +365 -0
- zettcode/app/errors.py +12 -0
- zettcode/app/plugins/__init__.py +110 -0
- zettcode/app/plugins/builtins.py +33 -0
- zettcode/app/plugins/groups.py +503 -0
- zettcode/app/plugins/loader.py +268 -0
- zettcode/app/py.typed +0 -0
- zettcode/app/ui/__init__.py +63 -0
- zettcode/app/ui/app.py +1507 -0
- zettcode/app/ui/bars.py +290 -0
- zettcode/app/ui/boot.py +131 -0
- zettcode/app/ui/clipboard.py +340 -0
- zettcode/app/ui/commands.py +323 -0
- zettcode/app/ui/config_form.py +343 -0
- zettcode/app/ui/demo.py +180 -0
- zettcode/app/ui/followups.py +430 -0
- zettcode/app/ui/gc_policy.py +147 -0
- zettcode/app/ui/keys.py +462 -0
- zettcode/app/ui/messaging.py +260 -0
- zettcode/app/ui/notices.py +195 -0
- zettcode/app/ui/providers.py +626 -0
- zettcode/app/ui/sessions.py +422 -0
- zettcode/app/ui/settings.py +161 -0
- zettcode/app/ui/shell.py +361 -0
- zettcode/app/ui/turns.py +115 -0
- zettcode/app/ui/updating.py +204 -0
- zettcode/app/ui/widgets/__init__.py +161 -0
- zettcode/app/ui/widgets/approval_page.py +284 -0
- zettcode/app/ui/widgets/ask_page.py +386 -0
- zettcode/app/ui/widgets/bash_page.py +90 -0
- zettcode/app/ui/widgets/completer.py +100 -0
- zettcode/app/ui/widgets/composer.py +529 -0
- zettcode/app/ui/widgets/document_page.py +159 -0
- zettcode/app/ui/widgets/effort_page.py +294 -0
- zettcode/app/ui/widgets/follow_up.py +168 -0
- zettcode/app/ui/widgets/form_page.py +380 -0
- zettcode/app/ui/widgets/input_page.py +175 -0
- zettcode/app/ui/widgets/model_page.py +63 -0
- zettcode/app/ui/widgets/model_settings_page.py +653 -0
- zettcode/app/ui/widgets/pet.py +247 -0
- zettcode/app/ui/widgets/pet_page.py +347 -0
- zettcode/app/ui/widgets/plugins_page.py +442 -0
- zettcode/app/ui/widgets/root.py +68 -0
- zettcode/app/ui/widgets/self_compact_page.py +363 -0
- zettcode/app/ui/widgets/send_kind_page.py +193 -0
- zettcode/app/ui/widgets/sessions_page.py +511 -0
- zettcode/app/ui/widgets/statusline_page.py +427 -0
- zettcode/app/ui/widgets/steering.py +126 -0
- zettcode/app/ui/widgets/transcript.py +287 -0
- zettcode/app/ui/widgets/update_page.py +65 -0
- zettcode/app/ui/widgets/welcome.py +24 -0
- zettcode/collector.py +54 -0
- zettcode/update.py +519 -0
- zettcode_app-0.4.2.dist-info/METADATA +26 -0
- zettcode_app-0.4.2.dist-info/RECORD +81 -0
- zettcode_app-0.4.2.dist-info/WHEEL +4 -0
- zettcode_app-0.4.2.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""What ``/config add`` means: turn a choice of provider and a few answers into one model entry.
|
|
2
|
+
|
|
3
|
+
The panel asks one question at a time and ``/config add`` takes everything on one line; both end up
|
|
4
|
+
here, so a model added either way is the same model. This module is plain data in and data out: it
|
|
5
|
+
knows nothing about widgets or the file, which is what makes the rules testable on their own.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import shlex
|
|
12
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
15
|
+
|
|
16
|
+
from zettcode.providers import CUSTOM, Api, Provider, check_url, find_provider
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING: # pragma: no cover - annotations only
|
|
19
|
+
from zettcode.sdk.types import ModelInfo
|
|
20
|
+
|
|
21
|
+
T = TypeVar("T")
|
|
22
|
+
|
|
23
|
+
USAGE = (
|
|
24
|
+
"/config add <provider> [--model ID] [--token KEY] [--url URL] [--api chat|responses|anthropic] "
|
|
25
|
+
"[--name SHOWN] [--multimodal] [--context TOKENS] [--compact PERCENT] [--temperature T] "
|
|
26
|
+
"[--web-search] [--search-tool TYPE]"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
#: The smallest context window the panel accepts. A window is counted in tokens, so a small number is nearly always a
|
|
30
|
+
#: window written in thousands (``200``); the file itself would take it, and the model would be compacted at once.
|
|
31
|
+
MIN_CONTEXT_WINDOW = 1_000
|
|
32
|
+
|
|
33
|
+
#: The flags ``/config add`` takes, and whether each one carries a value.
|
|
34
|
+
_FLAGS = {
|
|
35
|
+
"--model": True,
|
|
36
|
+
"--token": True,
|
|
37
|
+
"--url": True,
|
|
38
|
+
"--api": True,
|
|
39
|
+
"--name": True,
|
|
40
|
+
"--multimodal": False,
|
|
41
|
+
"--context": True,
|
|
42
|
+
"--compact": True,
|
|
43
|
+
"--temperature": True,
|
|
44
|
+
"--web-search": False,
|
|
45
|
+
"--search-tool": True,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(slots=True)
|
|
50
|
+
class Draft:
|
|
51
|
+
"""What is known about a model that is being added, filled in as it is learned.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
provider: The described host the model is at, or ``None`` for a custom one.
|
|
55
|
+
api: Which protocol it speaks. Starts as the provider's, and a reader may change it.
|
|
56
|
+
url: The address; ``None`` is the SDK's own default.
|
|
57
|
+
model: The model id sent to the endpoint.
|
|
58
|
+
token: The credential. May stay empty: a local server wants none, and the environment can supply one.
|
|
59
|
+
name: What the picker shows; empty means the model id.
|
|
60
|
+
multimodal: Whether it takes images.
|
|
61
|
+
context_window: Tokens it can carry; ``None`` leaves the default.
|
|
62
|
+
compact_percent: The share of the window at which a request is summarized first; ``None`` leaves the default.
|
|
63
|
+
temperature: The sampling temperature; ``None`` sends none.
|
|
64
|
+
web_search: Whether the provider searches the web for the model.
|
|
65
|
+
search_tool: The tool type that search is sent as; empty takes the default for the API.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
provider: Provider | None = None
|
|
69
|
+
api: Api = Api.CHAT
|
|
70
|
+
url: str | None = None
|
|
71
|
+
model: str = ""
|
|
72
|
+
token: str = ""
|
|
73
|
+
name: str = ""
|
|
74
|
+
multimodal: bool = False
|
|
75
|
+
context_window: int | None = None
|
|
76
|
+
compact_percent: float | None = None
|
|
77
|
+
temperature: float | None = None
|
|
78
|
+
web_search: bool = False
|
|
79
|
+
search_tool: str = ""
|
|
80
|
+
#: Whether the reader picked chat completions outright (from the list, or ``--api chat``), which is written as
|
|
81
|
+
#: ``chat = true``; a provider whose protocol is chat by default does not ask, and writes nothing.
|
|
82
|
+
chat: bool = False
|
|
83
|
+
#: Which values the reader already gave on the command line, so the panel does not ask again.
|
|
84
|
+
given: set[str] = field(default_factory=set)
|
|
85
|
+
#: The questions answered in the panel, newest last, each with what was typed, so that
|
|
86
|
+
#: Escape can take back the last one and show it again as it was answered.
|
|
87
|
+
trail: list[tuple[str, str]] = field(default_factory=list)
|
|
88
|
+
#: Whether the flow was entered from the panel's provider list, which is where Escape from
|
|
89
|
+
#: the first question goes back to; from a typed line there is nothing behind it.
|
|
90
|
+
from_panel: bool = False
|
|
91
|
+
|
|
92
|
+
def undo(self) -> str | None:
|
|
93
|
+
"""Take back the last answer given in the panel; return what it was, or ``None`` if none was.
|
|
94
|
+
|
|
95
|
+
The field goes back to being unanswered, so it is the next thing asked.
|
|
96
|
+
"""
|
|
97
|
+
if not self.trail:
|
|
98
|
+
return None
|
|
99
|
+
name, previous = self.trail.pop()
|
|
100
|
+
if name == "api":
|
|
101
|
+
self.api = Api.CHAT
|
|
102
|
+
self.chat = False
|
|
103
|
+
elif name == "url":
|
|
104
|
+
self.url = None
|
|
105
|
+
elif name == "model":
|
|
106
|
+
self.model = ""
|
|
107
|
+
else:
|
|
108
|
+
self.token = ""
|
|
109
|
+
self.given.discard(name)
|
|
110
|
+
return previous
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def for_provider(cls, provider: Provider | None) -> Draft:
|
|
114
|
+
"""Start a draft with what the provider already says."""
|
|
115
|
+
if provider is None:
|
|
116
|
+
return cls()
|
|
117
|
+
return cls(
|
|
118
|
+
provider=provider,
|
|
119
|
+
api=provider.api,
|
|
120
|
+
url=provider.base_url,
|
|
121
|
+
multimodal=provider.multimodal,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def custom(self) -> bool:
|
|
126
|
+
"""Return whether the host is one nobody described, so its address has to be asked."""
|
|
127
|
+
return self.provider is None
|
|
128
|
+
|
|
129
|
+
def missing(self) -> list[str]:
|
|
130
|
+
"""Return what still has to be asked, in the order to ask it.
|
|
131
|
+
|
|
132
|
+
The token is asked about, not required: a draft that was not told one (``--token``) is shown to the reader
|
|
133
|
+
with the line empty, and an empty line is a valid answer.
|
|
134
|
+
"""
|
|
135
|
+
wanted: list[str] = []
|
|
136
|
+
if self.custom and "api" not in self.given:
|
|
137
|
+
wanted.append("api")
|
|
138
|
+
if (self.custom or self.url is None) and "url" not in self.given and self.api is not Api.ANTHROPIC:
|
|
139
|
+
wanted.append("url")
|
|
140
|
+
if not self.model:
|
|
141
|
+
wanted.append("model")
|
|
142
|
+
if "token" not in self.given:
|
|
143
|
+
wanted.append("token")
|
|
144
|
+
return wanted
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def entry_for(draft: Draft) -> dict[str, object]:
|
|
148
|
+
"""Return the ``[[models]]`` keys a finished draft stands for.
|
|
149
|
+
|
|
150
|
+
Only the model id is required.
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
ValueError: When the model id is missing, or something given is not usable.
|
|
154
|
+
"""
|
|
155
|
+
if not draft.model.strip():
|
|
156
|
+
raise ValueError("A model id is required (--model)")
|
|
157
|
+
entry: dict[str, object] = {"model": draft.model.strip()}
|
|
158
|
+
if draft.token.strip():
|
|
159
|
+
entry["token"] = draft.token.strip()
|
|
160
|
+
if draft.name.strip():
|
|
161
|
+
entry["display_model"] = draft.name.strip()
|
|
162
|
+
if draft.url:
|
|
163
|
+
entry["base_url"] = check_url(draft.url)
|
|
164
|
+
elif draft.custom and draft.api is not Api.ANTHROPIC:
|
|
165
|
+
raise ValueError("A custom provider needs an address (--url)")
|
|
166
|
+
entry.update(draft.api.keys)
|
|
167
|
+
if draft.chat and draft.api is Api.CHAT:
|
|
168
|
+
entry["chat"] = True
|
|
169
|
+
if draft.multimodal:
|
|
170
|
+
entry["multimodal"] = True
|
|
171
|
+
if draft.context_window is not None:
|
|
172
|
+
entry["context_window"] = draft.context_window
|
|
173
|
+
if draft.compact_percent is not None:
|
|
174
|
+
entry["compact_percent"] = whole_if_whole(draft.compact_percent)
|
|
175
|
+
if draft.temperature is not None:
|
|
176
|
+
entry["temperature"] = whole_if_whole(draft.temperature)
|
|
177
|
+
if draft.web_search:
|
|
178
|
+
entry["web_search"] = True
|
|
179
|
+
if draft.search_tool.strip():
|
|
180
|
+
entry["web_search_name"] = draft.search_tool.strip()
|
|
181
|
+
return entry
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def parse_add(argument: str, providers: Sequence[Provider]) -> Draft:
|
|
185
|
+
"""Read what follows ``/config add`` into a draft, which may still be missing answers.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
argument: The text after ``add``: a provider name (or ``custom``) and optional flags.
|
|
189
|
+
providers: The hosts that can be named.
|
|
190
|
+
|
|
191
|
+
Raises:
|
|
192
|
+
ValueError: With a sentence saying what is wrong and how to write it.
|
|
193
|
+
"""
|
|
194
|
+
try:
|
|
195
|
+
words = shlex.split(argument)
|
|
196
|
+
except ValueError as error:
|
|
197
|
+
raise ValueError(f"cannot read that: {error}") from error
|
|
198
|
+
if not words or words[0].startswith("--"):
|
|
199
|
+
names = ", ".join([*(provider.name for provider in providers), CUSTOM])
|
|
200
|
+
raise ValueError(f"Name the provider: one of {names}\nUsage: {USAGE}")
|
|
201
|
+
name, flags = words[0].lower(), _flags(words[1:])
|
|
202
|
+
provider = None if name == CUSTOM else find_provider(name, providers)
|
|
203
|
+
if provider is None and name != CUSTOM:
|
|
204
|
+
known = ", ".join([*(item.name for item in providers), CUSTOM])
|
|
205
|
+
raise ValueError(f"Unknown provider {words[0]!r}; use one of {known}")
|
|
206
|
+
draft = Draft.for_provider(provider)
|
|
207
|
+
if "--api" in flags:
|
|
208
|
+
try:
|
|
209
|
+
draft.api = Api.parse(flags["--api"])
|
|
210
|
+
except ValueError as error:
|
|
211
|
+
raise ValueError(f"--api: {error}") from None
|
|
212
|
+
draft.chat = draft.api is Api.CHAT
|
|
213
|
+
draft.given.add("api")
|
|
214
|
+
if "--url" in flags:
|
|
215
|
+
draft.url = check_url(flags["--url"], what="--url")
|
|
216
|
+
draft.given.add("url")
|
|
217
|
+
if "--model" in flags:
|
|
218
|
+
draft.model = flags["--model"].strip()
|
|
219
|
+
if "--token" in flags:
|
|
220
|
+
draft.token = flags["--token"].strip()
|
|
221
|
+
draft.given.add("token")
|
|
222
|
+
if "--name" in flags:
|
|
223
|
+
draft.name = flags["--name"].strip()
|
|
224
|
+
if "--multimodal" in flags:
|
|
225
|
+
draft.multimodal = True
|
|
226
|
+
if "--web-search" in flags:
|
|
227
|
+
draft.web_search = True
|
|
228
|
+
if "--search-tool" in flags:
|
|
229
|
+
draft.search_tool = flags["--search-tool"].strip()
|
|
230
|
+
draft.web_search = True
|
|
231
|
+
if "--context" in flags:
|
|
232
|
+
draft.context_window = _flag(parse_tokens, flags["--context"], "--context")
|
|
233
|
+
if "--compact" in flags:
|
|
234
|
+
draft.compact_percent = _flag(parse_percent, flags["--compact"], "--compact")
|
|
235
|
+
if "--temperature" in flags:
|
|
236
|
+
draft.temperature = _flag(
|
|
237
|
+
lambda text: parse_temperature(text, draft.api), flags["--temperature"], "--temperature"
|
|
238
|
+
)
|
|
239
|
+
return draft
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _flag(parse: Callable[[str], T], text: str, flag: str) -> T:
|
|
243
|
+
"""Read the value of a flag with ``parse``, naming the flag in what is wrong with it."""
|
|
244
|
+
try:
|
|
245
|
+
return parse(text)
|
|
246
|
+
except ValueError as error:
|
|
247
|
+
raise ValueError(f"{flag}: {error}") from None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def parse_tokens(text: str) -> int:
|
|
251
|
+
"""Read a size in tokens: ``128000``, ``128,000``, ``128k``, ``1m`` or ``1.5m``.
|
|
252
|
+
|
|
253
|
+
Raises:
|
|
254
|
+
ValueError: With a sentence saying how to write it, when it is not a size or is too small to be a window.
|
|
255
|
+
"""
|
|
256
|
+
cleaned = re.sub(r"[\s,_]|tokens?$", "", text.strip().lower())
|
|
257
|
+
found = re.fullmatch(r"(\d+(?:\.\d+)?)([km]?)", cleaned)
|
|
258
|
+
if found is None:
|
|
259
|
+
raise ValueError("write the size in tokens: 128000, 128k or 1m")
|
|
260
|
+
tokens = round(float(found.group(1)) * {"": 1, "k": 1_000, "m": 1_000_000}[found.group(2)])
|
|
261
|
+
if tokens < MIN_CONTEXT_WINDOW:
|
|
262
|
+
raise ValueError(
|
|
263
|
+
f"a context window is counted in tokens and is at least {MIN_CONTEXT_WINDOW:,}; "
|
|
264
|
+
"write 200k for two hundred thousand"
|
|
265
|
+
)
|
|
266
|
+
return tokens
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def parse_temperature(text: str, api: Api = Api.CHAT) -> float:
|
|
270
|
+
"""Read a sampling temperature: from 0 to 2, or from 0 to 1 for the Anthropic API.
|
|
271
|
+
|
|
272
|
+
Raises:
|
|
273
|
+
ValueError: With a sentence saying what is allowed.
|
|
274
|
+
"""
|
|
275
|
+
limit = 1.0 if api is Api.ANTHROPIC else 2.0
|
|
276
|
+
try:
|
|
277
|
+
value = float(text.strip())
|
|
278
|
+
except ValueError:
|
|
279
|
+
raise ValueError(f"write the temperature as a number from 0 to {limit:g}, such as 0.2") from None
|
|
280
|
+
if not 0.0 <= value <= limit:
|
|
281
|
+
raise ValueError(f"the temperature is from 0 to {limit:g}" + (" for the Anthropic API" if limit == 1.0 else ""))
|
|
282
|
+
return value
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def parse_percent(text: str) -> float:
|
|
286
|
+
"""Read a share of the window: ``80`` or ``80%``, more than 0 and at most 100.
|
|
287
|
+
|
|
288
|
+
Raises:
|
|
289
|
+
ValueError: With a sentence saying what is allowed.
|
|
290
|
+
"""
|
|
291
|
+
try:
|
|
292
|
+
percent = float(text.strip().removesuffix("%").strip())
|
|
293
|
+
except ValueError:
|
|
294
|
+
raise ValueError("write a percentage of the window, such as 80") from None
|
|
295
|
+
if not 0 < percent <= 100:
|
|
296
|
+
raise ValueError("the percentage must be more than 0 and at most 100")
|
|
297
|
+
return percent
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def whole_if_whole(number: float) -> int | float:
|
|
301
|
+
"""Return ``80`` for ``80.0``, so the file reads ``compact_percent = 80`` and not ``80.0``."""
|
|
302
|
+
return int(number) if float(number).is_integer() else number
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def tokens_text(tokens: int) -> str:
|
|
306
|
+
"""Return a size the way a reader says it: ``128,000 tokens``."""
|
|
307
|
+
return f"{tokens:,} tokens"
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def limits_text(model: ModelInfo) -> str:
|
|
311
|
+
"""Return what a model's window and compaction setting come to, in one line."""
|
|
312
|
+
percent = whole_if_whole(model.compact_percent)
|
|
313
|
+
return (
|
|
314
|
+
f"{tokens_text(model.context_window)} \u00b7 compressed at {percent}% ({model.compaction_max_tokens:,} tokens)"
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _flags(words: Sequence[str]) -> Mapping[str, str]:
|
|
319
|
+
"""Read ``--flag value`` pairs; a flag that takes no value maps to the empty string."""
|
|
320
|
+
found: dict[str, str] = {}
|
|
321
|
+
index = 0
|
|
322
|
+
while index < len(words):
|
|
323
|
+
word = words[index]
|
|
324
|
+
if word not in _FLAGS:
|
|
325
|
+
raise ValueError(f"Unknown option {word!r}\nUsage: {USAGE}")
|
|
326
|
+
if word in found:
|
|
327
|
+
raise ValueError(f"{word} was given twice")
|
|
328
|
+
if _FLAGS[word]:
|
|
329
|
+
if index + 1 >= len(words) or words[index + 1].startswith("--"):
|
|
330
|
+
raise ValueError(f"{word} needs a value\nUsage: {USAGE}")
|
|
331
|
+
found[word] = words[index + 1]
|
|
332
|
+
index += 2
|
|
333
|
+
else:
|
|
334
|
+
found[word] = ""
|
|
335
|
+
index += 1
|
|
336
|
+
return found
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def describe(entry: Mapping[str, object]) -> str:
|
|
340
|
+
"""Return one line saying what an entry is, with the token never shown."""
|
|
341
|
+
api = Api.ANTHROPIC if entry.get("anthropic") else Api.RESPONSES if entry.get("responses_api") else Api.CHAT
|
|
342
|
+
url = entry.get("base_url") or "default address"
|
|
343
|
+
return f"{entry.get('display_model') or entry['model']} \u00b7 {entry['model']} \u00b7 {api} \u00b7 {url}"
|
zettcode/app/ui/demo.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Watch a turn's waiting row sweep: ``make demo-thinking``.
|
|
2
|
+
|
|
3
|
+
The sweep normally belongs to a live request, where it is easy to miss: the
|
|
4
|
+
first token can arrive before the eye reaches the row. This module mounts the
|
|
5
|
+
same machinery in a throwaway application and walks one turn after another —
|
|
6
|
+
wait, then thinking, then a tool call — so the one row that moves can be watched
|
|
7
|
+
beside the rows that stay still.
|
|
8
|
+
|
|
9
|
+
It is a demo, not a second entry point: nothing here is imported by the shell.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
|
|
17
|
+
from zettcode.presenters import pulse as pulse_module
|
|
18
|
+
from zettcode.tui import DARK, ELLIPSIS, SEPARATOR, AnyEvent, Host, Slot, Text, TuiApp, VBox, Widget, run_app
|
|
19
|
+
from zettcode.tui.widgets import Rule
|
|
20
|
+
|
|
21
|
+
from ..agent.conversation.transcript import Transcript
|
|
22
|
+
from .widgets import TranscriptView
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def hint() -> str:
|
|
26
|
+
"""Return the key hint, including the pace one pass takes."""
|
|
27
|
+
seconds = pulse_module.move_frames() * pulse_module.ANIMATION_SECONDS
|
|
28
|
+
return f" q / Esc quit {SEPARATOR} [ slower {SEPARATOR} ] faster {SEPARATOR} one pass per {seconds:.1f} s"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def frames(seconds: float) -> int:
|
|
32
|
+
"""Return how many animation steps a stretch of seconds is.
|
|
33
|
+
|
|
34
|
+
The pace is a period, so the keys and the limits are written in seconds and turned
|
|
35
|
+
into steps here: a step is a hundred-and-twentieth of a second, and "five of them"
|
|
36
|
+
would be a twitch of the dial, not a notch.
|
|
37
|
+
"""
|
|
38
|
+
return round(seconds / pulse_module.ANIMATION_SECONDS)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
#: How much one keypress changes the pulse by.
|
|
42
|
+
PACE_STEP_SECONDS = 0.5
|
|
43
|
+
|
|
44
|
+
#: The slowest and fastest pulse the keys reach: "too fast to follow" to "almost
|
|
45
|
+
#: stationary".
|
|
46
|
+
PACE_SECONDS = (1.0, 5.0)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def nudge_pace(delta: int) -> None:
|
|
50
|
+
"""Change the pulse pace, so the effect can be judged without a rebuild.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
delta: Animation steps to add to one pulse; the pace is kept between
|
|
54
|
+
:data:`PACE_SECONDS`.
|
|
55
|
+
"""
|
|
56
|
+
shortest, longest = frames(PACE_SECONDS[0]), frames(PACE_SECONDS[1])
|
|
57
|
+
pulse_module.PULSE_FRAMES = min(longest, max(shortest, pulse_module.PULSE_FRAMES + delta))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Blinking(Widget):
|
|
61
|
+
"""Root that keeps frames coming and moves the transcript's animation on.
|
|
62
|
+
|
|
63
|
+
The real shell does this from its own root while a request runs; the demo
|
|
64
|
+
needs it because no request ever ends here.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, transcript: Transcript, body: Widget) -> None:
|
|
68
|
+
"""Keep the transcript to advance and the tree to paint."""
|
|
69
|
+
super().__init__()
|
|
70
|
+
self.transcript = transcript
|
|
71
|
+
self.body = body
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def children(self) -> tuple[Widget, ...]:
|
|
75
|
+
"""Expose the body so it lays out and paints."""
|
|
76
|
+
return (self.body,)
|
|
77
|
+
|
|
78
|
+
def layout(self, rect) -> None:
|
|
79
|
+
"""Give the body the full rectangle."""
|
|
80
|
+
super().layout(rect)
|
|
81
|
+
self.body.layout(rect)
|
|
82
|
+
|
|
83
|
+
def render(self, canvas) -> None:
|
|
84
|
+
"""Paint the body into the shared canvas."""
|
|
85
|
+
self.body.render(canvas)
|
|
86
|
+
|
|
87
|
+
def cursor(self):
|
|
88
|
+
"""Forward the cursor request to the body."""
|
|
89
|
+
return self.body.cursor()
|
|
90
|
+
|
|
91
|
+
def on_tick(self) -> None:
|
|
92
|
+
"""Ask for the next frame, then advance the animation.
|
|
93
|
+
|
|
94
|
+
Re-registering the animation token on every tick is what keeps the loop
|
|
95
|
+
alive: the scheduler stops waiting once nothing is animating, and unlike
|
|
96
|
+
a request there is nothing here that would release it later.
|
|
97
|
+
"""
|
|
98
|
+
if self.app is not None:
|
|
99
|
+
self.app.scheduler.animate("blink")
|
|
100
|
+
self.transcript.advance_frame()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _quit(event: AnyEvent, host: Host) -> bool:
|
|
104
|
+
"""Leave the demo."""
|
|
105
|
+
host.exit()
|
|
106
|
+
return True
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _pace(delta: int) -> Callable[[AnyEvent, Host], bool]:
|
|
110
|
+
"""Return a binding that nudges the sweep period by ``delta`` frames."""
|
|
111
|
+
|
|
112
|
+
def run(event: AnyEvent, host: Host) -> bool:
|
|
113
|
+
nudge_pace(delta)
|
|
114
|
+
host.invalidate()
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
return run
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def build(transcript: Transcript | None = None) -> TuiApp:
|
|
121
|
+
"""Build the demo application around an empty transcript."""
|
|
122
|
+
transcript = Transcript() if transcript is None else transcript
|
|
123
|
+
body = VBox(
|
|
124
|
+
[
|
|
125
|
+
Slot(Rule(), size=1),
|
|
126
|
+
Slot(TranscriptView(transcript, theme=DARK), flex=1),
|
|
127
|
+
Slot(Rule(), size=1),
|
|
128
|
+
Slot(Text(hint, muted=True), size=1),
|
|
129
|
+
]
|
|
130
|
+
)
|
|
131
|
+
app = TuiApp(Blinking(transcript, body), theme=DARK)
|
|
132
|
+
app.commands.add("quit", _quit)
|
|
133
|
+
app.commands.add("slower", _pace(frames(PACE_STEP_SECONDS)))
|
|
134
|
+
app.commands.add("faster", _pace(-frames(PACE_STEP_SECONDS)))
|
|
135
|
+
for key in ("q", "escape", "ctrl_c"):
|
|
136
|
+
app.keymap.bind(key, "quit", priority="capture")
|
|
137
|
+
app.keymap.bind("[", "slower", priority="capture")
|
|
138
|
+
app.keymap.bind("]", "faster", priority="capture")
|
|
139
|
+
return app
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def script(transcript: Transcript) -> None:
|
|
143
|
+
"""Walk one turn after another so each running row can be watched.
|
|
144
|
+
|
|
145
|
+
The pauses are deliberately uneven: the wait is the shortest state, because
|
|
146
|
+
a real model usually answers before a thinking block appears.
|
|
147
|
+
"""
|
|
148
|
+
while True:
|
|
149
|
+
transcript.begin_turn("演示:请求已发出,等模型开口")
|
|
150
|
+
await asyncio.sleep(2.0)
|
|
151
|
+
transcript.start_thinking()
|
|
152
|
+
for line in (f"先看清问题{ELLIPSIS}", f"再看相关文件{ELLIPSIS}", f"最后给出答案{ELLIPSIS}"):
|
|
153
|
+
transcript.append_thinking(line)
|
|
154
|
+
await asyncio.sleep(1.2)
|
|
155
|
+
transcript.start_tool("call-1", "read_file", {"path": "src/zettcode/app/ui/app.py"})
|
|
156
|
+
await asyncio.sleep(2.0)
|
|
157
|
+
transcript.complete_tool("call-1", "def main() -> None:\n ...")
|
|
158
|
+
transcript.append_answer("演示到此结束,下一轮重新开始。\n")
|
|
159
|
+
await asyncio.sleep(2.0)
|
|
160
|
+
transcript.clear()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
async def _main() -> None:
|
|
164
|
+
"""Run the application and the scripting task together."""
|
|
165
|
+
transcript = Transcript()
|
|
166
|
+
turn = asyncio.create_task(script(transcript))
|
|
167
|
+
try:
|
|
168
|
+
await run_app(build(transcript))
|
|
169
|
+
finally:
|
|
170
|
+
turn.cancel()
|
|
171
|
+
await asyncio.gather(turn, return_exceptions=True)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def main() -> None:
|
|
175
|
+
"""Console entry point for ``make demo-thinking``."""
|
|
176
|
+
asyncio.run(_main())
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
main()
|