diff --git a/src/dashboard/app.py b/src/dashboard/app.py index 7e1ccba9..d86edb4b 100644 --- a/src/dashboard/app.py +++ b/src/dashboard/app.py @@ -43,6 +43,7 @@ from dashboard.routes.memory import router as memory_router from dashboard.routes.mobile import router as mobile_router from dashboard.routes.models import api_router as models_api_router from dashboard.routes.models import router as models_router +from dashboard.routes.monologue import router as monologue_router from dashboard.routes.quests import router as quests_router from dashboard.routes.scorecards import router as scorecards_router from dashboard.routes.spark import router as spark_router @@ -618,6 +619,7 @@ app.include_router(models_api_router) app.include_router(chat_api_router) app.include_router(chat_api_v1_router) app.include_router(thinking_router) +app.include_router(monologue_router) app.include_router(calm_router) app.include_router(tasks_router) app.include_router(work_orders_router) diff --git a/src/dashboard/routes/monologue.py b/src/dashboard/routes/monologue.py new file mode 100644 index 00000000..fd4af407 --- /dev/null +++ b/src/dashboard/routes/monologue.py @@ -0,0 +1,110 @@ +"""Internal Monologue Visualizer — real-time agent reasoning stream. + +GET /monologue — render the monologue visualizer page +GET /monologue/api — JSON list of recent thoughts (with optional summary) +WS /monologue/ws — WebSocket stream of live thoughts +""" + +import json +import logging + +from fastapi import APIRouter, Query, Request, WebSocket, WebSocketDisconnect +from fastapi.responses import HTMLResponse, JSONResponse + +from dashboard.templating import templates +from timmy.thinking import thinking_engine + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/monologue", tags=["monologue"]) + + +def _summarise(content: str, max_len: int = 120) -> str: + """Return a short summary of a thought — first sentence or truncated.""" + # Take first sentence + for sep in (".", "!", "?"): + idx = content.find(sep) + if 0 < idx < max_len: + return content[: idx + 1] + # Fallback: truncate + if len(content) <= max_len: + return content + return content[:max_len].rsplit(" ", 1)[0] + "..." + + +@router.get("", response_class=HTMLResponse) +async def monologue_page(request: Request): + """Render the internal monologue visualizer.""" + thoughts = thinking_engine.get_recent_thoughts(limit=50) + return templates.TemplateResponse( + request, + "monologue.html", + {"thoughts": thoughts}, + ) + + +@router.get("/api", response_class=JSONResponse) +async def monologue_api( + limit: int = Query(default=30, ge=1, le=100), + view: str = Query(default="raw", pattern="^(raw|summarized)$"), +): + """Return recent thoughts as JSON, optionally summarised.""" + thoughts = thinking_engine.get_recent_thoughts(limit=limit) + results = [] + for t in thoughts: + entry = { + "id": t.id, + "content": t.content, + "seed_type": t.seed_type, + "parent_id": t.parent_id, + "created_at": t.created_at, + } + if view == "summarized": + entry["summary"] = _summarise(t.content) + results.append(entry) + return results + + +@router.websocket("/ws") +async def monologue_ws(websocket: WebSocket): + """Stream thoughts to the client in real time. + + Piggybacks on the existing ws_manager broadcast infrastructure. + We register a dedicated connection list here so monologue clients + only receive thought events (not all swarm traffic). + """ + await websocket.accept() + logger.info("Monologue WebSocket connected") + + # Send recent thoughts as backfill + try: + recent = thinking_engine.get_recent_thoughts(limit=20) + for t in reversed(recent): # oldest first + await websocket.send_text( + json.dumps( + { + "event": "thought", + "data": { + "thought_id": t.id, + "content": t.content, + "seed_type": t.seed_type, + "parent_id": t.parent_id, + "created_at": t.created_at, + }, + } + ) + ) + except Exception as exc: + logger.warning("Monologue backfill error: %s", exc) + + # Register with ws_manager so we receive broadcast events + from infrastructure.ws_manager.handler import ws_manager + + await ws_manager.connect(websocket, accept=False) + try: + while True: + # Keep alive — client may send pings or view-mode switches + await websocket.receive_text() + except (WebSocketDisconnect, Exception) as exc: + logger.debug("Monologue WebSocket disconnect: %s", exc) + ws_manager.disconnect(websocket) diff --git a/src/dashboard/templates/base.html b/src/dashboard/templates/base.html index 7a1b234d..85cf52b2 100644 --- a/src/dashboard/templates/base.html +++ b/src/dashboard/templates/base.html @@ -49,6 +49,7 @@ TASKS BRIEFING THINKING + MONOLOGUE MISSION CTRL SWARM SCORECARDS @@ -122,6 +123,7 @@ TASKS BRIEFING THINKING + MONOLOGUE MISSION CONTROL SWARM SCORECARDS diff --git a/src/dashboard/templates/monologue.html b/src/dashboard/templates/monologue.html new file mode 100644 index 00000000..268266fb --- /dev/null +++ b/src/dashboard/templates/monologue.html @@ -0,0 +1,221 @@ +{% extends "base.html" %} + +{% block title %}Internal Monologue{% endblock %} + +{% block extra_styles %}{% endblock %} + +{% block content %} +
+ +
+
Internal Monologue
+
+ Real-time reasoning stream — watch the agent think. +
+
+ + {# ── Controls ── #} +
+
+ + +
+
+ + Connecting... +
+ +
+ + {# ── Thought stream ── #} +
+
+ // INTERNAL MONOLOGUE + {{ thoughts | length }} thoughts +
+
+
+ {% if thoughts %} + {% for thought in thoughts %} +
+
+ {{ thought.created_at[11:19] }} + {{ thought.seed_type }} +
+
+ {{ thought.content | e }} +
+
+ {% endfor %} + {% else %} +
+ Waiting for first thought... the thinking engine will begin shortly after startup. +
+ {% endif %} +
+
+
+ +
+ + +{% endblock %} diff --git a/static/css/mission-control.css b/static/css/mission-control.css index fc844301..cef42e19 100644 --- a/static/css/mission-control.css +++ b/static/css/mission-control.css @@ -2547,3 +2547,73 @@ .tower-adv-title { font-size: 0.85rem; font-weight: 600; color: var(--text-bright); } .tower-adv-detail { font-size: 0.8rem; color: var(--text); margin-top: 2px; } .tower-adv-action { font-size: 0.75rem; color: var(--green); margin-top: 4px; font-style: italic; } + +/* ── Internal Monologue Visualizer ────────────────────────── */ + +.monologue-container { max-width: 960px; } +.monologue-title { font-size: 1.6rem; font-weight: 700; color: var(--text-bright); letter-spacing: 0.04em; } +.monologue-subtitle { font-size: 0.85rem; color: var(--text-dim); margin-top: 2px; } + +/* Controls bar */ +.monologue-controls { flex-wrap: wrap; } +.monologue-btn { font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; letter-spacing: 0.08em; border: 1px solid var(--border); color: var(--text-dim); background: transparent; padding: 3px 10px; transition: all 0.2s ease; } +.monologue-btn:hover { color: var(--text); border-color: var(--purple); } +.monologue-btn.active { color: var(--purple); border-color: var(--purple); background: rgba(168, 85, 247, 0.08); } +.monologue-count-badge { background: rgba(168, 85, 247, 0.15); color: var(--purple); font-size: 0.7rem; letter-spacing: 0.06em; } + +/* Status indicator */ +.monologue-status { display: flex; align-items: center; gap: 6px; font-size: 0.7rem; color: var(--text-dim); letter-spacing: 0.06em; } +.monologue-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-dim); transition: background 0.3s ease; } +.monologue-dot-connected { background: var(--green); box-shadow: 0 0 6px var(--green); animation: monologue-pulse 2s ease-in-out infinite; } +.monologue-dot-connecting { background: var(--amber); animation: monologue-pulse 1s ease-in-out infinite; } +.monologue-dot-disconnected { background: var(--red); } + +@keyframes monologue-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* Stream container */ +.monologue-stream { max-height: 70vh; overflow-y: auto; padding: 0; font-family: 'JetBrains Mono', monospace; font-size: 0.8rem; line-height: 1.6; scrollbar-width: thin; scrollbar-color: var(--border) transparent; } +.monologue-stream::-webkit-scrollbar { width: 6px; } +.monologue-stream::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } +.monologue-stream::-webkit-scrollbar-track { background: transparent; } + +/* Individual thought entry */ +.monologue-entry { display: flex; gap: 12px; padding: 8px 14px; border-bottom: 1px solid rgba(59, 26, 92, 0.3); transition: opacity 0.4s ease, transform 0.4s ease, background 0.2s ease; } +.monologue-entry:hover { background: rgba(168, 85, 247, 0.04); } +.monologue-entry-new { opacity: 0; transform: translateY(8px); } + +/* Gutter (timestamp + seed badge) */ +.monologue-entry-gutter { display: flex; flex-direction: column; align-items: flex-end; min-width: 90px; flex-shrink: 0; gap: 2px; padding-top: 1px; } +.monologue-timestamp { font-size: 0.65rem; color: var(--text-dim); letter-spacing: 0.04em; } +.monologue-seed { font-size: 0.55rem; letter-spacing: 0.08em; text-transform: uppercase; padding: 1px 5px; border-radius: 2px; color: var(--text-dim); background: rgba(59, 26, 92, 0.3); } + +/* Seed-type colour accents */ +.seed-existential { color: var(--purple); background: rgba(168, 85, 247, 0.12); } +.seed-swarm { color: var(--green); background: rgba(0, 232, 122, 0.10); } +.seed-scripture { color: var(--amber); background: rgba(255, 184, 0, 0.10); } +.seed-creative { color: var(--orange); background: rgba(255, 122, 42, 0.10); } +.seed-memory { color: #60a5fa; background: rgba(96, 165, 250, 0.10); } +.seed-freeform { color: var(--text-dim); background: rgba(59, 26, 92, 0.3); } +.seed-sovereignty { color: var(--red); background: rgba(255, 68, 85, 0.08); } +.seed-observation { color: var(--green); background: rgba(0, 232, 122, 0.08); } +.seed-workspace { color: var(--amber); background: rgba(255, 184, 0, 0.08); } +.seed-prompted { color: var(--text-bright); background: rgba(237, 224, 255, 0.08); } + +/* Content area */ +.monologue-entry-content { flex: 1; color: var(--text); word-break: break-word; } +.monologue-summary { color: var(--text-dim); font-style: italic; } + +/* Empty state */ +.monologue-empty { padding: 40px 20px; text-align: center; color: var(--text-dim); font-size: 0.85rem; } + +/* Scroll button */ +.monologue-scroll-btn.active { color: var(--green); border-color: var(--green); background: rgba(0, 232, 122, 0.08); } + +/* Mobile adjustments */ +@media (max-width: 576px) { + .monologue-entry { flex-direction: column; gap: 4px; } + .monologue-entry-gutter { flex-direction: row; align-items: center; min-width: auto; gap: 8px; } + .monologue-stream { max-height: 60vh; font-size: 0.75rem; } +} diff --git a/tests/dashboard/test_monologue.py b/tests/dashboard/test_monologue.py new file mode 100644 index 00000000..f16a242c --- /dev/null +++ b/tests/dashboard/test_monologue.py @@ -0,0 +1,84 @@ +"""Tests for the internal monologue visualizer routes.""" + +import pytest + + +@pytest.fixture(name="client") +def client_fixture(): + from fastapi.testclient import TestClient + + from dashboard.app import app + + with TestClient(app) as c: + yield c + + +class TestMonologuePage: + """GET /monologue — renders the visualizer page.""" + + def test_page_returns_200(self, client): + resp = client.get("/monologue") + assert resp.status_code == 200 + assert "Internal Monologue" in resp.text + + def test_page_contains_stream_container(self, client): + resp = client.get("/monologue") + assert 'id="monologue-stream"' in resp.text + + def test_page_contains_view_toggle(self, client): + resp = client.get("/monologue") + assert 'data-view="raw"' in resp.text + assert 'data-view="summarized"' in resp.text + + +class TestMonologueAPI: + """GET /monologue/api — JSON thought list.""" + + def test_api_returns_list(self, client): + resp = client.get("/monologue/api") + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + + def test_api_respects_limit(self, client): + resp = client.get("/monologue/api?limit=5") + assert resp.status_code == 200 + data = resp.json() + assert len(data) <= 5 + + def test_api_raw_view_no_summary(self, client): + resp = client.get("/monologue/api?view=raw") + assert resp.status_code == 200 + data = resp.json() + for entry in data: + assert "summary" not in entry + + def test_api_summarized_view_has_summary(self, client): + resp = client.get("/monologue/api?view=summarized") + assert resp.status_code == 200 + data = resp.json() + for entry in data: + assert "summary" in entry + + +class TestSummarise: + """Unit tests for the _summarise helper.""" + + def test_short_text_unchanged(self): + from dashboard.routes.monologue import _summarise + + assert _summarise("Short thought.") == "Short thought." + + def test_first_sentence_extracted(self): + from dashboard.routes.monologue import _summarise + + text = "First sentence. Second sentence continues here." + assert _summarise(text) == "First sentence." + + def test_long_text_truncated(self): + from dashboard.routes.monologue import _summarise + + text = "A " * 200 # No sentence-ending punctuation + result = _summarise(text.strip()) + assert len(result) <= 125 # max_len + ellipsis + assert result.endswith("...")