pylogue 0.3__py3-none-any.whl → 0.3.30__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.
- pylogue/core.py +804 -0
- pylogue/embeds.py +32 -0
- pylogue/integrations/__init__.py +1 -0
- pylogue/integrations/pydantic_ai.py +417 -0
- pylogue/legacy/cards.py +112 -0
- pylogue/{chat.py → legacy/chat.py} +54 -27
- pylogue/{chatapp.py → legacy/chatapp.py} +65 -26
- pylogue/legacy/design_system.py +117 -0
- pylogue/legacy/renderer.py +284 -0
- pylogue/shell.py +342 -0
- pylogue/static/pylogue-core.css +372 -0
- pylogue/static/pylogue-core.js +199 -0
- pylogue/static/pylogue-markdown.js +745 -0
- {pylogue-0.3.dist-info → pylogue-0.3.30.dist-info}/METADATA +10 -1
- pylogue-0.3.30.dist-info/RECORD +26 -0
- {pylogue-0.3.dist-info → pylogue-0.3.30.dist-info}/WHEEL +1 -1
- pylogue/cards.py +0 -174
- pylogue/renderer.py +0 -139
- pylogue-0.3.dist-info/RECORD +0 -17
- /pylogue/{__init__.py → legacy/__init__.py} +0 -0
- /pylogue/{__pre_init__.py → legacy/__pre_init__.py} +0 -0
- /pylogue/{_modidx.py → legacy/_modidx.py} +0 -0
- /pylogue/{health.py → legacy/health.py} +0 -0
- /pylogue/{service.py → legacy/service.py} +0 -0
- /pylogue/{session.py → legacy/session.py} +0 -0
- {pylogue-0.3.dist-info → pylogue-0.3.30.dist-info}/entry_points.txt +0 -0
- {pylogue-0.3.dist-info → pylogue-0.3.30.dist-info}/licenses/AUTHORS.md +0 -0
- {pylogue-0.3.dist-info → pylogue-0.3.30.dist-info}/licenses/LICENSE +0 -0
- {pylogue-0.3.dist-info → pylogue-0.3.30.dist-info}/top_level.txt +0 -0
pylogue/core.py
ADDED
|
@@ -0,0 +1,804 @@
|
|
|
1
|
+
# Core FastHTML + MonsterUI chat
|
|
2
|
+
from fasthtml.common import *
|
|
3
|
+
from monsterui.all import Theme, Container, ContainerT, TextPresets, Button, ButtonT, FastHTML as MUFastHTML, UkIcon
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from urllib.parse import quote_plus
|
|
7
|
+
from starlette.requests import Request
|
|
8
|
+
from starlette.responses import FileResponse, RedirectResponse
|
|
9
|
+
import asyncio
|
|
10
|
+
import inspect
|
|
11
|
+
import json
|
|
12
|
+
import base64
|
|
13
|
+
import html as html_lib
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
IMPORT_PREFIX = "__PYLOGUE_IMPORT__:"
|
|
19
|
+
STOP_PREFIX = "__PYLOGUE_STOP__:"
|
|
20
|
+
_CORE_STATIC_DIR = Path(__file__).resolve().parent / "static"
|
|
21
|
+
_LOG = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class GoogleOAuthConfig:
|
|
26
|
+
client_id: str
|
|
27
|
+
client_secret: str
|
|
28
|
+
allowed_domains: tuple[str, ...] = ()
|
|
29
|
+
allowed_emails: tuple[str, ...] = ()
|
|
30
|
+
auth_required: bool = True
|
|
31
|
+
session_secret: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _split_csv_env(value: str | None) -> tuple[str, ...]:
|
|
35
|
+
if not value:
|
|
36
|
+
return ()
|
|
37
|
+
return tuple(part.strip() for part in value.split(",") if part.strip())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _env_bool(name: str, default: bool = False) -> bool:
|
|
41
|
+
raw = os.getenv(name)
|
|
42
|
+
if raw is None:
|
|
43
|
+
return default
|
|
44
|
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def google_oauth_config_from_env() -> GoogleOAuthConfig | None:
|
|
48
|
+
client_id = os.getenv("PYLOGUE_GOOGLE_CLIENT_ID") or os.getenv("PYLOGUE_CLIENT_ID")
|
|
49
|
+
client_secret = os.getenv("PYLOGUE_GOOGLE_CLIENT_SECRET") or os.getenv("PYLOGUE_CLIENT_SECRET")
|
|
50
|
+
if not (client_id and client_secret):
|
|
51
|
+
return None
|
|
52
|
+
return GoogleOAuthConfig(
|
|
53
|
+
client_id=client_id,
|
|
54
|
+
client_secret=client_secret,
|
|
55
|
+
allowed_domains=_split_csv_env(os.getenv("PYLOGUE_GOOGLE_ALLOWED_DOMAINS")),
|
|
56
|
+
allowed_emails=_split_csv_env(os.getenv("PYLOGUE_GOOGLE_ALLOWED_EMAILS")),
|
|
57
|
+
auth_required=_env_bool("PYLOGUE_AUTH_REQUIRED", default=True),
|
|
58
|
+
session_secret=os.getenv("PYLOGUE_SESSION_SECRET"),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _normalize_base_path(base_path: str) -> str:
|
|
63
|
+
base_path = (base_path or "").strip()
|
|
64
|
+
if base_path in {"", "/"}:
|
|
65
|
+
return ""
|
|
66
|
+
normalized = "/" + base_path.strip("/")
|
|
67
|
+
if ".." in normalized.split("/"):
|
|
68
|
+
raise ValueError("base_path cannot contain '..'")
|
|
69
|
+
return normalized
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _request_auth(request: Request):
|
|
73
|
+
try:
|
|
74
|
+
auth = request.session.get("auth")
|
|
75
|
+
except Exception:
|
|
76
|
+
auth = None
|
|
77
|
+
if isinstance(auth, dict):
|
|
78
|
+
return auth
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _connection_auth(conn):
|
|
83
|
+
try:
|
|
84
|
+
scope = conn.scope
|
|
85
|
+
except Exception:
|
|
86
|
+
scope = None
|
|
87
|
+
if not isinstance(scope, dict):
|
|
88
|
+
return None
|
|
89
|
+
session = scope.get("session")
|
|
90
|
+
if session is None:
|
|
91
|
+
return None
|
|
92
|
+
try:
|
|
93
|
+
auth = session.get("auth")
|
|
94
|
+
except Exception:
|
|
95
|
+
auth = None
|
|
96
|
+
if isinstance(auth, dict):
|
|
97
|
+
return auth
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _user_context_from_auth(auth):
|
|
102
|
+
if not isinstance(auth, dict):
|
|
103
|
+
return None
|
|
104
|
+
name = auth.get("name") or auth.get("username")
|
|
105
|
+
email = auth.get("email")
|
|
106
|
+
return {
|
|
107
|
+
"name": name,
|
|
108
|
+
"email": email,
|
|
109
|
+
"display_name": name or email,
|
|
110
|
+
"provider": auth.get("provider"),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _build_responder_context(conn):
|
|
115
|
+
auth = _connection_auth(conn)
|
|
116
|
+
if not auth:
|
|
117
|
+
return None
|
|
118
|
+
return {
|
|
119
|
+
"auth": auth,
|
|
120
|
+
"user": _user_context_from_auth(auth),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _invoke_responder(responder, prompt: str, context):
|
|
125
|
+
try:
|
|
126
|
+
signature = inspect.signature(responder)
|
|
127
|
+
except (TypeError, ValueError):
|
|
128
|
+
signature = None
|
|
129
|
+
|
|
130
|
+
if signature is not None:
|
|
131
|
+
params = signature.parameters
|
|
132
|
+
if "context" in params or any(
|
|
133
|
+
p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()
|
|
134
|
+
):
|
|
135
|
+
return responder(prompt, context=context)
|
|
136
|
+
try:
|
|
137
|
+
return responder(prompt)
|
|
138
|
+
except TypeError:
|
|
139
|
+
return responder(prompt, context)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _oauth_base_url(request: Request) -> str:
|
|
143
|
+
explicit = os.getenv("PYLOGUE_PUBLIC_URL")
|
|
144
|
+
if explicit:
|
|
145
|
+
return explicit.rstrip("/")
|
|
146
|
+
base = str(request.base_url).rstrip("/")
|
|
147
|
+
return base.replace("://0.0.0.0", "://localhost")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _session_cookie_name() -> str:
|
|
151
|
+
return os.getenv("PYLOGUE_SESSION_COOKIE", "pylogue_session")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _register_google_auth_routes(app, cfg: GoogleOAuthConfig, base_path: str = "") -> dict[str, str]:
|
|
155
|
+
try:
|
|
156
|
+
from authlib.integrations.starlette_client import OAuth
|
|
157
|
+
except Exception as exc:
|
|
158
|
+
raise RuntimeError("Google OAuth requires authlib. Install with `pip install authlib`.") from exc
|
|
159
|
+
|
|
160
|
+
oauth = OAuth()
|
|
161
|
+
oauth.register(
|
|
162
|
+
name="google",
|
|
163
|
+
client_id=cfg.client_id,
|
|
164
|
+
client_secret=cfg.client_secret,
|
|
165
|
+
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
|
166
|
+
userinfo_endpoint="https://openidconnect.googleapis.com/v1/userinfo",
|
|
167
|
+
client_kwargs={"scope": "openid email profile"},
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
base = _normalize_base_path(base_path)
|
|
171
|
+
login_path = f"{base}/login" if base else "/login"
|
|
172
|
+
login_google_path = f"{base}/login/google" if base else "/login/google"
|
|
173
|
+
callback_path = f"{base}/auth/google/callback" if base else "/auth/google/callback"
|
|
174
|
+
logout_path = f"{base}/logout" if base else "/logout"
|
|
175
|
+
default_next = f"{base}/" if base else "/"
|
|
176
|
+
|
|
177
|
+
@app.route(login_path, methods=["GET"])
|
|
178
|
+
async def pylogue_google_login(request: Request):
|
|
179
|
+
error = request.query_params.get("error")
|
|
180
|
+
return Div(
|
|
181
|
+
H2("Login", cls="uk-h2"),
|
|
182
|
+
A(
|
|
183
|
+
Span("Continue with Google", cls="text-sm font-semibold"),
|
|
184
|
+
href=login_google_path,
|
|
185
|
+
cls=(
|
|
186
|
+
"inline-flex items-center justify-center px-4 py-2 my-6 rounded-md "
|
|
187
|
+
"border border-slate-700 bg-slate-800 text-slate-100 hover:bg-slate-900 "
|
|
188
|
+
"hover:border-slate-900 transition-colors max-w-sm mx-auto"
|
|
189
|
+
),
|
|
190
|
+
),
|
|
191
|
+
P(error, cls="text-red-500 mt-4") if error else None,
|
|
192
|
+
cls="prose mx-auto mt-24 text-center",
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
@app.route(login_google_path)
|
|
196
|
+
async def pylogue_google_login_redirect(request: Request):
|
|
197
|
+
next_url = request.session.get("next") or request.query_params.get("next") or default_next
|
|
198
|
+
request.session["next"] = next_url
|
|
199
|
+
redirect_uri = _oauth_base_url(request) + callback_path
|
|
200
|
+
return await oauth.google.authorize_redirect(request, redirect_uri)
|
|
201
|
+
|
|
202
|
+
@app.route(callback_path)
|
|
203
|
+
async def pylogue_google_callback(request: Request):
|
|
204
|
+
try:
|
|
205
|
+
token = await oauth.google.authorize_access_token(request)
|
|
206
|
+
userinfo = token.get("userinfo")
|
|
207
|
+
if not userinfo:
|
|
208
|
+
try:
|
|
209
|
+
userinfo = await oauth.google.parse_id_token(request, token)
|
|
210
|
+
except Exception:
|
|
211
|
+
# Fallback to userinfo endpoint when id_token is unavailable.
|
|
212
|
+
resp = await oauth.google.get("https://openidconnect.googleapis.com/v1/userinfo", token=token)
|
|
213
|
+
userinfo = resp.json()
|
|
214
|
+
except Exception as exc:
|
|
215
|
+
_LOG.exception("Google OAuth callback failed: %s", exc)
|
|
216
|
+
err = quote_plus(f"Google authentication failed ({type(exc).__name__})")
|
|
217
|
+
return RedirectResponse(f"{login_path}?error={err}", status_code=303)
|
|
218
|
+
|
|
219
|
+
email = userinfo.get("email") if isinstance(userinfo, dict) else None
|
|
220
|
+
if not email:
|
|
221
|
+
return RedirectResponse(f"{login_path}?error=Google+authentication+failed+(no+email)", status_code=303)
|
|
222
|
+
if cfg.allowed_domains:
|
|
223
|
+
if not email:
|
|
224
|
+
return RedirectResponse(f"{login_path}?error=Google+account+not+allowed", status_code=303)
|
|
225
|
+
domain = email.split("@")[-1]
|
|
226
|
+
if domain not in cfg.allowed_domains:
|
|
227
|
+
return RedirectResponse(f"{login_path}?error=Google+account+not+allowed", status_code=303)
|
|
228
|
+
if cfg.allowed_emails and (not email or email not in cfg.allowed_emails):
|
|
229
|
+
return RedirectResponse(f"{login_path}?error=Google+account+not+allowed", status_code=303)
|
|
230
|
+
|
|
231
|
+
request.session["auth"] = {
|
|
232
|
+
"provider": "google",
|
|
233
|
+
"email": email,
|
|
234
|
+
"name": userinfo.get("name") if isinstance(userinfo, dict) else None,
|
|
235
|
+
"picture": userinfo.get("picture") if isinstance(userinfo, dict) else None,
|
|
236
|
+
}
|
|
237
|
+
next_url = request.session.pop("next", default_next)
|
|
238
|
+
return RedirectResponse(next_url, status_code=303)
|
|
239
|
+
|
|
240
|
+
@app.route(logout_path)
|
|
241
|
+
async def pylogue_google_logout(request: Request):
|
|
242
|
+
request.session.pop("auth", None)
|
|
243
|
+
request.session.pop("next", None)
|
|
244
|
+
return RedirectResponse(login_path, status_code=303)
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
"login_path": login_path,
|
|
248
|
+
"logout_path": logout_path,
|
|
249
|
+
"default_next": default_next,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def register_core_static(app):
|
|
254
|
+
if getattr(app, "_pylogue_static_registered", False):
|
|
255
|
+
return
|
|
256
|
+
app._pylogue_static_registered = True
|
|
257
|
+
|
|
258
|
+
@app.route("/static/pylogue-core.css")
|
|
259
|
+
def _pylogue_core_css():
|
|
260
|
+
return FileResponse(_CORE_STATIC_DIR / "pylogue-core.css")
|
|
261
|
+
|
|
262
|
+
@app.route("/static/pylogue-core.js")
|
|
263
|
+
def _pylogue_core_js():
|
|
264
|
+
return FileResponse(_CORE_STATIC_DIR / "pylogue-core.js")
|
|
265
|
+
|
|
266
|
+
@app.route("/static/pylogue-markdown.js")
|
|
267
|
+
def _pylogue_markdown_js():
|
|
268
|
+
return FileResponse(_CORE_STATIC_DIR / "pylogue-markdown.js")
|
|
269
|
+
|
|
270
|
+
class EchoResponder:
|
|
271
|
+
async def __call__(self, message: str, context=None):
|
|
272
|
+
user = context.get("user") if isinstance(context, dict) else None
|
|
273
|
+
display_name = user.get("display_name") if isinstance(user, dict) else None
|
|
274
|
+
if display_name:
|
|
275
|
+
response = f"[ECHO] {display_name}:\n{message}"
|
|
276
|
+
else:
|
|
277
|
+
response = f"[ECHO]:\n{message}"
|
|
278
|
+
for ch in response:
|
|
279
|
+
await asyncio.sleep(0.005)
|
|
280
|
+
yield ch
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def render_input():
|
|
284
|
+
return Textarea(
|
|
285
|
+
id="msg",
|
|
286
|
+
name="msg",
|
|
287
|
+
placeholder="Say hi...",
|
|
288
|
+
autofocus=True,
|
|
289
|
+
rows=3,
|
|
290
|
+
cls="uk-textarea w-full bg-white border-slate-300 focus:border-slate-500 focus:ring-2 focus:ring-slate-200 font-mono",
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def render_cards(cards):
|
|
295
|
+
rows = []
|
|
296
|
+
data_json = json.dumps(cards)
|
|
297
|
+
for card in cards:
|
|
298
|
+
card_id = card.get("id", "")
|
|
299
|
+
assistant_id = f"assistant-{card_id}" if card_id else ""
|
|
300
|
+
rows.append(
|
|
301
|
+
Div(
|
|
302
|
+
P("You", cls=(TextPresets.muted_sm, "text-right")),
|
|
303
|
+
Div(
|
|
304
|
+
card["question"],
|
|
305
|
+
data_raw_b64=base64.b64encode(card["question"].encode("utf-8")).decode("ascii"),
|
|
306
|
+
cls="marked text-base text-right",
|
|
307
|
+
),
|
|
308
|
+
cls="chat-row-block chat-row-user",
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
rows.append(
|
|
312
|
+
Div(
|
|
313
|
+
P("Assistant", cls=(TextPresets.muted_sm, "text-left")),
|
|
314
|
+
Div(
|
|
315
|
+
Button(
|
|
316
|
+
UkIcon("copy"),
|
|
317
|
+
cls="uk-button uk-button-text copy-btn",
|
|
318
|
+
type="button",
|
|
319
|
+
data_copy_target=assistant_id,
|
|
320
|
+
aria_label="Copy response",
|
|
321
|
+
title="Copy response",
|
|
322
|
+
),
|
|
323
|
+
cls="flex justify-end",
|
|
324
|
+
),
|
|
325
|
+
Div(
|
|
326
|
+
card["answer"] or "…",
|
|
327
|
+
id=assistant_id if assistant_id else None,
|
|
328
|
+
data_raw_b64=base64.b64encode((card["answer"] or "").encode("utf-8")).decode("ascii"),
|
|
329
|
+
cls="marked text-base text-left",
|
|
330
|
+
),
|
|
331
|
+
cls="chat-row-block chat-row-assistant",
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
return Div(
|
|
335
|
+
*rows,
|
|
336
|
+
Div(id="scroll-anchor"),
|
|
337
|
+
Input(type="hidden", id="chat-data", value=data_json),
|
|
338
|
+
Input(type="hidden", id="chat-export", value=json.dumps({"cards": cards})),
|
|
339
|
+
id="cards",
|
|
340
|
+
cls="divide-y divide-slate-200",
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def render_chat_data(cards):
|
|
345
|
+
return Input(
|
|
346
|
+
type="hidden",
|
|
347
|
+
id="chat-data",
|
|
348
|
+
value=json.dumps(cards),
|
|
349
|
+
hx_swap_oob="true",
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
_TOOL_HTML_RE = re.compile(r'<div class="tool-html">.*?</div>', re.DOTALL)
|
|
353
|
+
_TAG_RE = re.compile(r"<[^>]+>")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _normalize_answer_for_history(answer: str) -> str:
|
|
357
|
+
if not isinstance(answer, str) or not answer:
|
|
358
|
+
return ""
|
|
359
|
+
text = _TOOL_HTML_RE.sub("Rendered tool output.", answer)
|
|
360
|
+
text = _TAG_RE.sub("", text)
|
|
361
|
+
return html_lib.unescape(text).strip()
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def build_export_payload(cards, responder=None):
|
|
365
|
+
export_cards = []
|
|
366
|
+
for card in cards:
|
|
367
|
+
if not isinstance(card, dict):
|
|
368
|
+
continue
|
|
369
|
+
answer = card.get("answer", "")
|
|
370
|
+
answer_text = card.get("answer_text")
|
|
371
|
+
if not isinstance(answer_text, str) or not answer_text.strip():
|
|
372
|
+
answer_text = _normalize_answer_for_history(answer)
|
|
373
|
+
export_card = dict(card)
|
|
374
|
+
export_card["answer_text"] = answer_text
|
|
375
|
+
export_cards.append(export_card)
|
|
376
|
+
|
|
377
|
+
payload = {"cards": export_cards}
|
|
378
|
+
if responder is not None and hasattr(responder, "get_export_state"):
|
|
379
|
+
try:
|
|
380
|
+
meta = responder.get_export_state()
|
|
381
|
+
except Exception:
|
|
382
|
+
meta = None
|
|
383
|
+
if meta:
|
|
384
|
+
payload["meta"] = meta
|
|
385
|
+
return payload
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def render_chat_export(cards, responder=None):
|
|
389
|
+
payload = build_export_payload(cards, responder=responder)
|
|
390
|
+
return Input(
|
|
391
|
+
type="hidden",
|
|
392
|
+
id="chat-export",
|
|
393
|
+
value=json.dumps(payload),
|
|
394
|
+
hx_swap_oob="true",
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def render_assistant_update(card):
|
|
400
|
+
card_id = card.get("id", "")
|
|
401
|
+
assistant_id = f"assistant-{card_id}" if card_id else ""
|
|
402
|
+
return Div(
|
|
403
|
+
card.get("answer", "") or "…",
|
|
404
|
+
id=assistant_id if assistant_id else None,
|
|
405
|
+
data_raw_b64=base64.b64encode((card.get("answer", "") or "").encode("utf-8")).decode("ascii"),
|
|
406
|
+
cls="marked text-base text-left",
|
|
407
|
+
hx_swap_oob="true",
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def get_core_headers(include_markdown: bool = True):
|
|
412
|
+
headers = list(Theme.slate.headers())
|
|
413
|
+
if include_markdown:
|
|
414
|
+
headers.extend(
|
|
415
|
+
[
|
|
416
|
+
Link(rel="stylesheet", href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css"),
|
|
417
|
+
Link(
|
|
418
|
+
rel="stylesheet",
|
|
419
|
+
href="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11.9.0/styles/github.min.css",
|
|
420
|
+
),
|
|
421
|
+
Script(src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"),
|
|
422
|
+
Script(src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"),
|
|
423
|
+
Script(src="https://cdn.jsdelivr.net/npm/vega@5"),
|
|
424
|
+
Script(src="https://cdn.jsdelivr.net/npm/vega-lite@5"),
|
|
425
|
+
Script(src="https://cdn.jsdelivr.net/npm/vega-embed@6"),
|
|
426
|
+
]
|
|
427
|
+
)
|
|
428
|
+
headers.append(
|
|
429
|
+
Script(src="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11.9.0/highlight.min.js")
|
|
430
|
+
)
|
|
431
|
+
headers.append(Script(src="/static/pylogue-markdown.js", type="module"))
|
|
432
|
+
|
|
433
|
+
headers.append(Link(rel="stylesheet", href="/static/pylogue-core.css"))
|
|
434
|
+
headers.append(Script(src="/static/pylogue-core.js", type="module"))
|
|
435
|
+
|
|
436
|
+
return headers
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def register_ws_routes(
|
|
440
|
+
app,
|
|
441
|
+
responder=None,
|
|
442
|
+
responder_factory=None,
|
|
443
|
+
base_path: str = "",
|
|
444
|
+
sessions: dict | None = None,
|
|
445
|
+
auth_required: bool = False,
|
|
446
|
+
):
|
|
447
|
+
if responder_factory is None:
|
|
448
|
+
responder = responder or EchoResponder()
|
|
449
|
+
base_path = _normalize_base_path(base_path)
|
|
450
|
+
ws_path = f"{base_path}/ws" if base_path else "/ws"
|
|
451
|
+
if sessions is None:
|
|
452
|
+
sessions = {}
|
|
453
|
+
|
|
454
|
+
def _on_connect(ws, send):
|
|
455
|
+
if auth_required and not _connection_auth(ws):
|
|
456
|
+
return
|
|
457
|
+
session_context = _build_responder_context(ws)
|
|
458
|
+
session_responder = responder_factory() if responder_factory else responder
|
|
459
|
+
if hasattr(session_responder, "set_context"):
|
|
460
|
+
try:
|
|
461
|
+
session_responder.set_context(session_context)
|
|
462
|
+
except Exception:
|
|
463
|
+
pass
|
|
464
|
+
ws_id = id(ws)
|
|
465
|
+
sessions[ws_id] = {
|
|
466
|
+
"cards": [],
|
|
467
|
+
"responder": session_responder,
|
|
468
|
+
"task": None,
|
|
469
|
+
"context": session_context,
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
def _on_disconnect(ws):
|
|
473
|
+
session = sessions.pop(id(ws), None)
|
|
474
|
+
if session is None:
|
|
475
|
+
return
|
|
476
|
+
task = session.get("task")
|
|
477
|
+
if task is not None and not task.done():
|
|
478
|
+
task.cancel()
|
|
479
|
+
|
|
480
|
+
@app.ws(ws_path, conn=_on_connect, disconn=_on_disconnect)
|
|
481
|
+
async def ws_handler(msg: str, send, ws):
|
|
482
|
+
if auth_required and not _connection_auth(ws):
|
|
483
|
+
return
|
|
484
|
+
ws_id = id(ws)
|
|
485
|
+
session = sessions.get(ws_id)
|
|
486
|
+
if session is None:
|
|
487
|
+
session_context = _build_responder_context(ws)
|
|
488
|
+
session_responder = responder_factory() if responder_factory else responder
|
|
489
|
+
if hasattr(session_responder, "set_context"):
|
|
490
|
+
try:
|
|
491
|
+
session_responder.set_context(session_context)
|
|
492
|
+
except Exception:
|
|
493
|
+
pass
|
|
494
|
+
session = {
|
|
495
|
+
"cards": [],
|
|
496
|
+
"responder": session_responder,
|
|
497
|
+
"task": None,
|
|
498
|
+
"context": session_context,
|
|
499
|
+
}
|
|
500
|
+
sessions[ws_id] = session
|
|
501
|
+
cards = session["cards"]
|
|
502
|
+
session_responder = session["responder"]
|
|
503
|
+
current_task = session.get("task")
|
|
504
|
+
context = _build_responder_context(ws)
|
|
505
|
+
if context is not None:
|
|
506
|
+
session["context"] = context
|
|
507
|
+
if hasattr(session_responder, "set_context"):
|
|
508
|
+
try:
|
|
509
|
+
session_responder.set_context(context)
|
|
510
|
+
except Exception:
|
|
511
|
+
pass
|
|
512
|
+
|
|
513
|
+
async def _run_message(prompt: str):
|
|
514
|
+
cards.append({"id": str(len(cards)), "question": prompt, "answer": ""})
|
|
515
|
+
await send(render_cards(cards))
|
|
516
|
+
try:
|
|
517
|
+
result = _invoke_responder(
|
|
518
|
+
session_responder,
|
|
519
|
+
prompt,
|
|
520
|
+
context=session.get("context"),
|
|
521
|
+
)
|
|
522
|
+
if inspect.isasyncgen(result):
|
|
523
|
+
async for chunk in result:
|
|
524
|
+
cards[-1]["answer"] += str(chunk)
|
|
525
|
+
await send(render_assistant_update(cards[-1]))
|
|
526
|
+
else:
|
|
527
|
+
if inspect.isawaitable(result):
|
|
528
|
+
result = await result
|
|
529
|
+
for ch in str(result):
|
|
530
|
+
cards[-1]["answer"] += ch
|
|
531
|
+
await send(render_assistant_update(cards[-1]))
|
|
532
|
+
except asyncio.CancelledError:
|
|
533
|
+
if cards and cards[-1].get("answer"):
|
|
534
|
+
cards[-1]["answer"] += "\n\n[Stopped]"
|
|
535
|
+
else:
|
|
536
|
+
cards[-1]["answer"] = "[Stopped]"
|
|
537
|
+
await send(render_assistant_update(cards[-1]))
|
|
538
|
+
finally:
|
|
539
|
+
await send(render_chat_data(cards))
|
|
540
|
+
await send(render_chat_export(cards, responder=session_responder))
|
|
541
|
+
session["task"] = None
|
|
542
|
+
return
|
|
543
|
+
|
|
544
|
+
if isinstance(msg, str) and msg.startswith(IMPORT_PREFIX):
|
|
545
|
+
if current_task is not None and not current_task.done():
|
|
546
|
+
current_task.cancel()
|
|
547
|
+
payload = msg[len(IMPORT_PREFIX) :].strip()
|
|
548
|
+
try:
|
|
549
|
+
imported = json.loads(payload) if payload else []
|
|
550
|
+
except json.JSONDecodeError:
|
|
551
|
+
imported = []
|
|
552
|
+
meta = None
|
|
553
|
+
if isinstance(imported, dict):
|
|
554
|
+
meta = imported.get("meta")
|
|
555
|
+
imported = imported.get("cards", [])
|
|
556
|
+
normalized = []
|
|
557
|
+
if isinstance(imported, list):
|
|
558
|
+
if imported and all(isinstance(item, dict) and "role" in item for item in imported):
|
|
559
|
+
pending_question = None
|
|
560
|
+
for item in imported:
|
|
561
|
+
role = item.get("role")
|
|
562
|
+
content = item.get("content", "")
|
|
563
|
+
if role == "User":
|
|
564
|
+
pending_question = content
|
|
565
|
+
elif role == "Assistant":
|
|
566
|
+
if pending_question is None:
|
|
567
|
+
continue
|
|
568
|
+
normalized.append(
|
|
569
|
+
{
|
|
570
|
+
"id": str(len(normalized)),
|
|
571
|
+
"question": pending_question,
|
|
572
|
+
"answer": content,
|
|
573
|
+
}
|
|
574
|
+
)
|
|
575
|
+
pending_question = None
|
|
576
|
+
else:
|
|
577
|
+
for item in imported:
|
|
578
|
+
if not isinstance(item, dict):
|
|
579
|
+
continue
|
|
580
|
+
question = item.get("question")
|
|
581
|
+
answer = item.get("answer")
|
|
582
|
+
answer_text = item.get("answer_text")
|
|
583
|
+
if question is None or answer is None:
|
|
584
|
+
continue
|
|
585
|
+
normalized.append(
|
|
586
|
+
{
|
|
587
|
+
"id": str(len(normalized)),
|
|
588
|
+
"question": str(question),
|
|
589
|
+
"answer": str(answer),
|
|
590
|
+
"answer_text": str(answer_text) if answer_text is not None else None,
|
|
591
|
+
}
|
|
592
|
+
)
|
|
593
|
+
session["cards"] = normalized
|
|
594
|
+
if meta is not None and hasattr(session_responder, "load_state"):
|
|
595
|
+
try:
|
|
596
|
+
session_responder.load_state(meta)
|
|
597
|
+
except Exception:
|
|
598
|
+
pass
|
|
599
|
+
if hasattr(session_responder, "load_history"):
|
|
600
|
+
try:
|
|
601
|
+
session_responder.load_history(normalized, context=session.get("context"))
|
|
602
|
+
except Exception:
|
|
603
|
+
pass
|
|
604
|
+
await send(render_cards(normalized))
|
|
605
|
+
await send(render_chat_data(normalized))
|
|
606
|
+
await send(render_chat_export(normalized, responder=session_responder))
|
|
607
|
+
return
|
|
608
|
+
|
|
609
|
+
if isinstance(msg, str) and msg.startswith(STOP_PREFIX):
|
|
610
|
+
if current_task is not None and not current_task.done():
|
|
611
|
+
current_task.cancel()
|
|
612
|
+
return
|
|
613
|
+
|
|
614
|
+
if current_task is not None and not current_task.done():
|
|
615
|
+
current_task.cancel()
|
|
616
|
+
|
|
617
|
+
session["task"] = asyncio.create_task(_run_message(msg))
|
|
618
|
+
return
|
|
619
|
+
|
|
620
|
+
return sessions
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def register_routes(
|
|
624
|
+
app,
|
|
625
|
+
responder=None,
|
|
626
|
+
responder_factory=None,
|
|
627
|
+
tag_line: str = "STREAMING DEMO",
|
|
628
|
+
title: str = "Minimal Stream Chat",
|
|
629
|
+
subtitle: str = "One question, one answer card. Response streams character-by-character.",
|
|
630
|
+
base_path: str = "",
|
|
631
|
+
inject_headers: bool = False,
|
|
632
|
+
include_markdown: bool = True,
|
|
633
|
+
tag_line_href: str = "",
|
|
634
|
+
google_oauth_config: GoogleOAuthConfig | None = None,
|
|
635
|
+
auth_required: bool | None = None,
|
|
636
|
+
):
|
|
637
|
+
if responder_factory is None and responder is not None and hasattr(responder, "message_history"):
|
|
638
|
+
raise ValueError(
|
|
639
|
+
"Responder appears to be stateful (has message_history). "
|
|
640
|
+
"Pass responder_factory to create a fresh responder per connection."
|
|
641
|
+
)
|
|
642
|
+
register_core_static(app)
|
|
643
|
+
base_path = _normalize_base_path(base_path)
|
|
644
|
+
chat_path = f"{base_path}/" if base_path else "/"
|
|
645
|
+
ws_path = f"{base_path}/ws" if base_path else "/ws"
|
|
646
|
+
|
|
647
|
+
oauth_cfg = google_oauth_config or google_oauth_config_from_env()
|
|
648
|
+
if auth_required is None:
|
|
649
|
+
auth_required = bool(oauth_cfg and oauth_cfg.auth_required)
|
|
650
|
+
auth_paths = None
|
|
651
|
+
if oauth_cfg:
|
|
652
|
+
auth_paths = _register_google_auth_routes(app, oauth_cfg, base_path=base_path)
|
|
653
|
+
elif auth_required:
|
|
654
|
+
raise ValueError("auth_required=True needs google_oauth_config or PYLOGUE_GOOGLE_* env vars.")
|
|
655
|
+
|
|
656
|
+
if inject_headers:
|
|
657
|
+
for header in get_core_headers(include_markdown=include_markdown):
|
|
658
|
+
app.hdrs = (*app.hdrs, header)
|
|
659
|
+
|
|
660
|
+
if responder_factory is None:
|
|
661
|
+
responder = responder or EchoResponder()
|
|
662
|
+
register_ws_routes(
|
|
663
|
+
app,
|
|
664
|
+
responder=responder,
|
|
665
|
+
responder_factory=responder_factory,
|
|
666
|
+
base_path=base_path,
|
|
667
|
+
auth_required=auth_required,
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
@app.route(chat_path)
|
|
671
|
+
def home(request: Request):
|
|
672
|
+
auth = _request_auth(request)
|
|
673
|
+
if auth_required and not auth:
|
|
674
|
+
request.session["next"] = chat_path
|
|
675
|
+
login_path = auth_paths["login_path"] if auth_paths else "/login"
|
|
676
|
+
return RedirectResponse(f"{login_path}?next={quote_plus(chat_path)}", status_code=303)
|
|
677
|
+
|
|
678
|
+
tag_line_node = (
|
|
679
|
+
A(
|
|
680
|
+
tag_line,
|
|
681
|
+
href=tag_line_href,
|
|
682
|
+
cls="text-xs uppercase tracking-widest text-slate-500 hover:text-slate-700",
|
|
683
|
+
)
|
|
684
|
+
if tag_line_href
|
|
685
|
+
else P(tag_line, cls="text-xs uppercase tracking-widest text-slate-500")
|
|
686
|
+
)
|
|
687
|
+
user_email = auth.get("email") if isinstance(auth, dict) else None
|
|
688
|
+
logout_href = auth_paths["logout_path"] if auth_paths else "/logout"
|
|
689
|
+
auth_bar = (
|
|
690
|
+
Div(
|
|
691
|
+
P(user_email or "Signed in", cls="text-xs text-slate-500"),
|
|
692
|
+
A("Sign out", href=logout_href, cls="text-xs text-slate-700 hover:text-slate-900 underline"),
|
|
693
|
+
cls="flex items-center justify-end gap-3",
|
|
694
|
+
)
|
|
695
|
+
if auth
|
|
696
|
+
else None
|
|
697
|
+
)
|
|
698
|
+
return (
|
|
699
|
+
Title(title),
|
|
700
|
+
Meta(name="viewport", content="width=device-width, initial-scale=1.0"),
|
|
701
|
+
Body(
|
|
702
|
+
Container(
|
|
703
|
+
Div(
|
|
704
|
+
auth_bar,
|
|
705
|
+
Div(
|
|
706
|
+
tag_line_node,
|
|
707
|
+
H1(title, cls="text-3xl md:text-4xl font-semibold text-slate-900"),
|
|
708
|
+
P(subtitle, cls=(TextPresets.muted_sm, "text-slate-600")),
|
|
709
|
+
cls="space-y-2",
|
|
710
|
+
),
|
|
711
|
+
Div(
|
|
712
|
+
Button(
|
|
713
|
+
UkIcon("download"),
|
|
714
|
+
cls="uk-button uk-button-text copy-chat-btn",
|
|
715
|
+
type="button",
|
|
716
|
+
aria_label="Download conversation JSON",
|
|
717
|
+
title="Download conversation JSON",
|
|
718
|
+
),
|
|
719
|
+
Button(
|
|
720
|
+
UkIcon("upload"),
|
|
721
|
+
cls="uk-button uk-button-text copy-chat-btn upload-chat-btn",
|
|
722
|
+
type="button",
|
|
723
|
+
aria_label="Upload conversation JSON",
|
|
724
|
+
title="Upload conversation JSON",
|
|
725
|
+
),
|
|
726
|
+
Input(
|
|
727
|
+
type="file",
|
|
728
|
+
id="chat-upload",
|
|
729
|
+
accept="application/json",
|
|
730
|
+
cls="sr-only",
|
|
731
|
+
),
|
|
732
|
+
cls="flex justify-end gap-2",
|
|
733
|
+
),
|
|
734
|
+
Div(
|
|
735
|
+
Div(render_cards([])),
|
|
736
|
+
Form(
|
|
737
|
+
render_input(),
|
|
738
|
+
Div(
|
|
739
|
+
Button("Send", cls=ButtonT.primary, type="submit", id="chat-send-btn"),
|
|
740
|
+
P("Cmd/Ctrl+Enter to send", cls="text-xs text-slate-400"),
|
|
741
|
+
cls="flex flex-col gap-2 items-stretch",
|
|
742
|
+
),
|
|
743
|
+
id="form",
|
|
744
|
+
hx_ext="ws",
|
|
745
|
+
ws_connect=ws_path,
|
|
746
|
+
ws_send=True,
|
|
747
|
+
hx_target="#cards",
|
|
748
|
+
hx_swap="outerHTML",
|
|
749
|
+
cls="flex flex-col sm:flex-row gap-3 items-stretch pt-4",
|
|
750
|
+
),
|
|
751
|
+
cls="chat-panel space-y-4",
|
|
752
|
+
),
|
|
753
|
+
cls="space-y-6",
|
|
754
|
+
),
|
|
755
|
+
cls=(ContainerT.lg, "py-10"),
|
|
756
|
+
),
|
|
757
|
+
cls="min-h-screen bg-slate-50 text-slate-900",
|
|
758
|
+
),
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
def main(
|
|
762
|
+
responder=None,
|
|
763
|
+
responder_factory=None,
|
|
764
|
+
tag_line: str = "STREAMING DEMO",
|
|
765
|
+
title: str = "Minimal Stream Chat",
|
|
766
|
+
subtitle: str = "One question, one answer card. Response streams character-by-character.",
|
|
767
|
+
include_markdown: bool = True,
|
|
768
|
+
tag_line_href: str = "",
|
|
769
|
+
google_oauth_config: GoogleOAuthConfig | None = None,
|
|
770
|
+
auth_required: bool | None = None,
|
|
771
|
+
):
|
|
772
|
+
if responder is None:
|
|
773
|
+
responder = EchoResponder()
|
|
774
|
+
headers = get_core_headers(include_markdown=include_markdown)
|
|
775
|
+
oauth_cfg = google_oauth_config or google_oauth_config_from_env()
|
|
776
|
+
session_secret = (
|
|
777
|
+
oauth_cfg.session_secret
|
|
778
|
+
if oauth_cfg and oauth_cfg.session_secret
|
|
779
|
+
else os.getenv("PYLOGUE_SESSION_SECRET")
|
|
780
|
+
)
|
|
781
|
+
app_kwargs = {"exts": "ws", "hdrs": tuple(headers), "pico": False}
|
|
782
|
+
app_kwargs["session_cookie"] = _session_cookie_name()
|
|
783
|
+
if session_secret:
|
|
784
|
+
app_kwargs["secret_key"] = session_secret
|
|
785
|
+
app = MUFastHTML(**app_kwargs)
|
|
786
|
+
register_routes(
|
|
787
|
+
app,
|
|
788
|
+
responder=responder,
|
|
789
|
+
responder_factory=responder_factory,
|
|
790
|
+
tag_line=tag_line,
|
|
791
|
+
title=title,
|
|
792
|
+
subtitle=subtitle,
|
|
793
|
+
tag_line_href=tag_line_href,
|
|
794
|
+
base_path="",
|
|
795
|
+
google_oauth_config=oauth_cfg,
|
|
796
|
+
auth_required=auth_required,
|
|
797
|
+
)
|
|
798
|
+
return app
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
if __name__ == "__main__":
|
|
802
|
+
import uvicorn
|
|
803
|
+
|
|
804
|
+
uvicorn.run("pylogue.core:main", host="0.0.0.0", port=5001, reload=True, factory=True)
|