5176c706b6
Built from her own 6-19 idea: a continuing train of thought she keeps across days, organized into threads she returns to, that she can bring TO Brian and that his feedback advances or closes. Where the dream cycle's reflect() gives isolated, overwriting reflections, the thought loop adds continuity (threads), surfacing (#6 — she leads with a thought when Brian returns after a gap), and a feedback loop (his reply folds in next pass). - lyra/thoughts.py: thought_threads + thoughts tables; think() with new/continue/respond modes; salience-gated maybe_surface(); record_response() feedback; lazy-schema _c() mirroring poker. - dream.py: curiosity stage advances the loop after reflecting (error-isolated). - chat.py: build_messages surfaces the top thread after a >=90min gap, once. - web: /thoughts feed (page + data + respond + status routes), thoughts.html, nav 💭 entry. lyra-think entry point. Every thought also lands in her journal. - clock.gap_seconds(); tests/test_thoughts.py (8 tests). Full suite 58 passing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
389 lines
16 KiB
Python
389 lines
16 KiB
Python
"""Web server for the vendored chat UI.
|
|
|
|
Serves the static single-page UI and implements the small endpoint contract it
|
|
expects (originally provided by the old Node relay), backed by the new Python
|
|
chat loop and SQLite memory. SQLite is the single source of truth for messages:
|
|
`/v1/chat/completions` persists via `chat.respond`, so the UI's `POST /sessions`
|
|
saves are accepted but treated as no-ops (the row is ensured, messages are not
|
|
re-stored).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI, Request, Response
|
|
from fastapi.responses import FileResponse, StreamingResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from lyra import chat, logbus, memory, modes, poker, self_state, summary, thoughts
|
|
from lyra.llm import Backend
|
|
|
|
|
|
def _sse(event: dict) -> str:
|
|
return f"data: {json.dumps(event)}\n\n"
|
|
|
|
_STATIC = Path(__file__).parent / "static"
|
|
|
|
# UI backend labels -> our two backends. Cloud is the default.
|
|
_CLOUD = {"OPENAI", "cloud", "custom"}
|
|
|
|
|
|
def _backend_for(label: str | None) -> Backend:
|
|
key = (label or "").lower()
|
|
if key == "mi50":
|
|
return "mi50"
|
|
if key in {"local", "primary", "secondary", "fallback"}:
|
|
return "local"
|
|
return "cloud"
|
|
|
|
|
|
def _last_user_message(messages: list[dict]) -> str:
|
|
for m in reversed(messages):
|
|
if m.get("role") == "user":
|
|
return m.get("content", "")
|
|
return messages[-1].get("content", "") if messages else ""
|
|
|
|
|
|
def create_app() -> FastAPI:
|
|
app = FastAPI(title="Lyra Web")
|
|
|
|
@app.get("/_health")
|
|
async def health() -> dict:
|
|
return {"ok": True}
|
|
|
|
@app.get("/sessions")
|
|
async def list_sessions() -> list[dict]:
|
|
return memory.list_sessions()
|
|
|
|
@app.get("/sessions/{session_id}")
|
|
async def get_session(session_id: str) -> list[dict]:
|
|
return [{"role": ex.role, "content": ex.content} for ex in memory.history(session_id)]
|
|
|
|
@app.post("/sessions/{session_id}")
|
|
async def save_session(session_id: str, request: Request) -> dict:
|
|
# Messages are already persisted by chat.respond; just ensure the row exists.
|
|
await request.body() # drain the history payload we intentionally ignore
|
|
memory.ensure_session(session_id)
|
|
return {"ok": True}
|
|
|
|
@app.patch("/sessions/{session_id}/metadata")
|
|
async def rename_session(session_id: str, request: Request) -> dict:
|
|
body = await request.json()
|
|
memory.ensure_session(session_id, name=body.get("name"))
|
|
return {"ok": True}
|
|
|
|
@app.delete("/sessions/{session_id}")
|
|
async def delete_session(session_id: str) -> dict:
|
|
memory.delete_session(session_id)
|
|
return {"ok": True}
|
|
|
|
@app.post("/sessions/{session_id}/summarize")
|
|
async def summarize(session_id: str) -> dict:
|
|
gist = await asyncio.to_thread(summary.summarize_session, session_id)
|
|
return {"ok": gist is not None, "summary": gist}
|
|
|
|
@app.get("/modes")
|
|
async def list_modes() -> dict:
|
|
"""Available conversation modes, for the UI switcher."""
|
|
return {"modes": modes.listing(), "default": modes.DEFAULT}
|
|
|
|
@app.get("/sessions/{session_id}/mode")
|
|
async def get_mode(session_id: str) -> dict:
|
|
return {"mode": memory.get_session_mode(session_id) or modes.DEFAULT}
|
|
|
|
@app.post("/sessions/{session_id}/mode")
|
|
async def set_mode(session_id: str, request: Request) -> dict:
|
|
body = await request.json()
|
|
mode = body.get("mode") or modes.DEFAULT
|
|
memory.set_session_mode(session_id, mode)
|
|
logbus.log("info", "mode set", session=session_id, mode=mode)
|
|
return {"ok": True, "mode": mode}
|
|
|
|
@app.get("/session")
|
|
async def session_hud_page() -> FileResponse:
|
|
"""Live session HUD — stack, hands, villains, notes for the open session."""
|
|
return FileResponse(str(_STATIC / "session.html"))
|
|
|
|
@app.get("/session/data")
|
|
async def session_hud_data(id: int | None = None) -> dict:
|
|
"""HUD bundle for the live session, or a specific past session via ?id=."""
|
|
bundle = await asyncio.to_thread(poker.hud, id)
|
|
return bundle or {"session": None}
|
|
|
|
@app.patch("/session/{session_id}")
|
|
async def session_update(session_id: int, request: Request) -> dict:
|
|
"""Edit a session's details (venue/stakes/game/buy-in/cash-out/…)."""
|
|
body = await request.json()
|
|
s = await asyncio.to_thread(lambda: poker.update_session(session_id, **body))
|
|
logbus.log("info", "session edited", id=session_id, fields=list(body))
|
|
return {"ok": s is not None, "session": s}
|
|
|
|
@app.delete("/session/entry/{kind}/{entry_id}")
|
|
async def delete_entry(kind: str, entry_id: int) -> dict:
|
|
"""Delete one HUD entry (hand | stack | read | ritual) by id."""
|
|
ok = await asyncio.to_thread(poker.delete_entry, kind, entry_id)
|
|
logbus.log("info", "hud entry deleted", kind=kind, id=entry_id, ok=ok)
|
|
return {"ok": ok}
|
|
|
|
@app.get("/history")
|
|
async def history_page() -> FileResponse:
|
|
"""Browsable list of past poker sessions."""
|
|
return FileResponse(str(_STATIC / "history.html"))
|
|
|
|
@app.get("/history/data")
|
|
async def history_data(limit: int = 100, include_review: bool = False) -> dict:
|
|
return {"sessions": poker.list_sessions(limit=limit, include_review=include_review)}
|
|
|
|
@app.delete("/history/{session_id}")
|
|
async def history_delete(session_id: int) -> dict:
|
|
removed = await asyncio.to_thread(poker.delete_session, session_id)
|
|
logbus.log("info", "poker session deleted", id=session_id, removed=removed)
|
|
return {"ok": True, "removed": removed}
|
|
|
|
@app.post("/v1/chat/completions")
|
|
async def chat_completions(request: Request) -> dict:
|
|
body = await request.json()
|
|
session_id = body.get("sessionId") or "default"
|
|
backend = _backend_for(body.get("backend"))
|
|
user_msg = _last_user_message(body.get("messages", []))
|
|
|
|
model_override = body.get("model") or None
|
|
memory.ensure_session(session_id)
|
|
if body.get("mode"):
|
|
memory.set_session_mode(session_id, body["mode"])
|
|
try:
|
|
reply = await asyncio.to_thread(chat.respond, session_id, user_msg, backend, model_override)
|
|
except Exception as exc:
|
|
logbus.log("error", "chat failed", session=session_id, error=str(exc))
|
|
reply = f"[error] {exc}"
|
|
|
|
return {
|
|
"object": "chat.completion",
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"message": {"role": "assistant", "content": reply},
|
|
"finish_reason": "stop",
|
|
}
|
|
],
|
|
}
|
|
|
|
@app.post("/v1/chat/stream")
|
|
async def chat_stream(request: Request) -> StreamingResponse:
|
|
"""Server-Sent Events: stream Lyra's reply token-by-token.
|
|
|
|
`chat.respond_stream` is a blocking generator (httpx/openai), so it runs in
|
|
a worker thread and bridges chunks to this async generator via a queue.
|
|
"""
|
|
body = await request.json()
|
|
session_id = body.get("sessionId") or "default"
|
|
backend = _backend_for(body.get("backend"))
|
|
user_msg = _last_user_message(body.get("messages", []))
|
|
model_override = body.get("model") or None
|
|
memory.ensure_session(session_id)
|
|
if body.get("mode"):
|
|
memory.set_session_mode(session_id, body["mode"])
|
|
|
|
async def gen():
|
|
loop = asyncio.get_running_loop()
|
|
q: asyncio.Queue = asyncio.Queue()
|
|
done = object()
|
|
|
|
def produce():
|
|
try:
|
|
for event in chat.respond_stream(session_id, user_msg, backend, model_override):
|
|
loop.call_soon_threadsafe(q.put_nowait, event)
|
|
except Exception as exc: # surface to the client stream, don't hang
|
|
logbus.log("error", "chat stream failed", session=session_id, error=str(exc))
|
|
loop.call_soon_threadsafe(q.put_nowait, ("error", str(exc)))
|
|
finally:
|
|
loop.call_soon_threadsafe(q.put_nowait, done)
|
|
|
|
loop.run_in_executor(None, produce)
|
|
while True:
|
|
item = await q.get()
|
|
if item is done:
|
|
break
|
|
ev, payload = item
|
|
yield f"data: {json.dumps({'type': ev, 'payload': payload})}\n\n"
|
|
|
|
return StreamingResponse(gen(), media_type="text/event-stream")
|
|
|
|
@app.get("/logs")
|
|
async def logs_page() -> FileResponse:
|
|
"""Full-page, mobile-friendly live log viewer (separate from the chat UI)."""
|
|
return FileResponse(str(_STATIC / "logs.html"))
|
|
|
|
@app.get("/self")
|
|
async def self_page() -> FileResponse:
|
|
"""'Read her mind' — a view of Lyra's current self-state."""
|
|
return FileResponse(str(_STATIC / "self.html"))
|
|
|
|
@app.get("/self/state")
|
|
async def self_state_json() -> dict:
|
|
"""Lyra's current interiority + when it last changed."""
|
|
return {"state": self_state.load(), "updated_at": memory.self_state_updated_at()}
|
|
|
|
@app.post("/self/reflect")
|
|
async def self_reflect() -> dict:
|
|
"""Run one two-step reflection now, in this process, so the draft ->
|
|
revised -> critique lands in the live log (/logs)."""
|
|
state = await asyncio.to_thread(self_state.reflect)
|
|
return {"ok": True, "mood": state.get("mood")}
|
|
|
|
@app.get("/journal")
|
|
async def journal_page() -> FileResponse:
|
|
"""Lyra's journal — the permanent, append-only record of her thoughts."""
|
|
return FileResponse(str(_STATIC / "journal.html"))
|
|
|
|
@app.get("/journal/data")
|
|
async def journal_data(limit: int = 300) -> dict:
|
|
return {"entries": memory.list_journal(limit=limit)}
|
|
|
|
@app.get("/thoughts")
|
|
async def thoughts_page() -> FileResponse:
|
|
"""Lyra's thought loop — threads she's been turning over, and a place to reply."""
|
|
return FileResponse(str(_STATIC / "thoughts.html"))
|
|
|
|
@app.get("/thoughts/data")
|
|
async def thoughts_data(limit: int = 200) -> dict:
|
|
"""Every thread with its chain of thoughts, newest-active first."""
|
|
def bundle() -> list[dict]:
|
|
order = {"surfaced": 0, "open": 1, "resting": 2, "answered": 3, "dropped": 4}
|
|
threads = thoughts.list_threads(limit=limit)
|
|
threads.sort(key=lambda t: (order.get(t["status"], 9), t["updated_at"]), reverse=False)
|
|
for t in threads:
|
|
t["thoughts"] = thoughts.thread_thoughts(t["id"])
|
|
return threads
|
|
return {"threads": await asyncio.to_thread(bundle)}
|
|
|
|
@app.post("/thoughts/{thread_id}/respond")
|
|
async def thoughts_respond(thread_id: int, request: Request) -> dict:
|
|
"""Brian replies to a thread — folds in next dream pass (the feedback loop)."""
|
|
b = await request.json()
|
|
ok = await asyncio.to_thread(thoughts.record_response, thread_id, b.get("text", ""))
|
|
return {"ok": ok}
|
|
|
|
@app.post("/thoughts/{thread_id}/status")
|
|
async def thoughts_status(thread_id: int, request: Request) -> dict:
|
|
"""Set a thread's status (e.g. drop a thread, or reopen one)."""
|
|
b = await request.json()
|
|
ok = await asyncio.to_thread(thoughts.set_status, thread_id, b.get("status", ""))
|
|
return {"ok": ok}
|
|
|
|
@app.post("/rate")
|
|
async def rate(request: Request) -> dict:
|
|
"""Record Brian's 👍/👎 on a Lyra output (chat reply, reflection, journal)."""
|
|
b = await request.json()
|
|
rating = int(b.get("rating", 0))
|
|
content = (b.get("content") or "").strip()
|
|
if not content or rating == 0:
|
|
return {"ok": False}
|
|
memory.add_rating(
|
|
kind=b.get("kind") or "chat", rating=rating, content=content,
|
|
context=(b.get("context") or None), ref=b.get("ref"), note=b.get("note"),
|
|
)
|
|
logbus.log("info", "rating", kind=b.get("kind"), rating=1 if rating >= 0 else -1)
|
|
return {"ok": True, "counts": memory.rating_counts()}
|
|
|
|
@app.get("/ratings/counts")
|
|
async def ratings_counts() -> dict:
|
|
return memory.rating_counts()
|
|
|
|
@app.get("/ratings/export")
|
|
async def ratings_export() -> Response:
|
|
"""All ratings as JSONL — the seed for a future fine-tune / preference set."""
|
|
lines = "\n".join(json.dumps(r) for r in memory.list_ratings())
|
|
return Response(content=lines + ("\n" if lines else ""), media_type="application/x-ndjson",
|
|
headers={"Content-Disposition": 'attachment; filename="lyra_ratings.jsonl"'})
|
|
|
|
@app.get("/hand/{hand_id}")
|
|
async def hand_page(hand_id: int) -> FileResponse:
|
|
"""Replayable hand-history viewer."""
|
|
return FileResponse(str(_STATIC / "hand.html"))
|
|
|
|
@app.get("/hand/{hand_id}/data")
|
|
async def hand_data(hand_id: int) -> dict:
|
|
return poker.get_hand(hand_id) or {}
|
|
|
|
@app.post("/hand/{hand_id}/reconstruct")
|
|
async def hand_reconstruct(hand_id: int) -> dict:
|
|
"""Parse a flat (quick-logged) hand's narrative into a replayable structure."""
|
|
out = await asyncio.to_thread(poker.reconstruct_hand, hand_id)
|
|
logbus.log("info", "hand reconstructed", id=hand_id, ok=out is not None)
|
|
return {"ok": out is not None}
|
|
|
|
@app.get("/hands")
|
|
async def hands_page() -> FileResponse:
|
|
return FileResponse(str(_STATIC / "hands.html"))
|
|
|
|
@app.get("/hands/data")
|
|
async def hands_data(limit: int = 60) -> dict:
|
|
return {"hands": poker.list_recent_hands(limit=limit)}
|
|
|
|
@app.get("/recap/{session_id}")
|
|
async def recap_page() -> FileResponse:
|
|
return FileResponse(str(_STATIC / "recap.html"))
|
|
|
|
@app.get("/recap/{session_id}/data")
|
|
async def recap_data(session_id: int) -> dict:
|
|
s = poker.get_session(session_id) or {}
|
|
return {"session": s, "markdown": s.get("recap_md")}
|
|
|
|
@app.get("/recap/{session_id}/download")
|
|
async def recap_download(session_id: int) -> Response:
|
|
s = poker.get_session(session_id) or {}
|
|
md = s.get("recap_md") or "# No recap generated yet\n"
|
|
date = (s.get("started_at") or "session")[:10]
|
|
fname = f"pokerlog_{date}_s{session_id}.md"
|
|
return Response(content=md, media_type="text/markdown",
|
|
headers={"Content-Disposition": f'attachment; filename="{fname}"'})
|
|
|
|
@app.get("/stream/logs")
|
|
async def stream_logs(request: Request) -> StreamingResponse:
|
|
"""Live activity feed: replay the recent buffer, then stream new events."""
|
|
async def gen():
|
|
backlog = logbus.since(0)
|
|
last = backlog[-1]["seq"] if backlog else 0
|
|
for e in backlog:
|
|
yield _sse(e)
|
|
yield _sse(
|
|
{"seq": last, "ts": time.time(), "level": "system",
|
|
"msg": "live log connected", "fields": {}}
|
|
)
|
|
while True:
|
|
if await request.is_disconnected():
|
|
break
|
|
for e in logbus.since(last):
|
|
last = e["seq"]
|
|
yield _sse(e)
|
|
await asyncio.sleep(0.5)
|
|
|
|
return StreamingResponse(gen(), media_type="text/event-stream")
|
|
|
|
# Static UI last, so the API routes above take precedence. html=True serves
|
|
# index.html at "/" and assets (style.css, manifest.json) at their paths.
|
|
app.mount("/", StaticFiles(directory=str(_STATIC), html=True), name="ui")
|
|
return app
|
|
|
|
|
|
app = create_app()
|
|
|
|
|
|
def serve() -> None:
|
|
"""Console-script entry: `lyra-web`."""
|
|
import os
|
|
|
|
import uvicorn
|
|
|
|
host = os.getenv("LYRA_WEB_HOST", "0.0.0.0")
|
|
port = int(os.getenv("LYRA_WEB_PORT", "7078"))
|
|
uvicorn.run(app, host=host, port=port)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
serve()
|