pylogue 0.2.1__py3-none-any.whl → 0.3.29__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 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)