jaeswift-website/js/chat.js

381 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ===================================================
JAESWIFT.XYZ — JAE-AI Chat Terminal
Venice API chat interface + Memoria-style memory
=================================================== */
(function () {
'use strict';
const chatMessages = document.getElementById('chatMessages');
const chatInput = document.getElementById('chatInput');
const chatSend = document.getElementById('chatSend');
const chatStatus = document.getElementById('chatStatus');
const chatHeader = document.querySelector('.chat-terminal .panel-header');
if (!chatMessages || !chatInput || !chatSend) return;
const mem = window.chatMemory || null;
let history = [];
let isWaiting = false;
// ─── Render a message bubble ───
function addMessage(role, text) {
const welcome = chatMessages.querySelector('.chat-welcome');
if (welcome) welcome.remove();
const msg = document.createElement('div');
msg.className = `chat-msg chat-msg-${role}`;
const label = document.createElement('span');
label.className = 'chat-msg-label';
label.textContent = role === 'user' ? 'YOU' : 'JAE-AI';
const body = document.createElement('div');
body.className = 'chat-msg-body';
body.textContent = text;
msg.appendChild(label);
msg.appendChild(body);
chatMessages.appendChild(msg);
chatMessages.scrollTop = chatMessages.scrollHeight;
return body;
}
// ─── Typing indicator ───
function showTyping() {
const indicator = document.createElement('div');
indicator.className = 'chat-msg chat-msg-assistant chat-typing-indicator';
indicator.id = 'chatTyping';
indicator.innerHTML = `
<span class="chat-msg-label">JAE-AI</span>
<div class="chat-msg-body">
<span class="typing-dot"></span>
<span class="typing-dot"></span>
<span class="typing-dot"></span>
</div>
`;
chatMessages.appendChild(indicator);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function hideTyping() {
const el = document.getElementById('chatTyping');
if (el) el.remove();
}
// ─── Typewriter effect ───
function typewriterEffect(element, text, speed) {
speed = speed || 12;
let i = 0;
element.textContent = '';
return new Promise(function (resolve) {
function tick() {
if (i < text.length) {
element.textContent += text.charAt(i);
i++;
chatMessages.scrollTop = chatMessages.scrollHeight;
setTimeout(tick, speed);
} else {
resolve();
}
}
tick();
});
}
// ─── Restore previous chat history from localStorage ───
function restoreHistory() {
if (!mem) return false;
const saved = mem.getHistory();
if (!saved || saved.length === 0) return false;
// Remove welcome and render saved history
const welcome = chatMessages.querySelector('.chat-welcome');
if (welcome) welcome.remove();
saved.forEach(function (m) {
const role = m.role === 'user' ? 'user' : 'assistant';
const label = document.createElement('span');
label.className = 'chat-msg-label';
label.textContent = role === 'user' ? 'YOU' : 'JAE-AI';
const body = document.createElement('div');
body.className = 'chat-msg-body';
body.textContent = m.content || '';
const el = document.createElement('div');
el.className = `chat-msg chat-msg-${role}`;
el.appendChild(label);
el.appendChild(body);
chatMessages.appendChild(el);
});
// Divider showing restore
const divider = document.createElement('div');
divider.className = 'chat-divider';
divider.innerHTML = '<span>— RESTORED FROM MEMORY —</span>';
chatMessages.appendChild(divider);
chatMessages.scrollTop = chatMessages.scrollHeight;
// populate in-memory history
history = saved.slice();
return true;
}
// ─── Send message to API ───
async function sendMessage() {
const text = chatInput.value.trim();
if (!text || isWaiting) return;
isWaiting = true;
chatInput.value = '';
chatStatus.textContent = '● PROCESSING';
chatStatus.classList.remove('status-green');
chatStatus.classList.add('status-amber');
addMessage('user', text);
history.push({ role: 'user', content: text });
if (mem) mem.appendHistory({ role: 'user', content: text });
showTyping();
// Build memory context block
const memoryBlock = mem ? mem.getContextBlock(text) : '';
try {
const resp = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: text,
history: history.slice(0, -1),
memory_context: memoryBlock
})
});
hideTyping();
if (!resp.ok) {
const err = await resp.json().catch(function () { return {}; });
addMessage('assistant', 'ERROR: ' + (err.error || 'Connection failed'));
chatStatus.textContent = '● ERROR';
chatStatus.classList.remove('status-amber');
chatStatus.classList.add('status-red');
isWaiting = false;
return;
}
const data = await resp.json();
const reply = data.reply || 'No response received.';
history.push({ role: 'assistant', content: reply });
if (mem) mem.appendHistory({ role: 'assistant', content: reply });
if (history.length > 40) history = history.slice(-30);
const bodyEl = addMessage('assistant', '');
await typewriterEffect(bodyEl, reply, 10);
// Trigger memory extraction every N user messages (non-blocking)
if (mem) {
mem.incrementUserMsgAndMaybeExtract().catch(function () { /* silent */ });
}
} catch (e) {
hideTyping();
addMessage('assistant', 'ERROR: Network failure — ' + e.message);
chatStatus.textContent = '● OFFLINE';
chatStatus.classList.remove('status-amber');
chatStatus.classList.add('status-red');
}
chatStatus.textContent = '● ONLINE';
chatStatus.classList.remove('status-amber', 'status-red');
chatStatus.classList.add('status-green');
isWaiting = false;
chatInput.focus();
}
// ─── Memory modal ───
function formatDate(ts) {
try { return new Date(ts).toLocaleString('en-GB'); } catch (e) { return ''; }
}
function renderMemoryModal() {
if (!mem) return;
const existing = document.getElementById('memModal');
if (existing) { existing.remove(); return; }
const modal = document.createElement('div');
modal.id = 'memModal';
modal.className = 'mem-modal';
const all = mem.getAll();
const grouped = {};
all.forEach(function (m) {
const c = m.category || 'other';
if (!grouped[c]) grouped[c] = [];
grouped[c].push(m);
});
const categories = ['identity', 'preference', 'project', 'skill', 'goal', 'relationship', 'other'];
const order = categories.filter(function (c) { return grouped[c]; })
.concat(Object.keys(grouped).filter(function (c) { return categories.indexOf(c) === -1; }));
let bodyHTML = '';
if (all.length === 0) {
bodyHTML = '<div class="mem-empty">NO MEMORIES STORED // CHAT TO BUILD PROFILE</div>';
} else {
order.forEach(function (cat) {
bodyHTML += '<div class="mem-group"><div class="mem-group-title">' + cat.toUpperCase() + ' <span class="mem-count">(' + grouped[cat].length + ')</span></div>';
grouped[cat].sort(function (a, b) { return (b.timestamp || 0) - (a.timestamp || 0); }).forEach(function (m) {
bodyHTML += '<div class="mem-item" data-id="' + m.id + '">' +
'<div class="mem-item-text">' + escapeHtml(m.text) + '</div>' +
'<div class="mem-item-meta">imp: ' + (m.importance || 0).toFixed(2) + ' // ' + formatDate(m.timestamp) + '</div>' +
'<button class="mem-del" data-id="' + m.id + '" title="Delete">×</button>' +
'</div>';
});
bodyHTML += '</div>';
});
}
modal.innerHTML = `
<div class="mem-modal-inner">
<div class="mem-modal-header">
<span class="mem-modal-title">🧠 JAE-AI MEMORY VAULT</span>
<button class="mem-close" id="memClose">× CLOSE</button>
</div>
<div class="mem-modal-subtitle">STORED LOCALLY // ${all.length} MEMORIES // NEVER LEAVES DEVICE</div>
<div class="mem-modal-body">${bodyHTML}</div>
<div class="mem-modal-footer">
<button class="mem-btn mem-btn-export" id="memExport">⬇ EXPORT JSON</button>
<button class="mem-btn mem-btn-extract" id="memExtract">⚡ FORCE EXTRACT</button>
<button class="mem-btn mem-btn-clearhist" id="memClearHist">🗑 CLEAR HISTORY</button>
<button class="mem-btn mem-btn-clear" id="memClear">⚠ CLEAR ALL MEMORIES</button>
</div>
</div>
`;
document.body.appendChild(modal);
document.getElementById('memClose').addEventListener('click', function () { modal.remove(); });
modal.addEventListener('click', function (e) {
if (e.target === modal) modal.remove();
});
modal.querySelectorAll('.mem-del').forEach(function (btn) {
btn.addEventListener('click', function () {
const id = btn.getAttribute('data-id');
mem.remove(id);
renderMemoryModal(); renderMemoryModal(); // toggle off/on to refresh
});
});
document.getElementById('memExport').addEventListener('click', function () {
const blob = new Blob([mem.exportJSON()], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'jae-ai-memory-' + Date.now() + '.json';
a.click();
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
});
document.getElementById('memExtract').addEventListener('click', function () {
const btn = document.getElementById('memExtract');
btn.disabled = true;
btn.textContent = '⌛ EXTRACTING...';
mem.extractFromRecentChat().then(function () {
renderMemoryModal(); renderMemoryModal();
}).catch(function () {
btn.textContent = '✖ EXTRACT FAILED';
});
});
document.getElementById('memClearHist').addEventListener('click', function () {
if (confirm('Clear chat history? Memories will be kept.')) {
mem.clearHistory();
alert('Chat history cleared. Reload the page.');
}
});
document.getElementById('memClear').addEventListener('click', function () {
if (confirm('PERMANENTLY DELETE ALL MEMORIES? This cannot be undone.')) {
mem.clear();
renderMemoryModal(); renderMemoryModal();
}
});
}
function escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, function (c) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
});
}
// ─── Inject memory button + privacy info into chat header ───
function injectMemoryUI() {
if (!chatHeader) return;
if (document.getElementById('memBtn')) return;
const btn = document.createElement('button');
btn.id = 'memBtn';
btn.className = 'chat-mem-btn';
btn.title = 'View / manage local memories';
btn.innerHTML = '🧠 <span class="chat-mem-count" id="memBtnCount">0</span>';
btn.addEventListener('click', renderMemoryModal);
const info = document.createElement('a');
info.href = '#';
info.className = 'chat-mem-info';
info.title = 'All memories stored locally in your browser. Nothing leaves your device except current message context.';
info.textContent = '';
info.addEventListener('click', function (e) {
e.preventDefault();
alert('PRIVACY NOTICE\n\nAll JAE-AI memories are stored locally in your browser (localStorage). They never leave your device.\n\nPer message, only the current text plus a small set of relevant memories is sent as context to Venice AI to provide continuity.\n\nYou can view, export, or clear all memories via the 🧠 button.');
});
chatHeader.appendChild(info);
chatHeader.appendChild(btn);
updateMemCount();
}
function updateMemCount() {
const el = document.getElementById('memBtnCount');
if (el && mem) el.textContent = mem.getAll().length;
}
// ─── Event listeners ───
chatSend.addEventListener('click', sendMessage);
chatInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// ─── Init ───
injectMemoryUI();
// Update memory count periodically
setInterval(updateMemCount, 5000);
const restored = restoreHistory();
if (!restored) {
// Auto-greeting after short delay (only if no history restored)
setTimeout(function () {
showTyping();
setTimeout(function () {
hideTyping();
var greeting = 'Welcome to JAESWIFT.XYZ — I\'m JAE-AI, your onboard guide. Ask me anything about this system, or try saying "what can I explore here?"';
history.push({ role: 'assistant', content: greeting });
if (mem) mem.appendHistory({ role: 'assistant', content: greeting });
var bodyEl = addMessage('assistant', '');
typewriterEffect(bodyEl, greeting, 15);
}, 1500);
}, 2000);
}
})();