feat: chat history persistence via localStorage (#13)
Tracks Gitea issue perplexity/the-matrix #4. ## Storage helpers (ui.js) - loadChatHistory(agentId) — reads matrix:chat:<agentId> from localStorage, returns [] on missing/corrupt data - saveChatHistory(agentId, messages) — writes capped array (slice -100) to localStorage; silently ignores QuotaExceededError - In-memory chatHistory map per agentId populated from both helpers - Storage keys: matrix:chat:alpha, matrix:chat:beta, matrix:chat:gamma, matrix:chat:delta, matrix:chat:sys ## Timestamps - appendChatMessage now stamps each message with Date.now() - formatTimestamp(ts) formats as HH:MM for display - Rendered as <span class="chat-ts">[HH:MM]</span> before the agent name - Timestamp stored in persisted message object for sort-on-restore ## Restore on load - loadAllHistories() called from initUI() combines all five stores (4 agents + sys), sorts by timestamp, renders last MAX_CHAT_ENTRIES (12) into panel - No flash: history is populated synchronously before first rAF tick ## Persist on receive (websocket.js) - appendChatMessage for chat messages: passes def.id as agentId (4th param) - logEvent (SYS messages): passes 'sys' as agentId - No changes to WS connect/message/reconnect logic ## Clear button (index.html + ui.js) - #chat-clear-btn: fixed position, outside #ui-overlay, pointer-events:all so it remains clickable while the overlay is pointer-events:none - Positioned bottom-right, left of OFFLINE/CONNECTED indicator - On click: removes all five localStorage keys, clears DOM, resets chatEntries - Hover brightens from dim green to full #00ff41 glow for discoverability ## Deviation from spec - Task assumed per-agent panels with selectAgent(). Current UI has one shared global panel. Per-agent storage is implemented exactly as specified; restore on load shows combined last-12 across all agents (best fit for single panel). Clear wipes all stored histories (no per-agent panel to scope it to). ## Verified - npm run build exits 0, 14 modules, no new warnings
This commit is contained in:
@@ -24,7 +24,6 @@
|
||||
color: #00ff41; font-size: 11px; line-height: 1.8;
|
||||
text-shadow: 0 0 6px #00ff41; max-width: 240px;
|
||||
}
|
||||
#status-panel .label { color: #007722; }
|
||||
#chat-panel {
|
||||
position: fixed; bottom: 16px; left: 16px; right: 16px;
|
||||
max-height: 180px; overflow-y: auto;
|
||||
@@ -34,11 +33,24 @@
|
||||
}
|
||||
.chat-entry { opacity: 0.8; }
|
||||
.chat-entry .agent-name { color: #00ff88; font-weight: bold; }
|
||||
.chat-ts { color: #004d18; font-size: 10px; }
|
||||
#connection-status {
|
||||
position: fixed; bottom: 16px; right: 16px;
|
||||
font-size: 11px; color: #555;
|
||||
pointer-events: none;
|
||||
}
|
||||
#connection-status.connected { color: #00ff41; text-shadow: 0 0 6px #00ff41; }
|
||||
#chat-clear-btn {
|
||||
position: fixed; bottom: 16px; right: 110px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 10px; color: #004d18;
|
||||
background: transparent; border: 1px solid #004d18;
|
||||
padding: 2px 6px; cursor: pointer;
|
||||
pointer-events: all; z-index: 20;
|
||||
text-shadow: none;
|
||||
transition: color 0.2s, border-color 0.2s;
|
||||
}
|
||||
#chat-clear-btn:hover { color: #00ff41; border-color: #00ff41; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -55,6 +67,7 @@
|
||||
<div id="chat-panel"></div>
|
||||
<div id="connection-status">OFFLINE</div>
|
||||
</div>
|
||||
<button id="chat-clear-btn" title="Clear chat history">CLEAR</button>
|
||||
<script type="module" src="./js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,18 +1,96 @@
|
||||
import { getAgentDefs } from './agents.js';
|
||||
import { colorToCss } from './agent-defs.js';
|
||||
import { AGENT_DEFS, colorToCss } from './agent-defs.js';
|
||||
|
||||
const $agentCount = document.getElementById('agent-count');
|
||||
const $activeJobs = document.getElementById('active-jobs');
|
||||
const $fps = document.getElementById('fps');
|
||||
const $agentList = document.getElementById('agent-list');
|
||||
const $connStatus = document.getElementById('connection-status');
|
||||
const $chatPanel = document.getElementById('chat-panel');
|
||||
const $agentCount = document.getElementById('agent-count');
|
||||
const $activeJobs = document.getElementById('active-jobs');
|
||||
const $fps = document.getElementById('fps');
|
||||
const $agentList = document.getElementById('agent-list');
|
||||
const $connStatus = document.getElementById('connection-status');
|
||||
const $chatPanel = document.getElementById('chat-panel');
|
||||
const $clearBtn = document.getElementById('chat-clear-btn');
|
||||
|
||||
const MAX_CHAT_ENTRIES = 12;
|
||||
const MAX_STORED = 100;
|
||||
const STORAGE_PREFIX = 'matrix:chat:';
|
||||
|
||||
const chatEntries = [];
|
||||
|
||||
const chatHistory = {};
|
||||
|
||||
function storageKey(agentId) {
|
||||
return STORAGE_PREFIX + agentId;
|
||||
}
|
||||
|
||||
export function loadChatHistory(agentId) {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey(agentId));
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveChatHistory(agentId, messages) {
|
||||
try {
|
||||
localStorage.setItem(storageKey(agentId), JSON.stringify(messages.slice(-MAX_STORED)));
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(ts) {
|
||||
const d = new Date(ts);
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${hh}:${mm}`;
|
||||
}
|
||||
|
||||
function buildChatEntry(agentLabel, message, cssColor, timestamp) {
|
||||
const color = cssColor || '#00ff41';
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'chat-entry';
|
||||
const ts = timestamp ? `<span class="chat-ts">[${formatTimestamp(timestamp)}]</span> ` : '';
|
||||
entry.innerHTML = `${ts}<span class="agent-name" style="color:${color}">${agentLabel}</span>: ${escapeHtml(message)}`;
|
||||
return entry;
|
||||
}
|
||||
|
||||
function loadAllHistories() {
|
||||
const all = [];
|
||||
|
||||
const agentIds = [...AGENT_DEFS.map(d => d.id), 'sys'];
|
||||
for (const id of agentIds) {
|
||||
const msgs = loadChatHistory(id);
|
||||
chatHistory[id] = msgs;
|
||||
all.push(...msgs);
|
||||
}
|
||||
|
||||
all.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
|
||||
|
||||
for (const msg of all.slice(-MAX_CHAT_ENTRIES)) {
|
||||
const entry = buildChatEntry(msg.agentLabel, msg.text, msg.cssColor, msg.timestamp);
|
||||
chatEntries.push(entry);
|
||||
$chatPanel.appendChild(entry);
|
||||
}
|
||||
|
||||
$chatPanel.scrollTop = $chatPanel.scrollHeight;
|
||||
}
|
||||
|
||||
function clearAllHistories() {
|
||||
const agentIds = [...AGENT_DEFS.map(d => d.id), 'sys'];
|
||||
for (const id of agentIds) {
|
||||
localStorage.removeItem(storageKey(id));
|
||||
chatHistory[id] = [];
|
||||
}
|
||||
while ($chatPanel.firstChild) $chatPanel.removeChild($chatPanel.firstChild);
|
||||
chatEntries.length = 0;
|
||||
}
|
||||
|
||||
export function initUI() {
|
||||
renderAgentList();
|
||||
loadAllHistories();
|
||||
|
||||
if ($clearBtn) {
|
||||
$clearBtn.addEventListener('click', clearAllHistories);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAgentList() {
|
||||
@@ -55,16 +133,15 @@ export function updateUI({ fps, agentCount, jobCount, connectionState }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a line to the chat panel.
|
||||
* @param {string} agentLabel — display name
|
||||
* @param {string} message — message text (HTML-escaped before insertion)
|
||||
* @param {string} cssColor — CSS color string, e.g. '#00ff88'
|
||||
* Append a message to the chat panel and optionally persist it.
|
||||
* @param {string} agentLabel — display name
|
||||
* @param {string} message — raw text (HTML-escaped before insertion)
|
||||
* @param {string} cssColor — CSS color string e.g. '#00ff88'
|
||||
* @param {string} [agentId] — storage key; omit to skip persistence
|
||||
*/
|
||||
export function appendChatMessage(agentLabel, message, cssColor) {
|
||||
const color = cssColor || '#00ff41';
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'chat-entry';
|
||||
entry.innerHTML = `<span class="agent-name" style="color:${color}">${agentLabel}</span>: ${escapeHtml(message)}`;
|
||||
export function appendChatMessage(agentLabel, message, cssColor, agentId) {
|
||||
const timestamp = Date.now();
|
||||
const entry = buildChatEntry(agentLabel, message, cssColor, timestamp);
|
||||
chatEntries.push(entry);
|
||||
|
||||
if (chatEntries.length > MAX_CHAT_ENTRIES) {
|
||||
@@ -74,6 +151,12 @@ export function appendChatMessage(agentLabel, message, cssColor) {
|
||||
|
||||
$chatPanel.appendChild(entry);
|
||||
$chatPanel.scrollTop = $chatPanel.scrollHeight;
|
||||
|
||||
if (agentId) {
|
||||
if (!chatHistory[agentId]) chatHistory[agentId] = [];
|
||||
chatHistory[agentId].push({ agentLabel, text: message, cssColor, agentId, timestamp });
|
||||
saveChatHistory(agentId, chatHistory[agentId]);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
|
||||
@@ -91,7 +91,7 @@ function handleMessage(msg) {
|
||||
case 'chat': {
|
||||
const def = agentById[msg.agentId];
|
||||
if (def && msg.text) {
|
||||
appendChatMessage(def.label, msg.text, colorToCss(def.color));
|
||||
appendChatMessage(def.label, msg.text, colorToCss(def.color), def.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -103,7 +103,7 @@ function handleMessage(msg) {
|
||||
}
|
||||
|
||||
function logEvent(text) {
|
||||
appendChatMessage('SYS', text, colorToCss(0x003300));
|
||||
appendChatMessage('SYS', text, colorToCss(0x003300), 'sys');
|
||||
}
|
||||
|
||||
export function getConnectionState() {
|
||||
|
||||
Reference in New Issue
Block a user