mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-15 17:25:26 -04:00
2cbd55b8bd
- Agent: pass the open email reader (uid/folder/account/from/subject/body
preview) on every chat submit so 'reply to this' / 'write email saying
hi' route to ui_control open_email_reply with the right UID instead of
inventing a new .md draft. Code-level enforcement (chat_routes strips
create_document + send_email when active_email is set); cross-session
active_doc_id is now trusted instead of being silently dropped.
set_active_email/clear_active_email tool-layer helpers in
tool_implementations.
- ui_control open_email_reply: optional body argument so the agent can
open-and-write in one call; envelope now forwards uid/folder/account/
body/panel through tool_output. Tool description sharpened and the
parser rejects empty bodies on reply/reply-all (forces the agent to
write rather than open an empty draft).
- Email library: search now runs against [Gmail]/All Mail when the
current folder is INBOX (archived emails surface). Whirlpool spinner
+ 'Searching…' placeholder while in flight. Each search result is
stamped with its source folder so clicks open the right email instead
of whatever shares its UID in INBOX. Search no longer re-applies the
same text pill locally (which only checks subject/from/snippet, never
body) so body-only matches don't get dropped after IMAP returns them.
Initial inbox load bumped 100→500.
- Email favorites: 'Favorite (pin to top)' / 'Unfavorite' in both the
card menu and the open-reader more menu, backed by a new
/api/email/flag/{uid}?on=true|false endpoint. Flagged emails always
bubble to the top of the grid regardless of active sort.
- AI reply in doc editor: never overwrites existing draft text or the
quoted history. AI suggestion is prepended; AI-generated 'On …
wrote:' re-quotes are stripped so the original quote isn't visually
edited.
- Cookbook serve: pre-launch GPU driver / has_gpu / install / version-
floor checks (vllm minimax_m2 needs 0.10.0+, deepseek_r1 needs 0.7.0
etc.) before the launch chain starts. Detect 'another model already
running on this host' and offer Stop & launch (with graceful then
force tmux kill helpers, port release wait). Per-vendor deep-link
buttons (vLLM recipe / SGLang cookbook) with hardware hash. Backend
picker is now a custom dropdown with accent-coloured logos for vLLM,
SGLang, llama.cpp, Ollama, Diffusers; same glyphs added next to
package names in Dependencies. Runtime-readiness note moved inside
the panel (green when ready, red when missing) with an × dismiss.
Esc collapses the expanded card; expanded card scrolls when it
overflows; Trust Remote / Auto Tool / Reasoning Parser / Enforce
Eager / Prefix Caching / Expert Parallel / Speculative / MoE Env on
one row (Reasoning Parser auto-detected per model family).
Dtype→Row 1, GPUs→Row 2 (rightmost). Removed redundant GPU 'auto'
input — command builders read from the GPU button strip. Default
cookbook open is Download tab.
- Cookbook hwfit: 'Model (latest)' / 'Model (oldest)' header sorts by
release_date; release dates can be backfilled with the new
scripts/backfill_model_release_dates.py and recipe metadata pulled
with scripts/import_from_vllm_recipes.py against the upstream
vllm-project/recipes catalog (vllm_recipe + min_vllm_version stamped
on entries).
- Calendar: Quick add hint cycles a random Odysseus-themed example per
open (wooden horse Friday, crew muster 10am daily, council on
Ithaca, …). Typing a time like '11pm' in the event title updates
the hero clock live.
- Doc editor: email-mode Reply button (sparkle icon, accent) opens the
same Fast/Full + context popover the email reader uses; Ctrl+Alt+M
toggles markdown preview.
- Memories panel: custom sort picker with per-option icons, default
'Latest', visible Enabled/Disabled toggle text matching the section
description style.
1526 lines
56 KiB
JavaScript
1526 lines
56 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'];
|
||
|
||
// 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.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() {
|
||
_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';
|
||
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();
|
||
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;
|