spatialagents 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spatialagents/__init__.py +66 -0
- spatialagents/approvals.py +137 -0
- spatialagents/backend.py +181 -0
- spatialagents/cache_plan.py +117 -0
- spatialagents/chat/__init__.py +11 -0
- spatialagents/chat/cli.py +84 -0
- spatialagents/chat/http.py +308 -0
- spatialagents/chat/server.py +269 -0
- spatialagents/chat/static/chat.css +219 -0
- spatialagents/chat/static/chat.js +933 -0
- spatialagents/chat/static/index.html +16 -0
- spatialagents/conversations.py +292 -0
- spatialagents/errors.py +73 -0
- spatialagents/gateway.py +192 -0
- spatialagents/offload.py +92 -0
- spatialagents/orchestrator.py +259 -0
- spatialagents/plans.py +800 -0
- spatialagents/policy.py +118 -0
- spatialagents/prefix.py +79 -0
- spatialagents/py.typed +0 -0
- spatialagents/runner.py +813 -0
- spatialagents/schema.py +167 -0
- spatialagents/serialize.py +88 -0
- spatialagents/sessions.py +276 -0
- spatialagents/testing.py +927 -0
- spatialagents/toolcache.py +144 -0
- spatialagents/tools.py +460 -0
- spatialagents/trace.py +237 -0
- spatialagents/wire.py +194 -0
- spatialagents-0.1.0.dist-info/METADATA +55 -0
- spatialagents-0.1.0.dist-info/RECORD +33 -0
- spatialagents-0.1.0.dist-info/WHEEL +4 -0
- spatialagents-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""The chat's HTTP + SSE contract, framework-free: `ChatApi`.
|
|
2
|
+
|
|
3
|
+
The page (`chat/static/`) and Plexora's chat panel speak one wire. Paths are
|
|
4
|
+
relative to wherever a host mounts the API (Plexora's `/ai/v1`, the stdlib
|
|
5
|
+
server's `/api`):
|
|
6
|
+
|
|
7
|
+
POST /conversations start one {title, viewer}
|
|
8
|
+
GET /conversations list them ?limit=
|
|
9
|
+
GET /conversations/<id> one, with events ?after=
|
|
10
|
+
POST /conversations/<id>/messages the user's message {text, images}
|
|
11
|
+
GET /conversations/<id>/events SSE, or a held poll (?after=&wait=)
|
|
12
|
+
POST /conversations/<id>/control pause|resume|stop {action}
|
|
13
|
+
POST /conversations/<id>/approve approve|deny {approval_id, decision}
|
|
14
|
+
POST /conversations/<id>/undo an Undo chip {operation_id} (when the host can undo)
|
|
15
|
+
|
|
16
|
+
and, when the host gives a `plans.PlanService`:
|
|
17
|
+
|
|
18
|
+
POST /plans start one: compose a plan for {goal, dataset}
|
|
19
|
+
GET /plans list them
|
|
20
|
+
GET /plans/<run_id> the run's status and its plan
|
|
21
|
+
GET /plans/<run_id>/events SSE, or a held poll
|
|
22
|
+
POST /plans/<run_id>/answer the person's answer {decision_id, answer}
|
|
23
|
+
POST /plans/<run_id>/resolve which option free text meant {decision_id, text}
|
|
24
|
+
POST /plans/<run_id>/confirm confirm and run
|
|
25
|
+
POST /plans/<run_id>/continue go on after a checkpoint {from_step?, until_step?}
|
|
26
|
+
POST /plans/<run_id>/summarise the report
|
|
27
|
+
POST /plans/<run_id>/retry send a paused packet again
|
|
28
|
+
POST /plans/<run_id>/stop stop between steps
|
|
29
|
+
|
|
30
|
+
A JSON answer is `{"success": true, "ok": true, ...result}`, or `{"success":
|
|
31
|
+
false, "ok": false, "error": {code, message, detail, retryable}}` with 409 for a
|
|
32
|
+
conflict, 403 for a licence or a permission, 404 for an unknown id and 400
|
|
33
|
+
otherwise. `/events` streams server-sent events when the client asks for them
|
|
34
|
+
(`Accept: text/event-stream`, what `EventSource` sends, or `?stream=1`) --
|
|
35
|
+
`id: <seq>`, `event: <name>`, `data: <json>`, a `retry:` hint first and
|
|
36
|
+
keep-alive comments while idle, picking up after `Last-Event-ID` on a
|
|
37
|
+
reconnect -- and otherwise answers a held poll, the fallback when a proxy
|
|
38
|
+
buffers the stream. `?until_idle=1` ends a stream with an `idle` event once
|
|
39
|
+
nothing is running.
|
|
40
|
+
|
|
41
|
+
The operations behind the routes are `ChatOperations` over a
|
|
42
|
+
`conversations.ChatService`; a product that drives its conversations through
|
|
43
|
+
its own capability layer (Plexora's `ai.chat_*`, for its licence check and
|
|
44
|
+
audit) gives its own, and keeps its own guards in front. Approving is the
|
|
45
|
+
person's hand: a host must only mount `/approve` where the caller is the
|
|
46
|
+
person at the page (the stdlib server's cookie, Plexora's viewer), never on a
|
|
47
|
+
surface a model's tools can reach.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
import json
|
|
53
|
+
import re
|
|
54
|
+
import time
|
|
55
|
+
from dataclasses import dataclass, field
|
|
56
|
+
from typing import Any, Iterator
|
|
57
|
+
from urllib.parse import unquote
|
|
58
|
+
|
|
59
|
+
from spatialagents.errors import AgentError
|
|
60
|
+
|
|
61
|
+
#: How long one SSE response stays open before the client reconnects.
|
|
62
|
+
STREAM_S = 300.0
|
|
63
|
+
#: The longest a held poll waits.
|
|
64
|
+
HELD_POLL_S = 20.0
|
|
65
|
+
#: How long a stream waits for an event before a keep-alive comment.
|
|
66
|
+
KEEPALIVE_S = 10.0
|
|
67
|
+
STATUS = {"conflict": 409, "license_required": 403, "permission_required": 403}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class Response:
|
|
72
|
+
"""What a host sends back: JSON (`body`), or a stream of SSE chunks."""
|
|
73
|
+
|
|
74
|
+
status: int = 200
|
|
75
|
+
body: Any = None
|
|
76
|
+
headers: dict = field(default_factory=dict)
|
|
77
|
+
stream: Iterator[str] | None = None
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def content_type(self) -> str:
|
|
81
|
+
return "text/event-stream" if self.stream is not None else "application/json"
|
|
82
|
+
|
|
83
|
+
def json_bytes(self) -> bytes:
|
|
84
|
+
return json.dumps(self.body, default=str, ensure_ascii=False).encode("utf-8")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def ok(result: dict | None = None, status: int = 200) -> Response:
|
|
88
|
+
return Response(status, {"success": True, "ok": True, **(result or {})})
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def refused(error: dict, status: int | None = None) -> Response:
|
|
92
|
+
code = error.get("code") if isinstance(error, dict) else None
|
|
93
|
+
return Response(status or STATUS.get(code, 400), {"success": False, "ok": False, "error": error})
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def missing(message: str) -> Response:
|
|
97
|
+
return Response(404, {"success": False, "error": message})
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ChatOperations:
|
|
101
|
+
"""The routes' operations over a `ChatService`, as the person at the page
|
|
102
|
+
asks for them. Each returns a result dict or raises `AgentError`."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, service, *, policy=None):
|
|
105
|
+
from spatialagents.policy import Policy
|
|
106
|
+
|
|
107
|
+
self.service = service
|
|
108
|
+
self.policy = policy or Policy(principal="page")
|
|
109
|
+
|
|
110
|
+
def start(self, *, title: str | None = None, viewer: bool = False) -> dict:
|
|
111
|
+
return self.service.start(self.policy, title=title, viewer=viewer)
|
|
112
|
+
|
|
113
|
+
def history(self, conversation_id: str | None = None, **options) -> dict:
|
|
114
|
+
"""The conversations (`limit`), or one with its events (`after`,
|
|
115
|
+
`wait_s`): only what the route was given is passed."""
|
|
116
|
+
return self.service.history(conversation_id, after=int(options.get("after", 0)),
|
|
117
|
+
limit=int(options.get("limit", 500)), wait_s=float(options.get("wait_s", 0)))
|
|
118
|
+
|
|
119
|
+
def send(self, conversation_id: str, text: str, images: list) -> dict:
|
|
120
|
+
if not str(text or "").strip() and not images:
|
|
121
|
+
raise AgentError("invalid_input", "send some text or an image")
|
|
122
|
+
sent = self.service.send(conversation_id, text, images, policy=self.policy)
|
|
123
|
+
return {**sent, **self.service.events(conversation_id, sent["after"])}
|
|
124
|
+
|
|
125
|
+
def control(self, conversation_id: str, action: str) -> dict:
|
|
126
|
+
return {"control": self.service.control(conversation_id, action)}
|
|
127
|
+
|
|
128
|
+
def approve(self, conversation_id: str, approval_id: str, decision: str) -> dict:
|
|
129
|
+
if decision not in ("approve", "deny"):
|
|
130
|
+
raise AgentError("invalid_input", f"decision is approve or deny, not {decision!r}")
|
|
131
|
+
approval = self.service.approve(conversation_id, approval_id, decision == "approve", by="page")
|
|
132
|
+
return {"approval": approval}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
_CONVERSATION = re.compile(r"^/conversations/([^/]+)(?:/([a-z_]+))?$")
|
|
136
|
+
_PLAN = re.compile(r"^/plans/([^/]+)(?:/([a-z_]+))?$")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class ChatApi:
|
|
140
|
+
"""The contract above, over a `ChatService` (and a `PlanService`).
|
|
141
|
+
|
|
142
|
+
`undo(operation_id) -> result` is the host's reversal of a write a
|
|
143
|
+
conversation made (Plexora's `undo_operation`); without it `/undo` is 404."""
|
|
144
|
+
|
|
145
|
+
def __init__(self, service, *, operations: ChatOperations | None = None, plans=None, undo=None,
|
|
146
|
+
stream_s: float = STREAM_S, held_poll_s: float = HELD_POLL_S, keepalive_s: float = KEEPALIVE_S):
|
|
147
|
+
self.service = service
|
|
148
|
+
self.operations = operations or ChatOperations(service)
|
|
149
|
+
self.plans = plans
|
|
150
|
+
self.undo = undo
|
|
151
|
+
self.stream_s = stream_s
|
|
152
|
+
self.held_poll_s = held_poll_s
|
|
153
|
+
self.keepalive_s = keepalive_s
|
|
154
|
+
|
|
155
|
+
# -- dispatch -------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def handle(self, method: str, path: str, *, query: dict | None = None, body: dict | None = None,
|
|
158
|
+
headers: dict | None = None) -> Response:
|
|
159
|
+
method = method.upper()
|
|
160
|
+
path = "/" + path.strip("/")
|
|
161
|
+
query = {k: (v[0] if isinstance(v, list) else v) for k, v in (query or {}).items()}
|
|
162
|
+
headers = {str(k).lower(): v for k, v in (headers or {}).items()}
|
|
163
|
+
body = body if isinstance(body, dict) else {}
|
|
164
|
+
try:
|
|
165
|
+
if path == "/conversations":
|
|
166
|
+
if method == "POST":
|
|
167
|
+
return ok(self.operations.start(title=body.get("title"), viewer=bool(body.get("viewer"))))
|
|
168
|
+
if method == "GET":
|
|
169
|
+
return ok(self.operations.history(limit=int(_number(query, "limit", 50))))
|
|
170
|
+
found = _CONVERSATION.match(path)
|
|
171
|
+
if found:
|
|
172
|
+
return self._conversation(method, unquote(found.group(1)), found.group(2), query, body, headers)
|
|
173
|
+
if self.plans is not None:
|
|
174
|
+
if path == "/plans":
|
|
175
|
+
if method == "POST":
|
|
176
|
+
return ok(self.plans.start(str(body.get("goal") or ""), str(body.get("dataset") or "")),
|
|
177
|
+
201)
|
|
178
|
+
if method == "GET":
|
|
179
|
+
return ok({"plans": self.plans.list(int(_number(query, "limit", 50)))})
|
|
180
|
+
found = _PLAN.match(path)
|
|
181
|
+
if found:
|
|
182
|
+
return self._plan(method, unquote(found.group(1)), found.group(2), query, body, headers)
|
|
183
|
+
except AgentError as exc:
|
|
184
|
+
return refused(exc.to_problem())
|
|
185
|
+
except (TypeError, ValueError) as exc:
|
|
186
|
+
return refused({"code": "invalid_input", "message": str(exc), "detail": None, "retryable": False})
|
|
187
|
+
return missing(f"no route {method} {path}")
|
|
188
|
+
|
|
189
|
+
def _conversation(self, method, cid, action, query, body, headers) -> Response:
|
|
190
|
+
if action is None and method == "GET":
|
|
191
|
+
return ok(self.operations.history(cid, after=int(_number(query, "after", 0))))
|
|
192
|
+
if action == "messages" and method == "POST":
|
|
193
|
+
return ok(self.operations.send(cid, body.get("text") or "", body.get("images") or []))
|
|
194
|
+
if action == "events" and method == "GET":
|
|
195
|
+
return self._events(cid, query, headers)
|
|
196
|
+
if action == "control" and method == "POST":
|
|
197
|
+
return ok(self.operations.control(cid, body.get("action")))
|
|
198
|
+
if action == "approve" and method == "POST":
|
|
199
|
+
return ok(self.operations.approve(cid, body.get("approval_id"), body.get("decision")))
|
|
200
|
+
if action == "undo" and method == "POST" and self.undo is not None:
|
|
201
|
+
return self._undo(cid, str(body.get("operation_id") or ""))
|
|
202
|
+
return missing(f"no route {method} /conversations/{cid}/{action or ''}".rstrip("/"))
|
|
203
|
+
|
|
204
|
+
def _undo(self, cid: str, operation_id: str) -> Response:
|
|
205
|
+
store = self.service.store
|
|
206
|
+
if not store.exists(cid):
|
|
207
|
+
return missing(f"no conversation {cid!r}")
|
|
208
|
+
made = {e.get("operation_id") for e in store.decisions(cid) if e.get("event") == "tool_result"}
|
|
209
|
+
if not operation_id or operation_id not in made:
|
|
210
|
+
return Response(400, {"success": False, "error": "that operation was not made by this conversation"})
|
|
211
|
+
try:
|
|
212
|
+
result = self.undo(operation_id)
|
|
213
|
+
except AgentError as exc:
|
|
214
|
+
return refused(exc.to_problem())
|
|
215
|
+
self.service.note(cid, f"[The user undid operation {operation_id} from the chat panel.]")
|
|
216
|
+
store.append_event(cid, {"event": "undone", "operation_id": operation_id})
|
|
217
|
+
return ok(result if isinstance(result, dict) else {})
|
|
218
|
+
|
|
219
|
+
# -- events -------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
@staticmethod
|
|
222
|
+
def _wants_stream(query, headers) -> bool:
|
|
223
|
+
return "text/event-stream" in str(headers.get("accept") or "") or query.get("stream") == "1"
|
|
224
|
+
|
|
225
|
+
@staticmethod
|
|
226
|
+
def _cursor(query, headers) -> int:
|
|
227
|
+
after = int(_number(query, "after", 0))
|
|
228
|
+
last = str(headers.get("last-event-id") or "")
|
|
229
|
+
return int(last) if last.isdigit() else after
|
|
230
|
+
|
|
231
|
+
def _events(self, cid, query, headers) -> Response:
|
|
232
|
+
if not self._wants_stream(query, headers):
|
|
233
|
+
wait = max(0.0, min(_number(query, "wait", 0), self.held_poll_s))
|
|
234
|
+
return ok(self.operations.history(cid, after=int(_number(query, "after", 0)), wait_s=wait))
|
|
235
|
+
after = self._cursor(query, headers)
|
|
236
|
+
if not self.service.store.exists(cid):
|
|
237
|
+
return missing(f"no conversation {cid!r}")
|
|
238
|
+
|
|
239
|
+
def poll(cursor, wait_s):
|
|
240
|
+
polled = self.service.events(cid, cursor, wait_s=wait_s)
|
|
241
|
+
return polled["events"], polled["running"]
|
|
242
|
+
|
|
243
|
+
return self._stream(poll, after, until_idle=query.get("until_idle") == "1")
|
|
244
|
+
|
|
245
|
+
def _stream(self, poll, after: int, *, until_idle: bool) -> Response:
|
|
246
|
+
def chunks():
|
|
247
|
+
cursor = after
|
|
248
|
+
deadline = time.monotonic() + self.stream_s
|
|
249
|
+
yield "retry: 2000\n\n"
|
|
250
|
+
while time.monotonic() < deadline:
|
|
251
|
+
events, running = poll(cursor, self.keepalive_s)
|
|
252
|
+
for event in events:
|
|
253
|
+
cursor = event["seq"]
|
|
254
|
+
yield (f"id: {cursor}\nevent: {event.get('event', 'message')}\n"
|
|
255
|
+
f"data: {json.dumps(event, default=str)}\n\n")
|
|
256
|
+
if not events:
|
|
257
|
+
if not running and until_idle:
|
|
258
|
+
yield f"event: idle\ndata: {json.dumps({'after': cursor})}\n\n"
|
|
259
|
+
return
|
|
260
|
+
yield ": keep-alive\n\n"
|
|
261
|
+
|
|
262
|
+
return Response(200, None, {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, chunks())
|
|
263
|
+
|
|
264
|
+
# -- plans ----------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
def _plan(self, method, run_id, action, query, body, headers) -> Response:
|
|
267
|
+
plans = self.plans
|
|
268
|
+
if action is None and method == "GET":
|
|
269
|
+
return ok(plans.status(run_id))
|
|
270
|
+
if action == "events" and method == "GET":
|
|
271
|
+
if not self._wants_stream(query, headers):
|
|
272
|
+
wait = max(0.0, min(_number(query, "wait", 0), self.held_poll_s))
|
|
273
|
+
return ok(plans.events(run_id, int(_number(query, "after", 0)), wait))
|
|
274
|
+
after = self._cursor(query, headers)
|
|
275
|
+
plans.run(run_id) # 400 now for an unknown run, not mid-stream
|
|
276
|
+
|
|
277
|
+
def poll(cursor, wait_s):
|
|
278
|
+
polled = plans.events(run_id, cursor, wait_s)
|
|
279
|
+
return polled["events"], polled["running"]
|
|
280
|
+
|
|
281
|
+
return self._stream(poll, after, until_idle=query.get("until_idle") == "1")
|
|
282
|
+
if method != "POST":
|
|
283
|
+
return missing(f"no route {method} /plans/{run_id}/{action or ''}".rstrip("/"))
|
|
284
|
+
if action == "answer":
|
|
285
|
+
if "decision_id" not in body or "answer" not in body:
|
|
286
|
+
raise AgentError("invalid_input", "answer needs decision_id and answer")
|
|
287
|
+
return ok(plans.answer(run_id, str(body["decision_id"]), body["answer"]))
|
|
288
|
+
if action == "resolve":
|
|
289
|
+
return ok(plans.resolve(run_id, str(body.get("decision_id") or ""), str(body.get("text") or "")))
|
|
290
|
+
if action == "confirm":
|
|
291
|
+
return ok(plans.confirm(run_id), 202)
|
|
292
|
+
if action == "continue":
|
|
293
|
+
return ok(plans.proceed(run_id, from_step=body.get("from_step"), until_step=body.get("until_step")),
|
|
294
|
+
202)
|
|
295
|
+
if action == "summarise":
|
|
296
|
+
return ok(plans.summarise(run_id), 202)
|
|
297
|
+
if action == "retry":
|
|
298
|
+
return ok(plans.retry(run_id), 202)
|
|
299
|
+
if action == "stop":
|
|
300
|
+
return ok(plans.stop(run_id))
|
|
301
|
+
return missing(f"no route POST /plans/{run_id}/{action}")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _number(query: dict, name: str, default: float) -> float:
|
|
305
|
+
try:
|
|
306
|
+
return float(query.get(name, default))
|
|
307
|
+
except (TypeError, ValueError):
|
|
308
|
+
return default
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""The chat page and its API on a loopback port, with nothing but the standard library.
|
|
2
|
+
|
|
3
|
+
server = ChatServer(ChatApi(service, plans=plans), title="SCIMAP Pro AI")
|
|
4
|
+
server.start() # a thread; or serve_forever()
|
|
5
|
+
print(server.launch_url) # http://127.0.0.1:<port>/?token=<one-time token>
|
|
6
|
+
|
|
7
|
+
This is the host a product with no web app of its own gives a person (SCIMAP
|
|
8
|
+
Pro's `scimappro ai chat`), and the one a notebook kernel runs in a thread. It
|
|
9
|
+
serves the page (`chat/static/`) at `/`, its assets at `/static/`, and the
|
|
10
|
+
`ChatApi` at `/api/`.
|
|
11
|
+
|
|
12
|
+
**Who may talk to it.** It binds a loopback address only. The launch URL
|
|
13
|
+
carries a one-time token; the first request with it trades the token for an
|
|
14
|
+
`HttpOnly`, `SameSite=Strict` session cookie (Plexora's idiom: only the entry
|
|
15
|
+
URL ever carries a secret) and is redirected to the same page without it, so
|
|
16
|
+
the token is in no history, log or `Referer`. The token is used once: a second
|
|
17
|
+
request with it is refused. Every other request needs the cookie; a POST also
|
|
18
|
+
needs `Content-Type: application/json` and, when the browser sends one, an
|
|
19
|
+
`Origin` that is this server's. The token comes from the caller or the
|
|
20
|
+
environment (`SPATIALAGENTS_CHAT_TOKEN`, how a parent process hands it to a
|
|
21
|
+
sidecar), never from argv, where other users of the machine could read it.
|
|
22
|
+
|
|
23
|
+
`/approve` is the person's hand: only the page holding the cookie can reach
|
|
24
|
+
it, and no tool a model can call talks to this server.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import hmac
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import secrets
|
|
33
|
+
import threading
|
|
34
|
+
from http.cookies import SimpleCookie
|
|
35
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
36
|
+
from importlib import resources
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
39
|
+
|
|
40
|
+
TOKEN_ENV = "SPATIALAGENTS_CHAT_TOKEN"
|
|
41
|
+
COOKIE = "spatialagents_chat"
|
|
42
|
+
LOOPBACK = ("127.0.0.1", "::1", "localhost")
|
|
43
|
+
#: The most a request body may be (images ride in it, base64).
|
|
44
|
+
MAX_BODY = 40 * 1024 * 1024
|
|
45
|
+
STATIC_TYPES = {".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8",
|
|
46
|
+
".html": "text/html; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def static_dir() -> Path:
|
|
50
|
+
"""The page's files, as installed."""
|
|
51
|
+
return Path(str(resources.files("spatialagents.chat") / "static"))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ChatServer:
|
|
55
|
+
"""`api` behind a one-time token and a session cookie, on a loopback port.
|
|
56
|
+
|
|
57
|
+
`page` (the page's settings: `title`, `assistant`, `dataset`, `plans`,
|
|
58
|
+
`undo`) is handed to the page as JSON; nothing the server holds -- the
|
|
59
|
+
token, the session -- is ever in it."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, api, *, host: str = "127.0.0.1", port: int = 0, token: str | None = None,
|
|
62
|
+
title: str = "AI assistant", page: dict | None = None):
|
|
63
|
+
if host not in LOOPBACK:
|
|
64
|
+
raise ValueError(f"the chat server binds a loopback address only, not {host!r}")
|
|
65
|
+
self.api = api
|
|
66
|
+
self.title = title
|
|
67
|
+
self.page = {"title": title, "base": "api", "plans": api.plans is not None, "undo": api.undo is not None,
|
|
68
|
+
**(page or {})}
|
|
69
|
+
self._token = token or os.environ.get(TOKEN_ENV) or secrets.token_urlsafe(24)
|
|
70
|
+
self._token_used = False
|
|
71
|
+
self._sessions: set[str] = set()
|
|
72
|
+
self._lock = threading.Lock()
|
|
73
|
+
self.httpd = ThreadingHTTPServer((host, port), self._handler())
|
|
74
|
+
self.httpd.daemon_threads = True
|
|
75
|
+
self.thread: threading.Thread | None = None
|
|
76
|
+
|
|
77
|
+
# -- addresses -------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def address(self) -> tuple:
|
|
81
|
+
return self.httpd.server_address[:2]
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def url(self) -> str:
|
|
85
|
+
host, port = self.address
|
|
86
|
+
host = f"[{host}]" if ":" in host else host
|
|
87
|
+
return f"http://{host}:{port}/"
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def launch_url(self) -> str:
|
|
91
|
+
"""Where the person starts: the page, with the one-time token."""
|
|
92
|
+
return self.url + "?" + urlencode({"token": self._token})
|
|
93
|
+
|
|
94
|
+
# -- running ------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
def start(self) -> "ChatServer":
|
|
97
|
+
self.thread = threading.Thread(target=self.httpd.serve_forever, name="spatialagents-chat-server",
|
|
98
|
+
daemon=True)
|
|
99
|
+
self.thread.start()
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
def serve_forever(self) -> None:
|
|
103
|
+
self.httpd.serve_forever()
|
|
104
|
+
|
|
105
|
+
def stop(self) -> None:
|
|
106
|
+
self.httpd.shutdown()
|
|
107
|
+
self.httpd.server_close()
|
|
108
|
+
|
|
109
|
+
def __enter__(self):
|
|
110
|
+
return self.start()
|
|
111
|
+
|
|
112
|
+
def __exit__(self, *exc):
|
|
113
|
+
self.stop()
|
|
114
|
+
|
|
115
|
+
# -- who is asking ---------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
def _redeem(self, supplied: str) -> str | None:
|
|
118
|
+
"""A session for the one-time token, or None once it has been used."""
|
|
119
|
+
with self._lock:
|
|
120
|
+
if self._token_used or not hmac.compare_digest(str(supplied), self._token):
|
|
121
|
+
return None
|
|
122
|
+
self._token_used = True
|
|
123
|
+
session = secrets.token_urlsafe(32)
|
|
124
|
+
self._sessions.add(session)
|
|
125
|
+
return session
|
|
126
|
+
|
|
127
|
+
def _session_ok(self, cookie_header: str | None) -> bool:
|
|
128
|
+
cookie = SimpleCookie()
|
|
129
|
+
try:
|
|
130
|
+
cookie.load(cookie_header or "")
|
|
131
|
+
except Exception: # noqa: BLE001 -- a bad cookie is no cookie
|
|
132
|
+
return False
|
|
133
|
+
morsel = cookie.get(COOKIE)
|
|
134
|
+
if morsel is None:
|
|
135
|
+
return False
|
|
136
|
+
with self._lock:
|
|
137
|
+
return any(hmac.compare_digest(morsel.value, known) for known in self._sessions)
|
|
138
|
+
|
|
139
|
+
# -- the handler -------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def _handler(self):
|
|
142
|
+
server = self
|
|
143
|
+
|
|
144
|
+
class Handler(BaseHTTPRequestHandler):
|
|
145
|
+
protocol_version = "HTTP/1.1"
|
|
146
|
+
|
|
147
|
+
def log_message(self, *args):
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
def _send(self, status: int, body: bytes, content_type: str, headers: dict | None = None):
|
|
151
|
+
self.send_response(status)
|
|
152
|
+
self.send_header("Content-Type", content_type)
|
|
153
|
+
self.send_header("Content-Length", str(len(body)))
|
|
154
|
+
self.send_header("Cache-Control", "no-store")
|
|
155
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
156
|
+
self.send_header("Referrer-Policy", "no-referrer")
|
|
157
|
+
for key, value in (headers or {}).items():
|
|
158
|
+
self.send_header(key, value)
|
|
159
|
+
self.end_headers()
|
|
160
|
+
if self.command != "HEAD":
|
|
161
|
+
self.wfile.write(body)
|
|
162
|
+
|
|
163
|
+
def _json(self, status: int, body: dict, headers: dict | None = None):
|
|
164
|
+
self._send(status, json.dumps(body, default=str).encode("utf-8"), "application/json", headers)
|
|
165
|
+
|
|
166
|
+
def _refuse(self, status: int, message: str):
|
|
167
|
+
if self.path.startswith("/api/"):
|
|
168
|
+
self._json(status, {"success": False, "ok": False,
|
|
169
|
+
"error": {"code": "permission_required", "message": message,
|
|
170
|
+
"detail": None, "retryable": False}})
|
|
171
|
+
else:
|
|
172
|
+
self._send(status, (message + "\n").encode("utf-8"), "text/plain; charset=utf-8")
|
|
173
|
+
|
|
174
|
+
def _authorised(self) -> bool:
|
|
175
|
+
if server._session_ok(self.headers.get("Cookie")):
|
|
176
|
+
return True
|
|
177
|
+
self._refuse(403, "This page needs the link it was started with (it carries a one-time "
|
|
178
|
+
"token). Start it again for a new one.")
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
def do_GET(self): # noqa: N802
|
|
182
|
+
parts = urlsplit(self.path)
|
|
183
|
+
query = parse_qs(parts.query)
|
|
184
|
+
if "token" in query:
|
|
185
|
+
session = server._redeem(query["token"][0])
|
|
186
|
+
if session is None:
|
|
187
|
+
return self._refuse(403, "That link has been used already; start the chat again "
|
|
188
|
+
"for a new one.")
|
|
189
|
+
rest = {k: v for k, v in query.items() if k != "token"}
|
|
190
|
+
target = parts.path + ("?" + urlencode(rest, doseq=True) if rest else "")
|
|
191
|
+
return self._send(303, b"", "text/plain", {
|
|
192
|
+
"Location": target or "/",
|
|
193
|
+
"Set-Cookie": f"{COOKIE}={session}; HttpOnly; SameSite=Strict; Path=/"})
|
|
194
|
+
if not self._authorised():
|
|
195
|
+
return None
|
|
196
|
+
if parts.path in ("/", "/index.html"):
|
|
197
|
+
return self._page()
|
|
198
|
+
if parts.path.startswith("/static/"):
|
|
199
|
+
return self._static(parts.path[len("/static/"):])
|
|
200
|
+
if parts.path.startswith("/api/"):
|
|
201
|
+
return self._api("GET", parts.path[len("/api"):], query, None)
|
|
202
|
+
return self._refuse(404, "not found")
|
|
203
|
+
|
|
204
|
+
def do_POST(self): # noqa: N802
|
|
205
|
+
parts = urlsplit(self.path)
|
|
206
|
+
if not self._authorised():
|
|
207
|
+
return None
|
|
208
|
+
origin = self.headers.get("Origin")
|
|
209
|
+
if origin and origin.rstrip("/") != server.url.rstrip("/"):
|
|
210
|
+
return self._refuse(403, "a request from another page")
|
|
211
|
+
if not str(self.headers.get("Content-Type") or "").startswith("application/json"):
|
|
212
|
+
return self._refuse(415, "send JSON")
|
|
213
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
214
|
+
if length > MAX_BODY:
|
|
215
|
+
return self._refuse(413, "that request is too large")
|
|
216
|
+
try:
|
|
217
|
+
body = json.loads(self.rfile.read(length) or b"{}")
|
|
218
|
+
except ValueError:
|
|
219
|
+
return self._json(400, {"success": False, "ok": False,
|
|
220
|
+
"error": {"code": "invalid_input", "message": "the body is not JSON",
|
|
221
|
+
"detail": None, "retryable": False}})
|
|
222
|
+
if not parts.path.startswith("/api/"):
|
|
223
|
+
return self._refuse(404, "not found")
|
|
224
|
+
return self._api("POST", parts.path[len("/api"):], parse_qs(parts.query), body)
|
|
225
|
+
|
|
226
|
+
def _api(self, method, path, query, body):
|
|
227
|
+
answer = server.api.handle(method, path, query=query, body=body, headers=dict(self.headers))
|
|
228
|
+
if answer.stream is None:
|
|
229
|
+
return self._send(answer.status, answer.json_bytes(), "application/json", answer.headers)
|
|
230
|
+
self.send_response(answer.status)
|
|
231
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
232
|
+
self.send_header("Connection", "close")
|
|
233
|
+
for key, value in answer.headers.items():
|
|
234
|
+
self.send_header(key, value)
|
|
235
|
+
self.end_headers()
|
|
236
|
+
self.close_connection = True
|
|
237
|
+
try:
|
|
238
|
+
for chunk in answer.stream:
|
|
239
|
+
self.wfile.write(chunk.encode("utf-8"))
|
|
240
|
+
self.wfile.flush()
|
|
241
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
242
|
+
pass # the page went away; its next connection picks up the cursor
|
|
243
|
+
finally:
|
|
244
|
+
close = getattr(answer.stream, "close", None)
|
|
245
|
+
if close:
|
|
246
|
+
close()
|
|
247
|
+
|
|
248
|
+
def _page(self):
|
|
249
|
+
html = (static_dir() / "index.html").read_text(encoding="utf-8")
|
|
250
|
+
settings = json.dumps(server.page, default=str).replace("<", "\\u003c")
|
|
251
|
+
html = html.replace("{{TITLE}}", _escape(server.title)).replace("{{SETTINGS}}", settings)
|
|
252
|
+
self._send(200, html.encode("utf-8"), "text/html; charset=utf-8",
|
|
253
|
+
{"Content-Security-Policy": "default-src 'self'; img-src 'self' data:; "
|
|
254
|
+
"style-src 'self'; script-src 'self'; "
|
|
255
|
+
"connect-src 'self'; frame-ancestors 'self'"})
|
|
256
|
+
|
|
257
|
+
def _static(self, name: str):
|
|
258
|
+
root = static_dir().resolve()
|
|
259
|
+
path = (root / name).resolve()
|
|
260
|
+
if root not in path.parents or not path.is_file() or path.suffix not in STATIC_TYPES:
|
|
261
|
+
return self._refuse(404, "not found")
|
|
262
|
+
self._send(200, path.read_bytes(), STATIC_TYPES[path.suffix])
|
|
263
|
+
|
|
264
|
+
return Handler
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _escape(text: str) -> str:
|
|
268
|
+
return (str(text).replace("&", "&").replace("<", "<").replace(">", ">")
|
|
269
|
+
.replace('"', """))
|