381 lines
15 KiB
JavaScript
381 lines
15 KiB
JavaScript
/* ===================================================
|
||
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 ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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);
|
||
}
|
||
|
||
})();
|