1421 lines
50 KiB
JavaScript
1421 lines
50 KiB
JavaScript
// Memory Management Functions
|
||
// This module handles all memory-related operations
|
||
|
||
import uiModule from './ui.js';
|
||
import sessionModule from './sessions.js';
|
||
import spinnerModule from './spinner.js';
|
||
import { makeWindowDraggable } from './windowDrag.js';
|
||
import { snapModalToZone } from './tileManager.js';
|
||
|
||
var escapeHtml = uiModule.esc;
|
||
|
||
let memories = [];
|
||
let activeCategory = 'all';
|
||
let sortOrder = 'newest';
|
||
let selectMode = false;
|
||
let selectedIds = new Set();
|
||
|
||
|
||
const MEMORY_CATEGORIES = ['fact', 'identity', 'preference', 'contact', 'project', 'goal', 'task'];
|
||
|
||
let _memoryDragWired = false;
|
||
function _wireMemoryDrag() {
|
||
if (_memoryDragWired) return;
|
||
const modal = document.getElementById('memory-modal');
|
||
const content = modal && modal.querySelector('.modal-content');
|
||
const header = modal && modal.querySelector('.modal-header');
|
||
if (!modal || !content || !header) return;
|
||
_memoryDragWired = true;
|
||
makeWindowDraggable(modal, {
|
||
content,
|
||
header,
|
||
skipSelector: 'button, input, select, label',
|
||
enableDock: true,
|
||
enableLeftDock: true,
|
||
onEnterFullscreen: () => {
|
||
snapModalToZone(modal, {
|
||
name: 'fullscreen',
|
||
rect: {
|
||
left: 0,
|
||
top: 0,
|
||
width: window.innerWidth || document.documentElement.clientWidth || 0,
|
||
height: window.innerHeight || document.documentElement.clientHeight || 0,
|
||
},
|
||
});
|
||
},
|
||
});
|
||
}
|
||
|
||
function relativeTime(timestamp) {
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const diff = now - timestamp;
|
||
if (diff < 60) return 'just now';
|
||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
|
||
if (diff < 2592000) return `${Math.floor(diff / 604800)}w ago`;
|
||
if (diff < 31536000) return `${Math.floor(diff / 2592000)}mo ago`;
|
||
return `${Math.floor(diff / 31536000)}y ago`;
|
||
}
|
||
|
||
function buildCategoryChips() {
|
||
const container = document.getElementById('memory-category-filters');
|
||
if (!container) return;
|
||
|
||
// Hide the chip row entirely when there are no memories — no point showing
|
||
// an "all" chip with nothing to filter.
|
||
if (!memories.length) { container.innerHTML = ''; return; }
|
||
|
||
const cats = new Set(memories.map(m => m.category || 'fact'));
|
||
const sorted = ['all', ...Array.from(cats).sort()];
|
||
|
||
container.innerHTML = '';
|
||
sorted.forEach(cat => {
|
||
const btn = document.createElement('button');
|
||
btn.className = 'memory-cat-chip' + (cat === activeCategory ? ' active' : '');
|
||
btn.dataset.cat = cat;
|
||
btn.textContent = cat;
|
||
btn.addEventListener('click', () => {
|
||
activeCategory = cat;
|
||
container.querySelectorAll('.memory-cat-chip').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
renderMemoryList();
|
||
updateMemoryCount();
|
||
});
|
||
container.appendChild(btn);
|
||
});
|
||
}
|
||
|
||
async function syncToggles() {
|
||
// The settings tab no longer hosts a separate "Memory in context" toggle —
|
||
// the header toggle owns that pref directly now.
|
||
await syncPrefToggle('memory-enabled-header-toggle', 'memory_enabled', 'Memory enabled', 'Memory disabled', false);
|
||
// The Skills header toggle owns the `skills_enabled` pref (was never wired —
|
||
// toggling it did nothing, so skills stayed on). Now it actually gates skill
|
||
// injection (see chat_helpers.py: uprefs.skills_enabled).
|
||
await syncPrefToggle('skills-enabled-header-toggle', 'skills_enabled', 'Skills enabled', 'Skills disabled', false);
|
||
await syncPrefToggle('auto-memory-toggle', 'auto_memory', 'Auto-extract memories enabled', 'Auto-extract memories disabled', false);
|
||
await syncPrefToggle('auto-skills-toggle', 'auto_skills', 'Auto-extract skills enabled', 'Auto-extract skills disabled', false);
|
||
await syncPrefToggle('auto-approve-skills-toggle', 'auto_approve_skills', 'Auto-approve skills enabled', 'Auto-approve skills disabled', false);
|
||
await syncPrefSlider('skill-confidence-slider', 'skill_min_confidence', 'skill-confidence-label', 0.85);
|
||
await syncPrefNumber('skill-max-input', 'skill_max_injected', 3);
|
||
|
||
// Reflect the header toggle into the sidebar dim + modal body opacity.
|
||
const headerToggle = document.getElementById('memory-enabled-header-toggle');
|
||
if (headerToggle) {
|
||
const modalBody = document.querySelector('.memory-modal-body');
|
||
if (modalBody) modalBody.style.opacity = headerToggle.checked ? '' : '0.3';
|
||
reflectMemoryToggleInSidebar(headerToggle.checked);
|
||
if (!headerToggle.dataset.boundUx) {
|
||
headerToggle.dataset.boundUx = '1';
|
||
headerToggle.addEventListener('change', () => {
|
||
if (modalBody) modalBody.style.opacity = headerToggle.checked ? '' : '0.3';
|
||
reflectMemoryToggleInSidebar(headerToggle.checked);
|
||
});
|
||
}
|
||
}
|
||
|
||
// Same dim treatment for the Skills toggle — dims the skills panel when off.
|
||
const skillsToggle = document.getElementById('skills-enabled-header-toggle');
|
||
if (skillsToggle) {
|
||
const skillsPanel = document.querySelector('[data-memory-panel="skills"]');
|
||
const applyDim = () => { if (skillsPanel) skillsPanel.style.opacity = skillsToggle.checked ? '' : '0.3'; };
|
||
applyDim();
|
||
if (!skillsToggle.dataset.boundUx) {
|
||
skillsToggle.dataset.boundUx = '1';
|
||
skillsToggle.addEventListener('change', applyDim);
|
||
}
|
||
}
|
||
}
|
||
|
||
function reflectMemoryToggleInSidebar(enabled) {
|
||
const btn = document.getElementById('tool-memory-btn');
|
||
if (btn) btn.classList.toggle('tool-disabled', !enabled);
|
||
}
|
||
|
||
function syncToggleDim(toggle) {
|
||
const card = toggle.closest('.admin-card');
|
||
if (!card) return;
|
||
const toggleRow = toggle.closest('div[style*="justify-content"]');
|
||
let sibling = toggleRow ? toggleRow.nextElementSibling : null;
|
||
while (sibling) {
|
||
sibling.style.opacity = toggle.checked ? '' : '0.35';
|
||
sibling.style.pointerEvents = toggle.checked ? '' : 'none';
|
||
sibling = sibling.nextElementSibling;
|
||
}
|
||
}
|
||
|
||
/** Load/save a confidence slider backed by a float pref (0 = "All", else
|
||
* 0.50–1.00). Slider position is the percent; the MAX position means "All"
|
||
* (no minimum), and sliding down sets the bar to 95%, 90%, 85%… */
|
||
async function syncPrefSlider(elementId, prefKey, labelId, defaultVal) {
|
||
const slider = document.getElementById(elementId);
|
||
if (!slider) return;
|
||
const label = labelId ? document.getElementById(labelId) : null;
|
||
const maxPos = Number(slider.max);
|
||
const fmt = (pos) => (Number(pos) >= maxPos ? 'All' : `≥ ${pos}%`);
|
||
try {
|
||
const res = await fetch(`${window.location.origin}/api/prefs/${prefKey}`);
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
let pref = (data.value === undefined || data.value === null) ? defaultVal : Number(data.value);
|
||
// pref 0 (or falsy) = "All" → max slider position; else percent.
|
||
let pos = (!pref || pref <= 0) ? maxPos : Math.round(pref * 100);
|
||
pos = Math.max(Number(slider.min), Math.min(maxPos, pos));
|
||
slider.value = String(pos);
|
||
}
|
||
} catch (e) {
|
||
console.error(`Failed to load ${prefKey} pref:`, e);
|
||
}
|
||
if (label) label.textContent = fmt(slider.value);
|
||
if (!slider.dataset.bound) {
|
||
slider.dataset.bound = '1';
|
||
slider.addEventListener('input', () => { if (label) label.textContent = fmt(slider.value); });
|
||
slider.addEventListener('change', async () => {
|
||
const pos = Number(slider.value);
|
||
const pref = pos >= maxPos ? 0 : pos / 100;
|
||
try {
|
||
const res = await fetch(`${window.location.origin}/api/prefs/${prefKey}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ value: pref })
|
||
});
|
||
if (!res.ok) { showError('Failed to save preference'); return; }
|
||
showToast(pref === 0 ? 'Skill confidence: All' : `Skill confidence ≥ ${Math.round(pref * 100)}%`);
|
||
} catch (e) {
|
||
console.error(`Failed to save ${prefKey} pref:`, e);
|
||
showError('Failed to save preference');
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
/** Load/save an integer-valued pref backed by a <input type="number">. */
|
||
async function syncPrefNumber(elementId, prefKey, defaultVal) {
|
||
const input = document.getElementById(elementId);
|
||
if (!input) return;
|
||
const clamp = (raw) => {
|
||
let v = parseInt(raw, 10);
|
||
if (isNaN(v)) v = defaultVal;
|
||
const lo = Number(input.min), hi = Number(input.max);
|
||
if (!isNaN(lo)) v = Math.max(lo, v);
|
||
if (!isNaN(hi)) v = Math.min(hi, v);
|
||
return v;
|
||
};
|
||
try {
|
||
const res = await fetch(`${window.location.origin}/api/prefs/${prefKey}`);
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
input.value = String((data.value === undefined || data.value === null) ? defaultVal : clamp(data.value));
|
||
}
|
||
} catch (e) {
|
||
console.error(`Failed to load ${prefKey} pref:`, e);
|
||
}
|
||
if (!input.dataset.bound) {
|
||
input.dataset.bound = '1';
|
||
input.addEventListener('change', async () => {
|
||
const v = clamp(input.value);
|
||
input.value = String(v);
|
||
try {
|
||
const res = await fetch(`${window.location.origin}/api/prefs/${prefKey}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ value: v })
|
||
});
|
||
if (!res.ok) { showError('Failed to save preference'); return; }
|
||
showToast(v === 0 ? 'No skills injected' : `Max injected skills: ${v}`);
|
||
} catch (e) {
|
||
console.error(`Failed to save ${prefKey} pref:`, e);
|
||
showError('Failed to save preference');
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
async function syncPrefToggle(elementId, prefKey, onMsg, offMsg, dimBelow = true) {
|
||
const toggle = document.getElementById(elementId);
|
||
if (!toggle) return;
|
||
try {
|
||
const res = await fetch(`${window.location.origin}/api/prefs/${prefKey}`);
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
toggle.checked = data.value !== false;
|
||
}
|
||
} catch (e) {
|
||
console.error(`Failed to load ${prefKey} pref:`, e);
|
||
}
|
||
if (dimBelow) syncToggleDim(toggle);
|
||
if (!toggle.dataset.bound) {
|
||
toggle.dataset.bound = '1';
|
||
toggle.addEventListener('change', async () => {
|
||
if (dimBelow) syncToggleDim(toggle);
|
||
try {
|
||
const res = await fetch(`${window.location.origin}/api/prefs/${prefKey}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ value: toggle.checked })
|
||
});
|
||
if (!res.ok) {
|
||
console.error(`PUT ${prefKey} returned ${res.status}`);
|
||
toggle.checked = !toggle.checked; // revert
|
||
if (dimBelow) syncToggleDim(toggle);
|
||
showError('Failed to save preference');
|
||
return;
|
||
}
|
||
showToast(toggle.checked ? onMsg : offMsg);
|
||
} catch (e) {
|
||
console.error(`Failed to save ${prefKey} pref:`, e);
|
||
toggle.checked = !toggle.checked; // revert
|
||
if (dimBelow) syncToggleDim(toggle);
|
||
showError('Failed to save preference');
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
export async function loadMemories() {
|
||
try {
|
||
const response = await fetch(`${window.location.origin}/api/memory`);
|
||
|
||
if (!response.ok) {
|
||
console.error('Memory fetch failed with status:', response.status);
|
||
memories = [];
|
||
buildCategoryChips();
|
||
renderMemoryList();
|
||
updateMemoryCount();
|
||
syncToggles();
|
||
return;
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
if (data && data.memory) {
|
||
memories = data.memory;
|
||
} else if (Array.isArray(data)) {
|
||
memories = data;
|
||
} else {
|
||
memories = [];
|
||
}
|
||
|
||
buildCategoryChips();
|
||
renderMemoryList();
|
||
updateMemoryCount();
|
||
} catch (error) {
|
||
console.error('Failed to load memories:', error);
|
||
memories = [];
|
||
buildCategoryChips();
|
||
renderMemoryList();
|
||
updateMemoryCount();
|
||
}
|
||
// Always wire toggles, even if memory API failed
|
||
syncToggles();
|
||
}
|
||
|
||
// ---- Bulk select mode ----
|
||
|
||
function enterSelectMode() {
|
||
selectMode = true;
|
||
selectedIds.clear();
|
||
const bulkBar = document.getElementById('memory-bulk-bar');
|
||
const selectBtn = document.getElementById('memory-select-btn');
|
||
if (bulkBar) bulkBar.classList.remove('hidden');
|
||
if (selectBtn) { selectBtn.classList.add('active'); selectBtn.textContent = 'Cancel'; }
|
||
updateBulkCount();
|
||
renderMemoryList();
|
||
}
|
||
|
||
function exitSelectMode() {
|
||
selectMode = false;
|
||
selectedIds.clear();
|
||
const bulkBar = document.getElementById('memory-bulk-bar');
|
||
const selectBtn = document.getElementById('memory-select-btn');
|
||
const selectAll = document.getElementById('memory-select-all');
|
||
if (bulkBar) bulkBar.classList.add('hidden');
|
||
if (selectBtn) { selectBtn.classList.remove('active'); selectBtn.textContent = 'Select'; }
|
||
if (selectAll) selectAll.checked = false;
|
||
renderMemoryList();
|
||
}
|
||
|
||
function toggleSelectItem(id) {
|
||
if (selectedIds.has(id)) {
|
||
selectedIds.delete(id);
|
||
} else {
|
||
selectedIds.add(id);
|
||
}
|
||
updateBulkCount();
|
||
}
|
||
|
||
function updateBulkCount() {
|
||
const countEl = document.getElementById('memory-selected-count');
|
||
const deleteBtn = document.getElementById('memory-bulk-delete');
|
||
if (countEl) countEl.textContent = `${selectedIds.size} Selected`;
|
||
if (deleteBtn) deleteBtn.disabled = selectedIds.size === 0;
|
||
}
|
||
|
||
function toggleSelectAll() {
|
||
const selectAllEl = document.getElementById('memory-select-all');
|
||
if (!selectAllEl) return;
|
||
|
||
if (selectAllEl.checked) {
|
||
// Select all currently visible/filtered items
|
||
const visible = getFilteredMemories();
|
||
visible.forEach(m => selectedIds.add(m.id));
|
||
} else {
|
||
selectedIds.clear();
|
||
}
|
||
updateBulkCount();
|
||
renderMemoryList();
|
||
}
|
||
|
||
async function bulkDelete() {
|
||
if (selectedIds.size === 0) return;
|
||
const count = selectedIds.size;
|
||
if (!await uiModule.styledConfirm(`Delete ${count} ${count === 1 ? 'memory' : 'memories'}?`, { confirmText: 'Delete', danger: true })) return;
|
||
|
||
let deleted = 0;
|
||
const deletedIds = [];
|
||
for (const id of selectedIds) {
|
||
try {
|
||
const res = await fetch(`${window.location.origin}/api/memory/${id}`, { method: 'DELETE' });
|
||
if (res.ok) {
|
||
deleted++;
|
||
deletedIds.push(id);
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to delete memory:', id, e);
|
||
}
|
||
}
|
||
|
||
await animateMemoryRemoval(deletedIds);
|
||
exitSelectMode();
|
||
await loadMemories();
|
||
showToast(`Deleted ${deleted} ${deleted === 1 ? 'memory' : 'memories'}`);
|
||
}
|
||
|
||
// ---- Tidy (audit) ----
|
||
|
||
export async function tidyMemories() {
|
||
const tidyBtn = document.getElementById('memory-tidy-btn');
|
||
let tidySpinner = null;
|
||
if (tidyBtn) {
|
||
tidyBtn.disabled = true;
|
||
tidyBtn.textContent = '';
|
||
// Drop the button border while the whirlpool spins — just the spinner,
|
||
// no box around it (restored in the finally below).
|
||
tidyBtn.style.border = 'none';
|
||
tidyBtn.style.background = 'none';
|
||
tidySpinner = spinnerModule.create('', 'clean', 'whirlpool');
|
||
const _spEl = tidySpinner.createElement();
|
||
_spEl.style.position = 'relative';
|
||
_spEl.style.top = '1px';
|
||
tidyBtn.appendChild(_spEl);
|
||
tidySpinner.start();
|
||
}
|
||
|
||
// Snapshot current state for diffing
|
||
const beforeMap = new Map(memories.map(m => [m.id, { ...m }]));
|
||
|
||
try {
|
||
const res = await fetch(`${window.location.origin}/api/memory/audit`, {
|
||
method: 'POST',
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const err = await res.json().catch(() => ({}));
|
||
throw new Error(err.detail || 'Audit failed');
|
||
}
|
||
|
||
const data = await res.json();
|
||
if ((data.removed || 0) === 0) {
|
||
if (tidySpinner) tidySpinner.destroy();
|
||
if (tidyBtn) { tidyBtn.disabled = false; tidyBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Tidy'; }
|
||
showToast('Already clean');
|
||
return;
|
||
}
|
||
|
||
// Fetch the new state
|
||
const freshRes = await fetch(`${window.location.origin}/api/memory`);
|
||
const freshData = await freshRes.json();
|
||
const afterList = freshData.memory || freshData || [];
|
||
const afterMap = new Map(afterList.map(m => [m.id, m]));
|
||
|
||
// Compute diff
|
||
const removed = []; // IDs that no longer exist
|
||
const edited = []; // IDs where text changed
|
||
for (const [id, oldMem] of beforeMap) {
|
||
if (!afterMap.has(id)) {
|
||
removed.push(id);
|
||
} else if (afterMap.get(id).text !== oldMem.text) {
|
||
edited.push({ id, oldText: oldMem.text, newText: afterMap.get(id).text });
|
||
}
|
||
}
|
||
|
||
if (tidySpinner) tidySpinner.updateMessage('Tidying memories');
|
||
|
||
// Animate the diff on the currently rendered list
|
||
await animateTidyDiff(removed, edited);
|
||
|
||
// Now load the clean state
|
||
memories = afterList;
|
||
buildCategoryChips();
|
||
renderMemoryList();
|
||
updateMemoryCount();
|
||
|
||
showToast(`Tidied: ${data.removed} removed (${data.before} \u2192 ${data.after})`);
|
||
} catch (error) {
|
||
console.error('Tidy failed:', error);
|
||
showError('Tidy failed — check console');
|
||
} finally {
|
||
if (tidySpinner) tidySpinner.destroy();
|
||
if (tidyBtn) {
|
||
tidyBtn.disabled = false;
|
||
tidyBtn.style.border = '';
|
||
tidyBtn.style.background = '';
|
||
tidyBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Tidy';
|
||
}
|
||
}
|
||
}
|
||
|
||
function sleep(ms) {
|
||
return new Promise(r => setTimeout(r, ms));
|
||
}
|
||
|
||
async function animateMemoryRemoval(ids) {
|
||
const idSet = new Set([...ids].map(id => String(id)));
|
||
const memoryList = document.getElementById('memory-list');
|
||
if (!memoryList || !idSet.size) return;
|
||
const items = Array.from(memoryList.querySelectorAll('.memory-item[data-memory-id]'))
|
||
.filter(el => idSet.has(String(el.dataset.memoryId)));
|
||
if (!items.length) return;
|
||
for (const el of items) {
|
||
el.style.maxHeight = `${Math.max(el.getBoundingClientRect().height, el.scrollHeight)}px`;
|
||
el.classList.add('memory-tidy-removing');
|
||
}
|
||
await sleep(520);
|
||
}
|
||
|
||
async function animateTidyDiff(removedIds, editedItems) {
|
||
const memoryList = document.getElementById('memory-list');
|
||
if (!memoryList) return;
|
||
|
||
// Tag each rendered item with its memory ID for lookup
|
||
const items = memoryList.querySelectorAll('.memory-item');
|
||
const itemMap = new Map();
|
||
const filtered = getFilteredMemories();
|
||
items.forEach((el, i) => {
|
||
if (filtered[i]) itemMap.set(filtered[i].id, el);
|
||
});
|
||
|
||
// Animate edits first — show text morphing
|
||
for (const { id, oldText, newText } of editedItems) {
|
||
const el = itemMap.get(id);
|
||
if (!el) continue;
|
||
|
||
const textEl = el.querySelector('.memory-item-text');
|
||
if (!textEl) continue;
|
||
|
||
el.classList.add('memory-tidy-editing');
|
||
textEl.classList.add('memory-tidy-text-old');
|
||
await sleep(300);
|
||
|
||
textEl.textContent = newText;
|
||
textEl.classList.remove('memory-tidy-text-old');
|
||
textEl.classList.add('memory-tidy-text-new');
|
||
await sleep(400);
|
||
|
||
el.classList.remove('memory-tidy-editing');
|
||
textEl.classList.remove('memory-tidy-text-new');
|
||
await sleep(100);
|
||
}
|
||
|
||
// Animate removals — strikethrough then fade out
|
||
for (const id of removedIds) {
|
||
const el = itemMap.get(id);
|
||
if (!el) continue;
|
||
|
||
el.classList.add('memory-tidy-removing');
|
||
await sleep(200);
|
||
}
|
||
|
||
// Let all removals animate together, then wait for them to finish
|
||
if (removedIds.length > 0) {
|
||
await sleep(500);
|
||
}
|
||
}
|
||
|
||
// ---- Filtering helper ----
|
||
|
||
function getFilteredMemories() {
|
||
const searchTerm = document.getElementById('memory-search')?.value?.toLowerCase().trim() || '';
|
||
|
||
let filtered = searchTerm
|
||
? memories.filter(m => m.text && m.text.toLowerCase().includes(searchTerm))
|
||
: [...memories];
|
||
|
||
if (activeCategory !== 'all') {
|
||
filtered = filtered.filter(m => (m.category || 'fact') === activeCategory);
|
||
}
|
||
|
||
const sortSelect = document.getElementById('memory-sort');
|
||
const sort = sortSelect ? sortSelect.value : sortOrder;
|
||
if (sort === 'newest') {
|
||
filtered.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
|
||
} else if (sort === 'oldest') {
|
||
filtered.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
|
||
} else if (sort === 'alpha') {
|
||
filtered.sort((a, b) => (a.text || '').localeCompare(b.text || ''));
|
||
} else if (sort === 'uses') {
|
||
filtered.sort((a, b) => (b.uses || 0) - (a.uses || 0) || (b.timestamp || 0) - (a.timestamp || 0));
|
||
}
|
||
|
||
// Pinned always float to top
|
||
filtered.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0));
|
||
|
||
return filtered;
|
||
}
|
||
|
||
// ---- Render ----
|
||
|
||
export function renderMemoryList() {
|
||
const memoryList = document.getElementById('memory-list');
|
||
if (!memoryList) {
|
||
console.error('Memory list element not found');
|
||
return;
|
||
}
|
||
|
||
const filtered = getFilteredMemories();
|
||
memoryList.innerHTML = '';
|
||
|
||
if (filtered.length === 0) {
|
||
const searchTerm = document.getElementById('memory-search')?.value?.trim() || '';
|
||
const _smiley = '<span style="vertical-align:-3px;margin-left:6px;">' + uiModule.emptyStateIcon('smiley') + '</span>';
|
||
if (searchTerm || activeCategory !== 'all') {
|
||
memoryList.innerHTML = `<div class="memory-empty">No matches.</div>`;
|
||
} else {
|
||
memoryList.innerHTML = `<div class="memory-empty" style="display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;">
|
||
<span>No memories yet${_smiley}</span>
|
||
<span style="opacity:0.7;font-size:11px;display:block;">
|
||
<a href="#" data-mem-goto-add style="color:var(--accent,var(--red));text-decoration:underline;">Import in Add tab</a>
|
||
</span>
|
||
</div>`;
|
||
memoryList.querySelector('[data-mem-goto-add]')?.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
document.querySelector('.memory-tab[data-memory-tab="add"]')?.click();
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
filtered.forEach(memory => {
|
||
const item = document.createElement('div');
|
||
item.className = 'memory-item';
|
||
item.dataset.memoryId = String(memory.id);
|
||
|
||
// Checkbox for select mode
|
||
if (selectMode) {
|
||
const cb = document.createElement('input');
|
||
cb.type = 'checkbox';
|
||
cb.className = 'memory-select-cb';
|
||
cb.checked = selectedIds.has(memory.id);
|
||
cb.addEventListener('change', () => {
|
||
toggleSelectItem(memory.id);
|
||
const selectAllEl = document.getElementById('memory-select-all');
|
||
if (selectAllEl) selectAllEl.checked = filtered.every(m => selectedIds.has(m.id));
|
||
});
|
||
item.appendChild(cb);
|
||
item.style.cursor = 'pointer';
|
||
item.addEventListener('click', (e) => {
|
||
if (e.target === cb) return;
|
||
cb.checked = !cb.checked;
|
||
cb.dispatchEvent(new Event('change'));
|
||
});
|
||
}
|
||
|
||
// Content: text + metadata
|
||
const content = document.createElement('div');
|
||
content.className = 'memory-item-content';
|
||
|
||
const textSpan = document.createElement('span');
|
||
textSpan.className = 'memory-item-text';
|
||
textSpan.textContent = memory.text;
|
||
|
||
const meta = document.createElement('div');
|
||
meta.className = 'memory-item-meta';
|
||
|
||
if (memory.pinned) {
|
||
const pinBadge = document.createElement('span');
|
||
pinBadge.className = 'memory-cat-badge memory-cat-pinned';
|
||
pinBadge.textContent = 'pinned';
|
||
meta.appendChild(pinBadge);
|
||
}
|
||
|
||
const catBadge = document.createElement('span');
|
||
const cat = memory.category || 'fact';
|
||
catBadge.className = 'memory-cat-badge memory-cat-' + cat;
|
||
catBadge.textContent = cat;
|
||
meta.appendChild(catBadge);
|
||
|
||
const srcSpan = document.createElement('span');
|
||
srcSpan.className = 'memory-item-source';
|
||
srcSpan.textContent = memory.source === 'auto' ? 'auto' : 'manual';
|
||
meta.appendChild(srcSpan);
|
||
|
||
const uses = Number(memory.uses || 0);
|
||
if (uses > 0) {
|
||
const useSpan = document.createElement('span');
|
||
useSpan.className = 'memory-item-uses';
|
||
useSpan.textContent = `${uses}×`;
|
||
useSpan.title = `Injected into chat context ${uses} time${uses === 1 ? '' : 's'}`;
|
||
meta.appendChild(useSpan);
|
||
}
|
||
|
||
if (memory.timestamp) {
|
||
const timeSpan = document.createElement('span');
|
||
timeSpan.className = 'memory-item-time';
|
||
timeSpan.textContent = relativeTime(memory.timestamp);
|
||
timeSpan.title = new Date(memory.timestamp * 1000).toLocaleString();
|
||
meta.appendChild(timeSpan);
|
||
}
|
||
|
||
content.appendChild(textSpan);
|
||
content.appendChild(meta);
|
||
|
||
if (memory.pinned) item.classList.add('memory-pinned');
|
||
|
||
item.appendChild(content);
|
||
|
||
// Double-click text to edit (not in select mode)
|
||
if (!selectMode) {
|
||
textSpan.addEventListener('dblclick', (e) => {
|
||
e.stopPropagation();
|
||
startInlineEdit(item, memory);
|
||
});
|
||
textSpan.style.cursor = 'text';
|
||
}
|
||
|
||
// Menu button (hidden in select mode)
|
||
if (!selectMode) {
|
||
const menuBtn = document.createElement('button');
|
||
menuBtn.className = 'memory-menu-btn';
|
||
menuBtn.innerHTML = '\u22EE';
|
||
menuBtn.title = 'Actions';
|
||
|
||
const dropdown = document.createElement('div');
|
||
dropdown.className = 'memory-item-dropdown';
|
||
|
||
// Pin / Unpin — bookmark icon matches the chat-session "Favorite" SVG.
|
||
// Filled when pinned, outlined when not.
|
||
const _bookmarkPath = '<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>';
|
||
const _pinSvg = memory.pinned
|
||
? `<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${_bookmarkPath}</svg>`
|
||
: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${_bookmarkPath}</svg>`;
|
||
const pinItem = document.createElement('div');
|
||
pinItem.className = 'dropdown-item-compact';
|
||
pinItem.innerHTML = `<span class="dropdown-icon">${_pinSvg}</span><span>${memory.pinned ? 'Unpin' : 'Pin'}</span>`;
|
||
pinItem.addEventListener('click', () => { dropdown.style.display = 'none'; togglePin(memory.id, !memory.pinned); });
|
||
|
||
const editItem = document.createElement('div');
|
||
editItem.className = 'dropdown-item-compact';
|
||
editItem.textContent = '✎ Edit';
|
||
editItem.addEventListener('click', () => { dropdown.style.display = 'none'; startInlineEdit(item, memory); });
|
||
|
||
const deleteItem = document.createElement('div');
|
||
deleteItem.className = 'dropdown-item-compact memory-dropdown-delete';
|
||
deleteItem.textContent = '✕ Delete';
|
||
deleteItem.addEventListener('click', () => { dropdown.style.display = 'none'; deleteMemory(memory.id); });
|
||
|
||
// Select — enters bulk-select mode and pre-selects this memory. Same
|
||
// pattern as the email/documents/skills Select item.
|
||
const selectItem = document.createElement('div');
|
||
selectItem.className = 'dropdown-item-compact';
|
||
selectItem.innerHTML = '<span class="dropdown-icon"><span style="font-size:16px;line-height:1;">●</span></span><span>Select</span>';
|
||
selectItem.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
if (dropdown.parentNode) dropdown.remove();
|
||
if (!selectMode) enterSelectMode();
|
||
selectedIds.add(memory.id);
|
||
updateBulkCount();
|
||
renderMemoryList();
|
||
});
|
||
|
||
// Mobile-only Cancel — mirrors the email/documents popup pattern. CSS
|
||
// hides `.dropdown-cancel-mobile` on desktop where outside-click already
|
||
// dismisses cleanly.
|
||
const cancelItem = document.createElement('div');
|
||
cancelItem.className = 'dropdown-item-compact dropdown-cancel-mobile';
|
||
cancelItem.textContent = '✕ Cancel';
|
||
cancelItem.addEventListener('click', (e) => { e.stopPropagation(); if (dropdown.parentNode) dropdown.remove(); });
|
||
|
||
dropdown.appendChild(pinItem);
|
||
dropdown.appendChild(selectItem);
|
||
dropdown.appendChild(editItem);
|
||
dropdown.appendChild(deleteItem);
|
||
dropdown.appendChild(cancelItem);
|
||
|
||
menuBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
// Close any other open dropdowns
|
||
document.querySelectorAll('.memory-item-dropdown').forEach(d => d.remove());
|
||
const rect = menuBtn.getBoundingClientRect();
|
||
dropdown.style.position = 'fixed';
|
||
dropdown.style.top = rect.bottom + 2 + 'px';
|
||
dropdown.style.right = (window.innerWidth - rect.right) + 'px';
|
||
dropdown.style.left = 'auto';
|
||
dropdown.style.zIndex = '10001';
|
||
dropdown.style.display = 'block';
|
||
document.body.appendChild(dropdown);
|
||
// Keep on-screen (mobile): flip above the button if it overflows the
|
||
// bottom, clamp the left edge, cap height as a last resort.
|
||
const dr = dropdown.getBoundingClientRect();
|
||
if (dr.bottom > window.innerHeight - 6) {
|
||
dropdown.style.top = Math.max(6, rect.top - dr.height - 2) + 'px';
|
||
}
|
||
if (dr.left < 6) {
|
||
dropdown.style.right = Math.max(6, window.innerWidth - 6 - dr.width) + 'px';
|
||
}
|
||
const dr2 = dropdown.getBoundingClientRect();
|
||
if (dr2.bottom > window.innerHeight - 6) {
|
||
dropdown.style.maxHeight = Math.max(80, window.innerHeight - 12 - dr2.top) + 'px';
|
||
dropdown.style.overflowY = 'auto';
|
||
}
|
||
|
||
// Swipe-down-to-dismiss — mirrors the documents library popup gesture.
|
||
// Drag the popup down past ~60px and release to close; release earlier
|
||
// and it snaps back. Vertical-only; horizontal flicks fall through.
|
||
let _sw = null;
|
||
let _swDy = 0;
|
||
const _onTS = (ev) => {
|
||
if (ev.touches.length !== 1) return;
|
||
_sw = { x: ev.touches[0].clientX, y: ev.touches[0].clientY };
|
||
_swDy = 0;
|
||
dropdown.style.transition = '';
|
||
};
|
||
const _onTM = (ev) => {
|
||
if (!_sw || ev.touches.length !== 1) return;
|
||
const dx = ev.touches[0].clientX - _sw.x;
|
||
const dy = ev.touches[0].clientY - _sw.y;
|
||
if (Math.abs(dy) < Math.abs(dx)) { _sw = null; return; }
|
||
if (dy > 0) {
|
||
_swDy = dy;
|
||
dropdown.style.transform = 'translateY(' + dy + 'px)';
|
||
dropdown.style.opacity = String(Math.max(0.3, 1 - dy / 240));
|
||
}
|
||
};
|
||
const _onTE = () => {
|
||
if (!_sw) return;
|
||
_sw = null;
|
||
if (_swDy > 60) {
|
||
dropdown.style.transition = 'transform 0.15s ease, opacity 0.15s ease';
|
||
dropdown.style.transform = 'translateY(120px)';
|
||
dropdown.style.opacity = '0';
|
||
setTimeout(() => { if (dropdown.parentNode) dropdown.remove(); }, 160);
|
||
} else {
|
||
dropdown.style.transition = 'transform 0.18s ease, opacity 0.18s ease';
|
||
dropdown.style.transform = '';
|
||
dropdown.style.opacity = '';
|
||
}
|
||
};
|
||
dropdown.addEventListener('touchstart', _onTS, { passive: true });
|
||
dropdown.addEventListener('touchmove', _onTM, { passive: true });
|
||
dropdown.addEventListener('touchend', _onTE);
|
||
});
|
||
|
||
item.appendChild(menuBtn);
|
||
|
||
// Long-press anywhere on the card opens the same dropdown — mirrors the
|
||
// documents library pattern. Skip when the touch starts on the kebab,
|
||
// checkbox, or another button (those have their own click handlers).
|
||
{
|
||
let hold = null;
|
||
let start = null;
|
||
const _lpCancel = () => { if (hold) { clearTimeout(hold); hold = null; } start = null; };
|
||
item.addEventListener('pointerdown', (e) => {
|
||
if (e.target.closest('.memory-menu-btn, .memory-select-cb, button, input')) return;
|
||
start = { x: e.clientX, y: e.clientY };
|
||
hold = setTimeout(() => {
|
||
hold = null;
|
||
item._suppressNextClick = true;
|
||
setTimeout(() => { item._suppressNextClick = false; }, 400);
|
||
if (navigator.vibrate) try { navigator.vibrate(15); } catch {}
|
||
menuBtn.click();
|
||
}, 500);
|
||
});
|
||
item.addEventListener('pointermove', (e) => {
|
||
if (!start) return;
|
||
if (Math.hypot(e.clientX - start.x, e.clientY - start.y) > 10) _lpCancel();
|
||
});
|
||
item.addEventListener('pointerup', _lpCancel);
|
||
item.addEventListener('pointercancel', _lpCancel);
|
||
}
|
||
|
||
// Close dropdown on outside click
|
||
document.addEventListener('click', () => { if (dropdown.parentNode) dropdown.remove(); }, { once: false });
|
||
}
|
||
|
||
memoryList.appendChild(item);
|
||
});
|
||
|
||
}
|
||
|
||
// ---- Inline edit with category picker ----
|
||
|
||
function startInlineEdit(item, memory) {
|
||
item.innerHTML = '';
|
||
item.className = 'memory-item memory-item-editing';
|
||
|
||
const editRow = document.createElement('div');
|
||
editRow.className = 'memory-edit-row';
|
||
|
||
const input = document.createElement('input');
|
||
input.type = 'text';
|
||
input.className = 'memory-item-edit-input';
|
||
input.value = memory.text;
|
||
|
||
const catSelect = document.createElement('select');
|
||
catSelect.className = 'memory-edit-cat-select';
|
||
MEMORY_CATEGORIES.forEach(cat => {
|
||
const opt = document.createElement('option');
|
||
opt.value = cat;
|
||
opt.textContent = cat;
|
||
if (cat === (memory.category || 'fact')) opt.selected = true;
|
||
catSelect.appendChild(opt);
|
||
});
|
||
|
||
editRow.appendChild(input);
|
||
editRow.appendChild(catSelect);
|
||
|
||
const actions = document.createElement('div');
|
||
actions.className = 'memory-item-actions';
|
||
actions.style.opacity = '1';
|
||
|
||
const saveBtn = document.createElement('button');
|
||
saveBtn.className = 'memory-item-btn save';
|
||
saveBtn.textContent = 'save';
|
||
saveBtn.addEventListener('click', () => saveInlineEdit(memory.id, input.value, catSelect.value));
|
||
|
||
const cancelBtn = document.createElement('button');
|
||
cancelBtn.className = 'memory-item-btn';
|
||
cancelBtn.textContent = 'cancel';
|
||
cancelBtn.addEventListener('click', () => renderMemoryList());
|
||
|
||
actions.appendChild(saveBtn);
|
||
actions.appendChild(cancelBtn);
|
||
|
||
item.appendChild(editRow);
|
||
item.appendChild(actions);
|
||
|
||
input.focus();
|
||
input.select();
|
||
|
||
input.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') saveInlineEdit(memory.id, input.value, catSelect.value);
|
||
if (e.key === 'Escape') {
|
||
e.stopPropagation();
|
||
e.stopImmediatePropagation();
|
||
renderMemoryList();
|
||
}
|
||
});
|
||
}
|
||
|
||
async function saveInlineEdit(id, newText, newCategory) {
|
||
newText = newText.trim();
|
||
if (!newText) return;
|
||
|
||
const memory = memories.find(m => m.id === id);
|
||
const catChanged = newCategory && newCategory !== (memory?.category || 'fact');
|
||
if (!memory || (newText === memory.text && !catChanged)) {
|
||
renderMemoryList();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const params = new URLSearchParams({ text: newText });
|
||
if (newCategory) params.append('category', newCategory);
|
||
|
||
const response = await fetch(`${window.location.origin}/api/memory/${id}`, {
|
||
method: 'PUT',
|
||
body: params
|
||
});
|
||
|
||
if (response.ok) {
|
||
await loadMemories();
|
||
showToast('Memory updated');
|
||
} else {
|
||
const errorData = await response.json();
|
||
throw new Error(errorData.detail || 'Failed to update memory');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error updating memory:', error);
|
||
showError('Failed to update memory');
|
||
}
|
||
}
|
||
|
||
export function updateMemoryCount() {
|
||
const h2Count = document.getElementById('memory-count-h2');
|
||
const tabCount = document.getElementById('memory-count'); // optional (may be absent)
|
||
if (!h2Count && !tabCount) return;
|
||
|
||
const searchInput = document.getElementById('memory-search');
|
||
const searchTerm = searchInput ? searchInput.value.toLowerCase().trim() : '';
|
||
|
||
let visible = memories;
|
||
const scopeTotal = visible.length;
|
||
if (searchTerm) {
|
||
visible = visible.filter(m => m.text && m.text.toLowerCase().includes(searchTerm));
|
||
}
|
||
if (activeCategory !== 'all') {
|
||
visible = visible.filter(m => (m.category || 'fact') === activeCategory);
|
||
}
|
||
|
||
const num = visible.length === scopeTotal ? `${scopeTotal}` : `${visible.length}/${scopeTotal}`;
|
||
// Header (next to the "Memories" title) reads "N memories", like the
|
||
// Documents header. The bare number still feeds any tab badge if present.
|
||
if (h2Count) h2Count.textContent = `${num} ${scopeTotal === 1 && visible.length === scopeTotal ? 'memory' : 'memories'}`;
|
||
if (tabCount) tabCount.textContent = num;
|
||
}
|
||
|
||
export async function addNewMemory() {
|
||
const input = document.getElementById('new-memory-input');
|
||
const text = input.value.trim();
|
||
|
||
if (!text) {
|
||
showError('Memory text cannot be empty');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`${window.location.origin}/api/memory/add`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
text: text,
|
||
})
|
||
});
|
||
|
||
if (response.ok) {
|
||
input.value = '';
|
||
await loadMemories();
|
||
showToast('Memory added');
|
||
} else {
|
||
const errorData = await response.json();
|
||
console.error('Server error details:', errorData);
|
||
throw new Error(errorData.detail || 'Failed to add memory');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error adding memory:', error);
|
||
showError('Failed to add memory');
|
||
}
|
||
}
|
||
|
||
export async function editMemory(id) {
|
||
const memory = memories.find(m => m.id === id);
|
||
if (!memory) return;
|
||
|
||
const newText = prompt('Edit memory:', memory.text);
|
||
if (!newText || newText === memory.text) return;
|
||
|
||
await saveInlineEdit(id, newText);
|
||
}
|
||
|
||
async function togglePin(id, pinned) {
|
||
try {
|
||
const res = await fetch(`${window.location.origin}/api/memory/${id}/pin`, {
|
||
method: 'POST',
|
||
body: new URLSearchParams({ pinned: pinned.toString() })
|
||
});
|
||
if (res.ok) {
|
||
const mem = memories.find(m => m.id === id);
|
||
if (mem) mem.pinned = pinned;
|
||
renderMemoryList();
|
||
showToast(pinned ? 'Pinned — always in context' : 'Unpinned — RAG only');
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to toggle pin:', e);
|
||
showError('Failed to update pin');
|
||
}
|
||
}
|
||
|
||
export async function deleteMemory(id) {
|
||
const memory = memories.find(m => m.id === id);
|
||
if (!memory) return;
|
||
|
||
if (!await uiModule.styledConfirm(`Delete this memory?\n"${memory.text}"`, { confirmText: 'Delete', danger: true })) return;
|
||
|
||
try {
|
||
const response = await fetch(`${window.location.origin}/api/memory/${id}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
if (response.ok) {
|
||
await animateMemoryRemoval([id]);
|
||
await loadMemories();
|
||
showToast('Memory deleted');
|
||
} else {
|
||
throw new Error('Failed to delete');
|
||
}
|
||
} catch (error) {
|
||
showError('Failed to delete memory');
|
||
}
|
||
}
|
||
|
||
export async function extractMemory(sessionId) {
|
||
const res = await fetch(`${window.location.origin}/api/memory/extract`, {
|
||
method: 'POST',
|
||
body: new URLSearchParams({ session: sessionId })
|
||
});
|
||
if (!res.ok) {
|
||
showError('Failed to extract memory suggestions');
|
||
return;
|
||
}
|
||
const data = await res.json();
|
||
const suggestions = data.suggestions || [];
|
||
|
||
const modal = document.getElementById('memory-modal');
|
||
const body = document.getElementById('memory-suggestions-body');
|
||
if (!body) {
|
||
console.error('memory-suggestions-body element not found');
|
||
return;
|
||
}
|
||
|
||
body.innerHTML = '';
|
||
body.classList.remove('hidden');
|
||
|
||
const memList = document.getElementById('memory-list');
|
||
if (memList) memList.classList.add('hidden');
|
||
|
||
if (suggestions.length === 0) {
|
||
body.innerHTML = '<div class="memory-empty">No useful information detected.</div>';
|
||
} else {
|
||
const header = document.createElement('div');
|
||
header.className = 'memory-suggestions-header';
|
||
header.innerHTML = '<span>Suggested memories</span>';
|
||
const backBtn = document.createElement('button');
|
||
backBtn.className = 'memory-item-btn';
|
||
backBtn.textContent = 'back';
|
||
backBtn.addEventListener('click', () => {
|
||
body.classList.add('hidden');
|
||
body.innerHTML = '';
|
||
if (memList) memList.classList.remove('hidden');
|
||
});
|
||
header.appendChild(backBtn);
|
||
body.appendChild(header);
|
||
|
||
suggestions.forEach(s => {
|
||
const div = document.createElement('div');
|
||
div.className = 'memory-suggestion-item';
|
||
const txt = document.createElement('span');
|
||
txt.className = 'memory-item-text';
|
||
txt.textContent = s;
|
||
const btn = document.createElement('button');
|
||
btn.className = 'memory-item-btn save';
|
||
btn.textContent = 'save';
|
||
btn.addEventListener('click', async () => {
|
||
await fetch(`${window.location.origin}/api/memory/add`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ text: s })
|
||
});
|
||
btn.disabled = true;
|
||
btn.textContent = 'saved';
|
||
showToast('Saved to memory');
|
||
});
|
||
div.appendChild(txt);
|
||
div.appendChild(btn);
|
||
body.appendChild(div);
|
||
});
|
||
}
|
||
|
||
modal.classList.remove('hidden');
|
||
}
|
||
|
||
// ---- Export ----
|
||
|
||
export function exportMemories() {
|
||
if (!memories || memories.length === 0) {
|
||
showToast('No memories to export');
|
||
return;
|
||
}
|
||
const data = JSON.stringify(memories, null, 2);
|
||
const blob = new Blob([data], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'memories.json';
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
showToast(`Exported ${memories.length} memories`);
|
||
}
|
||
|
||
// ---- Import from file ----
|
||
|
||
export async function importMemories() {
|
||
const fileInput = document.getElementById('memory-import-file');
|
||
if (!fileInput) return;
|
||
fileInput.click();
|
||
}
|
||
|
||
async function handleImportFile(file) {
|
||
if (!file) return;
|
||
|
||
const sessionId = sessionModule?.getCurrentSessionId?.();
|
||
if (!sessionId) {
|
||
showError('Open a session first — import needs an AI model');
|
||
return;
|
||
}
|
||
|
||
const importBtn = document.getElementById('memory-import-btn');
|
||
const _origImportHtml = importBtn ? importBtn.innerHTML : '';
|
||
let importSpin = null;
|
||
if (importBtn) {
|
||
importBtn.disabled = true;
|
||
importBtn.innerHTML = '';
|
||
importSpin = spinnerModule.createWhirlpool(12);
|
||
importSpin.element.style.cssText = 'width:12px;height:12px;margin:0 5px 0 0;display:inline-flex;vertical-align:-2px;transform:translateY(-1px);';
|
||
importBtn.appendChild(importSpin.element);
|
||
importBtn.appendChild(document.createTextNode('Importing'));
|
||
}
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
formData.append('session', sessionId);
|
||
|
||
const res = await fetch(`${window.location.origin}/api/memory/import`, {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const err = await res.json().catch(() => ({}));
|
||
throw new Error(err.detail || 'Import failed');
|
||
}
|
||
|
||
const data = await res.json();
|
||
const suggestions = data.suggestions || [];
|
||
|
||
// Show suggestions using the existing suggestions UI
|
||
const modal = document.getElementById('memory-modal');
|
||
const body = document.getElementById('memory-suggestions-body');
|
||
if (!body) return;
|
||
|
||
body.innerHTML = '';
|
||
body.classList.remove('hidden');
|
||
|
||
const memList = document.getElementById('memory-list');
|
||
if (memList) memList.classList.add('hidden');
|
||
|
||
if (suggestions.length === 0) {
|
||
body.innerHTML = '<div class="memory-empty">No useful information found in file.</div>';
|
||
} else {
|
||
const reviewItems = suggestions
|
||
.map((s) => ({
|
||
text: typeof s === 'string' ? s : s.text,
|
||
category: (typeof s === 'object' && s.category) || 'fact',
|
||
active: true,
|
||
}))
|
||
.filter((s) => s.text);
|
||
const header = document.createElement('div');
|
||
header.className = 'memory-suggestions-header';
|
||
const headerTitle = document.createElement('span');
|
||
const updateHeaderTitle = () => {
|
||
const remaining = reviewItems.filter((item) => item.active).length;
|
||
headerTitle.textContent = `Imported from ${data.filename || file.name} (${remaining}) Review`;
|
||
};
|
||
updateHeaderTitle();
|
||
const headerActions = document.createElement('div');
|
||
headerActions.className = 'memory-suggestions-actions';
|
||
const backBtn = document.createElement('button');
|
||
backBtn.className = 'memory-item-btn';
|
||
backBtn.textContent = 'back';
|
||
backBtn.addEventListener('click', () => {
|
||
body.classList.add('hidden');
|
||
body.innerHTML = '';
|
||
if (memList) memList.classList.remove('hidden');
|
||
});
|
||
const saveAllBtn = document.createElement('button');
|
||
saveAllBtn.className = 'memory-item-btn save';
|
||
saveAllBtn.textContent = 'save all';
|
||
saveAllBtn.addEventListener('click', async () => {
|
||
let saved = 0;
|
||
for (const s of reviewItems) {
|
||
if (!s.active || !s.text) continue;
|
||
try {
|
||
await fetch(`${window.location.origin}/api/memory/add`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ text: s.text, category: s.category })
|
||
});
|
||
saved++;
|
||
} catch (e) { /* skip */ }
|
||
}
|
||
body.classList.add('hidden');
|
||
body.innerHTML = '';
|
||
if (memList) memList.classList.remove('hidden');
|
||
await loadMemories();
|
||
document.querySelector('.memory-tab[data-memory-tab="browse"]')?.click();
|
||
showToast(`Saved ${saved} memories`);
|
||
});
|
||
headerActions.appendChild(saveAllBtn);
|
||
headerActions.appendChild(backBtn);
|
||
header.appendChild(headerTitle);
|
||
header.appendChild(headerActions);
|
||
body.appendChild(header);
|
||
|
||
reviewItems.forEach(item => {
|
||
const div = document.createElement('div');
|
||
div.className = 'memory-suggestion-item';
|
||
|
||
const content = document.createElement('div');
|
||
content.className = 'memory-item-content';
|
||
const txt = document.createElement('span');
|
||
txt.className = 'memory-item-text';
|
||
txt.textContent = item.text;
|
||
const catBadge = document.createElement('span');
|
||
catBadge.className = 'memory-cat-badge memory-cat-' + item.category;
|
||
catBadge.textContent = item.category;
|
||
content.appendChild(txt);
|
||
content.appendChild(catBadge);
|
||
|
||
const actionWrap = document.createElement('div');
|
||
actionWrap.className = 'memory-suggestion-actions';
|
||
const btn = document.createElement('button');
|
||
btn.className = 'memory-item-btn save';
|
||
btn.textContent = 'save';
|
||
btn.addEventListener('click', async () => {
|
||
await fetch(`${window.location.origin}/api/memory/add`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ text: item.text, category: item.category })
|
||
});
|
||
item.active = false;
|
||
div.remove();
|
||
updateHeaderTitle();
|
||
btn.disabled = true;
|
||
btn.textContent = 'saved';
|
||
showToast('Saved to memory');
|
||
});
|
||
const deleteBtn = document.createElement('button');
|
||
deleteBtn.className = 'memory-item-btn delete';
|
||
deleteBtn.textContent = 'delete';
|
||
deleteBtn.addEventListener('click', () => {
|
||
item.active = false;
|
||
div.remove();
|
||
updateHeaderTitle();
|
||
});
|
||
actionWrap.appendChild(btn);
|
||
actionWrap.appendChild(deleteBtn);
|
||
|
||
div.appendChild(content);
|
||
div.appendChild(actionWrap);
|
||
body.appendChild(div);
|
||
});
|
||
}
|
||
|
||
modal.classList.remove('hidden');
|
||
document.querySelector('.memory-tab[data-memory-tab="browse"]')?.click();
|
||
} catch (error) {
|
||
console.error('Import failed:', error);
|
||
showError('Import failed — ' + error.message);
|
||
} finally {
|
||
if (importSpin) importSpin.destroy();
|
||
if (importBtn) {
|
||
importBtn.disabled = false;
|
||
importBtn.innerHTML = _origImportHtml;
|
||
}
|
||
// Reset file input so the same file can be re-selected
|
||
const fileInput = document.getElementById('memory-import-file');
|
||
if (fileInput) fileInput.value = '';
|
||
}
|
||
}
|
||
|
||
// Utility aliases (canonical implementations live in uiModule)
|
||
var showToast = uiModule.showToast;
|
||
var showError = uiModule.showError;
|
||
|
||
// Event listeners
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
_wireMemoryDrag();
|
||
|
||
// Memory modal tabs
|
||
document.querySelectorAll('.memory-tab[data-memory-tab]').forEach(tab => {
|
||
tab.addEventListener('click', () => {
|
||
const target = tab.dataset.memoryTab;
|
||
document.querySelectorAll('.memory-tab').forEach(t => t.classList.toggle('active', t === tab));
|
||
document.querySelectorAll('.memory-tab-panel[data-memory-panel]').forEach(p => {
|
||
p.classList.toggle('hidden', p.dataset.memoryPanel !== target);
|
||
});
|
||
// Lazy-load skills tab (cascade=true → play the domino-in entrance)
|
||
if (target === 'skills') {
|
||
import('./skills.js').then(m => { if (m.loadSkills) m.loadSkills(true); else if (m.default?.loadSkills) m.default.loadSkills(true); });
|
||
}
|
||
});
|
||
});
|
||
|
||
const sortSelect = document.getElementById('memory-sort');
|
||
if (sortSelect) {
|
||
sortSelect.addEventListener('change', () => {
|
||
sortOrder = sortSelect.value;
|
||
renderMemoryList();
|
||
});
|
||
}
|
||
|
||
const tidyBtn = document.getElementById('memory-tidy-btn');
|
||
if (tidyBtn) tidyBtn.addEventListener('click', tidyMemories);
|
||
|
||
const selectBtn = document.getElementById('memory-select-btn');
|
||
if (selectBtn) selectBtn.addEventListener('click', () => {
|
||
if (selectMode) exitSelectMode();
|
||
else enterSelectMode();
|
||
});
|
||
|
||
const selectAll = document.getElementById('memory-select-all');
|
||
if (selectAll) selectAll.addEventListener('change', toggleSelectAll);
|
||
|
||
const bulkBar = document.getElementById('memory-bulk-bar');
|
||
if (bulkBar) bulkBar.addEventListener('click', (e) => {
|
||
if (e.target.closest('button') || e.target === selectAll) return;
|
||
selectAll.checked = !selectAll.checked;
|
||
selectAll.dispatchEvent(new Event('change'));
|
||
});
|
||
|
||
const bulkDeleteBtn = document.getElementById('memory-bulk-delete');
|
||
if (bulkDeleteBtn) bulkDeleteBtn.addEventListener('click', bulkDelete);
|
||
|
||
const bulkCancelBtn = document.getElementById('memory-bulk-cancel');
|
||
if (bulkCancelBtn) bulkCancelBtn.addEventListener('click', exitSelectMode);
|
||
|
||
const exportBtn = document.getElementById('memory-export-btn');
|
||
if (exportBtn) exportBtn.addEventListener('click', exportMemories);
|
||
|
||
const importBtn = document.getElementById('memory-import-btn');
|
||
if (importBtn) importBtn.addEventListener('click', importMemories);
|
||
|
||
const importFile = document.getElementById('memory-import-file');
|
||
if (importFile) importFile.addEventListener('change', (e) => {
|
||
if (e.target.files[0]) handleImportFile(e.target.files[0]);
|
||
});
|
||
|
||
window.addEventListener('memory-refresh', () => {
|
||
loadMemories();
|
||
});
|
||
});
|
||
|
||
const memoryModule = {
|
||
loadMemories,
|
||
renderMemoryList,
|
||
updateMemoryCount,
|
||
addNewMemory,
|
||
editMemory,
|
||
deleteMemory,
|
||
extractMemory,
|
||
buildCategoryChips,
|
||
tidyMemories,
|
||
importMemories,
|
||
exportMemories
|
||
};
|
||
|
||
export default memoryModule;
|
||
window.memoryModule = memoryModule;
|