forked from Rockachopa/Timmy-time-dashboard
feat: persistent chat history with clear button
- Add dashboard/store.py: MessageLog dataclass singleton tracking user/agent/error messages for the lifetime of the server process - agents.py: write each chat turn to MessageLog; add GET and DELETE /agents/timmy/history routes returning the history.html partial - partials/history.html: render stored messages by role (YOU / TIMMY / SYSTEM); falls back to the Mission Control init message when empty - index.html: chat-log loads history via hx-get on page start; new CLEAR button in panel header sends hx-delete to reset the log - style.css: add .mc-btn-clear (muted, red-on-hover for the header) - tests: autouse reset_message_log fixture in conftest; 5 new history tests covering empty state, recording, offline errors, clear, and post-clear state → 32 tests total, all passing https://claude.ai/code/session_01KZMfwBpLuiv6x9GbzTqbys
This commit is contained in:
@@ -6,6 +6,7 @@ from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from timmy.agent import create_timmy
|
||||
from dashboard.store import message_log
|
||||
|
||||
router = APIRouter(prefix="/agents", tags=["agents"])
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
|
||||
@@ -27,6 +28,25 @@ async def list_agents():
|
||||
return {"agents": list(AGENT_REGISTRY.values())}
|
||||
|
||||
|
||||
@router.get("/timmy/history", response_class=HTMLResponse)
|
||||
async def get_history(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"partials/history.html",
|
||||
{"messages": message_log.all()},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/timmy/history", response_class=HTMLResponse)
|
||||
async def clear_history(request: Request):
|
||||
message_log.clear()
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"partials/history.html",
|
||||
{"messages": []},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/timmy/chat", response_class=HTMLResponse)
|
||||
async def chat_timmy(request: Request, message: str = Form(...)):
|
||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||
@@ -40,6 +60,12 @@ async def chat_timmy(request: Request, message: str = Form(...)):
|
||||
except Exception as exc:
|
||||
error_text = f"Timmy is offline: {exc}"
|
||||
|
||||
message_log.append(role="user", content=message, timestamp=timestamp)
|
||||
if response_text is not None:
|
||||
message_log.append(role="agent", content=response_text, timestamp=timestamp)
|
||||
else:
|
||||
message_log.append(role="error", content=error_text, timestamp=timestamp)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"partials/chat_message.html",
|
||||
|
||||
31
src/dashboard/store.py
Normal file
31
src/dashboard/store.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class Message:
|
||||
role: str # "user" | "agent" | "error"
|
||||
content: str
|
||||
timestamp: str
|
||||
|
||||
|
||||
class MessageLog:
|
||||
"""In-memory chat history for the lifetime of the server process."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._entries: list[Message] = []
|
||||
|
||||
def append(self, role: str, content: str, timestamp: str) -> None:
|
||||
self._entries.append(Message(role=role, content=content, timestamp=timestamp))
|
||||
|
||||
def all(self) -> list[Message]:
|
||||
return list(self._entries)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._entries.clear()
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._entries)
|
||||
|
||||
|
||||
# Module-level singleton shared across the app
|
||||
message_log = MessageLog()
|
||||
@@ -46,15 +46,20 @@
|
||||
<!-- Chat Panel -->
|
||||
<div class="col-12 col-md-9 d-flex flex-column mc-chat-panel">
|
||||
<div class="card mc-panel flex-grow-1 d-flex flex-column min-h-0">
|
||||
<div class="card-header mc-panel-header">// TIMMY INTERFACE</div>
|
||||
|
||||
<div class="chat-log flex-grow-1 overflow-auto p-3" id="chat-log">
|
||||
<div class="chat-message agent">
|
||||
<div class="msg-meta">TIMMY // SYSTEM</div>
|
||||
<div class="msg-body">Mission Control initialized. Timmy ready — awaiting input.</div>
|
||||
</div>
|
||||
<div class="card-header mc-panel-header d-flex justify-content-between align-items-center">
|
||||
<span>// TIMMY INTERFACE</span>
|
||||
<button class="mc-btn-clear"
|
||||
hx-delete="/agents/timmy/history"
|
||||
hx-target="#chat-log"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Clear conversation history?">CLEAR</button>
|
||||
</div>
|
||||
|
||||
<div class="chat-log flex-grow-1 overflow-auto p-3" id="chat-log"
|
||||
hx-get="/agents/timmy/history"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML"></div>
|
||||
|
||||
<div class="card-footer mc-chat-footer">
|
||||
<form hx-post="/agents/timmy/chat"
|
||||
hx-target="#chat-log"
|
||||
|
||||
25
src/dashboard/templates/partials/history.html
Normal file
25
src/dashboard/templates/partials/history.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{% if messages %}
|
||||
{% for msg in messages %}
|
||||
{% if msg.role == "user" %}
|
||||
<div class="chat-message user">
|
||||
<div class="msg-meta">YOU // {{ msg.timestamp }}</div>
|
||||
<div class="msg-body">{{ msg.content }}</div>
|
||||
</div>
|
||||
{% elif msg.role == "agent" %}
|
||||
<div class="chat-message agent">
|
||||
<div class="msg-meta">TIMMY // {{ msg.timestamp }}</div>
|
||||
<div class="msg-body">{{ msg.content }}</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="chat-message error-msg">
|
||||
<div class="msg-meta">SYSTEM // {{ msg.timestamp }}</div>
|
||||
<div class="msg-body">{{ msg.content }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="chat-message agent">
|
||||
<div class="msg-meta">TIMMY // SYSTEM</div>
|
||||
<div class="msg-body">Mission Control initialized. Timmy ready — awaiting input.</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -204,6 +204,22 @@ body {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mc-btn-clear {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
padding: 3px 8px;
|
||||
letter-spacing: 0.12em;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.mc-btn-clear:hover { border-color: var(--red); color: var(--red); }
|
||||
|
||||
/* Bootstrap form-control overrides */
|
||||
.mc-input {
|
||||
background: var(--bg-deep) !important;
|
||||
|
||||
@@ -18,6 +18,15 @@ for _mod in [
|
||||
sys.modules.setdefault(_mod, MagicMock())
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_message_log():
|
||||
"""Clear the in-memory chat log before and after every test."""
|
||||
from dashboard.store import message_log
|
||||
message_log.clear()
|
||||
yield
|
||||
message_log.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
from dashboard.app import app
|
||||
|
||||
@@ -108,3 +108,57 @@ def test_chat_timmy_ollama_offline(client):
|
||||
def test_chat_timmy_requires_message(client):
|
||||
response = client.post("/agents/timmy/chat", data={})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
# ── History ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_history_empty_shows_init_message(client):
|
||||
response = client.get("/agents/timmy/history")
|
||||
assert response.status_code == 200
|
||||
assert "Mission Control initialized" in response.text
|
||||
|
||||
|
||||
def test_history_records_user_and_agent_messages(client):
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.run.return_value = MagicMock(content="I am operational.")
|
||||
|
||||
with patch("dashboard.routes.agents.create_timmy", return_value=mock_agent):
|
||||
client.post("/agents/timmy/chat", data={"message": "status check"})
|
||||
|
||||
response = client.get("/agents/timmy/history")
|
||||
assert "status check" in response.text
|
||||
assert "I am operational." in response.text
|
||||
|
||||
|
||||
def test_history_records_error_when_offline(client):
|
||||
with patch("dashboard.routes.agents.create_timmy", side_effect=Exception("refused")):
|
||||
client.post("/agents/timmy/chat", data={"message": "ping"})
|
||||
|
||||
response = client.get("/agents/timmy/history")
|
||||
assert "ping" in response.text
|
||||
assert "Timmy is offline" in response.text
|
||||
|
||||
|
||||
def test_history_clear_resets_to_init_message(client):
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.run.return_value = MagicMock(content="Acknowledged.")
|
||||
|
||||
with patch("dashboard.routes.agents.create_timmy", return_value=mock_agent):
|
||||
client.post("/agents/timmy/chat", data={"message": "hello"})
|
||||
|
||||
response = client.delete("/agents/timmy/history")
|
||||
assert response.status_code == 200
|
||||
assert "Mission Control initialized" in response.text
|
||||
|
||||
|
||||
def test_history_empty_after_clear(client):
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.run.return_value = MagicMock(content="OK.")
|
||||
|
||||
with patch("dashboard.routes.agents.create_timmy", return_value=mock_agent):
|
||||
client.post("/agents/timmy/chat", data={"message": "test"})
|
||||
|
||||
client.delete("/agents/timmy/history")
|
||||
response = client.get("/agents/timmy/history")
|
||||
assert "test" not in response.text
|
||||
assert "Mission Control initialized" in response.text
|
||||
|
||||
Reference in New Issue
Block a user