Files
the-nexus/frontend/js/ui.js
Alexander Whitestone cec0781d95
Some checks failed
CI / test (pull_request) Failing after 11s
CI / validate (pull_request) Failing after 11s
Review Approval Gate / verify-review (pull_request) Failing after 9s
feat: restore frontend shell and implement Project Mnemosyne visual memory bridge
2026-04-08 21:24:32 -04:00

286 lines
9.1 KiB
JavaScript

import { getAgentDefs } from './agents.js';
import { AGENT_DEFS, colorToCss } from './agent-defs.js';
import { logEntry } from './transcript.js';
import { getItem, setItem, removeItem } from './storage.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 $clearBtn = document.getElementById('chat-clear-btn');
const MAX_CHAT_ENTRIES = 12;
const MAX_STORED = 100;
const STORAGE_PREFIX = 'matrix:chat:';
const chatEntries = [];
const chatHistory = {};
const IDLE_COLOR = '#33aa55';
const ACTIVE_COLOR = '#00ff41';
/* ── localStorage chat history ────────────────────────── */
function storageKey(agentId) {
return STORAGE_PREFIX + agentId;
}
export function loadChatHistory(agentId) {
try {
const raw = getItem(storageKey(agentId));
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(m =>
m && typeof m.agentLabel === 'string' && typeof m.text === 'string'
);
} catch {
return [];
}
}
export function saveChatHistory(agentId, messages) {
try {
setItem(storageKey(agentId), JSON.stringify(messages.slice(-MAX_STORED)));
} catch { /* quota exceeded or private mode */ }
}
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 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) {
removeItem(storageKey(id));
chatHistory[id] = [];
}
while ($chatPanel.firstChild) $chatPanel.removeChild($chatPanel.firstChild);
chatEntries.length = 0;
}
function buildChatEntry(agentLabel, message, cssColor, timestamp) {
const color = escapeAttr(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}">${escapeHtml(agentLabel)}</span>: ${escapeHtml(message)}`;
return entry;
}
export function initUI() {
renderAgentList();
loadAllHistories();
if ($clearBtn) $clearBtn.addEventListener('click', clearAllHistories);
}
function renderAgentList() {
const defs = getAgentDefs();
$agentList.innerHTML = defs.map(a => {
const css = escapeAttr(colorToCss(a.color));
const safeLabel = escapeHtml(a.label);
const safeId = escapeAttr(a.id);
return `<div class="agent-row">
<span class="label">[</span>
<span style="color:${css}">${safeLabel}</span>
<span class="label">]</span>
<span id="agent-state-${safeId}" style="color:${IDLE_COLOR}"> IDLE</span>
</div>`;
}).join('');
}
export function updateUI({ fps, agentCount, jobCount, connectionState }) {
$fps.textContent = `FPS: ${fps}`;
$agentCount.textContent = `AGENTS: ${agentCount}`;
$activeJobs.textContent = `JOBS: ${jobCount}`;
if (connectionState === 'connected') {
$connStatus.textContent = '● CONNECTED';
$connStatus.className = 'connected';
} else if (connectionState === 'connecting') {
$connStatus.textContent = '◌ CONNECTING...';
$connStatus.className = '';
} else {
$connStatus.textContent = '○ OFFLINE';
$connStatus.className = '';
}
const defs = getAgentDefs();
defs.forEach(a => {
const el = document.getElementById(`agent-state-${a.id}`);
if (el) {
el.textContent = ` ${a.state.toUpperCase()}`;
el.style.color = a.state === 'active' ? ACTIVE_COLOR : IDLE_COLOR;
}
});
}
/**
* 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'
*/
export function appendChatMessage(agentLabel, message, cssColor, extraClass) {
const now = Date.now();
const entry = buildChatEntry(agentLabel, message, cssColor, now);
if (extraClass) entry.className += ' ' + extraClass;
chatEntries.push(entry);
while (chatEntries.length > MAX_CHAT_ENTRIES) {
const removed = chatEntries.shift();
try { $chatPanel.removeChild(removed); } catch { /* already removed */ }
}
$chatPanel.appendChild(entry);
$chatPanel.scrollTop = $chatPanel.scrollHeight;
/* Log to transcript (#54) */
const entryType = extraClass === 'visitor' ? 'visitor' : (agentLabel === 'SYS' ? 'system' : 'chat');
logEntry(agentLabel, message, entryType);
/* persist per-agent history */
const agentId = AGENT_DEFS.find(d => d.label === agentLabel)?.id || 'sys';
if (!chatHistory[agentId]) chatHistory[agentId] = [];
chatHistory[agentId].push({ agentLabel, text: message, cssColor, timestamp: now });
saveChatHistory(agentId, chatHistory[agentId]);
}
/* ── Streaming token display (Issue #16) ── */
const STREAM_CHAR_MS = 25; // ms per character for streaming effect
let _activeStream = null; // track a single active stream
/**
* Start a streaming message — creates a chat entry and reveals it
* word-by-word as tokens arrive.
*
* @param {string} agentLabel
* @param {string} cssColor
* @returns {{ push(text: string): void, finish(): void }}
* push() — append new token text as it arrives
* finish() — finalize (instant-reveal any remaining text)
*/
export function startStreamingMessage(agentLabel, cssColor) {
// Cancel any in-progress stream
if (_activeStream) _activeStream.finish();
const now = Date.now();
const color = escapeAttr(cssColor || '#00ff41');
const entry = document.createElement('div');
entry.className = 'chat-entry streaming';
const ts = `<span class="chat-ts">[${formatTimestamp(now)}]</span> `;
entry.innerHTML = `${ts}<span class="agent-name" style="color:${color}">${escapeHtml(agentLabel)}</span>: <span class="stream-text"></span><span class="stream-cursor">&#9608;</span>`;
chatEntries.push(entry);
while (chatEntries.length > MAX_CHAT_ENTRIES) {
const removed = chatEntries.shift();
try { $chatPanel.removeChild(removed); } catch { /* already removed */ }
}
$chatPanel.appendChild(entry);
$chatPanel.scrollTop = $chatPanel.scrollHeight;
const $text = entry.querySelector('.stream-text');
const $cursor = entry.querySelector('.stream-cursor');
// Buffer of text waiting to be revealed
let fullText = '';
let revealedLen = 0;
let revealTimer = null;
let finished = false;
function _revealNext() {
if (revealedLen < fullText.length) {
revealedLen++;
$text.textContent = fullText.slice(0, revealedLen);
$chatPanel.scrollTop = $chatPanel.scrollHeight;
revealTimer = setTimeout(_revealNext, STREAM_CHAR_MS);
} else {
revealTimer = null;
if (finished) _cleanup();
}
}
function _cleanup() {
if ($cursor) $cursor.remove();
entry.classList.remove('streaming');
_activeStream = null;
// Log final text to transcript + history
logEntry(agentLabel, fullText, 'chat');
const agentId = AGENT_DEFS.find(d => d.label === agentLabel)?.id || 'sys';
if (!chatHistory[agentId]) chatHistory[agentId] = [];
chatHistory[agentId].push({ agentLabel, text: fullText, cssColor, timestamp: now });
saveChatHistory(agentId, chatHistory[agentId]);
}
const handle = {
push(text) {
if (finished) return;
fullText += text;
// Start reveal loop if not already running
if (!revealTimer) {
revealTimer = setTimeout(_revealNext, STREAM_CHAR_MS);
}
},
finish() {
finished = true;
// Instantly reveal remaining
if (revealTimer) clearTimeout(revealTimer);
revealedLen = fullText.length;
$text.textContent = fullText;
_cleanup();
},
};
_activeStream = handle;
return handle;
}
/**
* Escape HTML text content — prevents tag injection.
*/
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* Escape a value for use inside an HTML attribute (style="...", id="...").
*/
function escapeAttr(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}