feat: add Agent Internal Monologue Visualizer
Some checks failed
Tests / lint (pull_request) Failing after 13s
Tests / test (pull_request) Has been skipped

Real-time UI component for watching the agent's thinking process:
- New /monologue route with page, JSON API, and WebSocket endpoint
- Live thought streaming via WebSocket with automatic reconnection
- Raw/Summarized view toggle for thought content
- Monospace terminal-style display with Mission Control theme
- Seed-type color-coded badges (existential, creative, sovereignty, etc.)
- Auto-scroll with manual override, connection status indicator
- Mobile-responsive layout
- 10 unit tests covering page, API, and summarise helper

Fixes #1005

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alexander Whitestone
2026-03-23 00:01:04 -04:00
parent 1697e55cdb
commit 2a6ddd1f81
6 changed files with 489 additions and 0 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -49,6 +49,7 @@
<a href="/tasks" class="mc-test-link">TASKS</a>
<a href="/briefing" class="mc-test-link">BRIEFING</a>
<a href="/thinking" class="mc-test-link mc-link-thinking">THINKING</a>
<a href="/monologue" class="mc-test-link mc-link-monologue">MONOLOGUE</a>
<a href="/swarm/mission-control" class="mc-test-link">MISSION CTRL</a>
<a href="/swarm/live" class="mc-test-link">SWARM</a>
<a href="/scorecards" class="mc-test-link">SCORECARDS</a>
@@ -122,6 +123,7 @@
<a href="/tasks" class="mc-mobile-link">TASKS</a>
<a href="/briefing" class="mc-mobile-link">BRIEFING</a>
<a href="/thinking" class="mc-mobile-link">THINKING</a>
<a href="/monologue" class="mc-mobile-link">MONOLOGUE</a>
<a href="/swarm/mission-control" class="mc-mobile-link">MISSION CONTROL</a>
<a href="/swarm/live" class="mc-mobile-link">SWARM</a>
<a href="/scorecards" class="mc-mobile-link">SCORECARDS</a>

View File

@@ -0,0 +1,221 @@
{% extends "base.html" %}
{% block title %}Internal Monologue{% endblock %}
{% block extra_styles %}{% endblock %}
{% block content %}
<div class="container monologue-container py-4">
<div class="monologue-header mb-4">
<div class="monologue-title">Internal Monologue</div>
<div class="monologue-subtitle">
Real-time reasoning stream &mdash; watch the agent think.
</div>
</div>
{# ── Controls ── #}
<div class="monologue-controls mb-3 d-flex align-items-center gap-3">
<div class="monologue-view-toggle btn-group" role="group" aria-label="View mode">
<button type="button" class="btn btn-sm monologue-btn active" data-view="raw">RAW</button>
<button type="button" class="btn btn-sm monologue-btn" data-view="summarized">SUMMARIZED</button>
</div>
<div class="monologue-status">
<span class="monologue-dot"></span>
<span class="monologue-status-text">Connecting...</span>
</div>
<button type="button" class="btn btn-sm monologue-btn monologue-scroll-btn" title="Auto-scroll to latest">AUTO-SCROLL</button>
</div>
{# ── Thought stream ── #}
<div class="card mc-panel">
<div class="card-header mc-panel-header d-flex justify-content-between align-items-center">
<span>// INTERNAL MONOLOGUE</span>
<span class="badge monologue-count-badge">{{ thoughts | length }} thoughts</span>
</div>
<div class="card-body p-0">
<div id="monologue-stream" class="monologue-stream">
{% if thoughts %}
{% for thought in thoughts %}
<div class="monologue-entry" data-thought-id="{{ thought.id }}" data-seed="{{ thought.seed_type }}">
<div class="monologue-entry-gutter">
<span class="monologue-timestamp">{{ thought.created_at[11:19] }}</span>
<span class="monologue-seed seed-{{ thought.seed_type }}">{{ thought.seed_type }}</span>
</div>
<div class="monologue-entry-content">
<span class="monologue-raw">{{ thought.content | e }}</span>
</div>
</div>
{% endfor %}
{% else %}
<div class="monologue-empty">
Waiting for first thought... the thinking engine will begin shortly after startup.
</div>
{% endif %}
</div>
</div>
</div>
</div>
<script>
(function() {
var stream = document.getElementById('monologue-stream');
var statusDot = document.querySelector('.monologue-dot');
var statusText = document.querySelector('.monologue-status-text');
var countBadge = document.querySelector('.monologue-count-badge');
var viewBtns = document.querySelectorAll('.monologue-view-toggle .monologue-btn');
var scrollBtn = document.querySelector('.monologue-scroll-btn');
var currentView = 'raw';
var autoScroll = true;
var thoughtCount = stream.querySelectorAll('.monologue-entry').length;
var ws = null;
var reconnectDelay = 1000;
var seenIds = new Set();
// Track already-rendered thought IDs
stream.querySelectorAll('.monologue-entry').forEach(function(el) {
var id = el.getAttribute('data-thought-id');
if (id) seenIds.add(id);
});
// ── View toggle ──
viewBtns.forEach(function(btn) {
btn.addEventListener('click', function() {
viewBtns.forEach(function(b) { b.classList.remove('active'); });
btn.classList.add('active');
currentView = btn.getAttribute('data-view');
applyView();
});
});
// ── Auto-scroll toggle ──
scrollBtn.classList.add('active');
scrollBtn.addEventListener('click', function() {
autoScroll = !autoScroll;
scrollBtn.classList.toggle('active', autoScroll);
});
function applyView() {
stream.querySelectorAll('.monologue-entry').forEach(function(entry) {
var raw = entry.querySelector('.monologue-raw');
var summary = entry.querySelector('.monologue-summary');
if (currentView === 'summarized') {
if (raw) raw.style.display = 'none';
if (summary) summary.style.display = '';
} else {
if (raw) raw.style.display = '';
if (summary) summary.style.display = 'none';
}
});
}
function summarise(text) {
// First sentence or truncate at 120 chars
var m = text.match(/^[^.!?]*[.!?]/);
if (m && m[0].length < 120) return m[0];
if (text.length <= 120) return text;
return text.substring(0, 120).replace(/\s+\S*$/, '') + '...';
}
function createEntry(data) {
var div = document.createElement('div');
div.className = 'monologue-entry monologue-entry-new';
div.setAttribute('data-thought-id', data.thought_id || '');
div.setAttribute('data-seed', data.seed_type || 'freeform');
var ts = '';
if (data.created_at && data.created_at.length >= 19) {
ts = data.created_at.substring(11, 19);
}
var escaped = (data.content || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
var summaryText = summarise(data.content || '');
var escapedSummary = summaryText.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
div.innerHTML =
'<div class="monologue-entry-gutter">' +
'<span class="monologue-timestamp">' + ts + '</span>' +
'<span class="monologue-seed seed-' + (data.seed_type || 'freeform') + '">' + (data.seed_type || 'freeform') + '</span>' +
'</div>' +
'<div class="monologue-entry-content">' +
'<span class="monologue-raw"' + (currentView === 'summarized' ? ' style="display:none"' : '') + '>' + escaped + '</span>' +
'<span class="monologue-summary"' + (currentView === 'raw' ? ' style="display:none"' : '') + '>' + escapedSummary + '</span>' +
'</div>';
return div;
}
function addThought(data) {
var id = data.thought_id;
if (id && seenIds.has(id)) return;
if (id) seenIds.add(id);
// Remove empty placeholder
var empty = stream.querySelector('.monologue-empty');
if (empty) empty.remove();
var entry = createEntry(data);
stream.appendChild(entry);
thoughtCount++;
countBadge.textContent = thoughtCount + ' thoughts';
// Trigger animation
requestAnimationFrame(function() {
entry.classList.remove('monologue-entry-new');
});
if (autoScroll) {
stream.scrollTop = stream.scrollHeight;
}
}
function setStatus(state) {
statusDot.className = 'monologue-dot monologue-dot-' + state;
if (state === 'connected') statusText.textContent = 'Live';
else if (state === 'connecting') statusText.textContent = 'Connecting...';
else statusText.textContent = 'Disconnected';
}
function connect() {
setStatus('connecting');
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(proto + '//' + location.host + '/monologue/ws');
ws.onopen = function() {
setStatus('connected');
reconnectDelay = 1000;
};
ws.onmessage = function(evt) {
try {
var msg = JSON.parse(evt.data);
// Accept both direct thought events and ws_manager broadcast format
if (msg.event === 'thought' && msg.data) {
addThought(msg.data);
} else if (msg.event === 'timmy_thought' && msg.data) {
addThought(msg.data);
}
} catch(e) {
// Ignore non-JSON or irrelevant messages
}
};
ws.onclose = function() {
setStatus('disconnected');
setTimeout(connect, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 1.5, 15000);
};
ws.onerror = function() {
setStatus('disconnected');
};
}
connect();
// Scroll to bottom on load
stream.scrollTop = stream.scrollHeight;
})();
</script>
{% endblock %}

View File

@@ -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; }
}

View File

@@ -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("...")