Files
odysseus/static/js/memory.js
T
Max Hsu fef08ed114 fix(modal): keep body-portaled dropdowns above their tool modal at any stack depth (#4720) (#4724)
* fix(memory): keep the Brain memory item menu above the modal at any stack depth

The memory item "⋮" dropdown is portaled to <body> with a hardcoded
z-index of 10001. Tool modals, however, get a monotonically increasing
z-index from modalManager's bring-to-front counter (_modalTopZ), which
climbs unbounded as modals are opened/restored over a session. Once that
counter passes 10001, the Brain modal stacks above the body-portaled
dropdown, so the menu renders behind the panel — visible only where it
spills past the modal's edge (#4720).

Derive the dropdown's z-index from the owning modal's current z-index
(+1), keeping 10001 as a floor for the common low-counter case, so the
menu always sits just above its modal however high the counter has climbed.

Verified with document.elementFromPoint at the dropdown's location: with a
high modal z-index the old build returns the modal at every sampled point
(menu behind); the fixed build returns the dropdown (menu on top). The
default low-counter case is unchanged (z stays 10001).

* refactor(modal): route body-portaled dropdowns through a shared topPortalZ() helper

The hardcoded z-index:10001 the Brain memory menu used (#4720) is the same
literal shared by ~16 body-portaled dropdowns across calendar, cookbook,
cookbookServe, documentLibrary, emailLibrary, gallery, notes, emojiPicker and
memory — each renders behind its owning tool modal once modalManager's
bring-to-front counter climbs past the literal over a long session.

Promote the per-dropdown fix into a single topPortalZ() helper in
toolWindowZOrder.js — the existing source of truth for tool-window z, already
imported by modalManager's _bringToFront and notes.js — returning
max(topToolWindowZ(), dock-chip floor) + 1, so a portaled dropdown always sits
just above the live tool-window stack however high the counter has climbed.
Route all 16 sites through it. The slashCommands tour tooltips and the
cookbookServe VRAM dialog are intentionally left out (neither is a modal-owned
portaled dropdown).

Add tests/test_portal_dropdown_z_js.py covering the helper, including the #4720
scenario (modal counter at 99999 -> dropdown at 100000). Existing
test_notes_z_order_js.py stays green.
2026-06-23 10:24:31 +02:00

1533 lines
56 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.
// 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';
import { topPortalZ } from './toolWindowZOrder.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'];
// Sort-option icons for the custom Memory sort picker (and Skills picker
// once it reuses the same markup). Each value maps to a 13px Feather-style
// SVG so the icon visually distinguishes Newest / Oldest / A-Z / Most used.
const _MEMORY_SORT_ICONS = {
newest: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
oldest: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><polyline points="3 3 3 8 8 8"/><polyline points="12 7 12 12 16 14"/></svg>',
alpha: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4h6"/><path d="M3 10h6"/><path d="M3 16h4"/><path d="M14 4l4 12"/><path d="M16 12h4"/><polyline points="17 18 21 14 17 10"/><line x1="21" y1="14" x2="13" y2="14"/></svg>',
uses: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/></svg>',
};
function _memorySortIcon(value) {
return _MEMORY_SORT_ICONS[value] || _MEMORY_SORT_ICONS.newest;
}
function _renderMemorySortPickerCurrent() {
const sel = document.getElementById('memory-sort');
const btn = document.getElementById('memory-sort-btn');
if (!sel || !btn) return;
const value = sel.value || 'newest';
const opt = sel.querySelector(`option[value="${CSS.escape(value)}"]`);
const label = opt ? opt.textContent : value;
const iconWrap = btn.querySelector('.memory-sort-icon-cur');
const labelEl = btn.querySelector('.memory-sort-label');
if (iconWrap) iconWrap.innerHTML = _memorySortIcon(value);
if (labelEl) labelEl.textContent = label;
}
function _initMemorySortPicker() {
const sel = document.getElementById('memory-sort');
const picker = document.getElementById('memory-sort-picker');
const btn = document.getElementById('memory-sort-btn');
const menu = document.getElementById('memory-sort-menu');
if (!sel || !picker || !btn || !menu || picker._wired) return;
picker._wired = true;
const items = Array.from(sel.children)
.filter(o => o.tagName === 'OPTION')
.map(o => ({ value: o.value, label: o.textContent }));
menu.innerHTML = items.map(it => `
<button type="button" role="option" class="memory-sort-item" data-value="${it.value}">
<span class="memory-sort-item-icon">${_memorySortIcon(it.value)}</span>
<span class="memory-sort-item-label">${it.label}</span>
</button>
`).join('');
const close = () => { menu.hidden = true; btn.setAttribute('aria-expanded', 'false'); };
const open = () => { menu.hidden = false; btn.setAttribute('aria-expanded', 'true'); };
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (menu.hidden) open(); else close();
});
menu.addEventListener('click', (e) => {
const item = e.target.closest('.memory-sort-item');
if (!item) return;
sel.value = item.dataset.value;
sel.dispatchEvent(new Event('change', { bubbles: true }));
_renderMemorySortPickerCurrent();
close();
});
document.addEventListener('click', (e) => {
if (!menu.hidden && !picker.contains(e.target)) close();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !menu.hidden) {
e.stopPropagation();
close();
}
}, { capture: true });
_renderMemorySortPickerCurrent();
}
function _ensureNewMemoryCategorySelect() {
const sel = document.getElementById('new-memory-category');
if (!sel || sel.dataset.wired === '1') return;
sel.dataset.wired = '1';
MEMORY_CATEGORIES.forEach(cat => {
const opt = document.createElement('option');
opt.value = cat;
opt.textContent = cat;
if (cat === 'fact') opt.selected = true;
sel.appendChild(opt);
});
}
function _readNewMemoryCategory() {
_ensureNewMemoryCategorySelect();
const sel = document.getElementById('new-memory-category');
const cat = sel?.value || 'fact';
return MEMORY_CATEGORIES.includes(cat) ? cat : 'fact';
}
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.501.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() {
_ensureNewMemoryCategorySelect();
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 ----
const _SELECT_BTN_DOT_SVG = '<svg class="memory-select-btn-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px;"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg>';
const _SELECT_BTN_X_SVG = '<svg class="memory-select-btn-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" style="vertical-align:-2px;margin-right:3px;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
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.innerHTML = _SELECT_BTN_X_SVG + '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.innerHTML = _SELECT_BTN_DOT_SVG + '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;color:var(--accent, var(--red));"><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;color:var(--accent, var(--red));"><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 selectBtn = document.getElementById('memory-select-btn');
if (selectBtn) selectBtn.disabled = true;
if (selectMode) exitSelectMode();
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;
}
const selectBtn = document.getElementById('memory-select-btn');
if (selectBtn) selectBtn.disabled = false;
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';
// Portaled to <body>, so it must outrank the Brain modal it belongs to.
// Tool modals get a monotonically increasing z-index from modalManager's
// bring-to-front counter, which climbs unbounded over a long session —
// once it passed the old hardcoded 10001 the menu rendered behind the
// panel (#4720). topPortalZ() derives the value from the live tool-window
// stack so the menu always sits just above, however high it has climbed.
dropdown.style.zIndex = String(topPortalZ());
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();
const category = _readNewMemoryCategory();
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,
category: category,
})
});
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?.();
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);
if (sessionId) {
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();
});
}
_initMemorySortPicker();
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;