zettcode-gateway 0.3.4__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/gateway/__init__.py +22 -0
- zettcode/gateway/app.py +454 -0
- zettcode/gateway/backend.py +368 -0
- zettcode/gateway/chat.py +142 -0
- zettcode/gateway/cli.py +495 -0
- zettcode/gateway/py.typed +0 -0
- zettcode/gateway/runfile.py +145 -0
- zettcode/gateway/service.py +1219 -0
- zettcode/gateway/settings.py +62 -0
- zettcode/gateway/static/app.css +1434 -0
- zettcode/gateway/static/app.js +3 -0
- zettcode/gateway/static/assets/KaTeX_AMS-Regular-U6PRYMIZ.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Caligraphic-Bold-5QL5CMTE.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Caligraphic-Regular-KX5MEWCF.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Fraktur-Bold-2QVFK6NQ.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Fraktur-Regular-2PEIFJSJ.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Main-Bold-YP5VVQRP.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Main-BoldItalic-N4V3DX7S.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Main-Italic-RELBIK7M.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Main-Regular-ARRPAO67.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Math-BoldItalic-K4WTGH3J.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Math-Italic-6KGCHLFN.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_SansSerif-Bold-RRNVJFFW.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_SansSerif-Italic-HMPFTM52.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_SansSerif-Regular-XIQ62X4E.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Script-Regular-APUWIHLP.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Size1-Regular-5LRUTBFT.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Size2-Regular-LELKET5D.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Size3-Regular-WQRQ47UD.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Size4-Regular-CDMV7U5C.woff2 +0 -0
- zettcode/gateway/static/assets/KaTeX_Typewriter-Regular-VBYJ4NRC.woff2 +0 -0
- zettcode/gateway/static/chunks/chunk-Y6SLVHK3.js +1 -0
- zettcode/gateway/static/chunks/render-G6G3TSB3.js +341 -0
- zettcode/gateway/static/icon.png +0 -0
- zettcode/gateway/static/index.html +23 -0
- zettcode/gateway/static/logo.svg +205 -0
- zettcode/gateway/static/manifest.webmanifest +11 -0
- zettcode_gateway-0.3.4.dist-info/METADATA +21 -0
- zettcode_gateway-0.3.4.dist-info/RECORD +40 -0
- zettcode_gateway-0.3.4.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,1219 @@
|
|
|
1
|
+
"""The gateway: the threads a page lists, what is sent and said in them, and the events that keep the page current.
|
|
2
|
+
|
|
3
|
+
A *thread* is one session as the page shows it: a daemon this machine runs, another session that is registered with
|
|
4
|
+
the broker, or a sender that once wrote to us. Everything about a thread comes from the SDK through :class:`.backend.Backend`
|
|
5
|
+
and nothing is stored here but what a page would otherwise forget: which of a thread's messages have been read.
|
|
6
|
+
|
|
7
|
+
Where the words are. A session's side of the conversation is in its stored conversation (``Backend.history``), and
|
|
8
|
+
that is what a thread shows, because a daemon does not always send what it concludes: its last answer may only be in
|
|
9
|
+
its own conversation. The broker's part is the control plane -- whether a message was taken, who is there, that
|
|
10
|
+
something arrived -- and the message a session sends us is shown from the stored conversation too, once, and not twice.
|
|
11
|
+
Only a sender with no stored conversation (a program that registered as external) is shown from what reached us.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import base64
|
|
18
|
+
import contextlib
|
|
19
|
+
import itertools
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import time
|
|
25
|
+
import unicodedata
|
|
26
|
+
from collections import deque
|
|
27
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from zettcode.sdk.errors import (
|
|
33
|
+
ConfigurationError,
|
|
34
|
+
DaemonStartError,
|
|
35
|
+
SendError,
|
|
36
|
+
SessionLockedError,
|
|
37
|
+
SessionNotFoundError,
|
|
38
|
+
ZettCodeError,
|
|
39
|
+
)
|
|
40
|
+
from zettcode.sdk.types import (
|
|
41
|
+
IMAGE_TYPES,
|
|
42
|
+
DaemonInfo,
|
|
43
|
+
Image,
|
|
44
|
+
IncomingMessage,
|
|
45
|
+
Message,
|
|
46
|
+
ModelInfo,
|
|
47
|
+
Peer,
|
|
48
|
+
Receipt,
|
|
49
|
+
SendLimits,
|
|
50
|
+
StreamEvent,
|
|
51
|
+
is_direct_notice,
|
|
52
|
+
)
|
|
53
|
+
from zettcode.version import __version__
|
|
54
|
+
|
|
55
|
+
from .backend import Backend
|
|
56
|
+
from .chat import items_of, preview, spoken, with_reply_request
|
|
57
|
+
from .chat import picture_id as picture_name
|
|
58
|
+
from .settings import Settings
|
|
59
|
+
|
|
60
|
+
#: Seconds between two looks at the daemons, the roster and the stored conversations when nothing woke the watcher.
|
|
61
|
+
POLL_SECONDS = 1.5
|
|
62
|
+
|
|
63
|
+
#: How long a probe of "does another process have this session open" is kept before it is asked again.
|
|
64
|
+
LOCK_PROBE_SECONDS = 5.0
|
|
65
|
+
|
|
66
|
+
#: The most of one turn's answer kept to show a page that opens in the middle of it, and how many tool calls.
|
|
67
|
+
LIVE_TEXT_LIMIT = 200_000
|
|
68
|
+
LIVE_TOOL_LIMIT = 40
|
|
69
|
+
|
|
70
|
+
#: A turn that has said nothing for this long while its session says it is idle is taken to have ended unseen.
|
|
71
|
+
LIVE_STALE_SECONDS = 4.0
|
|
72
|
+
|
|
73
|
+
#: How many notes about a session's failed direct turns a chat keeps.
|
|
74
|
+
NOTE_LIMIT = 4
|
|
75
|
+
|
|
76
|
+
#: How many chats' pictures are kept in memory for the page to fetch one by one, and the most bytes of pictures one chat
|
|
77
|
+
#: may have kept; a chat with more keeps only their names, and its bytes are read when they are asked for.
|
|
78
|
+
PICTURE_CHATS = 4
|
|
79
|
+
PICTURE_BYTES = 32 << 20
|
|
80
|
+
|
|
81
|
+
#: What a picture's name looks like (see :func:`~.chat.picture_id`): anything else is not one, and nothing is read for it.
|
|
82
|
+
PICTURE_NAME = re.compile(r"^[0-9a-f]{20}$")
|
|
83
|
+
|
|
84
|
+
#: The most messages kept from senders that have no stored conversation, per sender.
|
|
85
|
+
INBOX_LIMIT = 200
|
|
86
|
+
|
|
87
|
+
#: The most items a thread returns unless asked for all of them.
|
|
88
|
+
ITEM_LIMIT = 400
|
|
89
|
+
|
|
90
|
+
#: The longest a daemon's label may be.
|
|
91
|
+
NAME_LIMIT = 40
|
|
92
|
+
|
|
93
|
+
#: A session id as the store writes them.
|
|
94
|
+
SESSION_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
|
95
|
+
|
|
96
|
+
#: The HTTP status of each reason a send can fail for; anything else is the sender's mistake.
|
|
97
|
+
SEND_STATUS = {
|
|
98
|
+
"offline": 503,
|
|
99
|
+
"unknown": 404,
|
|
100
|
+
"ambiguous": 409,
|
|
101
|
+
"gone": 410,
|
|
102
|
+
"busy": 503,
|
|
103
|
+
"rejected": 409,
|
|
104
|
+
"rate": 429,
|
|
105
|
+
"too_long": 413,
|
|
106
|
+
"empty": 400,
|
|
107
|
+
"timeout": 504,
|
|
108
|
+
"lost": 504,
|
|
109
|
+
"loop": 422,
|
|
110
|
+
"invalid": 400,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def read_pictures(raw: object, limits: SendLimits) -> list[Image]:
|
|
115
|
+
"""Read the pictures a page sent (``[{media_type, data}]``, the file in base64) and refuse what the broker would.
|
|
116
|
+
|
|
117
|
+
Checked here so that a page is told at once and in its own terms: how many, how large, and of a type a model takes.
|
|
118
|
+
That what a file says it is matches what it is, the broker decides.
|
|
119
|
+
"""
|
|
120
|
+
if raw is None:
|
|
121
|
+
return []
|
|
122
|
+
if not isinstance(raw, list):
|
|
123
|
+
raise GatewayError(400, "invalid", "images is a list")
|
|
124
|
+
if len(raw) > limits.max_images:
|
|
125
|
+
raise GatewayError(413, "too_long", f"At most {limits.max_images} pictures go with one message")
|
|
126
|
+
pictures: list[Image] = []
|
|
127
|
+
total = 0
|
|
128
|
+
for item in raw:
|
|
129
|
+
if not isinstance(item, Mapping) or not isinstance(item.get("data"), str):
|
|
130
|
+
raise GatewayError(400, "invalid", "A picture is {media_type, data}")
|
|
131
|
+
kind = item.get("media_type")
|
|
132
|
+
if not isinstance(kind, str) or kind not in IMAGE_TYPES:
|
|
133
|
+
raise GatewayError(400, "invalid", f"A picture is one of: {', '.join(sorted(IMAGE_TYPES))}")
|
|
134
|
+
# Before it is decoded: a file that is too large is turned away for its length, not after it is all in memory.
|
|
135
|
+
if len(item["data"]) > (limits.max_image_bytes + 2) // 3 * 4:
|
|
136
|
+
raise GatewayError(413, "too_long", f"A picture is limited to {limits.max_image_bytes >> 20} MiB")
|
|
137
|
+
try:
|
|
138
|
+
data = base64.b64decode(item["data"], validate=True)
|
|
139
|
+
picture = Image(data, kind)
|
|
140
|
+
except ValueError as error:
|
|
141
|
+
raise GatewayError(400, "invalid", f"A picture could not be read: {error}") from None
|
|
142
|
+
if len(data) > limits.max_image_bytes:
|
|
143
|
+
raise GatewayError(413, "too_long", f"A picture is limited to {limits.max_image_bytes >> 20} MiB")
|
|
144
|
+
total += len(data)
|
|
145
|
+
if total > limits.max_images_bytes:
|
|
146
|
+
raise GatewayError(
|
|
147
|
+
413,
|
|
148
|
+
"too_long",
|
|
149
|
+
f"The pictures of a message are limited to {limits.max_images_bytes >> 20} MiB together",
|
|
150
|
+
)
|
|
151
|
+
pictures.append(picture)
|
|
152
|
+
return pictures
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
logger = logging.getLogger("zettcode.gateway")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class GatewayError(Exception):
|
|
159
|
+
"""A request the gateway refuses, as a status, a code a page can branch on and a sentence for a person."""
|
|
160
|
+
|
|
161
|
+
def __init__(self, status: int, code: str, message: str, **extra: Any) -> None:
|
|
162
|
+
super().__init__(message)
|
|
163
|
+
self.status = status
|
|
164
|
+
self.code = code
|
|
165
|
+
self.extra = extra
|
|
166
|
+
|
|
167
|
+
def to_dict(self) -> dict[str, Any]:
|
|
168
|
+
"""Return the body the page receives."""
|
|
169
|
+
return {"error": {"code": self.code, "message": str(self), **self.extra}}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
#: The keys of a model entry the page may write, each with the type it must have. The token is among them but is only
|
|
173
|
+
#: ever written: no response carries it back.
|
|
174
|
+
MODEL_FIELDS: dict[str, tuple[type, ...]] = {
|
|
175
|
+
"model": (str,),
|
|
176
|
+
"token": (str,),
|
|
177
|
+
"display_model": (str,),
|
|
178
|
+
"base_url": (str,),
|
|
179
|
+
"anthropic": (bool,),
|
|
180
|
+
"responses_api": (bool,),
|
|
181
|
+
"chat": (bool,),
|
|
182
|
+
"multimodal": (bool,),
|
|
183
|
+
"temperature": (int, float),
|
|
184
|
+
"web_search": (bool,),
|
|
185
|
+
"web_search_name": (str,),
|
|
186
|
+
"context_window": (int,),
|
|
187
|
+
"compact_percent": (int, float),
|
|
188
|
+
"reasoning_efforts": (list,),
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def model_fields(data: Mapping[str, Any], *, creating: bool) -> dict[str, object]:
|
|
193
|
+
"""Return the keys of a model entry a request names, refusing a key or a type the file does not have.
|
|
194
|
+
|
|
195
|
+
When creating, a blank string or an unset flag is left out, so the file holds only what was chosen. When changing,
|
|
196
|
+
``None`` stays: it means remove the key, which is how a temperature goes back to the provider's own. A blank
|
|
197
|
+
token is dropped in both cases, because the page never shows the stored one and an empty box means keep it.
|
|
198
|
+
"""
|
|
199
|
+
out: dict[str, object] = {}
|
|
200
|
+
for key, value in data.items():
|
|
201
|
+
kinds = MODEL_FIELDS.get(key)
|
|
202
|
+
if kinds is None:
|
|
203
|
+
raise GatewayError(400, "invalid", f"{key} is not a setting of a model")
|
|
204
|
+
if value is None:
|
|
205
|
+
if key in ("model", "token"):
|
|
206
|
+
continue
|
|
207
|
+
if not creating:
|
|
208
|
+
out[key] = None
|
|
209
|
+
continue
|
|
210
|
+
if isinstance(value, bool) != (bool in kinds) or not isinstance(value, kinds):
|
|
211
|
+
raise GatewayError(400, "invalid", f"{key} has the wrong type")
|
|
212
|
+
if isinstance(value, str):
|
|
213
|
+
value = value.strip()
|
|
214
|
+
if key == "token" and not value:
|
|
215
|
+
continue
|
|
216
|
+
if not value:
|
|
217
|
+
if creating:
|
|
218
|
+
continue
|
|
219
|
+
if key == "model":
|
|
220
|
+
raise GatewayError(400, "invalid", "The model id cannot be empty")
|
|
221
|
+
out[key] = None
|
|
222
|
+
continue
|
|
223
|
+
if creating and value is False:
|
|
224
|
+
continue
|
|
225
|
+
if key == "reasoning_efforts":
|
|
226
|
+
if not all(isinstance(level, str) for level in value):
|
|
227
|
+
raise GatewayError(400, "invalid", "reasoning_efforts is a list of names")
|
|
228
|
+
if creating and not value:
|
|
229
|
+
continue
|
|
230
|
+
out[key] = value
|
|
231
|
+
if creating and not out.get("model"):
|
|
232
|
+
raise GatewayError(400, "invalid", "A model needs the model id the endpoint expects")
|
|
233
|
+
return out
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def model_view(model: ModelInfo, index: int) -> dict[str, Any]:
|
|
237
|
+
"""Return one model as a page shows it. The token is never part of it: the file keeps it, the page only writes it."""
|
|
238
|
+
return {
|
|
239
|
+
"name": model.name,
|
|
240
|
+
"model": model.model,
|
|
241
|
+
"display_model": model.display_model,
|
|
242
|
+
"default": index == 0,
|
|
243
|
+
"context_window": model.context_window,
|
|
244
|
+
"compact_percent": model.compact_percent,
|
|
245
|
+
"multimodal": model.multimodal,
|
|
246
|
+
"efforts": list(model.efforts),
|
|
247
|
+
"api": model.api,
|
|
248
|
+
"base_url": model.base_url,
|
|
249
|
+
"temperature": model.temperature,
|
|
250
|
+
"web_search": model.web_search,
|
|
251
|
+
"web_search_name": model.web_search_name,
|
|
252
|
+
"can_search": model.can_search,
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@dataclass(slots=True)
|
|
257
|
+
class Meta:
|
|
258
|
+
"""What was learned from a thread's stored conversation the last time it changed."""
|
|
259
|
+
|
|
260
|
+
stamp: tuple[int, int] | None = None
|
|
261
|
+
preview: str = ""
|
|
262
|
+
spoken: int = 0
|
|
263
|
+
updated: float = 0.0
|
|
264
|
+
opened: bool = False
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class Gateway:
|
|
268
|
+
"""The gateway's state and its rules. One per process."""
|
|
269
|
+
|
|
270
|
+
def __init__(
|
|
271
|
+
self,
|
|
272
|
+
backend: Backend,
|
|
273
|
+
settings: Settings,
|
|
274
|
+
*,
|
|
275
|
+
poll: float = POLL_SECONDS,
|
|
276
|
+
clock: Any = time.time,
|
|
277
|
+
) -> None:
|
|
278
|
+
"""Hold a backend; nothing is read until :meth:`start`."""
|
|
279
|
+
self.backend = backend
|
|
280
|
+
self.settings = settings
|
|
281
|
+
self.poll = poll
|
|
282
|
+
self.clock = clock
|
|
283
|
+
self._probes: dict[str, tuple[float, bool]] = {}
|
|
284
|
+
#: What the turn running in each session has said so far, by session: for a page that opens in the middle of it.
|
|
285
|
+
self._live: dict[str, dict[str, Any]] = {}
|
|
286
|
+
self._finishing: set[asyncio.Task[None]] = set()
|
|
287
|
+
self._turns = itertools.count(1)
|
|
288
|
+
#: What a session said when a direct turn of ours ended without an answer: shown in its chat until we write again.
|
|
289
|
+
self._notes: dict[str, list[str]] = {}
|
|
290
|
+
#: The pictures of the chats last read, by chat and then by name, oldest chat first.
|
|
291
|
+
self._pictures: dict[str, dict[str, tuple[bytes | None, str]]] = {}
|
|
292
|
+
#: The sessions we asked to see the turns of, so one that is no longer listed can be let go of.
|
|
293
|
+
self._watching: set[str] = set()
|
|
294
|
+
self.roots = tuple(root.expanduser().resolve() for root in settings.roots)
|
|
295
|
+
self._views: dict[str, dict[str, Any]] = {}
|
|
296
|
+
self._meta: dict[str, Meta] = {}
|
|
297
|
+
self._inbox: dict[str, deque[dict[str, Any]]] = {}
|
|
298
|
+
self._senders: dict[str, str] = {}
|
|
299
|
+
self._seen: dict[str, int] = self._load_seen()
|
|
300
|
+
self._created: set[str] = set()
|
|
301
|
+
self._sent: deque[float] = deque()
|
|
302
|
+
self._subscribers: set[asyncio.Queue[dict[str, Any]]] = set()
|
|
303
|
+
self._lock = asyncio.Lock()
|
|
304
|
+
#: Serialises model edits: each is a read-modify-write of one file, so two at once would drop one of them.
|
|
305
|
+
self._models_lock = asyncio.Lock()
|
|
306
|
+
self._wake = asyncio.Event()
|
|
307
|
+
self._watcher: asyncio.Task[None] | None = None
|
|
308
|
+
self._reachable: bool | None = None
|
|
309
|
+
#: The last failed refresh, so a permanent fault does not append a
|
|
310
|
+
#: traceback every poll interval.
|
|
311
|
+
self._refresh_error: str | None = None
|
|
312
|
+
|
|
313
|
+
# -- life -------------------------------------------------------------------------------------
|
|
314
|
+
async def start(self, *, watch: bool = True) -> None:
|
|
315
|
+
"""Join the broker and begin watching; a test that wants to say when the gateway looks passes ``watch=False``."""
|
|
316
|
+
await self.backend.start(self._received, self._wake_up)
|
|
317
|
+
self.backend.on_stream(self._streamed)
|
|
318
|
+
self.backend.observe(self._traffic)
|
|
319
|
+
await self.refresh()
|
|
320
|
+
if watch:
|
|
321
|
+
self._watcher = asyncio.ensure_future(self._watch())
|
|
322
|
+
|
|
323
|
+
async def close(self) -> None:
|
|
324
|
+
"""Stop watching and leave the broker."""
|
|
325
|
+
self.backend.observe(None)
|
|
326
|
+
finishing = list(self._finishing)
|
|
327
|
+
for task in finishing:
|
|
328
|
+
task.cancel()
|
|
329
|
+
await asyncio.gather(*finishing, return_exceptions=True)
|
|
330
|
+
watcher, self._watcher = self._watcher, None
|
|
331
|
+
if watcher is not None:
|
|
332
|
+
watcher.cancel()
|
|
333
|
+
await asyncio.gather(watcher, return_exceptions=True)
|
|
334
|
+
await self.backend.close()
|
|
335
|
+
|
|
336
|
+
def _wake_up(self) -> None:
|
|
337
|
+
self._wake.set()
|
|
338
|
+
|
|
339
|
+
async def _watch(self) -> None:
|
|
340
|
+
while True:
|
|
341
|
+
await self._poll_once()
|
|
342
|
+
with contextlib.suppress(asyncio.TimeoutError):
|
|
343
|
+
await asyncio.wait_for(self._wake.wait(), self.poll)
|
|
344
|
+
self._wake.clear()
|
|
345
|
+
await asyncio.sleep(0.05)
|
|
346
|
+
|
|
347
|
+
async def _poll_once(self) -> None:
|
|
348
|
+
"""Take one refresh step, recording only a failure transition and its recovery."""
|
|
349
|
+
try:
|
|
350
|
+
await self.refresh()
|
|
351
|
+
except asyncio.CancelledError:
|
|
352
|
+
raise
|
|
353
|
+
except Exception as error: # noqa: BLE001 - a look that fails is tried again, and the page keeps what it has
|
|
354
|
+
try:
|
|
355
|
+
reason = f"{type(error).__name__}: {error}"
|
|
356
|
+
except Exception: # noqa: BLE001 - an unprintable error still has a type
|
|
357
|
+
reason = type(error).__name__
|
|
358
|
+
if reason != self._refresh_error:
|
|
359
|
+
logger.exception("refresh.failed")
|
|
360
|
+
self._refresh_error = reason
|
|
361
|
+
else:
|
|
362
|
+
if self._refresh_error is not None:
|
|
363
|
+
logger.info("refresh.recovered")
|
|
364
|
+
self._refresh_error = None
|
|
365
|
+
|
|
366
|
+
# -- what is there ----------------------------------------------------------------------------
|
|
367
|
+
async def refresh(self) -> None:
|
|
368
|
+
"""Look at the daemons, the roster and the stored conversations once, and tell the pages what moved."""
|
|
369
|
+
async with self._lock:
|
|
370
|
+
daemons = {item.session_id: item for item in await asyncio.to_thread(self.backend.daemons)}
|
|
371
|
+
mine = self.backend.gateway_id
|
|
372
|
+
peers = {item.session_id: item for item in self.backend.peers() if item.session_id != mine}
|
|
373
|
+
for session_id in peers:
|
|
374
|
+
if session_id not in self._watching:
|
|
375
|
+
self._watching.add(session_id)
|
|
376
|
+
self.backend.watch(session_id) # costs a session nothing until it works
|
|
377
|
+
for gone in self._watching - peers.keys():
|
|
378
|
+
self._watching.discard(gone)
|
|
379
|
+
self.backend.unwatch(gone) # every run is a new id: they would add up, and the broker caps them
|
|
380
|
+
order = list(dict.fromkeys([*daemons, *peers, *self._inbox]))
|
|
381
|
+
views: dict[str, dict[str, Any]] = {}
|
|
382
|
+
moved: list[str] = []
|
|
383
|
+
for session_id in order:
|
|
384
|
+
daemon, peer = daemons.get(session_id), peers.get(session_id)
|
|
385
|
+
meta = self._meta.setdefault(session_id, Meta())
|
|
386
|
+
workspace = self._workspace_of(daemon, peer)
|
|
387
|
+
stamp = self.backend.stamp(session_id, workspace) if workspace else None
|
|
388
|
+
if stamp != meta.stamp:
|
|
389
|
+
# A session that is working changes its file all the time and is read when it goes quiet, so a
|
|
390
|
+
# long conversation is not parsed every look; one nobody is looking at is not read at all.
|
|
391
|
+
# A daemon is only different when nobody is running it: its record outlives its process, so a
|
|
392
|
+
# dead or offline daemon still has a conversation to show, while a working one is a peer.
|
|
393
|
+
working = peer is not None and peer.status == "working"
|
|
394
|
+
reading = meta.opened or peer is None or not working
|
|
395
|
+
if reading:
|
|
396
|
+
await self._learn(session_id, workspace, stamp, meta, wanted=True)
|
|
397
|
+
moved.append(session_id)
|
|
398
|
+
views[session_id] = self._view(session_id, daemon, peer, meta)
|
|
399
|
+
expired = self._expire_live(peers)
|
|
400
|
+
for gone in {*self._meta, *self._probes, *self._notes} - views.keys():
|
|
401
|
+
self._meta.pop(gone, None)
|
|
402
|
+
self._probes.pop(gone, None)
|
|
403
|
+
self._notes.pop(gone, None)
|
|
404
|
+
reachable = self.backend.reachable
|
|
405
|
+
changed = views != self._views
|
|
406
|
+
self._views = views
|
|
407
|
+
if changed or reachable != self._reachable:
|
|
408
|
+
if reachable and self._reachable is False:
|
|
409
|
+
# Pieces of every turn were missed while the broker was away: a bubble resumed mid-answer would be a
|
|
410
|
+
# truncated one, so what is shown live is dropped and the next ``begin`` starts it again.
|
|
411
|
+
for lost in list(self._live):
|
|
412
|
+
del self._live[lost]
|
|
413
|
+
self._publish({"type": "stream", "id": lost, "event": {"kind": "end"}})
|
|
414
|
+
self._reachable = reachable
|
|
415
|
+
self._publish({"type": "threads", "threads": self.threads(), "status": self.status()})
|
|
416
|
+
for session_id in moved:
|
|
417
|
+
self._publish({"type": "thread", "id": session_id})
|
|
418
|
+
# After what was stored has been announced, as at the end of a turn that was heard: the page has the message
|
|
419
|
+
# before the live one goes.
|
|
420
|
+
for session_id in expired:
|
|
421
|
+
self._publish({"type": "stream", "id": session_id, "event": {"kind": "end"}})
|
|
422
|
+
|
|
423
|
+
@staticmethod
|
|
424
|
+
def _workspace_of(daemon: DaemonInfo | None, peer: Peer | None) -> str:
|
|
425
|
+
if daemon is not None and daemon.workspace:
|
|
426
|
+
return daemon.workspace
|
|
427
|
+
return peer.workspace if peer is not None else ""
|
|
428
|
+
|
|
429
|
+
async def _learn(
|
|
430
|
+
self, session_id: str, workspace: str, stamp: tuple[int, int] | None, meta: Meta, *, wanted: bool
|
|
431
|
+
) -> None:
|
|
432
|
+
"""Take in a thread's stored conversation: its last words and how much it has said to us."""
|
|
433
|
+
meta.stamp = stamp
|
|
434
|
+
meta.updated = stamp[0] / 1e9 if stamp else 0.0
|
|
435
|
+
if stamp is None or not wanted:
|
|
436
|
+
return
|
|
437
|
+
try:
|
|
438
|
+
messages = await self.backend.history(session_id, workspace)
|
|
439
|
+
except (SessionNotFoundError, ZettCodeError, OSError):
|
|
440
|
+
return
|
|
441
|
+
items = items_of(messages, self.backend.gateway_id, handle=self.backend.handle)
|
|
442
|
+
meta.preview = preview(items)
|
|
443
|
+
meta.spoken = spoken(items)
|
|
444
|
+
self._baseline(session_id, meta.spoken)
|
|
445
|
+
|
|
446
|
+
def _baseline(self, session_id: str, count: int) -> None:
|
|
447
|
+
"""Count what a thread said before the page first saw it as read, unless we started the thread ourselves."""
|
|
448
|
+
if session_id not in self._seen:
|
|
449
|
+
self._seen[session_id] = 0 if session_id in self._created else count
|
|
450
|
+
self._save_seen()
|
|
451
|
+
|
|
452
|
+
def _view(self, session_id: str, daemon: DaemonInfo | None, peer: Peer | None, meta: Meta) -> dict[str, Any]:
|
|
453
|
+
"""Return one thread as a page lists it."""
|
|
454
|
+
sender = self._senders.get(session_id, session_id[:8])
|
|
455
|
+
if daemon is not None:
|
|
456
|
+
kind = "daemon"
|
|
457
|
+
elif peer is not None:
|
|
458
|
+
kind = "external" if peer.kind == "external" else "session"
|
|
459
|
+
else:
|
|
460
|
+
kind = "external" if session_id.startswith("external:") else "session"
|
|
461
|
+
title = (daemon.title if daemon is not None else None) or (peer.title if peer is not None else "")
|
|
462
|
+
name = (daemon.name if daemon is not None else None) or title or (peer.handle if peer else "") or sender
|
|
463
|
+
stored = meta.stamp is not None
|
|
464
|
+
said = meta.spoken if stored else len(self._inbox.get(session_id, ()))
|
|
465
|
+
# A stored conversation that is new to us is read up to now (see ``_baseline``); what reached us in this run is
|
|
466
|
+
# all newer than the gateway.
|
|
467
|
+
unread = max(0, said - self._seen.get(session_id, said if stored else 0))
|
|
468
|
+
last = self._inbox[session_id][-1]["text"] if not stored and self._inbox.get(session_id) else ""
|
|
469
|
+
return {
|
|
470
|
+
"id": session_id,
|
|
471
|
+
"kind": kind,
|
|
472
|
+
"name": name,
|
|
473
|
+
"handle": peer.handle if peer is not None else "",
|
|
474
|
+
"title": title,
|
|
475
|
+
"workspace": self._workspace_of(daemon, peer),
|
|
476
|
+
"status": self._status(daemon, peer),
|
|
477
|
+
"online": peer is not None,
|
|
478
|
+
"model": daemon.model if daemon is not None else None,
|
|
479
|
+
"effort": daemon.effort if daemon is not None else None,
|
|
480
|
+
"permission": daemon.permission if daemon is not None else None,
|
|
481
|
+
"pid": daemon.pid if daemon is not None else 0,
|
|
482
|
+
"started_at": daemon.started_at if daemon is not None else 0.0,
|
|
483
|
+
"updated_at": meta.updated or (daemon.started_at if daemon is not None else 0.0),
|
|
484
|
+
"preview": meta.preview or _short(str(last)),
|
|
485
|
+
"unread": unread,
|
|
486
|
+
"direct": peer is not None and peer.direct,
|
|
487
|
+
"locked": peer is None and stored and self._locked(session_id, self._workspace_of(daemon, peer)),
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
def _locked(self, session_id: str, workspace: str) -> bool:
|
|
491
|
+
"""Return whether another process has an unreachable session open, asked at most every few seconds."""
|
|
492
|
+
now = self.clock()
|
|
493
|
+
at, held = self._probes.get(session_id, (0.0, False))
|
|
494
|
+
if now - at < LOCK_PROBE_SECONDS:
|
|
495
|
+
return held
|
|
496
|
+
try:
|
|
497
|
+
held = bool(workspace) and self.backend.locked(session_id, workspace)
|
|
498
|
+
except Exception: # noqa: BLE001 - a probe that fails says nothing about the session
|
|
499
|
+
held = False
|
|
500
|
+
self._probes[session_id] = (now, held)
|
|
501
|
+
return held
|
|
502
|
+
|
|
503
|
+
async def recheck(self, session_id: str) -> dict[str, Any]:
|
|
504
|
+
"""Ask again whether another process still has a session open, and return the thread as it is now."""
|
|
505
|
+
if session_id not in self._views:
|
|
506
|
+
raise GatewayError(404, "not_found", "No such session is listed")
|
|
507
|
+
self._probes.pop(session_id, None)
|
|
508
|
+
await self.refresh()
|
|
509
|
+
view = self._views.get(session_id) # it may have gone while we looked
|
|
510
|
+
if view is None:
|
|
511
|
+
raise GatewayError(404, "not_found", "No such session is listed")
|
|
512
|
+
return view
|
|
513
|
+
|
|
514
|
+
@staticmethod
|
|
515
|
+
def _status(daemon: DaemonInfo | None, peer: Peer | None) -> str:
|
|
516
|
+
"""Return what a thread is doing: the roster's word when it is registered, the daemon's own otherwise."""
|
|
517
|
+
if peer is not None:
|
|
518
|
+
return "stopping" if daemon is not None and daemon.status == "stopping" else peer.status
|
|
519
|
+
if daemon is None:
|
|
520
|
+
return "offline"
|
|
521
|
+
return daemon.status if daemon.status in ("starting", "stopping", "dead") else "offline"
|
|
522
|
+
|
|
523
|
+
# -- what a page asks -------------------------------------------------------------------------
|
|
524
|
+
def threads(self) -> list[dict[str, Any]]:
|
|
525
|
+
"""Return the threads in two groups, the online ones first, each with the most recently active first."""
|
|
526
|
+
return sorted(self._views.values(), key=lambda view: (not view["online"], -float(view["updated_at"])))
|
|
527
|
+
|
|
528
|
+
def status(self) -> dict[str, Any]:
|
|
529
|
+
"""Return the gateway itself: who it is, whether the broker is there, and how much it may still send."""
|
|
530
|
+
limits = self.backend.limits
|
|
531
|
+
used = self._spent(limits.rate_window)
|
|
532
|
+
return {
|
|
533
|
+
"version": __version__,
|
|
534
|
+
"gateway": {"id": self.backend.gateway_id, "handle": self.backend.handle},
|
|
535
|
+
"broker": {"reachable": self.backend.reachable},
|
|
536
|
+
"limits": {
|
|
537
|
+
"max_text": limits.max_text,
|
|
538
|
+
"rate_limit": limits.rate_limit,
|
|
539
|
+
"rate_window": limits.rate_window,
|
|
540
|
+
"max_images": limits.max_images,
|
|
541
|
+
"max_image_bytes": limits.max_image_bytes,
|
|
542
|
+
"max_images_bytes": limits.max_images_bytes,
|
|
543
|
+
"used": used,
|
|
544
|
+
},
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
def config(self) -> dict[str, Any]:
|
|
548
|
+
"""Return what the new-daemon form and the settings pages need to know."""
|
|
549
|
+
try:
|
|
550
|
+
models = self.backend.models()
|
|
551
|
+
except ValueError as error:
|
|
552
|
+
raise GatewayError(500, "config", str(error)) from error
|
|
553
|
+
return {
|
|
554
|
+
"version": __version__,
|
|
555
|
+
"roots": [str(root) for root in self.roots],
|
|
556
|
+
"allow_full": self.settings.allow_full,
|
|
557
|
+
"permissions": ["deny", "safe", "full"],
|
|
558
|
+
"models": [model_view(model, index) for index, model in enumerate(models)],
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async def add_model(self, data: Mapping[str, Any]) -> dict[str, Any]:
|
|
562
|
+
"""Write a new model into the configuration file and return the list."""
|
|
563
|
+
entry = model_fields(data, creating=True)
|
|
564
|
+
return await self._edit_models(lambda: self.backend.add_model(entry))
|
|
565
|
+
|
|
566
|
+
async def change_model(self, name: str, data: Mapping[str, Any]) -> dict[str, Any]:
|
|
567
|
+
"""Change one model of the configuration file; what is not named stays, and so does the token."""
|
|
568
|
+
changes = model_fields(data, creating=False)
|
|
569
|
+
if not changes:
|
|
570
|
+
raise GatewayError(400, "invalid", "Name at least one thing to change")
|
|
571
|
+
return await self._edit_models(lambda: self.backend.update_model(name, changes))
|
|
572
|
+
|
|
573
|
+
async def remove_model(self, name: str) -> dict[str, Any]:
|
|
574
|
+
"""Remove one model from the configuration file."""
|
|
575
|
+
return await self._edit_models(lambda: self.backend.remove_model(name))
|
|
576
|
+
|
|
577
|
+
async def _edit_models(self, edit: Callable[[], list[ModelInfo]]) -> dict[str, Any]:
|
|
578
|
+
"""Run a configuration edit off the loop and answer with the list it left.
|
|
579
|
+
|
|
580
|
+
A daemon reads the file when it starts, so a change reaches the next one and touches no running session. A
|
|
581
|
+
refusal is the file's own sentence, which names the wrong key, and the file is left as it was. Edits made
|
|
582
|
+
through this gateway run one at a time; one made by a terminal's ``/config`` at the same moment is not seen.
|
|
583
|
+
"""
|
|
584
|
+
try:
|
|
585
|
+
async with self._models_lock:
|
|
586
|
+
models = await asyncio.to_thread(edit)
|
|
587
|
+
except ValueError as error:
|
|
588
|
+
raise GatewayError(400, "invalid", str(error)) from error
|
|
589
|
+
except OSError as error:
|
|
590
|
+
raise GatewayError(500, "config", f"The configuration file could not be written: {error}") from error
|
|
591
|
+
return {"models": [model_view(model, index) for index, model in enumerate(models)]}
|
|
592
|
+
|
|
593
|
+
async def thread(self, session_id: str, *, everything: bool = False) -> dict[str, Any]:
|
|
594
|
+
"""Return a thread and its conversation as chat items."""
|
|
595
|
+
view = self._views.get(session_id)
|
|
596
|
+
if view is None:
|
|
597
|
+
await self.refresh()
|
|
598
|
+
view = self._views.get(session_id)
|
|
599
|
+
if view is None:
|
|
600
|
+
raise GatewayError(404, "not_found", "No such session is listed")
|
|
601
|
+
meta = self._meta.setdefault(session_id, Meta())
|
|
602
|
+
meta.opened = True
|
|
603
|
+
items: list[dict[str, Any]] = []
|
|
604
|
+
stored = False
|
|
605
|
+
if view["workspace"]:
|
|
606
|
+
try:
|
|
607
|
+
messages = await self.backend.history(session_id, view["workspace"])
|
|
608
|
+
except (SessionNotFoundError, ZettCodeError, OSError):
|
|
609
|
+
messages = []
|
|
610
|
+
else:
|
|
611
|
+
stored = True
|
|
612
|
+
self._keep_pictures(session_id, messages)
|
|
613
|
+
items = items_of(messages, self.backend.gateway_id, handle=self.backend.handle)
|
|
614
|
+
if stored:
|
|
615
|
+
meta.preview, meta.spoken = preview(items), spoken(items)
|
|
616
|
+
self._baseline(session_id, meta.spoken)
|
|
617
|
+
if not items:
|
|
618
|
+
items = [dict(item) for item in self._inbox.get(session_id, ())]
|
|
619
|
+
total = len(items)
|
|
620
|
+
if not everything and total > ITEM_LIMIT:
|
|
621
|
+
items = items[-ITEM_LIMIT:]
|
|
622
|
+
# A turn that ended without an answer is not in the stored conversation, and the person is told here.
|
|
623
|
+
notes = [{"kind": "note", "text": text} for text in self._notes.get(session_id, ())]
|
|
624
|
+
items.extend(notes)
|
|
625
|
+
total += len(notes)
|
|
626
|
+
return {
|
|
627
|
+
"thread": self._views.get(session_id, view),
|
|
628
|
+
"items": items,
|
|
629
|
+
"total": total,
|
|
630
|
+
"truncated": total > len(items),
|
|
631
|
+
"live": self._live_view(session_id),
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async def read(self, session_id: str) -> None:
|
|
635
|
+
"""Mark everything a thread has said as read."""
|
|
636
|
+
view = self._views.get(session_id)
|
|
637
|
+
if view is None:
|
|
638
|
+
raise GatewayError(404, "not_found", "No such session is listed")
|
|
639
|
+
meta = self._meta.setdefault(session_id, Meta())
|
|
640
|
+
stored = meta.stamp is not None
|
|
641
|
+
self._seen[session_id] = meta.spoken if stored else len(self._inbox.get(session_id, ()))
|
|
642
|
+
self._save_seen()
|
|
643
|
+
await self.refresh()
|
|
644
|
+
|
|
645
|
+
# -- sending ----------------------------------------------------------------------------------
|
|
646
|
+
async def send(
|
|
647
|
+
self, session_id: str, text: str, *, reply: bool | None = None, images: object = None
|
|
648
|
+
) -> dict[str, Any]:
|
|
649
|
+
"""Send a message to a thread and return who took it and what became of it.
|
|
650
|
+
|
|
651
|
+
Args:
|
|
652
|
+
session_id: The thread.
|
|
653
|
+
text: What the person typed; it may be empty when pictures go with it.
|
|
654
|
+
reply: Whether to ask the agent to answer with ``send_message``. By default a daemon is asked, since it
|
|
655
|
+
has nobody to answer to otherwise, and a session with a person at it is not.
|
|
656
|
+
images: Pictures to send with it, as a page sends them (``[{media_type, data}]``, base64). Only a session
|
|
657
|
+
that runs the text as its own prompt and whose model reads pictures takes them.
|
|
658
|
+
|
|
659
|
+
Raises:
|
|
660
|
+
GatewayError: The message is empty or too long, the thread is not there, this gateway has used up what
|
|
661
|
+
the broker allows, or the broker refused. A reason of ``timeout`` or ``lost`` means the message may
|
|
662
|
+
or may not have arrived (``uncertain``), and nothing here sends it again.
|
|
663
|
+
"""
|
|
664
|
+
view = self._views.get(session_id)
|
|
665
|
+
if view is None:
|
|
666
|
+
raise GatewayError(404, "not_found", "No such session is listed")
|
|
667
|
+
text = text.replace("\r\n", "\n").strip()
|
|
668
|
+
pictures = read_pictures(images, self.backend.limits)
|
|
669
|
+
if not text and not pictures:
|
|
670
|
+
raise GatewayError(400, "empty", "A message needs some text")
|
|
671
|
+
if not view["online"]:
|
|
672
|
+
raise GatewayError(409, "offline", f"{view['name']} is not connected, so it cannot be written to")
|
|
673
|
+
# A session that runs the text as its own prompt answers by itself, and the broker brings the answer back; one that
|
|
674
|
+
# does not is a colleague to be asked, and a daemon has nobody to answer to otherwise.
|
|
675
|
+
direct = bool(view["direct"])
|
|
676
|
+
if pictures:
|
|
677
|
+
self._check_pictures(view, direct)
|
|
678
|
+
asked = False if direct else (view["kind"] == "daemon" if reply is None else reply)
|
|
679
|
+
body = with_reply_request(text, self.backend.gateway_id) if asked else text
|
|
680
|
+
limits = self.backend.limits
|
|
681
|
+
if len(body) > limits.max_text:
|
|
682
|
+
room = max(0, limits.max_text - (len(body) - len(text)))
|
|
683
|
+
raise GatewayError(413, "too_long", f"A message is limited to {room} characters here", limit=room)
|
|
684
|
+
self._spend(limits.rate_limit, limits.rate_window)
|
|
685
|
+
self._notes.pop(session_id, None)
|
|
686
|
+
try:
|
|
687
|
+
delivered = await self.backend.send(session_id, body, direct=direct, images=pictures)
|
|
688
|
+
except SendError as error:
|
|
689
|
+
raise GatewayError(
|
|
690
|
+
SEND_STATUS.get(error.reason, 400),
|
|
691
|
+
error.reason,
|
|
692
|
+
str(error),
|
|
693
|
+
uncertain=error.reason in ("timeout", "lost"),
|
|
694
|
+
) from error
|
|
695
|
+
self._wake_up()
|
|
696
|
+
return {"to": delivered.to, "disposition": delivered.disposition, "direct": delivered.direct}
|
|
697
|
+
|
|
698
|
+
def _check_pictures(self, view: Mapping[str, Any], direct: bool) -> None:
|
|
699
|
+
"""Refuse pictures for a session that cannot use them, in words a person can act on, before anything is sent."""
|
|
700
|
+
if not direct:
|
|
701
|
+
raise GatewayError(
|
|
702
|
+
400,
|
|
703
|
+
"no_pictures",
|
|
704
|
+
f"{view['name']} cannot be sent a picture: it does not run a chat's text as its own prompt",
|
|
705
|
+
)
|
|
706
|
+
name = view.get("model")
|
|
707
|
+
known = next((model for model in self.backend.models() if model.name == name), None)
|
|
708
|
+
if known is not None and not known.multimodal:
|
|
709
|
+
raise GatewayError(
|
|
710
|
+
400,
|
|
711
|
+
"no_pictures",
|
|
712
|
+
f"{name} does not accept pictures; send the text alone or start a daemon on a model that does",
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
async def picture(self, session_id: str, picture_id: str) -> tuple[bytes, str]:
|
|
716
|
+
"""Return a picture of a thread's stored conversation by the name its chat item gave it: its bytes and type.
|
|
717
|
+
|
|
718
|
+
Reading a conversation decodes every picture in it, so what a chat was last read with is kept — the names of its
|
|
719
|
+
pictures always, and their bytes while they weigh :data:`PICTURE_BYTES` or less — and a page fetching its pictures
|
|
720
|
+
one by one does not read the file once for each. A chat that has been read is the last word on what it holds: a
|
|
721
|
+
name it does not hold is answered without reading anything, so asking for names that are not there costs nothing.
|
|
722
|
+
"""
|
|
723
|
+
view = self._views.get(session_id)
|
|
724
|
+
if view is None or not view["workspace"] or not PICTURE_NAME.match(picture_id):
|
|
725
|
+
raise GatewayError(404, "not_found", "No such picture")
|
|
726
|
+
names = self._pictures.get(session_id)
|
|
727
|
+
messages: Sequence[Message] | None = None
|
|
728
|
+
if names is None:
|
|
729
|
+
messages = await self._history_of(session_id, view["workspace"])
|
|
730
|
+
names = self._keep_pictures(session_id, messages)
|
|
731
|
+
found = names.get(picture_id)
|
|
732
|
+
# Whatever the file says, only a type a model takes is served, and the page never lets a browser guess another.
|
|
733
|
+
if found is None or found[1] not in IMAGE_TYPES:
|
|
734
|
+
raise GatewayError(404, "not_found", "No such picture")
|
|
735
|
+
data, kind = found
|
|
736
|
+
if data is None: # a chat too heavy to keep in memory: its bytes are read when they are asked for
|
|
737
|
+
if messages is None:
|
|
738
|
+
messages = await self._history_of(session_id, view["workspace"])
|
|
739
|
+
data = next(
|
|
740
|
+
(
|
|
741
|
+
picture.data
|
|
742
|
+
for message in messages
|
|
743
|
+
for picture in message.pictures
|
|
744
|
+
if picture_name(picture.data) == picture_id
|
|
745
|
+
),
|
|
746
|
+
None,
|
|
747
|
+
)
|
|
748
|
+
if data is None: # it was in the conversation a moment ago and is not now
|
|
749
|
+
raise GatewayError(404, "not_found", "No such picture")
|
|
750
|
+
return data, kind
|
|
751
|
+
|
|
752
|
+
async def _history_of(self, session_id: str, workspace: str) -> Sequence[Message]:
|
|
753
|
+
try:
|
|
754
|
+
return await self.backend.history(session_id, workspace)
|
|
755
|
+
except (SessionNotFoundError, ZettCodeError, OSError):
|
|
756
|
+
raise GatewayError(404, "not_found", "No such picture") from None
|
|
757
|
+
|
|
758
|
+
def _keep_pictures(self, session_id: str, messages: Sequence[Message]) -> dict[str, tuple[bytes | None, str]]:
|
|
759
|
+
"""Remember the pictures of a conversation by name, for the last few chats, and return them.
|
|
760
|
+
|
|
761
|
+
A chat is remembered even when it has none, so that asking it for a picture is answered at once. Its bytes are
|
|
762
|
+
kept only when they come to :data:`PICTURE_BYTES` or less, so a conversation of large pictures cannot pin them.
|
|
763
|
+
"""
|
|
764
|
+
found = {
|
|
765
|
+
picture_name(picture.data): (picture.data, picture.media_type)
|
|
766
|
+
for message in messages
|
|
767
|
+
for picture in message.pictures
|
|
768
|
+
}
|
|
769
|
+
light = sum(len(data) for data, _ in found.values()) <= PICTURE_BYTES
|
|
770
|
+
names: dict[str, tuple[bytes | None, str]] = {
|
|
771
|
+
name: (data if light else None, kind) for name, (data, kind) in found.items()
|
|
772
|
+
}
|
|
773
|
+
self._pictures.pop(session_id, None)
|
|
774
|
+
self._pictures[session_id] = names
|
|
775
|
+
while len(self._pictures) > PICTURE_CHATS:
|
|
776
|
+
self._pictures.pop(next(iter(self._pictures)))
|
|
777
|
+
return names
|
|
778
|
+
|
|
779
|
+
def _spent(self, window: float) -> int:
|
|
780
|
+
now = self.clock()
|
|
781
|
+
while self._sent and now - self._sent[0] > window:
|
|
782
|
+
self._sent.popleft()
|
|
783
|
+
return len(self._sent)
|
|
784
|
+
|
|
785
|
+
def _spend(self, limit: int, window: float) -> None:
|
|
786
|
+
"""Count a send against what the broker allows, and refuse it here when that is used up."""
|
|
787
|
+
if self._spent(window) >= limit:
|
|
788
|
+
wait = max(1, int(window - (self.clock() - self._sent[0])) + 1)
|
|
789
|
+
raise GatewayError(
|
|
790
|
+
429, "rate", f"At most {limit} messages a minute can be sent; try again in {wait}s", retry_after=wait
|
|
791
|
+
)
|
|
792
|
+
self._sent.append(self.clock())
|
|
793
|
+
|
|
794
|
+
def _received(self, message: IncomingMessage) -> Receipt:
|
|
795
|
+
"""Take a message a session sent to this gateway: keep it for a thread that has no stored conversation."""
|
|
796
|
+
if is_direct_notice(message.text):
|
|
797
|
+
# Said about a turn of ours that ended without an answer: a note in that chat, not a message to be read (the
|
|
798
|
+
# conversation is stored, and a message here would show the line twice). A few are kept: any session can send one.
|
|
799
|
+
notes = self._notes.setdefault(message.sender_id, [])
|
|
800
|
+
notes[:] = [*notes, message.text][-NOTE_LIMIT:]
|
|
801
|
+
self._senders[message.sender_id] = message.sender
|
|
802
|
+
self._publish({"type": "thread", "id": message.sender_id})
|
|
803
|
+
# Told like any message that arrives, so a failure is noticed when the chat is not open: it adds no item to
|
|
804
|
+
# the conversation (that is the note), only the notification.
|
|
805
|
+
self._publish(
|
|
806
|
+
{
|
|
807
|
+
"type": "inbound",
|
|
808
|
+
"id": message.sender_id,
|
|
809
|
+
"sender": message.sender,
|
|
810
|
+
"text": _short(message.text, 140),
|
|
811
|
+
}
|
|
812
|
+
)
|
|
813
|
+
return Receipt(True, disposition="delivered")
|
|
814
|
+
box = self._inbox.setdefault(message.sender_id, deque(maxlen=INBOX_LIMIT))
|
|
815
|
+
box.append({"kind": "in", "text": message.text, "via": message.via, "sender": message.sender})
|
|
816
|
+
self._senders[message.sender_id] = message.sender
|
|
817
|
+
self._publish(
|
|
818
|
+
{
|
|
819
|
+
"type": "inbound",
|
|
820
|
+
"id": message.sender_id,
|
|
821
|
+
"sender": message.sender,
|
|
822
|
+
"text": _short(message.text, 140),
|
|
823
|
+
}
|
|
824
|
+
)
|
|
825
|
+
self._wake_up()
|
|
826
|
+
return Receipt(True, disposition="delivered")
|
|
827
|
+
|
|
828
|
+
# -- daemons ----------------------------------------------------------------------------------
|
|
829
|
+
async def spawn(self, form: Mapping[str, Any]) -> dict[str, Any]:
|
|
830
|
+
"""Start a daemon as the form says and return its thread, and how its first message went if it had one."""
|
|
831
|
+
workspace = self.workspace(str(form.get("workspace") or ""))
|
|
832
|
+
permission = str(form.get("permission") or "safe")
|
|
833
|
+
if permission not in ("deny", "safe", "full"):
|
|
834
|
+
raise GatewayError(400, "invalid", "The shell level is deny, safe or full")
|
|
835
|
+
if permission == "full" and not self.settings.allow_full:
|
|
836
|
+
raise GatewayError(
|
|
837
|
+
403, "forbidden", "Starting a daemon with the full shell level was not allowed when the gateway started"
|
|
838
|
+
)
|
|
839
|
+
model, effort = self._model_and_effort(form.get("model"), form.get("effort"))
|
|
840
|
+
resume = form.get("resume") or None
|
|
841
|
+
if resume is not None and not (isinstance(resume, str) and SESSION_ID.match(resume)):
|
|
842
|
+
raise GatewayError(400, "invalid", "A session to resume is named by its id")
|
|
843
|
+
try:
|
|
844
|
+
info = await self.backend.spawn(
|
|
845
|
+
workspace,
|
|
846
|
+
model=model,
|
|
847
|
+
effort=effort,
|
|
848
|
+
resume=resume,
|
|
849
|
+
name=_label(form.get("name")),
|
|
850
|
+
permission=permission,
|
|
851
|
+
)
|
|
852
|
+
except DaemonStartError as error:
|
|
853
|
+
raise GatewayError(502, "start_failed", str(error)) from error
|
|
854
|
+
except SessionLockedError as error:
|
|
855
|
+
raise GatewayError(409, "locked", str(error)) from error
|
|
856
|
+
except ConfigurationError as error:
|
|
857
|
+
raise GatewayError(400, "invalid", str(error)) from error
|
|
858
|
+
self._created.add(info.session_id)
|
|
859
|
+
await self.refresh()
|
|
860
|
+
answer: dict[str, Any] = {"thread": self._views.get(info.session_id)}
|
|
861
|
+
first = str(form.get("message") or "").strip()
|
|
862
|
+
if first:
|
|
863
|
+
answer["first"] = await self._first_message(info.session_id, first)
|
|
864
|
+
return answer
|
|
865
|
+
|
|
866
|
+
async def _first_message(self, session_id: str, text: str) -> dict[str, Any]:
|
|
867
|
+
"""Send the first message of a new daemon once the broker lists it; a failure is reported, not raised."""
|
|
868
|
+
for _ in range(50):
|
|
869
|
+
if any(peer.session_id == session_id for peer in self.backend.peers()):
|
|
870
|
+
break
|
|
871
|
+
await asyncio.sleep(0.1)
|
|
872
|
+
await self.refresh()
|
|
873
|
+
try:
|
|
874
|
+
return {"ok": True, **await self.send(session_id, text)}
|
|
875
|
+
except GatewayError as error:
|
|
876
|
+
return {"ok": False, **error.to_dict()}
|
|
877
|
+
|
|
878
|
+
async def stop(self, session_id: str, *, force: bool = False) -> dict[str, str]:
|
|
879
|
+
"""Stop a daemon; it stays listed as offline, with its conversation, and a working one needs ``force``."""
|
|
880
|
+
if not SESSION_ID.match(session_id):
|
|
881
|
+
raise GatewayError(400, "invalid", "A daemon is named by its session id")
|
|
882
|
+
try:
|
|
883
|
+
said = await self.backend.stop(session_id, force=force)
|
|
884
|
+
except SessionNotFoundError as error:
|
|
885
|
+
raise GatewayError(404, "not_found", str(error)) from error
|
|
886
|
+
except ConfigurationError as error:
|
|
887
|
+
raise GatewayError(409, "busy", str(error)) from error
|
|
888
|
+
await self.refresh()
|
|
889
|
+
return {"result": said}
|
|
890
|
+
|
|
891
|
+
async def delete(self, session_id: str) -> dict[str, str]:
|
|
892
|
+
"""Remove a stopped daemon for good: its record and its conversation."""
|
|
893
|
+
if not SESSION_ID.match(session_id):
|
|
894
|
+
raise GatewayError(400, "invalid", "A daemon is named by its session id")
|
|
895
|
+
try:
|
|
896
|
+
said = await self.backend.delete(session_id)
|
|
897
|
+
except SessionNotFoundError as error:
|
|
898
|
+
raise GatewayError(404, "not_found", str(error)) from error
|
|
899
|
+
except ConfigurationError as error:
|
|
900
|
+
raise GatewayError(409, "busy", str(error)) from error
|
|
901
|
+
self._forget(session_id)
|
|
902
|
+
await self.refresh()
|
|
903
|
+
return {"result": said}
|
|
904
|
+
|
|
905
|
+
def _forget(self, session_id: str) -> None:
|
|
906
|
+
"""Drop what the gateway holds about a session that was deleted, so it is not listed again."""
|
|
907
|
+
self._inbox.pop(session_id, None)
|
|
908
|
+
self._senders.pop(session_id, None)
|
|
909
|
+
self._pictures.pop(session_id, None)
|
|
910
|
+
self._notes.pop(session_id, None)
|
|
911
|
+
self._live.pop(session_id, None)
|
|
912
|
+
if self._seen.pop(session_id, None) is not None:
|
|
913
|
+
self._save_seen()
|
|
914
|
+
|
|
915
|
+
async def log(self, session_id: str) -> dict[str, Any]:
|
|
916
|
+
"""Return the end of a daemon's own log."""
|
|
917
|
+
try:
|
|
918
|
+
text = await asyncio.to_thread(self.backend.log, session_id, 300)
|
|
919
|
+
except SessionNotFoundError as error:
|
|
920
|
+
raise GatewayError(404, "not_found", str(error)) from error
|
|
921
|
+
return {"text": text}
|
|
922
|
+
|
|
923
|
+
def _model_and_effort(self, model: object, effort: object) -> tuple[str | None, str | None]:
|
|
924
|
+
models = self.backend.models()
|
|
925
|
+
chosen = str(model) if model else None
|
|
926
|
+
if chosen is None:
|
|
927
|
+
if effort:
|
|
928
|
+
chosen_info = models[0] if models else None
|
|
929
|
+
else:
|
|
930
|
+
return None, None
|
|
931
|
+
else:
|
|
932
|
+
chosen_info = next((item for item in models if item.name == chosen), None)
|
|
933
|
+
if chosen_info is None:
|
|
934
|
+
raise GatewayError(400, "invalid", f"No model is called {chosen!r}")
|
|
935
|
+
level = str(effort) if effort else None
|
|
936
|
+
if level is not None and (chosen_info is None or level not in chosen_info.efforts):
|
|
937
|
+
raise GatewayError(400, "invalid", f"{chosen or 'The default model'} has no reasoning level {level!r}")
|
|
938
|
+
return chosen, level
|
|
939
|
+
|
|
940
|
+
# -- where agents may work --------------------------------------------------------------------
|
|
941
|
+
def _inside(self, path: Path) -> bool:
|
|
942
|
+
return any(path == root or root in path.parents for root in self.roots)
|
|
943
|
+
|
|
944
|
+
def workspace(self, text: str) -> Path:
|
|
945
|
+
"""Return the directory a daemon may work in, or refuse: it exists, and lies within a configured root."""
|
|
946
|
+
if not text.strip():
|
|
947
|
+
raise GatewayError(400, "invalid", "A daemon needs a workspace")
|
|
948
|
+
if not self.roots:
|
|
949
|
+
raise GatewayError(403, "forbidden", "The gateway was started with no directories agents may work in")
|
|
950
|
+
path = Path(text).expanduser().resolve()
|
|
951
|
+
if not self._inside(path):
|
|
952
|
+
raise GatewayError(403, "forbidden", "That directory is outside the ones this gateway may start agents in")
|
|
953
|
+
if not path.is_dir():
|
|
954
|
+
raise GatewayError(400, "invalid", f"No such directory: {path}")
|
|
955
|
+
return path
|
|
956
|
+
|
|
957
|
+
def browse(self, text: str) -> dict[str, Any]:
|
|
958
|
+
"""List the directories under a path, for the page's folder picker.
|
|
959
|
+
|
|
960
|
+
With no path, the person's home directory when it is allowed, and the roots themselves when it is not.
|
|
961
|
+
"""
|
|
962
|
+
if not text.strip():
|
|
963
|
+
home = Path.home().resolve()
|
|
964
|
+
if self._inside(home):
|
|
965
|
+
return self.browse(str(home))
|
|
966
|
+
return {"path": "", "parent": None, "dirs": [{"name": str(root), "path": str(root)} for root in self.roots]}
|
|
967
|
+
path = self.workspace(text)
|
|
968
|
+
try:
|
|
969
|
+
children = sorted(
|
|
970
|
+
(child for child in path.iterdir() if child.is_dir() and not child.name.startswith(".")),
|
|
971
|
+
key=lambda child: child.name.lower(),
|
|
972
|
+
)
|
|
973
|
+
except OSError as error:
|
|
974
|
+
raise GatewayError(403, "forbidden", f"That directory cannot be read: {error.strerror or error}") from error
|
|
975
|
+
if path == path.parent:
|
|
976
|
+
parent: str | None = None # the top of the filesystem
|
|
977
|
+
elif path in self.roots:
|
|
978
|
+
parent = "" # back to the list of roots
|
|
979
|
+
else:
|
|
980
|
+
parent = str(path.parent)
|
|
981
|
+
return {
|
|
982
|
+
"path": str(path),
|
|
983
|
+
"parent": parent,
|
|
984
|
+
"dirs": [{"name": child.name, "path": str(child)} for child in children[:300]],
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
# -- events -----------------------------------------------------------------------------------
|
|
988
|
+
def subscribe(self) -> asyncio.Queue[dict[str, Any]]:
|
|
989
|
+
"""Return a queue of events, starting with the state of everything, for one connected page."""
|
|
990
|
+
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=256)
|
|
991
|
+
queue.put_nowait({"type": "threads", "threads": self.threads(), "status": self.status()})
|
|
992
|
+
self._subscribers.add(queue)
|
|
993
|
+
return queue
|
|
994
|
+
|
|
995
|
+
def unsubscribe(self, queue: asyncio.Queue[dict[str, Any]]) -> None:
|
|
996
|
+
"""Forget a page that has gone."""
|
|
997
|
+
self._subscribers.discard(queue)
|
|
998
|
+
|
|
999
|
+
# -- a turn as it happens ---------------------------------------------------------------------
|
|
1000
|
+
def _streamed(self, event: StreamEvent) -> None:
|
|
1001
|
+
"""Take one thing a watched session's turn said: keep it for a page that opens later, and pass it on."""
|
|
1002
|
+
session_id = event.session_id
|
|
1003
|
+
if session_id == self.backend.gateway_id:
|
|
1004
|
+
return
|
|
1005
|
+
live = self._live.get(session_id)
|
|
1006
|
+
kind = event.kind
|
|
1007
|
+
if kind == "begin":
|
|
1008
|
+
live = self._live[session_id] = self._new_turn()
|
|
1009
|
+
elif kind == "end":
|
|
1010
|
+
self._finish_later(session_id, live["turn"] if live is not None else None)
|
|
1011
|
+
return
|
|
1012
|
+
elif live is None:
|
|
1013
|
+
live = self._live[session_id] = self._new_turn()
|
|
1014
|
+
live["at"] = self.clock()
|
|
1015
|
+
wire: dict[str, Any] = {"kind": kind}
|
|
1016
|
+
if kind == "text":
|
|
1017
|
+
live["thinking"] = False
|
|
1018
|
+
live["text"] = (live["text"] + event.text)[-LIVE_TEXT_LIMIT:]
|
|
1019
|
+
wire["text"] = event.text
|
|
1020
|
+
elif kind == "thinking":
|
|
1021
|
+
live["thinking"] = True
|
|
1022
|
+
elif kind == "tool":
|
|
1023
|
+
live["thinking"] = False
|
|
1024
|
+
live["tools"] = [
|
|
1025
|
+
*live["tools"],
|
|
1026
|
+
{"id": event.call_id, "name": event.name, "brief": event.brief, "ok": None},
|
|
1027
|
+
][-LIVE_TOOL_LIMIT:]
|
|
1028
|
+
wire.update(id=event.call_id, name=event.name, brief=event.brief)
|
|
1029
|
+
elif kind == "tool_end":
|
|
1030
|
+
for tool in live["tools"]:
|
|
1031
|
+
if tool["id"] == event.call_id:
|
|
1032
|
+
tool["ok"] = event.ok
|
|
1033
|
+
wire.update(id=event.call_id, ok=event.ok)
|
|
1034
|
+
self._publish({"type": "stream", "id": session_id, "event": wire})
|
|
1035
|
+
|
|
1036
|
+
def _new_turn(self) -> dict[str, Any]:
|
|
1037
|
+
"""Return the state of a turn that has just been seen to begin, marked so its end is told from the next one's."""
|
|
1038
|
+
return {"turn": next(self._turns), "text": "", "thinking": False, "tools": [], "at": self.clock()}
|
|
1039
|
+
|
|
1040
|
+
def _live_view(self, session_id: str) -> dict[str, Any] | None:
|
|
1041
|
+
"""Return the turn running in a session as a page reads it (without the gateway's own bookkeeping)."""
|
|
1042
|
+
live = self._live.get(session_id)
|
|
1043
|
+
return None if live is None else {key: value for key, value in live.items() if key not in ("at", "turn")}
|
|
1044
|
+
|
|
1045
|
+
def _finish_later(self, session_id: str, turn: int | None) -> None:
|
|
1046
|
+
task = asyncio.ensure_future(self._finish(session_id, turn))
|
|
1047
|
+
self._finishing.add(task)
|
|
1048
|
+
task.add_done_callback(self._finishing.discard)
|
|
1049
|
+
|
|
1050
|
+
async def _finish(self, session_id: str, turn: int | None) -> None:
|
|
1051
|
+
"""The turn ended: read what it wrote first, so the page has the stored message before the live one goes.
|
|
1052
|
+
|
|
1053
|
+
Reading yields, and the session may begin its next turn meanwhile: that one is not this one's to end, and one
|
|
1054
|
+
that was already dropped (its session vanished) has been ended for the pages.
|
|
1055
|
+
"""
|
|
1056
|
+
with contextlib.suppress(Exception):
|
|
1057
|
+
await self.refresh()
|
|
1058
|
+
live = self._live.get(session_id)
|
|
1059
|
+
if live is not None and live["turn"] == turn:
|
|
1060
|
+
del self._live[session_id]
|
|
1061
|
+
self._publish({"type": "stream", "id": session_id, "event": {"kind": "end"}})
|
|
1062
|
+
self._publish({"type": "thread", "id": session_id})
|
|
1063
|
+
|
|
1064
|
+
def _expire_live(self, peers: dict[str, Peer]) -> list[str]:
|
|
1065
|
+
"""Drop a live turn whose session has gone, or has been idle and silent: its end was never heard.
|
|
1066
|
+
|
|
1067
|
+
Returns the sessions it dropped, for the caller to tell the pages once what was stored has been announced.
|
|
1068
|
+
"""
|
|
1069
|
+
now = self.clock()
|
|
1070
|
+
expired: list[str] = []
|
|
1071
|
+
for session_id, live in list(self._live.items()):
|
|
1072
|
+
peer = peers.get(session_id)
|
|
1073
|
+
if peer is None or (peer.status != "working" and now - float(live["at"]) > LIVE_STALE_SECONDS):
|
|
1074
|
+
del self._live[session_id]
|
|
1075
|
+
expired.append(session_id)
|
|
1076
|
+
return expired
|
|
1077
|
+
|
|
1078
|
+
# -- the graph --------------------------------------------------------------------------------
|
|
1079
|
+
def _traffic(self, line: dict[str, Any]) -> None:
|
|
1080
|
+
"""Tell the pages what the broker saw: a message that passed, or that who is here (or the groups) changed."""
|
|
1081
|
+
if line.get("kind") == "message" and isinstance(line.get("message"), dict):
|
|
1082
|
+
self._publish({"type": "traffic", "message": line["message"]})
|
|
1083
|
+
elif line.get("kind") == "graph":
|
|
1084
|
+
self._publish({"type": "graph"})
|
|
1085
|
+
|
|
1086
|
+
async def graph(self) -> dict[str, Any]:
|
|
1087
|
+
"""Return who is connected, the groups, how much each pair has said and the latest messages."""
|
|
1088
|
+
try:
|
|
1089
|
+
return await self.backend.graph()
|
|
1090
|
+
except SendError as error:
|
|
1091
|
+
raise GatewayError(503, error.reason, str(error)) from error
|
|
1092
|
+
|
|
1093
|
+
async def older(self, before: object, limit: object, group: object = None) -> dict[str, Any]:
|
|
1094
|
+
"""Return the stored messages before id ``before``, oldest first: what a feed loads as it is scrolled back.
|
|
1095
|
+
|
|
1096
|
+
With ``group`` they are that group's own messages (what its chat window shows), and ``before`` may be empty or
|
|
1097
|
+
``0`` for its newest.
|
|
1098
|
+
"""
|
|
1099
|
+
try:
|
|
1100
|
+
first = int(before) if before not in (None, "") else 0 # type: ignore[call-overload]
|
|
1101
|
+
count = int(limit) if limit not in (None, "") else 50 # type: ignore[call-overload]
|
|
1102
|
+
except (TypeError, ValueError):
|
|
1103
|
+
raise GatewayError(400, "invalid", "before is a message id, limit a number") from None
|
|
1104
|
+
name = str(group).lstrip("#") if group else ""
|
|
1105
|
+
if first < 1 and not name:
|
|
1106
|
+
raise GatewayError(400, "invalid", "before is a message id")
|
|
1107
|
+
try:
|
|
1108
|
+
return await self.backend.older(max(first, 0), max(1, min(count, 200)), name)
|
|
1109
|
+
except SendError as error:
|
|
1110
|
+
raise GatewayError(404 if error.reason == "unknown" else 503, error.reason, str(error)) from error
|
|
1111
|
+
|
|
1112
|
+
async def send_group(self, name: str, text: str) -> dict[str, Any]:
|
|
1113
|
+
"""Write to a group chat as the person at the gateway, joining the group first when this gateway is not in it.
|
|
1114
|
+
|
|
1115
|
+
Raises:
|
|
1116
|
+
GatewayError: The text is empty or too long, the group is not there, nobody else in it is connected, this
|
|
1117
|
+
gateway has used up what the broker allows, or the broker refused.
|
|
1118
|
+
"""
|
|
1119
|
+
name = name.lstrip("#")
|
|
1120
|
+
text = text.replace("\r\n", "\n").strip()
|
|
1121
|
+
if not text:
|
|
1122
|
+
raise GatewayError(400, "empty", "A message needs some text")
|
|
1123
|
+
limits = self.backend.limits
|
|
1124
|
+
if len(text) > limits.max_text:
|
|
1125
|
+
raise GatewayError(
|
|
1126
|
+
413, "too_long", f"A message is limited to {limits.max_text} characters", limit=limits.max_text
|
|
1127
|
+
)
|
|
1128
|
+
self._spend(limits.rate_limit, limits.rate_window)
|
|
1129
|
+
try:
|
|
1130
|
+
try:
|
|
1131
|
+
delivered = await self.backend.send_group(name, text)
|
|
1132
|
+
except SendError as error:
|
|
1133
|
+
if error.reason != "denied":
|
|
1134
|
+
raise
|
|
1135
|
+
# Not a member: the person at the gateway is writing in this group, so they are put in it.
|
|
1136
|
+
await self.backend.join_group(name)
|
|
1137
|
+
delivered = await self.backend.send_group(name, text)
|
|
1138
|
+
except SendError as error:
|
|
1139
|
+
raise GatewayError(
|
|
1140
|
+
404 if error.reason == "unknown" else SEND_STATUS.get(error.reason, 400),
|
|
1141
|
+
error.reason,
|
|
1142
|
+
str(error),
|
|
1143
|
+
uncertain=error.reason in ("timeout", "lost"),
|
|
1144
|
+
) from error
|
|
1145
|
+
self._wake_up()
|
|
1146
|
+
return {"delivered": [{"to": item.to, "disposition": item.disposition} for item in delivered]}
|
|
1147
|
+
|
|
1148
|
+
async def timeline(self, since: object, until: object, limit: object) -> dict[str, Any]:
|
|
1149
|
+
"""Return the stored messages between two times, for the page to replay. Nothing is sent again.
|
|
1150
|
+
|
|
1151
|
+
Args:
|
|
1152
|
+
since: Epoch seconds the stretch starts at; an hour before ``until`` when it is not given.
|
|
1153
|
+
until: Epoch seconds it ends at; now when it is not given.
|
|
1154
|
+
limit: The most messages to return (the newest of the stretch), at most 20000. The answer says ``truncated``
|
|
1155
|
+
when the stretch held more, which a replay must say rather than draw a flow that is missing its start.
|
|
1156
|
+
"""
|
|
1157
|
+
try:
|
|
1158
|
+
end = float(until) if until not in (None, "") else float(self.clock()) # type: ignore[arg-type]
|
|
1159
|
+
start = float(since) if since not in (None, "") else end - 3600 # type: ignore[arg-type]
|
|
1160
|
+
count = int(limit) if limit not in (None, "") else 500 # type: ignore[call-overload]
|
|
1161
|
+
except (TypeError, ValueError):
|
|
1162
|
+
raise GatewayError(400, "invalid", "since and until are epoch seconds, limit a number") from None
|
|
1163
|
+
if start > end:
|
|
1164
|
+
raise GatewayError(400, "invalid", "since is after until")
|
|
1165
|
+
try:
|
|
1166
|
+
return await self.backend.timeline(start, end, max(1, min(count, 20_000)))
|
|
1167
|
+
except SendError as error:
|
|
1168
|
+
raise GatewayError(503, error.reason, str(error)) from error
|
|
1169
|
+
|
|
1170
|
+
def _publish(self, event: dict[str, Any]) -> None:
|
|
1171
|
+
for queue in list(self._subscribers):
|
|
1172
|
+
try:
|
|
1173
|
+
queue.put_nowait(event)
|
|
1174
|
+
except asyncio.QueueFull:
|
|
1175
|
+
# A page that stopped reading is dropped, and told to go: its socket is closed so that it reconnects and
|
|
1176
|
+
# asks for everything again, instead of sitting on stale data believing it is live.
|
|
1177
|
+
self._subscribers.discard(queue)
|
|
1178
|
+
with contextlib.suppress(asyncio.QueueEmpty, asyncio.QueueFull):
|
|
1179
|
+
queue.get_nowait()
|
|
1180
|
+
queue.put_nowait({"type": "close"})
|
|
1181
|
+
|
|
1182
|
+
# -- what the page remembers ------------------------------------------------------------------
|
|
1183
|
+
def _seen_path(self) -> Path:
|
|
1184
|
+
return self.settings.directory / "ui.json"
|
|
1185
|
+
|
|
1186
|
+
def _load_seen(self) -> dict[str, int]:
|
|
1187
|
+
try:
|
|
1188
|
+
data = json.loads(self._seen_path().read_text(encoding="utf-8"))
|
|
1189
|
+
except (OSError, ValueError):
|
|
1190
|
+
return {}
|
|
1191
|
+
seen = data.get("seen") if isinstance(data, dict) else None
|
|
1192
|
+
if not isinstance(seen, dict):
|
|
1193
|
+
return {}
|
|
1194
|
+
return {key: value for key, value in seen.items() if isinstance(key, str) and isinstance(value, int)}
|
|
1195
|
+
|
|
1196
|
+
def _save_seen(self) -> None:
|
|
1197
|
+
"""Write which messages have been read; a failure only costs the marks, so it is not raised."""
|
|
1198
|
+
path = self._seen_path()
|
|
1199
|
+
temporary = path.with_name(f".ui.{os.getpid()}.tmp")
|
|
1200
|
+
try:
|
|
1201
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1202
|
+
temporary.write_text(json.dumps({"seen": self._seen}), encoding="utf-8")
|
|
1203
|
+
os.replace(temporary, path)
|
|
1204
|
+
except OSError:
|
|
1205
|
+
pass
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
def _short(text: str, limit: int = 90) -> str:
|
|
1209
|
+
line = " ".join(text.split())
|
|
1210
|
+
return line if len(line) <= limit else line[: limit - 1] + "\u2026"
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def _label(value: object) -> str | None:
|
|
1214
|
+
"""Return a daemon's label cleaned of control characters and cut to size, or ``None`` when nothing is left."""
|
|
1215
|
+
if not isinstance(value, str):
|
|
1216
|
+
return None
|
|
1217
|
+
kept = "".join(char for char in value if unicodedata.category(char)[0] != "C" or char == " ")
|
|
1218
|
+
line = " ".join(kept.split())[:NAME_LIMIT]
|
|
1219
|
+
return line or None
|